diff --git a/CanlabCore/@fmri_surface_data/apply_mask.m b/CanlabCore/@fmri_surface_data/apply_mask.m new file mode 100644 index 00000000..e1704932 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/apply_mask.m @@ -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 diff --git a/CanlabCore/@fmri_surface_data/apply_parcellation.m b/CanlabCore/@fmri_surface_data/apply_parcellation.m new file mode 100644 index 00000000..60c97577 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/apply_parcellation.m @@ -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_'). +% **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 diff --git a/CanlabCore/@fmri_surface_data/cat.m b/CanlabCore/@fmri_surface_data/cat.m new file mode 100644 index 00000000..f7a3d6b0 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/cat.m @@ -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 diff --git a/CanlabCore/@fmri_surface_data/compare_space.m b/CanlabCore/@fmri_surface_data/compare_space.m new file mode 100644 index 00000000..649fdb36 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/compare_space.m @@ -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 diff --git a/CanlabCore/@fmri_surface_data/fmri_surface_data.m b/CanlabCore/@fmri_surface_data/fmri_surface_data.m new file mode 100644 index 00000000..19f975e3 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/fmri_surface_data.m @@ -0,0 +1,432 @@ +classdef fmri_surface_data < image_vector +% fmri_surface_data: CANlab data object for cortical-surface and grayordinate (CIFTI) data +% +% fmri_surface_data is the surface/grayordinate analogue of fmri_data. It wraps +% the HCP CIFTI grayordinate standard (cortical-surface vertices + subcortical +% voxels) as a true CANlab object that is interoperable with the rest of the +% toolbox, while holding data in the canonical flat [grayordinates x maps] .dat +% matrix so generic analysis methods (ica, descriptives, ...) work unchanged. +% +% It is a subclass of image_vector (NOT fmri_data): it inherits the .dat-based +% machinery but does NOT reproduce fmri_data's volInfo-centric, empty-voxel +% squeezing design. CIFTI data is already compact (the medial wall is excluded +% by construction and there are no out-of-brain voxels), so: +% - .dat is ALWAYS the full [nGrayordinates x nMaps] matrix, in 1:1 row +% correspondence with .brain_model. Rows are never dropped. +% - remove_empty / replace_empty are no-ops (see those methods). +% - removed_voxels / removed_images are vestigial all-false vectors. +% See docs/fmri_surface_data_design_plan.md (decision D5b). +% +% ------------------------------------------------------------------------- +% Construction +% ------------------------------------------------------------------------- +% obj = fmri_surface_data % empty object +% obj = fmri_surface_data(cifti_filename) % .dscalar/.dtseries/.dlabel.nii +% obj = fmri_surface_data(gifti_filename) % .func/.shape/.label/.surf.gii +% obj = fmri_surface_data(cifti_struct) % output of canlab_read_cifti +% obj = fmri_surface_data('dat', X, 'brain_model', bm, ...) % key/value pairs +% obj = fmri_surface_data(image_vector_obj) % recast a matching object +% +% Native I/O is used (canlab_read_cifti / canlab_read_gifti) -- no external +% toolbox (gifti, FieldTrip, cifti-matlab, Connectome Workbench) is required. +% +% ------------------------------------------------------------------------- +% Key properties +% ------------------------------------------------------------------------- +% dat [nGrayordinates x nMaps] single (inherited) data matrix. +% brain_model struct: the geometry source of truth (mirrors CIFTI +% BrainModels). Fields .models{i} (.struct, .type 'surf'|'vox', +% .start, .count, .numvert, .vertlist 0-based, .voxlist 3xN), +% .vol (.dims, .sform), .grayordinate_type, .cluster. +% geom struct: ATTACHED custom mesh geometry (.vertices/.faces), +% only set when the object is built from a .surf.gii that +% carries geometry. EMPTY for a data-only CIFTI (dscalar/ +% dtseries/dlabel) -- which is normal: standard-space meshes +% (fs_LR-32k, fsaverage-164k) are NOT stored here; surface() +% loads them on demand from bundled assets by surface_space. +% imagetype 'dscalar'|'dtseries'|'dlabel'|'func'|'shape'|'label'. +% series_info struct for .dtseries (start/step/unit/exponent). +% label_table MATLAB table (key, name, rgba) for .dlabel/.label. +% surface_space e.g. 'fsLR_32k', 'fsaverage_164k'. +% X / Y / covariates / images_per_session / metadata_table ... +% per-map annotations (same names/roles as fmri_data). +% +% :See also: fmri_data, image_vector, canlab_read_cifti, canlab_read_gifti + +% .. +% Author: CANlab, 2026. Part of the fmri_surface_data surface-data toolset. +% See docs/fmri_surface_data_design_plan.md. +% .. + + properties + + % Geometry source of truth (mirrors the CIFTI BrainModels structure). + % Replaces volInfo's role for surface + grayordinate data. See D3/D5b. + brain_model = []; + + % Attached CUSTOM mesh geometry (.vertices/.faces), populated only when + % the object is constructed from a .surf.gii that carries geometry (see + % from_gifti_struct). It is EMPTY for a data-only CIFTI (e.g. a dscalar) + % -- that is expected, not a bug: the standard meshes for a known + % surface_space (fs_LR-32k, fsaverage-164k) are NOT cached here; surface() + % and area computations load them on demand from bundled assets keyed by + % surface_space (see private/load_surface_geom). Use .geom only to carry + % a non-standard mesh that no surface_space keyword can reproduce. + geom = []; + + % CIFTI/GIFTI image type (a.k.a. CIFTI intent): dscalar | dtseries | dlabel | func | shape | label + imagetype = ''; + + % For .dtseries: struct with .start/.step/.unit/.exponent + series_info = []; + + % For .dlabel/.label: MATLAB table with variables key, name, rgba + label_table = []; + + % Canonical surface-space tag, e.g. 'fsLR_32k', 'fsaverage_164k' + surface_space = ''; + + % Optional same-length logical over grayordinates (or another + % fmri_surface_data on the same space). Lightweight: masking just + % zeros/selects rows -- no fmri_mask_image, no resampling (D5b). + mask = []; + + % --- Per-map (column) annotations: same names/roles as fmri_data --- + X % design / predictor matrix [nMaps x p] + Y = []; % outcomes [nMaps x q] + Y_names; + covariates; + covariate_names = {''}; + images_per_session; + metadata_table; + image_metadata; + additional_info = struct(); % free-form; also stashes source CIFTI xml/hdr + + end % properties + + methods + + function obj = fmri_surface_data(varargin) + + % Initialize defaults via the image_vector constructor + obj = obj@image_vector(); + obj.removed_voxels = false(0, 1); + obj.removed_images = false(0, 1); + + if nargin == 0, return; end + + % ---- Single positional argument: file / struct / object ---- + if nargin == 1 + a = varargin{1}; + + if ischar(a) || isstring(a) + obj = load_surface_file(obj, char(a)); + return + + elseif isstruct(a) + if isfield(a, 'cdata') && isfield(a, 'diminfo') + obj = from_cifti_struct(obj, a); % canlab_read_cifti output + elseif isfield(a, 'vertices') || isfield(a, 'faces') || isfield(a, 'cdata') + obj = from_gifti_struct(obj, a); % canlab_read_gifti output + else + obj = copy_matching_fields(obj, a); % plain struct of properties + end + return + + elseif isa(a, 'image_vector') + obj = recast_from_object(obj, a); + return + end + end + + % ---- key / value pairs ---- + obj = parse_keyvalue(obj, varargin); + + % If a filename was passed via 'fullpath' and we have no data, load it + if isempty(obj.dat) && ~isempty(obj.fullpath) && ischar(obj.fullpath) ... + && exist(obj.fullpath, 'file') + obj = load_surface_file(obj, obj.fullpath); + end + + end % constructor + + end % methods + + % --------------------------------------------------------------------- + % Volume-only image_vector methods that have no meaningful surface / + % grayordinate behavior. They are overridden here inside a (Hidden) block + % so that (a) they no longer clutter methods(obj) / tab-completion, and + % (b) calling one gives a clear message (pointing to the surface + % equivalent) instead of a cryptic volume-machinery error. The class + % remains a full subclass of image_vector; only these specific methods are + % masked. See docs/fmri_surface_data_methods.md ("Unsupported methods"). + % --------------------------------------------------------------------- + methods (Hidden) + function varargout = flip(varargin), unsupported_surface_method('flip'); varargout = {}; end + function varargout = isosurface(varargin), unsupported_surface_method('isosurface'); varargout = {}; end + function varargout = interpolate(varargin), unsupported_surface_method('interpolate'); varargout = {}; end + function varargout = resample_space(varargin), unsupported_surface_method('resample_space', 'resample_space (surface): use resample_space on to_fmri_data(obj) for the subcortex, or keep surface data in its native grayordinate space'); varargout = {}; end + function varargout = resample_space_simple_reference(varargin), unsupported_surface_method('resample_space_simple_reference'); varargout = {}; end + function varargout = resample_time(varargin), unsupported_surface_method('resample_time'); varargout = {}; end + function varargout = extract_gray_white_csf(varargin), unsupported_surface_method('extract_gray_white_csf'); varargout = {}; end + function varargout = slice_movie(varargin), unsupported_surface_method('slice_movie'); varargout = {}; end + function varargout = display_slices(varargin), unsupported_surface_method('display_slices'); varargout = {}; end + function varargout = rmssd_movie(varargin), unsupported_surface_method('rmssd_movie'); varargout = {}; end + function varargout = render_on_cerebellar_flatmap(varargin),unsupported_surface_method('render_on_cerebellar_flatmap'); varargout = {}; end + function varargout = rebuild_volinfo_from_dat(varargin), unsupported_surface_method('rebuild_volinfo_from_dat'); varargout = {}; end + function varargout = trim_mask(varargin), unsupported_surface_method('trim_mask'); varargout = {}; end + function varargout = define_space_mapping(varargin), unsupported_surface_method('define_space_mapping'); varargout = {}; end + function varargout = plot_current_orthviews_coord(varargin),unsupported_surface_method('plot_current_orthviews_coord'); varargout = {}; end + function varargout = read_from_file(varargin), unsupported_surface_method('read_from_file', 'read_from_file: construct with fmri_surface_data(filename) instead (CIFTI/GIFTI are read natively)'); varargout = {}; end + function varargout = check_image_filenames(varargin), unsupported_surface_method('check_image_filenames'); varargout = {}; end + function varargout = searchlight(varargin), unsupported_surface_method('searchlight'); varargout = {}; end + function varargout = extract_roi_averages(varargin), unsupported_surface_method('extract_roi_averages', 'extract_roi_averages (volume ROI): use apply_parcellation(obj, surface_atlas) for surface data'); varargout = {}; end + function varargout = subdivide_by_atlas(varargin), unsupported_surface_method('subdivide_by_atlas'); varargout = {}; end + function varargout = expand_into_atlas_subregions(varargin),unsupported_surface_method('expand_into_atlas_subregions'); varargout = {}; end + function varargout = table_of_atlas_regions_covered(varargin), unsupported_surface_method('table_of_atlas_regions_covered'); varargout = {}; end + function varargout = pattern_surf_plot_mip(varargin), unsupported_surface_method('pattern_surf_plot_mip'); varargout = {}; end + end + +end % classdef + + +% ========================================================================= +function unsupported_surface_method(name, custom_msg) +% Consistent, informative error for volume-only methods masked on surface data. +if nargin >= 2 && ~isempty(custom_msg) + hint = custom_msg; +else + hint = ['For cortical surface data use surface(obj) / render_on_surface; ' ... + 'for the subcortical volume use to_fmri_data(obj) and the fmri_data method.']; +end +error('fmri_surface_data:unsupportedMethod', ... + ['%s is a volume-only image_vector method and is not supported for ' ... + 'fmri_surface_data (surface/grayordinate) objects.\n%s'], name, hint); +end + + +% ========================================================================= +% Local helper functions (private to the constructor) +% ========================================================================= + +function obj = load_surface_file(obj, fname) +% Dispatch a file to the native CIFTI or GIFTI reader based on extension. +if exist(fname, 'file') ~= 2 + error('fmri_surface_data:notfound', 'File not found: %s', fname); +end +lc = lower(fname); +if endsWith(lc, '.gii') + g = canlab_read_gifti(fname); + obj = from_gifti_struct(obj, g); +elseif endsWith(lc, '.nii') || endsWith(lc, '.nii.gz') + cii = canlab_read_cifti(fname); + obj = from_cifti_struct(obj, cii); +else + error('fmri_surface_data:badext', ... + 'Unrecognized surface/grayordinate extension: %s (expected .nii CIFTI or .gii GIFTI).', fname); +end +obj.fullpath = fname; +end + + +% ------------------------------------------------------------------------- +function obj = from_cifti_struct(obj, cii) +% Build the object from a canlab_read_cifti output struct. +obj.dat = single(cii.cdata); +obj.imagetype = cii.intent; + +bm = cii.diminfo{1}; % dense dimension +if ~isfield(bm, 'cluster'), bm.cluster = []; end +bm.grayordinate_type = infer_grayord_type(bm); +obj.brain_model = bm; +obj.surface_space = infer_surface_space(bm); + +% Populate the inherited volInfo slot for the subcortical voxel sub-block only +% (empty for surface-only objects). brain_model remains the geometry truth. +obj.volInfo = build_volinfo_subblock(bm); + +% Maps (second) dimension -> per-map annotations +md = cii.diminfo{2}; +switch md.type + case 'scalars' + obj.image_names = reshape({md.maps.name}, [], 1); + case 'labels' + obj.image_names = reshape({md.maps.name}, [], 1); + obj.label_table = struct2labeltable(md.maps(1).table); + obj.additional_info.label_tables = {md.maps.table}; + case 'series' + obj.series_info = struct('start', md.seriesStart, 'step', md.seriesStep, ... + 'unit', md.seriesUnit, 'exponent', getfielddef(md, 'seriesExponent', 0)); + obj.image_names = arrayfun(@(k) sprintf('t%d', k), (1:size(obj.dat,2))', 'unif', 0); +end + +obj.removed_voxels = false(size(obj.dat, 1), 1); +obj.removed_images = false(size(obj.dat, 2), 1); + +% Stash source XML/header so write() can faithfully re-emit if unchanged +if isfield(cii, 'xml'), obj.additional_info.cifti_xml = cii.xml; end +if isfield(cii, 'hdr'), obj.additional_info.cifti_hdr = cii.hdr; end + +obj.history{end+1} = sprintf(['fmri_surface_data created from CIFTI (%s): ' ... + '%d grayordinates x %d maps, space=%s'], cii.intent, size(obj.dat,1), size(obj.dat,2), obj.surface_space); +end + + +% ------------------------------------------------------------------------- +function obj = from_gifti_struct(obj, g) +% Build from a canlab_read_gifti output struct (functional/label or geometry). +hasData = isfield(g, 'cdata') && ~isempty(g.cdata) && ~iscell(g.cdata); +hasGeom = isfield(g, 'vertices') && ~isempty(g.vertices); + +if hasData + obj.dat = single(g.cdata); + n = size(g.cdata, 1); + m = struct('struct', 'CORTEX', 'type', 'surf', 'start', 1, 'count', n, ... + 'numvert', n, 'vertlist', 0:n-1, 'voxlist', []); + bm = struct('type', 'dense', 'length', n, 'models', {{m}}, 'vol', []); + bm.grayordinate_type = 'cortex_only'; + bm.cluster = []; + obj.brain_model = bm; + obj.surface_space = infer_surface_space(bm); + obj.imagetype = 'func'; + if isfield(g, 'labels') && ~isempty(g.labels) + obj.label_table = struct2labeltable(g.labels); + obj.imagetype = 'label'; + end + if isfield(g, 'intents') && ~isempty(g.intents) + obj.image_names = reshape(g.intents, [], 1); + end + obj.removed_voxels = false(n, 1); + obj.removed_images = false(size(obj.dat, 2), 1); + obj.history{end+1} = sprintf('fmri_surface_data created from GIFTI (%s): %d vertices x %d maps', ... + obj.imagetype, n, size(obj.dat,2)); +end + +if hasGeom + % Store geometry; if this is a pure .surf.gii (no data) the object holds a mesh. + obj.geom = struct('vertices', g.vertices, 'faces', g.faces); + if ~hasData + obj.history{end+1} = sprintf('fmri_surface_data loaded surface geometry: %d vertices, %d faces', ... + size(g.vertices,1), size(g.faces,1)); + end +end +end + + +% ------------------------------------------------------------------------- +function obj = copy_matching_fields(obj, s) +p = properties(obj); +fn = fieldnames(s); +for i = 1:numel(fn) + if ismember(fn{i}, p) + obj.(fn{i}) = s.(fn{i}); + end +end +if ~isempty(obj.dat), obj.dat = single(obj.dat); end +obj = normalize_removed(obj); +end + + +% ------------------------------------------------------------------------- +function obj = normalize_removed(obj) +% Keep the vestigial removed_* vectors all-false and length-correct (D5b), +% whenever .dat is set via key-value / struct / recast construction. +if ~isempty(obj.dat) + if numel(obj.removed_voxels) ~= size(obj.dat, 1) + obj.removed_voxels = false(size(obj.dat, 1), 1); + end + if numel(obj.removed_images) ~= size(obj.dat, 2) + obj.removed_images = false(size(obj.dat, 2), 1); + end +end +end + + +% ------------------------------------------------------------------------- +function obj = recast_from_object(obj, other) +p = properties(obj); +op = properties(other); +for i = 1:numel(op) + if ismember(op{i}, p) + try, obj.(op{i}) = other.(op{i}); catch, end %#ok + end +end +if ~isempty(obj.dat), obj.dat = single(obj.dat); end +obj = normalize_removed(obj); +obj.history{end+1} = sprintf('Recast from %s', class(other)); +end + + +% ------------------------------------------------------------------------- +function obj = parse_keyvalue(obj, args) +p = properties(obj); +i = 1; +while i <= numel(args) + key = args{i}; + if (ischar(key) || isstring(key)) && ismember(char(key), p) + obj.(char(key)) = args{i+1}; + i = i + 2; + elseif (ischar(key) || isstring(key)) && (contains(char(key), '.nii') || contains(char(key), '.gii')) ... + && exist(char(key), 'file') + obj.fullpath = char(key); + i = i + 1; + else + warning('fmri_surface_data:badinput', 'Unknown field or argument: %s', char(string(key))); + i = i + 1; + end +end +if ~isempty(obj.dat), obj.dat = single(obj.dat); end +obj = normalize_removed(obj); +end + + +% ------------------------------------------------------------------------- +function s = infer_surface_space(bm) +% Infer a canonical surface-space tag from the per-hemisphere vertex count. +s = ''; +nv = NaN; +for i = 1:numel(bm.models) + if strcmp(bm.models{i}.type, 'surf') && ~isempty(bm.models{i}.numvert) ... + && ~isnan(bm.models{i}.numvert) + nv = bm.models{i}.numvert; break + end +end +switch nv + case 32492, s = 'fsLR_32k'; + case 163842, s = 'fsaverage_164k'; + case 40962, s = 'fsaverage6'; + case 10242, s = 'fsaverage5'; + case 2562, s = 'fsaverage4'; + otherwise + if ~isnan(nv), s = sprintf('surf_%dverts', nv); end +end +end + + +% ------------------------------------------------------------------------- +function t = infer_grayord_type(bm) +% '91k'-style grayordinate (surface + subcortical voxels) vs cortex-only. +hasvox = any(cellfun(@(m) strcmp(m.type, 'vox'), bm.models)); +hassurf = any(cellfun(@(m) strcmp(m.type, 'surf'), bm.models)); +if hasvox && hassurf + if bm.length == 91282, t = '91k'; + else, t = sprintf('grayordinate_%d', bm.length); + end +elseif hassurf + t = 'cortex_only'; +elseif hasvox + t = 'volume_only'; +else + t = 'unknown'; +end +end + + +% ------------------------------------------------------------------------- +function v = getfielddef(s, f, d) +if isfield(s, f) && ~isempty(s.(f)) && ~(isnumeric(s.(f)) && all(isnan(s.(f)(:)))) + v = s.(f); +else + v = d; +end +end diff --git a/CanlabCore/@fmri_surface_data/horzcat.m b/CanlabCore/@fmri_surface_data/horzcat.m new file mode 100644 index 00000000..7fdd1ff1 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/horzcat.m @@ -0,0 +1,18 @@ +function c = horzcat(varargin) +% horzcat Concatenate fmri_surface_data objects with [a, b, ...] syntax. +% +% :Usage: +% :: +% combined = [obj1, obj2, obj3]; +% +% Thin wrapper over cat; concatenates along the image (map) dimension. All +% objects must share the same grayordinate space. +% +% :See also: cat, fmri_surface_data + +if nargin == 1 + c = varargin{1}; + return +end +c = cat(varargin{1}, varargin{2:end}); +end diff --git a/CanlabCore/@fmri_surface_data/ica.m b/CanlabCore/@fmri_surface_data/ica.m new file mode 100644 index 00000000..a9687981 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/ica.m @@ -0,0 +1,36 @@ +function icadat = ica(obj, varargin) +% ica Spatial ICA decomposition of grayordinate data. +% +% :Usage: +% :: +% icadat = ica(obj, [number of ICs]) +% +% Runs independent component analysis on obj.dat (mirroring image_vector.ica) by +% delegating to a proxy and remapping the spatial component maps back to an +% fmri_surface_data, so the components can be surface()-rendered. +% +% DEPENDENCY: like image_vector.ica, this requires icatb_fastICA (the GIFT / +% GroupICA "icatb" toolbox) on the path. This is the one fmri_surface_data method +% that is not fully self-contained; all other methods need no external toolbox. +% +% :Inputs: +% **obj:** fmri_surface_data. +% **nic:** (optional) number of components to save (passed to image_vector.ica). +% +% :Outputs: +% **icadat:** fmri_surface_data whose maps are the spatial IC maps. +% +% :See also: predict, image_vector.ica, rebuild_like, fmri_surface_data + +proxy = as_fmri_data_proxy(obj); +comp_obj = ica(proxy, varargin{:}); + +if isa(comp_obj, 'image_vector') && size(comp_obj.dat, 1) == size(obj.dat, 1) + icadat = rebuild_like(obj, double(comp_obj.dat)); + icadat.image_names = arrayfun(@(k) sprintf('IC%d', k), 1:size(icadat.dat,2), ... + 'UniformOutput', false)'; + icadat.history{end+1} = sprintf('ica: %d spatial components', size(icadat.dat,2)); +else + icadat = comp_obj; % unexpected shape: pass through +end +end diff --git a/CanlabCore/@fmri_surface_data/isempty.m b/CanlabCore/@fmri_surface_data/isempty.m new file mode 100644 index 00000000..f596a0b7 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/isempty.m @@ -0,0 +1,13 @@ +function tf = isempty(obj) +% isempty True only when an fmri_surface_data has no grayordinate data. +% +% Overrides image_vector/isempty, which also treats an empty volInfo as "empty". +% A cortex-only surface object legitimately has an EMPTY volInfo (only the +% subcortical voxel sub-block populates volInfo; see design decision D3), so the +% inherited test wrongly reports a fully-populated cortical map as empty. Here an +% object is empty only when its .dat has no rows. +% +% :See also: fmri_surface_data, image_vector.isempty + +tf = builtin('isempty', obj.dat) || size(obj.dat, 1) == 0; +end diff --git a/CanlabCore/@fmri_surface_data/mean.m b/CanlabCore/@fmri_surface_data/mean.m new file mode 100644 index 00000000..eba6af9a --- /dev/null +++ b/CanlabCore/@fmri_surface_data/mean.m @@ -0,0 +1,28 @@ +function m = mean(obj, varargin) +% mean Average across maps/images of an fmri_surface_data, returning one map. +% +% :Usage: +% :: +% m = mean(obj) +% m = mean(obj, 'omitnan') +% +% Averages obj.dat across the image (column) dimension and returns a new +% single-map fmri_surface_data carrying the same geometry (via rebuild_like). +% Surface analogue of fmri_data.mean; no volume rebuild. +% +% :Optional Inputs: +% **'omitnan':** ignore NaNs in the average (default includes them). +% +% :Outputs: +% **m:** fmri_surface_data with one map (the mean across images). +% +% :See also: fmri_surface_data, rebuild_like + +flag = 'includenan'; +if any(strcmpi(varargin, 'omitnan')), flag = 'omitnan'; end + +mdat = mean(double(obj.dat), 2, flag); +m = rebuild_like(obj, mdat); +m.image_names = {'mean'}; +m.history{end+1} = sprintf('mean across %d maps', size(obj.dat, 2)); +end diff --git a/CanlabCore/@fmri_surface_data/montage.m b/CanlabCore/@fmri_surface_data/montage.m new file mode 100644 index 00000000..75c7e3c8 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/montage.m @@ -0,0 +1,31 @@ +function varargout = montage(obj, varargin) +% montage Slice montage of the SUBCORTICAL volume of a grayordinate object. +% +% :Usage: +% :: +% montage(surf_obj) +% +% A slice montage only applies to the volumetric (subcortical) grayordinates; +% cortical-surface data is rendered with surface(obj). This routes the +% subcortical grayordinates through to_fmri_data and calls image_vector/montage. +% +% :Inputs: +% **obj:** an fmri_surface_data object with subcortical (volume) grayordinates. +% +% :See also: surface, to_fmri_data, orthviews, image_vector.montage + +if ~local_has_volume(obj) + error('fmri_surface_data:montage:cortexonly', ... + ['montage shows volumetric slices, but this object is cortex-only ' ... + '(no subcortical grayordinates). Use surface(obj) to render the cortical surface.']); +end + +vol = to_fmri_data(obj); +[varargout{1:nargout}] = montage(vol, varargin{:}); +end + + +function tf = local_has_volume(obj) +tf = ~isempty(obj.brain_model) && ... + any(cellfun(@(m) strcmp(m.type, 'vox'), obj.brain_model.models)); +end diff --git a/CanlabCore/@fmri_surface_data/orthviews.m b/CanlabCore/@fmri_surface_data/orthviews.m new file mode 100644 index 00000000..69d49994 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/orthviews.m @@ -0,0 +1,33 @@ +function varargout = orthviews(obj, varargin) +% orthviews Show the SUBCORTICAL volume of a grayordinate object in SPM orthviews. +% +% :Usage: +% :: +% orthviews(surf_obj) +% +% Cortical-surface data has no voxel representation, so a 3-plane volume viewer +% only applies to the subcortical/volumetric grayordinates. This method routes +% those through to_fmri_data and calls the standard fmri_data/orthviews, so you +% see the subcortical structures on slices. To view the cortical surface, use +% surface(obj) instead. +% +% :Inputs: +% **obj:** an fmri_surface_data object with subcortical (volume) grayordinates. +% +% :See also: surface, to_fmri_data, montage, image_vector.orthviews + +if ~local_has_volume(obj) + error('fmri_surface_data:orthviews:cortexonly', ... + ['orthviews shows volumetric data, but this object is cortex-only ' ... + '(no subcortical grayordinates). Use surface(obj) to render the cortical surface.']); +end + +vol = to_fmri_data(obj); +[varargout{1:nargout}] = orthviews(vol, varargin{:}); +end + + +function tf = local_has_volume(obj) +tf = ~isempty(obj.brain_model) && ... + any(cellfun(@(m) strcmp(m.type, 'vox'), obj.brain_model.models)); +end diff --git a/CanlabCore/@fmri_surface_data/plot.m b/CanlabCore/@fmri_surface_data/plot.m new file mode 100644 index 00000000..d900a7bb --- /dev/null +++ b/CanlabCore/@fmri_surface_data/plot.m @@ -0,0 +1,71 @@ +function han = plot(obj, varargin) +% plot Quick QC plots for an fmri_surface_data object. +% +% :Usage: +% :: +% han = plot(obj) +% +% Surface analogue of fmri_data.plot: a compact QC panel with (1) a histogram of +% grayordinate values, (2) the per-map mean/standard-deviation across +% grayordinates, and (3) a native surface render of the mean map. Geometry- +% agnostic summaries operate directly on .dat. +% +% :Optional Inputs: +% **'norender':** skip the surface render panel (faster; no mesh load). +% +% :Outputs: +% **han:** struct with handles (.figure, and .surface for the render). +% +% :See also: surface, descriptives, fmri_surface_data + +dorender = ~any(strcmpi(varargin, 'norender')); + +d = double(obj.dat); +nGray = size(d, 1); +nMaps = size(d, 2); + +fig = figure('Color', 'w', 'Name', 'fmri_surface_data QC'); + +% (1) Histogram of all values +subplot(2, 2, 1); +vals = d(:); vals = vals(~isnan(vals)); +histogram(vals, 100); +title(sprintf('Value histogram (%d grayordinates x %d maps)', nGray, nMaps)); +xlabel('value'); ylabel('count'); axis tight; + +% (2) Per-map mean +/- sd +subplot(2, 2, 2); +mu = mean(d, 1, 'omitnan'); +sd = std(d, 0, 1, 'omitnan'); +errorbar(1:nMaps, mu, sd, 'o-', 'LineWidth', 1); +title('Per-map mean \pm sd across grayordinates'); +xlabel('map'); ylabel('value'); xlim([0.5 nMaps + 0.5]); grid on; + +% (3) Fraction nonzero per map (coverage) +subplot(2, 2, 3); +cov = mean(d ~= 0 & ~isnan(d), 1); +bar(1:nMaps, cov); +title('Fraction nonzero per map'); xlabel('map'); ylabel('fraction'); ylim([0 1]); + +han = struct('figure', fig, 'surface', []); + +% (4) Surface render of the mean map +if dorender && ~isempty(obj.brain_model) ... + && any(cellfun(@(m) strcmp(m.type,'surf'), obj.brain_model.models)) + try + mobj = mean(obj); + ax = subplot(2, 2, 4); + geom = load_surface_geom(obj.surface_space, 'inflated'); + hp = patch('Parent', ax, 'Faces', geom.faces_lh, 'Vertices', geom.vertices_lh, ... + 'EdgeColor', 'none', 'FaceColor', [.5 .5 .5], 'Tag', 'left'); + axis(ax, 'off', 'image', 'vis3d'); view(ax, 270, 0); + try, lightRestoreSingle(ax); catch, camlight(ax); end %#ok + material(ax, 'dull'); + render_on_surface(mobj, hp); + title(ax, 'Mean map (left lateral)'); + han.surface = hp; + catch err + warning('fmri_surface_data:plot:render', 'Surface render skipped: %s', err.message); + end +end +end diff --git a/CanlabCore/@fmri_surface_data/predict.m b/CanlabCore/@fmri_surface_data/predict.m new file mode 100644 index 00000000..3b159560 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/predict.m @@ -0,0 +1,52 @@ +function [cverr, stats, optout] = predict(obj, varargin) +% predict Cross-validated multivariate prediction on grayordinate data. +% +% :Usage: +% :: +% [cverr, stats, optout] = predict(obj, 'algorithm_name', 'cv_lassopcr', 'nfolds', 5) +% +% Runs CANlab's cross-validated prediction (fmri_data.predict) on surface/ +% grayordinate data. The algorithm operates on obj.dat and obj.Y exactly as for +% volumetric data, so all algorithms and options are supported unchanged; this +% method delegates to fmri_data.predict via a proxy (each grayordinate treated as +% a feature) and then remaps the geometry-bearing weight map back to an +% fmri_surface_data (so you can surface()/write() it). +% +% Set the outcome to predict in obj.Y before calling (as with fmri_data). +% +% :Inputs: +% **obj:** fmri_surface_data with obj.Y set (one value per map/observation). +% +% :Optional Inputs: +% All fmri_data.predict options ('algorithm_name', 'nfolds', 'error_type', ...). +% +% :Outputs: +% **cverr:** cross-validated error/accuracy (as fmri_data.predict). +% **stats:** stats struct; stats.weight_obj is remapped to an +% fmri_surface_data carrying the object's geometry. +% **optout:** optional algorithm outputs (passed through). +% +% :Examples: +% :: +% obj.Y = behavioral_scores(:); +% [err, stats] = predict(obj, 'algorithm_name', 'cv_lassopcr', 'nfolds', 5); +% surface(stats.weight_obj); % render the predictive weight map +% +% :See also: regress, fmri_data.predict, rebuild_like, fmri_surface_data + +if isempty(obj.Y) + error('fmri_surface_data:predict:noY', ... + 'Set obj.Y (one outcome value per map/observation) before calling predict.'); +end + +proxy = as_fmri_data_proxy(obj); + +[cverr, stats, optout] = predict(proxy, varargin{:}); + +% Remap the predictive weight map back to a surface object +if isfield(stats, 'weight_obj') && ~isempty(stats.weight_obj) ... + && size(stats.weight_obj.dat, 1) == size(obj.dat, 1) + stats.weight_obj = rebuild_like(obj, double(stats.weight_obj.dat)); + stats.weight_obj.image_names = {'predictive weights'}; +end +end diff --git a/CanlabCore/@fmri_surface_data/private/as_fmri_data_proxy.m b/CanlabCore/@fmri_surface_data/private/as_fmri_data_proxy.m new file mode 100644 index 00000000..9fb730ae --- /dev/null +++ b/CanlabCore/@fmri_surface_data/private/as_fmri_data_proxy.m @@ -0,0 +1,36 @@ +function proxy = as_fmri_data_proxy(obj) +% as_fmri_data_proxy Wrap a surface object as an fmri_data with a dummy 1-D volInfo. +% +% Lets geometry-agnostic fmri_data analysis methods (predict, ica, ttest, ...) be +% reused on grayordinate data: each grayordinate is treated as a "voxel" in a +% 1-D volume. The returned proxy carries the same .dat / .X / .Y / covariates so +% the analysis is identical; only spatial reconstruction (which the analysis does +% not need) is meaningless on the proxy. Map geometry-bearing results back to a +% surface object with rebuild_like. +% +% :Inputs: **obj:** an fmri_surface_data object. +% :Outputs: **proxy:** an fmri_data object [nGrayordinates x nMaps]. +% +% :See also: predict, ica, ttest, rebuild_like, fmri_surface_data + +nGray = size(obj.dat, 1); + +vi = struct('mat', eye(4), 'dim', [nGray 1 1], 'dt', [16 0], ... + 'xyzlist', [(1:nGray)' ones(nGray,1) ones(nGray,1)], 'nvox', nGray, ... + 'image_indx', true(nGray,1), 'wh_inmask', (1:nGray)', 'n_inmask', nGray, 'fname', ''); + +iv = image_vector; +iv.volInfo = vi; +iv.dat = double(obj.dat); +iv.removed_voxels = false(nGray, 1); +iv.removed_images = false(size(obj.dat, 2), 1); +iv.image_names = obj.image_names; + +proxy = fmri_data(iv); + +% Carry the per-map analysis annotations +if ~isempty(obj.Y), proxy.Y = obj.Y; end +if ~isempty(obj.X), try, proxy.X = obj.X; catch, end; end %#ok +if ~isempty(obj.covariates), proxy.covariates = obj.covariates; end +if ~isempty(obj.Y_names), proxy.Y_names = obj.Y_names; end +end diff --git a/CanlabCore/@fmri_surface_data/private/build_volinfo_subblock.m b/CanlabCore/@fmri_surface_data/private/build_volinfo_subblock.m new file mode 100644 index 00000000..c7297b88 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/private/build_volinfo_subblock.m @@ -0,0 +1,62 @@ +function [volInfo, rows] = build_volinfo_subblock(bm) +% Build an SPM-style volInfo describing ONLY the subcortical/volumetric voxel +% models of a CIFTI brain_model, plus the grayordinate row indices they occupy. +% +% Used by fmri_surface_data to (a) populate the inherited .volInfo slot for the +% volume sub-block (so volInfo-aware code and to_fmri_data work) and (b) export +% the subcortex to an fmri_data object. Returns empty if there is no volume part. +% +% :Inputs: +% **bm:** a brain_model struct (fmri_surface_data.brain_model / CIFTI diminfo{1}), +% with .models{i} (.type 'surf'|'vox', .start, .count, .voxlist 3xN 0-based) +% and .vol (.dims, .sform). +% +% :Outputs: +% **volInfo:** struct (.mat 1-based SPM affine, .dim, .dt, .xyzlist Nx3 1-based, +% .nvox, .image_indx, .wh_inmask, .n_inmask, .fname). [] if no volume. +% **rows:** column vector of row indices into the full grayordinate .dat that +% correspond, in order, to volInfo.xyzlist / volInfo.wh_inmask. +% +% :See also: fmri_surface_data, to_fmri_data, reconstruct_image, extract_vol_from_cifti + +volInfo = []; +rows = []; + +if isempty(bm) || ~isfield(bm, 'vol') || isempty(bm.vol) + return +end +voxmodels = find(cellfun(@(m) strcmp(m.type, 'vox'), bm.models)); +if isempty(voxmodels) + return +end + +dims = bm.vol.dims(:)'; + +% CIFTI sform maps 0-based IJK -> mm. SPM .mat maps 1-based voxel -> mm. +% Convert: mat(:,4) = sform(:,4) - sform(:,1:3)*[1;1;1]. +affine = bm.vol.sform; +affine(:, 4) = affine(:, 4) - sum(affine(:, 1:3), 2); + +allvox = zeros(0, 3); % Nx3, 1-based IJK, in grayordinate (voxlist) order +rows = zeros(0, 1); +for k = voxmodels(:)' + m = bm.models{k}; + vx = m.voxlist; % 3xN, 0-based + allvox = [allvox; vx' + 1]; %#ok Nx3, 1-based + rows = [rows; (m.start:(m.start + m.count - 1))']; %#ok +end + +ind = sub2ind(dims, allvox(:, 1), allvox(:, 2), allvox(:, 3)); % linear, voxlist order + +volInfo = struct(); +volInfo.mat = affine; +volInfo.dim = dims; +volInfo.dt = [16 0]; +volInfo.xyzlist = allvox; +volInfo.nvox = prod(dims); +volInfo.image_indx = false(prod(dims), 1); +volInfo.image_indx(ind) = true; +volInfo.wh_inmask = ind; % aligned with xyzlist / rows (NOT sorted) +volInfo.n_inmask = size(allvox, 1); +volInfo.fname = ''; +end diff --git a/CanlabCore/@fmri_surface_data/private/labeltable2struct.m b/CanlabCore/@fmri_surface_data/private/labeltable2struct.m new file mode 100644 index 00000000..19ee2a77 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/private/labeltable2struct.m @@ -0,0 +1,28 @@ +function s = labeltable2struct(T) +% labeltable2struct Convert a label_table (MATLAB table) to a struct array. +% +% Inverse of struct2labeltable: turns the fmri_surface_data.label_table MATLAB +% table (variables key, name, rgba) back into the struct array (.key, .name, +% .rgba) that the native CIFTI/GIFTI writers expect. Passes an existing struct +% array through unchanged, so callers can accept either format. +% +% :See also: struct2labeltable, write, apply_parcellation + +if isstruct(T) + s = T; + return +end + +if isempty(T) || height(T) == 0 + s = struct('key', {}, 'name', {}, 'rgba', {}); + return +end + +n = height(T); +s = struct('key', cell(1, n), 'name', cell(1, n), 'rgba', cell(1, n)); +for i = 1:n + s(i).key = T.key(i); + s(i).name = char(T.name(i)); + s(i).rgba = T.rgba(i, :); +end +end diff --git a/CanlabCore/@fmri_surface_data/private/load_surface_geom.m b/CanlabCore/@fmri_surface_data/private/load_surface_geom.m new file mode 100644 index 00000000..ec279043 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/private/load_surface_geom.m @@ -0,0 +1,78 @@ +function geom = load_surface_geom(surface_space, surftype) +% load_surface_geom Load bundled L/R cortical meshes for a surface space. +% +% Returns faces/vertices for both hemispheres of a standard surface, loaded from +% the meshes bundled in CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/. +% No gifti toolbox is required (.mat meshes are load()ed; .surf.gii is read with +% canlab_read_gifti). +% +% :Inputs: +% **surface_space:** 'fsLR_32k' (32492 verts/hemi) or 'fsaverage_164k' (163842). +% **surftype:** 'inflated' (default), 'midthickness', 'sphere', 'veryinflated'. +% +% :Outputs: +% **geom:** struct with .vertices_lh/.faces_lh/.vertices_rh/.faces_rh (faces +% 1-based), .space, .surftype, .numvert. +% +% :See also: surface, fmri_surface_data, add_surface + +if nargin < 2 || isempty(surftype), surftype = 'inflated'; end + +switch surface_space + case 'fsLR_32k' + switch lower(surftype) + case {'inflated','veryinflated'} + fL = 'S12000.L.inflated_MSMAll.32k_fsl_LR.mat'; + fR = 'S12000.R.inflated_MSMAll.32k_fsl_LR.mat'; + case 'midthickness' + fL = 'S1200.L.midthickness_MSMAll.32k_fs_LR.surf.gii'; + fR = 'S1200.R.midthickness_MSMAll.32k_fs_LR.surf.gii'; + case 'sphere' + fL = 'S1200.L.sphere.32k_fs_LR.mat'; + fR = 'S1200.R.sphere.32k_fs_LR.mat'; + otherwise + error('load_surface_geom:surftype', 'Unknown fsLR_32k surftype: %s', surftype); + end + nv = 32492; + + case 'fsaverage_164k' + switch lower(surftype) + case {'inflated','veryinflated'} + fL = 'surf_freesurf_inflated_Left.mat'; + fR = 'surf_freesurf_inflated_Right.mat'; + otherwise + error('load_surface_geom:surftype', ... + 'fsaverage_164k currently supports surftype ''inflated'' only (got %s).', surftype); + end + nv = 163842; + + otherwise + error('load_surface_geom:space', ... + ['No bundled mesh for surface_space ''%s''. Supported: fsLR_32k, ' ... + 'fsaverage_164k. (vol2surf produces fsaverage_164k; native CIFTI is fsLR_32k.)'], ... + surface_space); +end + +[vL, faL] = local_load_mesh(fL); +[vR, faR] = local_load_mesh(fR); + +geom = struct('vertices_lh', vL, 'faces_lh', faL, ... + 'vertices_rh', vR, 'faces_rh', faR, ... + 'space', surface_space, 'surftype', surftype, 'numvert', nv); +end + + +% ------------------------------------------------------------------------- +function [vertices, faces] = local_load_mesh(fname) +p = which(fname); +if isempty(p), error('load_surface_geom:notfound', 'Mesh file not found on path: %s', fname); end +if endsWith(lower(p), '.gii') + g = canlab_read_gifti(p); + vertices = g.vertices; + faces = g.faces; % already 1-based +else + S = load(p); + vertices = S.vertices; + faces = S.faces; % .mat meshes store 1-based faces +end +end diff --git a/CanlabCore/@fmri_surface_data/private/obj_to_volume.m b/CanlabCore/@fmri_surface_data/private/obj_to_volume.m new file mode 100644 index 00000000..2cfb3694 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/private/obj_to_volume.m @@ -0,0 +1,15 @@ +function vol = obj_to_volume(obj) +% obj_to_volume Convert a surface/grayordinate object to a volumetric fmri_data. +% +% Used by rendering when the target is an arbitrary MNI surface (e.g. an addbrain +% pial surface) rather than the object's own mesh: the data is first projected to +% a volume so the standard image_vector.render_on_surface (which samples a volume +% at patch vertices) can be reused. +% +% Delegates to the public to_display_volume, which handles any surface space +% (fs_LR is resampled to fsaverage first) and merges cortex + subcortex. +% +% :See also: to_display_volume, surf2vol, to_fmri_data, surface, render_on_surface + +vol = to_display_volume(obj); +end diff --git a/CanlabCore/@fmri_surface_data/private/struct2labeltable.m b/CanlabCore/@fmri_surface_data/private/struct2labeltable.m new file mode 100644 index 00000000..bfa96ff9 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/private/struct2labeltable.m @@ -0,0 +1,33 @@ +function T = struct2labeltable(s) +% struct2labeltable Convert a CIFTI/GIFTI label struct array to a MATLAB table. +% +% The native readers return a label table as a struct array with fields +% .key (integer), .name (char), .rgba (1x4). The fmri_surface_data.label_table +% property stores this as a MATLAB table with variables key (double), name +% (string), rgba (Nx4 double). This helper converts struct -> table (and passes +% an existing table through unchanged). +% +% :See also: labeltable2struct, fmri_surface_data, canlab_read_cifti + +if istable(s) + T = s; + return +end + +if isempty(s) + T = table('Size', [0 3], 'VariableTypes', {'double', 'string', 'double'}, ... + 'VariableNames', {'key', 'name', 'rgba'}); + return +end + +n = numel(s); +key = reshape([s.key], [], 1); +name = strings(n, 1); +rgba = nan(n, 4); +for i = 1:n + name(i) = string(s(i).name); + c = s(i).rgba(:)'; + rgba(i, 1:numel(c)) = c; +end +T = table(key, name, rgba, 'VariableNames', {'key', 'name', 'rgba'}); +end diff --git a/CanlabCore/@fmri_surface_data/rebuild_like.m b/CanlabCore/@fmri_surface_data/rebuild_like.m new file mode 100644 index 00000000..2f354d2b --- /dev/null +++ b/CanlabCore/@fmri_surface_data/rebuild_like.m @@ -0,0 +1,49 @@ +function newobj = rebuild_like(obj, newdat) +% rebuild_like Build a new fmri_surface_data carrying obj's geometry, with new data. +% +% :Usage: +% :: +% newobj = rebuild_like(obj, newdat) +% +% Helper used by data-transforming methods (mean, ica, predict, threshold, ...) +% to wrap a new [grayordinates x maps] data matrix back into an +% fmri_surface_data that carries the original brain_model / geom / intent / +% surface_space -- the surface analogue of fmri_data's volInfo-based re-wrap. +% This avoids the inherited image_vector/fmri_data rebuild that would emit a +% volume object. +% +% The new data must have the same number of grayordinate rows as obj (the +% geometry is unchanged); the number of maps (columns) may differ. Per-map +% annotations (image_names/X/Y/...) are kept only if the map count is unchanged. +% +% :Inputs: +% **obj:** template fmri_surface_data object (geometry source). +% **newdat:** [nGrayordinates x K] numeric data. +% +% :Outputs: +% **newobj:** fmri_surface_data with .dat = single(newdat), same geometry. +% +% :See also: fmri_surface_data, mean, reconstruct_image + +if size(newdat, 1) ~= size(obj.dat, 1) + error('fmri_surface_data:rebuild_like:rowmismatch', ... + ['newdat has %d rows but the object has %d grayordinates. rebuild_like ' ... + 'preserves geometry; row count must match.'], size(newdat,1), size(obj.dat,1)); +end + +newobj = obj; +newobj.dat = single(newdat); + +K = size(newdat, 2); +newobj.removed_voxels = false(size(newdat, 1), 1); +newobj.removed_images = false(K, 1); + +% Drop per-map annotations if the number of maps changed (they no longer align) +if K ~= size(obj.dat, 2) + newobj.image_names = {}; + newobj.X = []; + newobj.Y = []; + newobj.covariates = []; + newobj.metadata_table = []; +end +end diff --git a/CanlabCore/@fmri_surface_data/reconstruct_image.m b/CanlabCore/@fmri_surface_data/reconstruct_image.m new file mode 100644 index 00000000..181490da --- /dev/null +++ b/CanlabCore/@fmri_surface_data/reconstruct_image.m @@ -0,0 +1,72 @@ +function out = reconstruct_image(obj) +% reconstruct_image Reconstruct surface + volume data from a grayordinate object. +% +% :Usage: +% :: +% out = reconstruct_image(surf_obj) +% +% Reconstructs the flat [grayordinates x maps] .dat back into its natural spatial +% representations: dense per-hemisphere vertex arrays for the cortical surface +% models (with the medial wall filled with NaN), and a 3-D/4-D volume for the +% subcortical voxel models. Unlike image_vector.reconstruct_image (which assumes +% a single volumetric space), this returns a struct, because grayordinate data +% spans surface and volume spaces. +% +% No replace_empty pre-step is needed: .dat is always the full grayordinate set +% (decision D5b). +% +% :Inputs: +% **obj:** an fmri_surface_data object. +% +% :Outputs: +% **out:** struct with fields (present only when that model exists): +% . dense [numvert x nMaps] per surface model, lowercased +% (e.g. .cortex_left, .cortex_right); medial wall = NaN. +% .volume [X x Y x Z x nMaps] subcortical volume (NaN outside). +% .volume_volInfo the SPM-style volInfo for .volume (1-based affine). +% .models the brain_model.models list (for reference). +% +% :Examples: +% :: +% s = fmri_surface_data(which('transcriptomic_gradients.dscalar.nii')); +% r = reconstruct_image(s); +% size(r.cortex_left) % [32492 x nMaps], medial wall NaN +% size(r.volume) % [91 109 91 x nMaps] +% +% :See also: fmri_surface_data, to_fmri_data, surface, render_on_surface + +bm = obj.brain_model; +if isempty(bm) + error('fmri_surface_data:reconstruct_image:nobm', 'Object has no brain_model.'); +end + +nMaps = size(obj.dat, 2); +out = struct(); +out.models = bm.models; + +% ---- Surface models -> dense per-hemisphere vertex arrays (medial wall NaN) ---- +for i = 1:numel(bm.models) + m = bm.models{i}; + if ~strcmp(m.type, 'surf'), continue; end + dense = nan(m.numvert, nMaps); + rows = m.start:(m.start + m.count - 1); + dense(m.vertlist + 1, :) = obj.dat(rows, :); % vertlist is 0-based + fld = matlab.lang.makeValidName(lower(m.struct)); + out.(fld) = dense; +end + +% ---- Volume models -> 3-D/4-D volume ---- +[vi, rows] = build_volinfo_subblock(bm); +if ~isempty(vi) && ~isempty(rows) + dims = vi.dim; + vol = nan([dims nMaps]); + nv = prod(dims); + for k = 1:nMaps + v = nan(nv, 1); + v(vi.wh_inmask) = obj.dat(rows, k); + vol(:, :, :, k) = reshape(v, dims); + end + out.volume = vol; + out.volume_volInfo = vi; +end +end diff --git a/CanlabCore/@fmri_surface_data/regress.m b/CanlabCore/@fmri_surface_data/regress.m new file mode 100644 index 00000000..dd8804e0 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/regress.m @@ -0,0 +1,75 @@ +function bobj = regress(obj, varargin) +% regress Grayordinate-wise OLS regression of the maps onto a design matrix. +% +% :Usage: +% :: +% bobj = regress(obj) % uses obj.X +% bobj = regress(obj, X) % explicit design matrix +% +% Fits an ordinary least-squares regression at each grayordinate: for each +% grayordinate, the values across maps (columns of obj.dat) are regressed onto +% the design matrix X ([nMaps x p], one row per map/observation). Returns an +% fmri_surface_data whose maps are the p regression coefficients, with the +% t-statistics, p-values, and standard errors in .additional_info.statistic. +% +% This is a lightweight native implementation (no external toolbox). For the full +% GLM machinery (contrasts, diagnostics), export the volumetric part with +% to_fmri_data and use fmri_data.regress / glm_map. +% +% :Inputs: +% **obj:** fmri_surface_data with multiple maps. +% **X:** (optional) [nMaps x p] design matrix. Default: obj.X. An intercept +% is NOT added automatically -- include a column of ones if desired. +% +% :Outputs: +% **bobj:** fmri_surface_data; .dat is [nGrayordinates x p] coefficients. +% .additional_info.statistic has .t, .p, .se, .dfe (each [nGray x p] +% or scalar dfe), .betas. +% +% :Examples: +% :: +% obj.X = [ones(n,1) age(:)]; % intercept + age +% b = regress(obj); +% surface(get_wh_image(b, 2)); % render the age effect (2nd coefficient) +% +% :See also: ttest, predict, to_fmri_data, fmri_data.regress + +if nargin >= 2 && ~isempty(varargin{1}) && isnumeric(varargin{1}) + X = varargin{1}; +else + X = obj.X; +end +if isempty(X) + error('fmri_surface_data:regress:noX', ... + 'No design matrix. Set obj.X or pass X as the second argument.'); +end + +nMaps = size(obj.dat, 2); +if size(X, 1) ~= nMaps + error('fmri_surface_data:regress:size', ... + 'Design matrix has %d rows but the object has %d maps.', size(X,1), nMaps); +end + +p = size(X, 2); +dfe = nMaps - p; +if dfe < 1 + error('fmri_surface_data:regress:df', 'Not enough maps (%d) for %d regressors.', nMaps, p); +end + +Y = double(obj.dat).'; % [nMaps x nGray] +B = X \ Y; % [p x nGray] +resid = Y - X * B; +sigma2 = sum(resid.^2, 1) / dfe; % [1 x nGray] +XtXinv = inv(X' * X); %#ok small p x p +se = sqrt(diag(XtXinv) * sigma2); % [p x nGray] +tvals = B ./ se; % [p x nGray] +pvals = 2 * tcdf(-abs(tvals), dfe); + +bobj = rebuild_like(obj, B.'); % [nGray x p] coefficients +bobj.imagetype = 'dscalar'; +bobj.image_names = arrayfun(@(k) sprintf('beta%d', k), 1:p, 'UniformOutput', false)'; + +stat = struct('betas', B.', 't', tvals.', 'p', pvals.', 'se', se.', 'dfe', dfe); +bobj.additional_info.statistic = stat; +bobj.history{end+1} = sprintf('regress: OLS on %d-column design, %d maps (betas in .dat; t/p in additional_info.statistic)', p, nMaps); +end diff --git a/CanlabCore/@fmri_surface_data/remove_empty.m b/CanlabCore/@fmri_surface_data/remove_empty.m new file mode 100644 index 00000000..2497a6b6 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/remove_empty.m @@ -0,0 +1,26 @@ +function obj = remove_empty(obj, varargin) +% remove_empty (fmri_surface_data): no-op. Grayordinate data is already compact. +% +% :Usage: +% :: +% obj = remove_empty(obj) +% +% Unlike image_vector/fmri_data -- which store sparse 3-D volumes and drop +% all-zero / NaN voxels to save space -- fmri_surface_data holds CIFTI +% grayordinate data, which is already compact (the medial wall is excluded by +% construction and there are no out-of-brain voxels). The .dat matrix is ALWAYS +% the full [nGrayordinates x nMaps] set, in 1:1 row correspondence with +% .brain_model, and is never squeezed. +% +% This override therefore returns the object UNCHANGED. It exists so that (a) +% inherited methods that call remove_empty internally still compose, and (b) +% user code that calls remove_empty by habit gets a valid object back. The +% .removed_voxels / .removed_images vectors stay all-false. +% +% See docs/fmri_surface_data_design_plan.md (decision D5b). +% +% :See also: replace_empty, fmri_surface_data, get_wh_image + +% Intentionally a no-op. (varargin accepted and ignored for signature parity +% with image_vector/remove_empty.) +end diff --git a/CanlabCore/@fmri_surface_data/render_on_surface.m b/CanlabCore/@fmri_surface_data/render_on_surface.m new file mode 100644 index 00000000..1d5791b1 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/render_on_surface.m @@ -0,0 +1,193 @@ +function out_handles = render_on_surface(obj, surface_handles, varargin) +% render_on_surface Color existing surface patches with grayordinate data. +% +% :Usage: +% :: +% render_on_surface(surf_obj, surface_handles) +% render_on_surface(surf_obj, surface_handles, 'colormap', 'hot', 'cmaprange', [0 5]) +% render_on_surface(surf_obj, surface_handles, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]) +% +% Colors one or more existing surface patch handles using an fmri_surface_data +% object's per-vertex values. For each patch: +% - If the patch has the same vertex count as one of the object's cortical +% hemispheres (e.g. the matching fs_LR / fsaverage inflated, midthickness, or +% sphere mesh from addbrain), the per-vertex values are applied DIRECTLY +% (no resampling) -- true native-space rendering. Left/right is resolved by +% the patch Tag ('left'/'right') when present, else by vertex count. +% - Otherwise (an arbitrary MNI surface, e.g. addbrain('left')), the object is +% projected to a volume (surf2vol / to_fmri_data) and the standard +% image_vector.render_on_surface is reused to sample the volume. +% +% Color options are harmonized with the volume visualization pipeline +% (render_on_surface / addblobs / set_colormap), so the same keywords color +% surface data and volume blobs. +% +% :Inputs: +% **obj:** an fmri_surface_data object. +% **surface_handles:** vector of patch handles (e.g. from addbrain or surface). +% +% :Optional Inputs (color, matching the volume pipeline): +% **'which_image':** map (column) to render. Default 1. +% **'clim' / 'cmaprange':** [lo hi] color limits. Default symmetric from data. +% **'colormap' / 'colormapname':** a named MATLAB colormap ('hot','parula',...) +% or an [n x 3] matrix -> a single (sequential) colormap over clim. +% **'pos_colormap' / 'neg_colormap':** [n x 3] split colormaps (default hot / cool). +% **'splitcolor':** {neg_low, neg_high, pos_low, pos_high} colors -> split map. +% **'maxcolor' / 'mincolor':** endpoint colors -> a single gradient over clim. +% **'color' / 'solid':** a single solid color for all in-data vertices. +% +% :Outputs: +% **out_handles:** the surface handles (colored in place). +% +% :See also: surface, canlab_surface_vertexcolors, image_vector.render_on_surface + +which_image = 1; +clim = []; +poscm = []; +negcm = []; +cmap_single = []; % single sequential colormap over clim +splitcolors = []; % {neg_low neg_high pos_low pos_high} +maxcolor = []; +mincolor = []; +solidcolor = []; % single solid color for in-data vertices +coloropts = {}; % color options to forward to the volume renderer +i = 1; +passthrough = {}; +while i <= numel(varargin) + key = varargin{i}; + if ischar(key) || isstring(key) + switch lower(char(key)) + case 'which_image', which_image = varargin{i+1}; i = i + 2; continue + case {'clim','cmaprange'}, clim = varargin{i+1}; coloropts = [coloropts {'clim', clim}]; i = i + 2; continue %#ok + case 'pos_colormap', poscm = varargin{i+1}; coloropts = [coloropts {'pos_colormap', poscm}]; i = i + 2; continue %#ok + case 'neg_colormap', negcm = varargin{i+1}; coloropts = [coloropts {'neg_colormap', negcm}]; i = i + 2; continue %#ok + case {'colormap','colormapname'}, cmap_single = local_resolve_cmap(varargin{i+1}); coloropts = [coloropts {'colormap', varargin{i+1}}]; i = i + 2; continue %#ok + case 'splitcolor', splitcolors = varargin{i+1}; coloropts = [coloropts {'splitcolor', splitcolors}]; i = i + 2; continue %#ok + case 'maxcolor', maxcolor = varargin{i+1}; i = i + 2; continue + case 'mincolor', mincolor = varargin{i+1}; i = i + 2; continue + case {'color','solid','onecolor'}, solidcolor = varargin{i+1}; coloropts = [coloropts {'color', solidcolor}]; i = i + 2; continue %#ok + end + end + passthrough{end+1} = varargin{i}; %#ok + i = i + 1; +end + +% Dense per-hemisphere data (medial wall = NaN) +r = reconstruct_image(obj); +Ldat = []; Rdat = []; +if isfield(r, 'cortex_left'), Ldat = r.cortex_left(:, which_image); end +if isfield(r, 'cortex_right'), Rdat = r.cortex_right(:, which_image); end +nL = numel(Ldat); nR = numel(Rdat); + +% Color limits shared across hemispheres +if isempty(clim) + allv = [Ldat; Rdat]; + allv = allv(~isnan(allv) & allv ~= 0); + m = max(abs(allv)); if isempty(m) || m == 0, m = 1; end + clim = [-m m]; +end + +% Resolve the color spec from the harmonized options +spec = struct('mode', 'split', 'clim', clim, 'poscm', poscm, 'negcm', negcm, ... + 'cmap', cmap_single, 'solid', solidcolor); +if ~isempty(solidcolor) + spec.mode = 'solid'; +elseif ~isempty(maxcolor) && ~isempty(mincolor) + spec.mode = 'single'; + spec.cmap = local_interp_cmap(mincolor, maxcolor, 256); + coloropts = [coloropts {'colormap', spec.cmap}]; +elseif ~isempty(cmap_single) + spec.mode = 'single'; +elseif ~isempty(splitcolors) && numel(splitcolors) >= 4 + spec.negcm = local_interp_cmap(splitcolors{1}, splitcolors{2}, 256); + spec.poscm = local_interp_cmap(splitcolors{3}, splitcolors{4}, 256); +end + +for h = 1:numel(surface_handles) + hp = surface_handles(h); + nv = size(get(hp, 'Vertices'), 1); + tag = ''; + try, tag = lower(get(hp, 'Tag')); catch, end %#ok + + isLeft = contains(tag, 'left') || contains(tag, ' l ') || endsWith(tag, ' l'); + isRight = contains(tag, 'right') || endsWith(tag, ' r'); + + if nv == nL && (isLeft || (~isRight && ~isempty(Ldat))) + local_apply(hp, Ldat, spec); + elseif nv == nR && (isRight || ~isempty(Rdat)) + local_apply(hp, Rdat, spec); + else + % Arbitrary surface: project to a volume and reuse the image_vector + % renderer, forwarding the harmonized color options. + vol = obj_to_volume(obj); + if which_image > 1 && size(vol.dat, 2) >= which_image + vol = get_wh_image(vol, which_image); + end + render_on_surface(vol, surface_handles, coloropts{:}, passthrough{:}); + out_handles = surface_handles; + return + end +end + +out_handles = surface_handles; +end + + +% ------------------------------------------------------------------------- +function local_apply(hp, vals, spec) +rgb = local_vertex_rgb(vals, spec); +set(hp, 'FaceVertexCData', rgb, 'FaceColor', 'interp', 'EdgeColor', 'none', 'FaceAlpha', 1); +end + + +function rgb = local_vertex_rgb(vals, spec) +vals = double(vals(:)); +N = numel(vals); +graycolor = [.5 .5 .5]; + +switch spec.mode + case 'solid' + rgb = repmat(graycolor, N, 1); + indata = ~isnan(vals) & vals ~= 0; + rgb(indata, :) = repmat(spec.solid(:)', nnz(indata), 1); + + case 'single' + cmap = spec.cmap; + n = size(cmap, 1); + rgb = repmat(graycolor, N, 1); + lo = spec.clim(1); hi = spec.clim(2); + if hi == lo, hi = lo + 1; end + ok = ~isnan(vals); + idx = round(1 + (vals(ok) - lo) / (hi - lo) * (n - 1)); + idx = min(max(idx, 1), n); + rgb(ok, :) = cmap(idx, :); + + otherwise % 'split' (default): hot for +, cool for -, gray for 0/NaN + rgb = canlab_surface_vertexcolors(vals, spec.clim, spec.poscm, spec.negcm); +end +end + + +function cmap = local_resolve_cmap(c) +if isnumeric(c) && size(c, 2) == 3 + cmap = c; +elseif ischar(c) || isstring(c) + try + cmap = feval(char(c), 256); + catch + error('fmri_surface_data:render_on_surface:colormap', ... + 'Unknown colormap "%s". Use a MATLAB colormap name or an [n x 3] matrix.', char(c)); + end +else + error('fmri_surface_data:render_on_surface:colormap', ... + 'colormap must be a name or an [n x 3] matrix.'); +end +end + + +function cmap = local_interp_cmap(clo, chi, n) +clo = double(clo(:))'; +chi = double(chi(:))'; +t = linspace(0, 1, n)'; +cmap = (1 - t) .* clo + t .* chi; +end diff --git a/CanlabCore/@fmri_surface_data/reparse_contiguous.m b/CanlabCore/@fmri_surface_data/reparse_contiguous.m new file mode 100644 index 00000000..002b6792 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/reparse_contiguous.m @@ -0,0 +1,91 @@ +function [obj, ncl] = reparse_contiguous(obj, varargin) +% reparse_contiguous Label contiguous clusters of grayordinates (mesh + volume). +% +% :Usage: +% :: +% [obj, ncl] = reparse_contiguous(obj) +% [obj, ncl] = reparse_contiguous(obj, 'which_image', 2) +% +% Finds connected components among the "active" (nonzero, non-NaN) grayordinates +% and stores an integer cluster label per grayordinate in obj.brain_model.cluster +% (0 = not in any cluster). For cortical-surface models, contiguity is computed +% on the cortical mesh edge graph (using the bundled mesh for the object's +% surface_space and MATLAB's built-in graph/conncomp -- no external toolbox). For +% subcortical voxel models, contiguity is 26-connected in the volume (spm_clusters). +% +% This is the surface analogue of image_vector.reparse_contiguous and is the +% basis for the region() conversion (a later milestone). +% +% :Optional Inputs: +% **'which_image':** which map (column) defines "active" grayordinates. Default 1. +% +% :Outputs: +% **obj:** the object with obj.brain_model.cluster populated [nGrayordinates x 1]. +% **ncl:** total number of clusters found. +% +% :See also: fmri_surface_data, load_surface_geom, region, reconstruct_image + +which_image = 1; +for i = 1:2:numel(varargin) + switch lower(varargin{i}) + case 'which_image', which_image = varargin{i+1}; + otherwise, error('reparse_contiguous:badopt', 'Unknown option: %s', varargin{i}); + end +end + +d = double(obj.dat(:, which_image)); +active = d ~= 0 & ~isnan(d); +cluster = zeros(size(obj.dat, 1), 1); +offset = 0; + +models = obj.brain_model.models; + +for mi = 1:numel(models) + m = models{mi}; + rows = (m.start:(m.start + m.count - 1))'; + act_local = active(rows); + if ~any(act_local), continue; end + + if strcmp(m.type, 'surf') + F = local_hemi_faces(obj.surface_space, m.struct); + G = local_mesh_graph(F, m.numvert); + meshverts = m.vertlist(:) + 1; % 1-based, aligned with rows + active_nodes = meshverts(act_local); + comp = conncomp(subgraph(G, active_nodes)); % component id per active node + cluster(rows(act_local)) = comp(:) + offset; + offset = offset + max(comp); + + else % voxel model: 26-connected components in the volume + ijk = m.voxlist(:, act_local); % 3 x nactive (0-based) + if isempty(ijk), continue; end + a = spm_clusters(ijk + 1); % 1-based voxel coords + cluster(rows(act_local)) = a(:) + offset; + offset = offset + max(a); + end +end + +obj.brain_model.cluster = cluster; +ncl = offset; +obj.history{end+1} = sprintf('reparse_contiguous: %d clusters (mesh + volume, image %d)', ncl, which_image); +end + + +% ------------------------------------------------------------------------- +function F = local_hemi_faces(surface_space, structname) +geom = load_surface_geom(surface_space, 'inflated'); % faces shared across surftypes +if contains(upper(structname), 'LEFT') + F = geom.faces_lh; +else + F = geom.faces_rh; +end +end + + +% ------------------------------------------------------------------------- +function G = local_mesh_graph(F, numvert) +% Build an undirected graph over numvert mesh vertices from triangle faces. +E = [F(:, [1 2]); F(:, [2 3]); F(:, [1 3])]; +E = sort(E, 2); +E = unique(E, 'rows'); +G = graph(E(:, 1), E(:, 2), [], numvert); +end diff --git a/CanlabCore/@fmri_surface_data/replace_empty.m b/CanlabCore/@fmri_surface_data/replace_empty.m new file mode 100644 index 00000000..4d0a6d1b --- /dev/null +++ b/CanlabCore/@fmri_surface_data/replace_empty.m @@ -0,0 +1,23 @@ +function obj = replace_empty(obj, varargin) +% replace_empty (fmri_surface_data): no-op. .dat is always full-size. +% +% :Usage: +% :: +% obj = replace_empty(obj) +% +% For image_vector/fmri_data, replace_empty re-expands a space-reduced .dat back +% to its original size (re-inserting removed voxels/images as zeros) so the data +% can be reconstructed into a volume. fmri_surface_data never reduces .dat -- it +% always holds the full [nGrayordinates x nMaps] grayordinate set in 1:1 row +% correspondence with .brain_model -- so there is nothing to re-insert. +% +% This override returns the object UNCHANGED, which removes the entire class of +% "forgot to replace_empty before reconstructing" bugs. Methods such as +% reconstruct_image / write / cat therefore need no replace_empty pre-step. +% +% See docs/fmri_surface_data_design_plan.md (decision D5b). +% +% :See also: remove_empty, fmri_surface_data, reconstruct_image + +% Intentionally a no-op. +end diff --git a/CanlabCore/@fmri_surface_data/resample_surface.m b/CanlabCore/@fmri_surface_data/resample_surface.m new file mode 100644 index 00000000..9aa97c34 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/resample_surface.m @@ -0,0 +1,374 @@ +function out = resample_surface(obj, target_space, varargin) +% resample_surface Resample cortical-surface data to another surface space. +% +% :Usage: +% :: +% out = resample_surface(obj, target_space) +% out = resample_surface(obj, target_space, 'interp', 'nearest') +% resample_surface(obj, 'list') % print the available target spaces +% +% Resamples the CORTICAL grayordinates of an fmri_surface_data object from its +% current surface space to a target surface space, natively (no FreeSurfer / +% Connectome Workbench). Supported spaces and the mapping between them: +% +% fsaverage_164k <-> fs_LR_32k (HCP CIFTI) -- spherical interpolation +% fsaverage_164k <-> fsaverage6/5/4 -- nested icosahedral +% fs_LR_32k <-> fsaverage6/5/4 -- via fsaverage frame +% +% fsaverage<->fs_LR uses the vendored HCP/Connectome-Workbench registration +% ("deformed") sphere that expresses fsaverage vertices in the fs_LR-aligned +% frame, so both meshes live in a common spherical frame and are resampled with +% barycentric (or nearest-neighbor) interpolation on the sphere. fsaverage down- +% sampling (164k -> fsaverage6/5/4) is exact: the lower-resolution FreeSurfer +% icosahedra are nested subsets (the first N vertices) of fsaverage-164k. +% +% Interpolation is barycentric ("linear") by default -- more accurate than +% nearest-neighbor and, because the interpolation weights depend only on the mesh +% geometry, they are computed ONCE and applied to all maps (a single sparse +% matrix multiply), so multi-map objects resample quickly. Binary masks and label +% (.dlabel) images default to NEAREST to preserve their discrete values. +% +% Only the cortical surface models are resampled. Any subcortical (voxel) models +% are volumetric and space-independent, so they are carried through unchanged. +% +% :Inputs: +% **obj:** an fmri_surface_data object whose surface_space is one of +% fsaverage_164k, fsaverage6, fsaverage5, fsaverage4, fs_LR_32k. +% **target_space:** a target-space keyword (see 'list'); case-insensitive, with +% aliases (e.g. 'fsaverage','fsLR','hcp','fsaverage6', ...). +% +% :Optional Inputs: +% **'interp':** 'linear' (barycentric, default for continuous data) or +% 'nearest' (default for binary masks / label images). +% +% :Outputs: +% **out:** an fmri_surface_data object in the target surface_space (cortex +% resampled; subcortex, if any, unchanged). +% +% :Examples: +% :: +% s = vol2surf(ttest(load_image_set('emotionreg'))); % fsaverage_164k +% s32 = resample_surface(s, 'fsLR_32k'); % -> HCP fs_LR-32k +% s6 = resample_surface(s, 'fsaverage6'); % -> nested fsaverage6 +% +% m = fmri_surface_data(which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii')); +% mfs = resample_surface(m, 'fsaverage_164k'); % fs_LR-32k -> fsaverage +% +% :References: +% fs_LR<->fsaverage registration spheres: Van Essen DC, Glasser MF, Dierker DL, +% Harwell J, Coalson T (2012). Parcellations and hemispheric asymmetries of +% human cerebral cortex analyzed on surface-based atlases. Cerebral Cortex +% 22(10):2241-2262 (HCP standard_mesh_atlases / resample_fsaverage). +% +% :See also: vol2surf, surf2vol, to_fmri_data, fmri_surface_data, apply_parcellation + +% ---- 'list' : print available target spaces and return ---- +if (ischar(target_space) || isstring(target_space)) && any(strcmpi(target_space, {'list', 'spaces', 'help'})) + out = local_print_spaces(); + return +end + +% ---- Parse options ---- +interp_opt = ''; % '' = auto +for i = 1:2:numel(varargin) + switch lower(varargin{i}) + case 'interp', interp_opt = lower(varargin{i + 1}); + otherwise, error('resample_surface:badopt', 'Unknown option: %s', varargin{i}); + end +end + +% ---- A patch handle or mesh struct target: resolve its space by vertex count. +% This lets rendering code resample onto an arbitrary isosurface (e.g. an addbrain +% patch) by passing the patch itself, not a keyword. +if ~(ischar(target_space) || isstring(target_space)) + target_space = local_space_from_patch(target_space); +end + +% ---- Resolve source / target spaces ---- +[tgt_name, tgt_N] = local_resolve_space(target_space); +[src_name, src_N] = local_resolve_space(obj.surface_space); + +if strcmp(src_name, tgt_name) + warning('resample_surface:noop', 'Object is already in %s; returning a copy.', tgt_name); + out = obj; + return +end + +% ---- Choose interpolation method ---- +if isempty(interp_opt) + if local_is_discrete(obj), method = 'nearest'; else, method = 'linear'; end +else + if ~any(strcmp(interp_opt, {'linear', 'barycentric', 'nearest'})) + error('resample_surface:interp', 'interp must be ''linear'' or ''nearest''.'); + end + if strcmp(interp_opt, 'barycentric'), interp_opt = 'linear'; end + method = interp_opt; +end + +% ---- Ensure the interpolation engine is on the path ---- +p_anchor = which('fsavg_sphere_lh.mat'); +if isempty(which('spherical_icosahedral_interpolation')) + if ~isempty(p_anchor), addpath(fullfile(fileparts(p_anchor), 'src')); end + if isempty(which('spherical_icosahedral_interpolation')) + error('resample_surface:engine', ['Cannot find spherical_icosahedral_interpolation ' ... + '(bundled under canlab_canonical_brains/Canonical_brains_surfaces/src).']); + end +end +% onavg spheres live in an 'onavg' subfolder that may not be on the path yet. +if isempty(which('onavg_sphere_fsLR_lh_41k.mat')) && ~isempty(p_anchor) + addpath(fullfile(fileparts(p_anchor), 'onavg')); +end + +% ---- Dense cortical hemisphere data (medial wall -> 0 for interpolation) ---- +r = reconstruct_image(obj); +nMaps = size(obj.dat, 2); +Ld = zeros(src_N, nMaps); Rd = zeros(src_N, nMaps); +if isfield(r, 'cortex_left') && ~isempty(r.cortex_left), Ld = double(r.cortex_left); end +if isfield(r, 'cortex_right') && ~isempty(r.cortex_right), Rd = double(r.cortex_right); end +if size(Ld, 1) ~= src_N || size(Rd, 1) ~= src_N + error('resample_surface:srcverts', ... + 'Expected %d vertices/hemisphere for %s; got L=%d R=%d.', src_N, src_name, size(Ld,1), size(Rd,1)); +end +Ld(~isfinite(Ld)) = 0; Rd(~isfinite(Rd)) = 0; + +% ---- Resample each hemisphere ---- +src_fam = local_family(src_name); tgt_fam = local_family(tgt_name); +if strcmp(src_fam, 'fsaverage') && strcmp(tgt_fam, 'fsaverage') && tgt_N < src_N + % Exact nested icosahedral downsample: lower-res vertices are the first N. + Lt = Ld(1:tgt_N, :); Rt = Rd(1:tgt_N, :); +else + [VsL, FsL, VsR, FsR] = local_sphere(src_name); + [VqL, ~, VqR, ~] = local_sphere(tgt_name); + WL = local_weights(VsL, FsL, VqL, method); + WR = local_weights(VsR, FsR, VqR, method); + Lt = WL * Ld; Rt = WR * Rd; +end + +% ---- Reassemble the target object (cortex + unchanged subcortex) ---- +out = local_build(obj, single(Lt), single(Rt), tgt_name, tgt_N, method); +end + + +% ========================================================================= +% Spherical interpolation weights (computed once from geometry, reused for all +% maps as a sparse matrix W: target_verts x source_verts, so Cq = W * C). +function W = local_weights(Vs, Fs, Vq, method) +Ns = size(Vs, 1); Nt = size(Vq, 1); +probe = zeros(Ns, 1); +if strcmpi(method, 'nearest') + [~, ~, vec] = spherical_icosahedral_interpolation(double(Vs), double(Fs), probe, double(Vq), 'nearest'); + W = sparse((1:Nt)', double(vec(:)), 1, Nt, Ns); +else + [~, coord, vec] = spherical_icosahedral_interpolation(double(Vs), double(Fs), probe, double(Vq)); + rows = repmat((1:Nt)', 1, 3); + W = sparse(rows(:), double(vec(:)), coord(:), Nt, Ns); +end +end + + +% ========================================================================= +function out = local_build(obj, Lt, Rt, tgt_name, tgt_N, method) +% Rebuild an fmri_surface_data in the target space: dense cortex + any subcortex. +mL = struct('struct', 'CORTEX_LEFT', 'type', 'surf', 'start', 1, 'count', tgt_N, ... + 'numvert', tgt_N, 'vertlist', 0:tgt_N - 1, 'voxlist', []); +mR = struct('struct', 'CORTEX_RIGHT', 'type', 'surf', 'start', tgt_N + 1, 'count', tgt_N, ... + 'numvert', tgt_N, 'vertlist', 0:tgt_N - 1, 'voxlist', []); +models = {mL, mR}; +new_dat = [Lt; Rt]; +start = 2 * tgt_N + 1; + +% Carry subcortical (voxel) models + their data through unchanged. +has_sub = false; +for i = 1:numel(obj.brain_model.models) + m = obj.brain_model.models{i}; + if ~strcmp(m.type, 'vox'), continue; end + has_sub = true; + rows = m.start:(m.start + m.count - 1); + new_dat = [new_dat; obj.dat(rows, :)]; %#ok + m.start = start; start = start + m.count; + models{end + 1} = m; %#ok +end + +bm = struct('type', 'dense', 'length', size(new_dat, 1), 'models', {models}, ... + 'vol', obj.brain_model.vol); +bm.cluster = []; +if has_sub + bm.grayordinate_type = sprintf('grayordinate_%d', size(new_dat, 1)); +else + bm.grayordinate_type = 'cortex_only'; +end + +out = fmri_surface_data('dat', new_dat, 'brain_model', bm, ... + 'surface_space', tgt_name, 'imagetype', obj.imagetype); +if ~isempty(obj.image_names), out.image_names = obj.image_names; end +if ~isempty(obj.label_table), out.label_table = obj.label_table; end +out.history = obj.history; +out.history{end + 1} = sprintf('resample_surface: %s -> %s (%s interp)', ... + obj.surface_space, tgt_name, method); +end + + +% ========================================================================= +% Per-hemisphere sphere vertices (and faces) in the COMMON fs_LR-aligned frame. +function [VL, FL, VR, FR] = local_sphere(space) +switch space + case 'fs_LR_32k' + L = local_load_sphere('S1200.L.sphere.32k_fs_LR.mat'); + R = local_load_sphere('S1200.R.sphere.32k_fs_LR.mat'); + VL = L.vertices; FL = L.faces; VR = R.vertices; FR = R.faces; + case {'fsaverage_164k', 'fsaverage6', 'fsaverage5', 'fsaverage4'} + % fsaverage vertices expressed in the fs_LR-aligned frame (deformed sphere). + L = local_load_sphere('fs_L-to-fs_LR_fsaverage.L_LR.spherical_std.164k_fs_L.mat'); + R = local_load_sphere('fs_R-to-fs_LR_fsaverage.R_LR.spherical_std.164k_fs_R.mat'); + n = local_space_n(space); + VL = L.vertices(1:n, :); VR = R.vertices(1:n, :); % nested subset + if n == size(L.vertices, 1) + FL = L.faces; FR = R.faces; + else + FL = convhulln(double(VL)); FR = convhulln(double(VR)); % ic6/5/4 faces + end + case {'onavg_41k', 'onavg_10k'} + % onavg vertices in the fs_LR-aligned frame (TemplateFlow tpl-onavg + % space-fsLR registration spheres), so they resample in the common frame. + d = local_onavg_density(space); + L = local_load_sphere(sprintf('onavg_sphere_fsLR_lh_%s.mat', d)); + R = local_load_sphere(sprintf('onavg_sphere_fsLR_rh_%s.mat', d)); + VL = L.vertices; FL = L.faces; VR = R.vertices; FR = R.faces; + otherwise + error('resample_surface:space', 'No sphere geometry for space %s.', space); +end +end + + +function d = local_onavg_density(space) +switch space + case 'onavg_41k', d = '41k'; + case 'onavg_10k', d = '10k'; + otherwise, error('resample_surface:onavg', 'Unknown onavg density %s.', space); +end +end + + +function S = local_load_sphere(fname) +p = which(fname); +if isempty(p) + error('resample_surface:sphere', ['Cannot find %s on the path (bundled under ' ... + 'canlab_canonical_brains/Canonical_brains_surfaces).'], fname); +end +S = load(p); +S.vertices = double(S.vertices); S.faces = double(S.faces); +end + + +% ========================================================================= +function [name, n] = local_resolve_space(kw) +kw = lower(strrep(strrep(char(kw), '-', '_'), ' ', '_')); +switch kw + case {'fslr_32k', 'fs_lr_32k', 'fslr32k', 'fslr', 'hcp', '32k', 'grayordinate', '91k', 'fs_lr'} + name = 'fs_LR_32k'; + case {'fsaverage', 'fsaverage_164k', 'fsaverage164k', 'fsavg', 'fsaverage7', '164k'} + name = 'fsaverage_164k'; + case {'fsaverage6', 'fsavg6', 'ico6'}, name = 'fsaverage6'; + case {'fsaverage5', 'fsavg5', 'ico5'}, name = 'fsaverage5'; + case {'fsaverage4', 'fsavg4', 'ico4'}, name = 'fsaverage4'; + case {'onavg', 'onavg_41k', 'onavg41k', 'onavg_ico6'}, name = 'onavg_41k'; + case {'onavg_10k', 'onavg10k', 'onavg_ico5'}, name = 'onavg_10k'; + otherwise + error('resample_surface:unknownspace', ... + ['Unknown surface space "%s". Run resample_surface(obj, ''list'') for the ' ... + 'available spaces.'], kw); +end +n = local_space_n(name); +end + + +function n = local_space_n(name) +switch name + case 'fs_LR_32k', n = 32492; + case 'fsaverage_164k', n = 163842; + case 'fsaverage6', n = 40962; + case 'fsaverage5', n = 10242; + case 'fsaverage4', n = 2562; + case 'onavg_41k', n = 40962; + case 'onavg_10k', n = 10242; + otherwise, error('resample_surface:space', 'Unknown space %s.', name); +end +end + + +function name = local_space_from_patch(target) +% Resolve a surface space from an isosurface patch handle or a mesh struct, by +% its per-hemisphere vertex count (a single patch is one hemisphere). +if isstruct(target) && isfield(target, 'vertices') + nv = size(target.vertices, 1); +elseif all(ishandle(target(:))) && strcmp(get(target(1), 'Type'), 'patch') + nv = size(get(target(1), 'Vertices'), 1); +else + error('resample_surface:target', ... + ['Target must be a surface-space keyword, a patch handle, or a struct with ' ... + '.vertices. Run resample_surface(obj, ''list'').']); +end +% Standard cortical meshes are recognized by vertex count. Ambiguous counts +% (40962/10242/2562 could be fsaverage or onavg) resolve to the FreeSurfer +% fsaverage mesh, which is what addbrain surfaces use. +switch nv + case 32492, name = 'fs_LR_32k'; + case 163842, name = 'fsaverage_164k'; + case 40962, name = 'fsaverage6'; + case 10242, name = 'fsaverage5'; + case 2562, name = 'fsaverage4'; + otherwise + error('resample_surface:unknownmesh', ... + ['The target surface has %d vertices/hemisphere, which is not a recognized ' ... + 'standard cortical mesh, so the data cannot be resampled onto it. Render ' ... + 'onto a standard fsaverage or fs_LR surface, or project via a volume ' ... + '(surf2vol + render_on_surface).'], nv); +end +end + + +function fam = local_family(name) +% Families gate the exact nested-subset fast path (fsaverage-only). onavg is a +% different (uniform-area) tessellation, so it always uses interpolation. +if strcmp(name, 'fs_LR_32k'), fam = 'fsLR'; +elseif strncmp(name, 'onavg', 5), fam = 'onavg'; +else, fam = 'fsaverage'; end +end + + +% ========================================================================= +function tf = local_is_discrete(obj) +% Binary masks and label/atlas images resample by nearest-neighbor. +tf = false; +if ~isempty(obj.imagetype) && any(strcmpi(obj.imagetype, {'dlabel', 'label'})) + tf = true; return +end +v = obj.dat(:); v = double(v(isfinite(v))); +u = unique(v); +if numel(u) <= 2 && all(ismember(u, [0 1])), tf = true; end % binary mask +end + + +% ========================================================================= +function t = local_print_spaces() +kw = {'fsaverage_164k'; 'fsaverage6'; 'fsaverage5'; 'fsaverage4'; 'fs_LR_32k'; 'onavg_41k'; 'onavg_10k'}; +nver = {163842; 40962; 10242; 2562; 32492; 40962; 10242}; +desc = { ... + 'FreeSurfer fsaverage, 7th-order icosahedron (vol2surf output)'; ... + 'FreeSurfer fsaverage6 (6th-order icosahedron; nested subset of 164k)'; ... + 'FreeSurfer fsaverage5 (5th-order icosahedron; nested subset)'; ... + 'FreeSurfer fsaverage4 (4th-order icosahedron; nested subset)'; ... + 'HCP fs_LR-32k (native CIFTI grayordinate cortex; Van Essen 2012)'; ... + 'onavg equal-area template, den-41k (Feilong et al. 2024; CC0)'; ... + 'onavg equal-area template, den-10k'}; +alias = { ... + 'fsaverage, fsavg, fsaverage7, 164k'; ... + 'fsavg6, ico6'; 'fsavg5, ico5'; 'fsavg4, ico4'; ... + 'fsLR, fs_LR, hcp, 32k, 91k'; ... + 'onavg, onavg41k'; 'onavg10k'}; +t = table(kw, nver, desc, alias, 'VariableNames', {'space', 'verts_per_hemi', 'description', 'aliases'}); +fprintf('\n==== resample_surface: available target spaces ====\n'); +disp(t); +fprintf(['Usage: out = resample_surface(obj, ''''); add ''interp'',''nearest'' for ' ... + 'label/binary maps.\n\n']); +end diff --git a/CanlabCore/@fmri_surface_data/slices.m b/CanlabCore/@fmri_surface_data/slices.m new file mode 100644 index 00000000..f8f35342 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/slices.m @@ -0,0 +1,31 @@ +function varargout = slices(obj, varargin) +% slices Plot slices of the SUBCORTICAL volume of a grayordinate object. +% +% :Usage: +% :: +% slices(surf_obj) +% +% A slice plot only applies to the volumetric (subcortical) grayordinates; +% cortical-surface data is rendered with surface(obj). This routes the +% subcortical grayordinates through to_fmri_data and calls image_vector/slices. +% +% :Inputs: +% **obj:** an fmri_surface_data object with subcortical (volume) grayordinates. +% +% :See also: surface, to_fmri_data, orthviews, montage, image_vector.slices + +if ~local_has_volume(obj) + error('fmri_surface_data:slices:cortexonly', ... + ['slices shows volumetric slices, but this object is cortex-only ' ... + '(no subcortical grayordinates). Use surface(obj) to render the cortical surface.']); +end + +vol = to_fmri_data(obj); +[varargout{1:nargout}] = slices(vol, varargin{:}); +end + + +function tf = local_has_volume(obj) +tf = ~isempty(obj.brain_model) && ... + any(cellfun(@(m) strcmp(m.type, 'vox'), obj.brain_model.models)); +end diff --git a/CanlabCore/@fmri_surface_data/surf2vol.m b/CanlabCore/@fmri_surface_data/surf2vol.m new file mode 100644 index 00000000..42bcb4e6 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/surf2vol.m @@ -0,0 +1,148 @@ +function vol = surf2vol(obj, varargin) +% surf2vol Project an fsaverage surface object back to an MNI152 volume (fmri_data). +% +% :Usage: +% :: +% vol = surf2vol(surf_obj) +% vol = surf2vol(surf_obj, 'reference', fmri_data_obj) +% +% Projects cortical-surface data on the fsaverage 164k surface into an MNI152 +% volume, returning an fmri_data object (which can then be written to .nii with +% its write method, montaged, etc.). +% +% THIS IS THE CBIG REGISTRATION-FUSION MAPPER, NATIVELY (inverse direction). It +% uses the same vendored CBIG RF-ANTs MNI152<->fsaverage per-vertex coordinates as +% vol2surf (Wu et al. 2018, Human Brain Mapping; MIT-licensed; see +% canlab_cbig_warp_path and CanlabCore/Cifti_plotting/ +% CBIG_registration_fusion_surf2vol_vol2surf/), scattering each fsaverage vertex's +% value into its MNI voxel and averaging co-located vertices (accumarray). Fully +% native -- no FreeSurfer / Connectome Workbench. +% +% Note: CBIG also ships a heavier fsaverage->volume script +% (CBIG_RF_projectfsaverage2Vol_single.m) that fills the whole cortical ribbon via +% a 256^3 conformed per-voxel mapping; that path requires FreeSurfer + the CBIG +% MARS toolbox + external mask geometry (not bundled), so it is not used here. This +% native inverse instead scatters the per-vertex coordinates, which is self- +% consistent with vol2surf and adequate for group cortical maps. +% +% This is the inverse of vol2surf. It requires an fsaverage_164k object (the CBIG +% warp is fsaverage-based). For the subcortical (volumetric) part of a +% grayordinate object, use to_fmri_data instead. +% +% :References: +% Wu J, Ngo GH, Greve DN, Li J, He T, Fischl B, Eickhoff SB, Yeo BTT (2018). +% Accurate nonlinear mapping between MNI volumetric and FreeSurfer surface +% coordinate systems. Human Brain Mapping 39(9):3793-3808. +% +% :Inputs: +% **obj:** an fmri_surface_data object in surface_space 'fsaverage_164k' +% (e.g. produced by vol2surf), with CORTEX_LEFT / CORTEX_RIGHT models. +% +% :Optional Inputs: +% **'reference':** an image_vector/fmri_data whose volInfo (mat + dim) defines +% the target grid. Default: MNI152 2 mm, [91 109 91]. +% **'interp':** 'nearest' (default) -- scatter assignment is nearest-voxel. +% +% :Outputs: +% **vol:** fmri_data over the cortical voxels hit by the projection +% [n_hit_voxels x nMaps], in the target MNI space. +% +% :Examples: +% :: +% s = vol2surf(ttest(load_image_set('emotionreg'))); +% v = surf2vol(s); +% % v.write('fname', '/tmp/back.nii'); +% +% :See also: vol2surf, to_fmri_data, fmri_surface_data + +% ---- Options ---- +refobj = []; +for i = 1:2:numel(varargin) + switch lower(varargin{i}) + case 'reference', refobj = varargin{i+1}; + case 'interp' % nearest only in v1; accepted for API parity + otherwise, error('surf2vol:badopt', 'Unknown option: %s', varargin{i}); + end +end + +if ~strcmp(obj.surface_space, 'fsaverage_164k') + error('surf2vol:space', ... + ['surf2vol requires an fsaverage_164k object (the CBIG warp is fsaverage-based). ' ... + 'This object is "%s". For the subcortical/volumetric part of a grayordinate ' ... + 'object, use to_fmri_data instead.'], obj.surface_space); +end + +% ---- Target grid ---- +if ~isempty(refobj) && ~isempty(refobj.volInfo) && isfield(refobj.volInfo, 'mat') + tmat = refobj.volInfo.mat; + dims = refobj.volInfo.dim; +else + % Standard MNI152 2 mm grid (1-based SPM .mat), matching the CIFTI subcortical grid + dims = [91 109 91]; + tmat = [-2 0 0 92; 0 2 0 -128; 0 0 2 -74; 0 0 0 1]; +end +nvox = prod(dims); + +% ---- Pull cortical hemisphere data ---- +[lh_dat, rh_dat] = local_hemi_data(obj); +nMaps = size(obj.dat, 2); + +NV = 163842; +L = load(canlab_cbig_warp_path('lh_ras')); lh_ras = L.ras; +R = load(canlab_cbig_warp_path('rh_ras')); rh_ras = R.ras; + +acc = zeros(nvox, nMaps); +cnt = zeros(nvox, 1); +for h = 1:2 + if h == 1, ras = lh_ras; hd = lh_dat; else, ras = rh_ras; hd = rh_dat; end + vox = round(tmat \ [ras; ones(1, NV)]); % 1-based voxel indices + in = vox(1,:) >= 1 & vox(1,:) <= dims(1) & ... + vox(2,:) >= 1 & vox(2,:) <= dims(2) & ... + vox(3,:) >= 1 & vox(3,:) <= dims(3); + lin = sub2ind(dims, vox(1,in), vox(2,in), vox(3,in))'; + cnt = cnt + accumarray(lin, 1, [nvox 1]); + for k = 1:nMaps + vk = hd(in, k); + acc(:, k) = acc(:, k) + accumarray(lin, double(vk), [nvox 1]); + end +end + +inmask = cnt > 0; +datm = acc(inmask, :) ./ cnt(inmask); + +% ---- Build fmri_data ---- +[ix, iy, iz] = ind2sub(dims, find(inmask)); +iv = image_vector; +iv.volInfo = struct('mat', tmat, 'dim', dims, 'dt', [16 0], ... + 'xyzlist', [ix iy iz], 'nvox', nvox, ... + 'image_indx', inmask, 'wh_inmask', find(inmask), ... + 'n_inmask', nnz(inmask), 'fname', ''); +iv.dat = single(datm); +iv.removed_voxels = false(size(iv.dat, 1), 1); +iv.removed_images = false(size(iv.dat, 2), 1); +iv.image_names = obj.image_names; +iv.history = obj.history; +iv.history{end+1} = sprintf('surf2vol: projected fsaverage_164k -> MNI %dx%dx%d (%d cortical voxels)', ... + dims(1), dims(2), dims(3), nnz(inmask)); + +vol = fmri_data(iv); +end + + +% ========================================================================= +function [lh_dat, rh_dat] = local_hemi_data(obj) +NV = 163842; +lh_dat = []; rh_dat = []; +for i = 1:numel(obj.brain_model.models) + m = obj.brain_model.models{i}; + if ~strcmp(m.type, 'surf'), continue; end + rows = m.start:(m.start + m.count - 1); + dense = zeros(m.numvert, size(obj.dat, 2)); + dense(m.vertlist + 1, :) = double(obj.dat(rows, :)); + if strcmpi(m.struct, 'CORTEX_LEFT'), lh_dat = dense; end + if strcmpi(m.struct, 'CORTEX_RIGHT'), rh_dat = dense; end +end +if isempty(lh_dat) || isempty(rh_dat) || size(lh_dat,1) ~= NV + error('surf2vol:models', 'Expected CORTEX_LEFT and CORTEX_RIGHT models with %d vertices.', NV); +end +end diff --git a/CanlabCore/@fmri_surface_data/surface.m b/CanlabCore/@fmri_surface_data/surface.m new file mode 100644 index 00000000..66671588 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/surface.m @@ -0,0 +1,155 @@ +function han = surface(obj, varargin) +% surface Render grayordinate/surface data on cortical surfaces. +% +% :Usage: +% :: +% han = surface(obj) % native inflated, 4 views +% han = surface(obj, 'surftype', 'midthickness') % native, other mesh +% han = surface(obj, 'which_image', 2, 'clim', [-3 3]) +% han = surface(obj, 'existingsurface', patch_handles) % render on given patches +% han = surface(obj, 'mni_surface', 'left') % render on an addbrain MNI surface +% +% Renders an fmri_surface_data object on cortical surfaces, mirroring +% image_vector.surface. Three modes: +% +% 1. NATIVE (default): builds a managed, stateful fmridisplay whose surface +% views are the mesh set that matches the object's surface_space (fs_LR-32k +% -> 'foursurfaces_hcp', fsaverage-164k -> 'foursurfaces_freesurfer'), then +% paints the data as a MANAGED surface-native layer (colored DIRECTLY from +% the per-vertex data -- no resampling; medial wall and zeros render gray). +% Because the returned object is a stateful fmridisplay under a controller, +% set_colormap / set_opacity / rethreshold / removeblobs / refresh act on +% the surfaces (this is why the colormap is now changeable after the fact). +% +% 2. EXISTING SURFACE ('existingsurface', han): colors patch handles you +% already have (e.g. from addbrain or a prior surface call). Matching-space +% meshes are colored directly; arbitrary MNI surfaces are handled by +% projecting to a volume (see render_on_surface). +% +% 3. MNI SURFACE ('mni_surface', name): creates an addbrain surface (e.g. +% 'left', 'right', 'hcp inflated') and renders onto it, projecting to a +% volume when the surface is not the object's native mesh. +% +% :Optional Inputs: +% **'surftype':** 'inflated' (default), 'midthickness', 'sphere' (fs_LR). +% **'which_image':** map (column) to render. Default 1. +% **'existingsurface':** vector of patch handles to color. +% **'mni_surface':** an addbrain keyword (string). +% +% Color options (harmonized with the volume pipeline; forwarded to +% render_on_surface -- see there for details): +% **'clim' / 'cmaprange':** [lo hi] color limits (default symmetric from data). +% **'colormap' / 'colormapname':** named MATLAB colormap or [n x 3] matrix +% (a single sequential map over clim). +% **'pos_colormap' / 'neg_colormap':** [n x 3] split colormaps (default hot/cool). +% **'splitcolor':** {neg_low, neg_high, pos_low, pos_high} colors. +% **'maxcolor' / 'mincolor':** endpoint colors -> a single gradient over clim. +% **'color':** a single solid color for all in-data vertices. +% +% :Outputs: +% **han:** In native mode (default) a stateful **fmridisplay** object with the +% surfaces registered as managed views and the data added as a layer; use +% set_colormap / removeblobs / refresh on it. In the 'existingsurface' and +% 'mni_surface' modes a struct with fields .figure, .axes, .surfaces. +% +% :Examples: +% :: +% s = fmri_surface_data(which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii')); +% surface(s, 'which_image', 1); +% +% v = ttest(load_image_set('emotionreg')); % a volumetric statistic_image +% surface(vol2surf(v)); % native fsaverage render +% surface(vol2surf(v), 'mni_surface', 'left'); % on an addbrain MNI surface +% +% :See also: render_on_surface, load_surface_geom, addbrain, fmri_surface_data + +surftype = 'inflated'; +which_image = 1; +existing = []; +mni_surface = ''; +coloropts = {}; % forwarded to render_on_surface (harmonized colors) + +i = 1; +while i <= numel(varargin) + switch lower(char(varargin{i})) + case 'surftype', surftype = varargin{i+1}; i = i + 2; + case 'which_image', which_image = varargin{i+1}; i = i + 2; + case {'existingsurface','surface_handles'}, existing = varargin{i+1}; i = i + 2; + case 'mni_surface', mni_surface = varargin{i+1}; i = i + 2; + case {'unique','solid'} % value-less colour-mode flags + coloropts = [coloropts, varargin(i)]; %#ok + i = i + 1; + otherwise + % Any other option (clim, colormap, cmaprange, pos_colormap / + % neg_colormap, splitcolor, maxcolor / mincolor, color, ...) is + % forwarded to render_on_surface, which harmonizes them with the + % volume visualization color pipeline. + coloropts = [coloropts, varargin(i:i+1)]; %#ok + i = i + 2; + end +end + +ropts = [{'which_image', which_image}, coloropts]; + +% ---- Mode 2: existing handles ---- +if ~isempty(existing) + render_on_surface(obj, existing, ropts{:}); + han = struct('figure', ancestor(existing(1), 'figure'), 'axes', [], 'surfaces', existing); + return +end + +% ---- Mode 3: addbrain MNI surface ---- +if ~isempty(mni_surface) + hp = addbrain(mni_surface); + render_on_surface(obj, hp, ropts{:}); + han = struct('figure', gcf, 'axes', gca, 'surfaces', hp); + return +end + +% ---- Mode 1: native managed render (returns a stateful fmridisplay) ---- +% Build a managed display whose surface views are the mesh set that matches this +% object's space, then add the data as a managed surface-native layer. This is +% what makes the colormap changeable afterwards (set_colormap / removeblobs act +% on the returned object). The heavy lifting -- pick the matching surfaces, add +% them, paint at full fidelity -- is done by @fmridisplay/surface's +% fmri_surface_data path, so there is a single code path for surface(obj) and +% surface(o2, obj). +o2 = fmridisplay; +o2 = surface(o2, obj, ropts{:}); + +% Honor a non-default surftype (midthickness / sphere) by swapping the managed +% patches' geometry to the requested mesh. The foursurfaces keyword draws +% inflated meshes; vertex ordering is shared across fs_LR surftypes, so swapping +% both vertices AND faces (self-consistent, from the same file) keeps the +% painted per-vertex data aligned. Best-effort: leave inflated on any failure. +if ~strcmpi(surftype, 'inflated') + try + o2 = swap_managed_geom(o2, obj.surface_space, surftype); + catch err + warning('fmri_surface_data:surface:surftype', ... + 'Could not apply surftype ''%s'' (%s); showing inflated.', surftype, err.message); + end +end + +han = o2; +end + + +function o2 = swap_managed_geom(o2, surface_space, surftype) +% Replace the Vertices/Faces of the managed cortical patches with the requested +% surftype's mesh (same space, same vertex count -> painted data stays aligned). +geom = load_surface_geom(surface_space, surftype); +for i = 1:numel(o2.surface) + h = o2.surface{i}.object_handle; + for hh = h(ishandle(h))' + if ~strcmp(get(hh, 'Type'), 'patch'), continue; end + nv = size(get(hh, 'Vertices'), 1); + tag = lower(get(hh, 'Tag')); + if nv == size(geom.vertices_rh, 1) && contains(tag, 'right') + set(hh, 'Vertices', geom.vertices_rh, 'Faces', geom.faces_rh); + elseif nv == size(geom.vertices_lh, 1) + set(hh, 'Vertices', geom.vertices_lh, 'Faces', geom.faces_lh); + end + end +end +end diff --git a/CanlabCore/@fmri_surface_data/surface_region.m b/CanlabCore/@fmri_surface_data/surface_region.m new file mode 100644 index 00000000..b2bd1c4e --- /dev/null +++ b/CanlabCore/@fmri_surface_data/surface_region.m @@ -0,0 +1,116 @@ +function reg = surface_region(obj, varargin) +% surface_region Summarize contiguous grayordinate clusters as region structs. +% +% :Usage: +% :: +% reg = surface_region(obj) +% reg = surface_region(obj, 'which_image', 2) +% +% Converts the contiguous clusters of an fmri_surface_data (active = nonzero, +% non-NaN grayordinates) into a struct array of per-cluster summaries -- the +% surface analogue of region(). Clusters are found with reparse_contiguous (mesh +% edge graph for cortex, 26-connectivity for subcortex). Cortical-cluster +% centroids are computed from the bundled mesh (midthickness if available); +% subcortical-cluster centroids come from voxel mm coordinates. +% +% (A full CANlab region object is volume-centric; this returns a lightweight +% surface-aware struct. For the subcortical part you can also use +% region(to_fmri_data(obj)).) +% +% :Optional Inputs: +% **'which_image':** which map defines "active" grayordinates. Default 1. +% +% :Outputs: +% **reg:** struct array, one element per cluster, with fields: +% .struct brain structure name (e.g. 'CORTEX_LEFT') +% .type 'surf' or 'vox' +% .cluster_id integer cluster label +% .grayord_rows row indices into .dat for this cluster +% .vertex_indices 0-based mesh vertices (surf clusters) or [] +% .XYZmm [3 x 1] centroid in mm +% .numVox number of grayordinates in the cluster +% .val mean value (which_image) over the cluster +% +% :See also: reparse_contiguous, region, to_fmri_data, fmri_surface_data + +which_image = 1; +for i = 1:2:numel(varargin) + if strcmpi(varargin{i}, 'which_image'), which_image = varargin{i+1}; end +end + +obj = reparse_contiguous(obj, 'which_image', which_image); +cluster = obj.brain_model.cluster; +d = double(obj.dat(:, which_image)); + +reg = struct('struct', {}, 'type', {}, 'cluster_id', {}, 'grayord_rows', {}, ... + 'vertex_indices', {}, 'XYZmm', {}, 'numVox', {}, 'val', {}); + +if isempty(cluster) || all(cluster == 0), return; end + +% mesh vertices for centroids (cortex) +geom = []; +% subcortical affine for centroids (vox) +hasvol = isfield(obj.brain_model, 'vol') && ~isempty(obj.brain_model.vol); + +for mi = 1:numel(obj.brain_model.models) + m = obj.brain_model.models{mi}; + rows = (m.start:(m.start + m.count - 1))'; + cl_local = cluster(rows); + ids = unique(cl_local(cl_local > 0)); + + if strcmp(m.type, 'surf') && ~isempty(ids) + if isempty(geom) + try, geom = load_geom_safe(obj.surface_space); catch, geom = []; end + end + if contains(upper(m.struct), 'LEFT') && ~isempty(geom), Vh = geom.vertices_lh; + elseif ~isempty(geom), Vh = geom.vertices_rh; else, Vh = []; end + end + + for c = ids(:)' + sel = find(cl_local == c); + gr = rows(sel); + s = struct(); + s.struct = m.struct; + s.type = m.type; + s.cluster_id = c; + s.grayord_rows = gr; + s.numVox = numel(gr); + s.val = mean(d(gr)); + if strcmp(m.type, 'surf') + verts = m.vertlist(sel) + 1; % 1-based mesh verts + s.vertex_indices = m.vertlist(sel); % store 0-based + if exist('Vh', 'var') && ~isempty(Vh) + s.XYZmm = mean(Vh(verts, :), 1)'; + else + s.XYZmm = [NaN; NaN; NaN]; + end + else + s.vertex_indices = []; + ijk = m.voxlist(:, sel) + 1; % 1-based + if hasvol + mm = obj.brain_model.vol.sform * [mean(ijk - 1, 2); 1]; % sform is 0-based + s.XYZmm = mm(1:3); + else + s.XYZmm = [NaN; NaN; NaN]; + end + end + reg(end+1) = s; %#ok + end +end +end + + +% ------------------------------------------------------------------------- +function g = load_geom_safe(space) +% Use midthickness for accurate centroids; fall back to inflated. +try + g = call_private_geom(space, 'midthickness'); +catch + g = call_private_geom(space, 'inflated'); +end +end + +function g = call_private_geom(space, surftype) +% load_surface_geom is private to @fmri_surface_data; reachable from class methods. +g = load_surface_geom(space, surftype); +end diff --git a/CanlabCore/@fmri_surface_data/threshold.m b/CanlabCore/@fmri_surface_data/threshold.m new file mode 100644 index 00000000..a956e33b --- /dev/null +++ b/CanlabCore/@fmri_surface_data/threshold.m @@ -0,0 +1,76 @@ +function obj = threshold(obj, thresh, varargin) +% threshold Raw-value threshold of grayordinate data (zeros sub-threshold values). +% +% :Usage: +% :: +% obj = threshold(obj, [lo hi]) +% obj = threshold(obj, t, 'positive') +% obj = threshold(obj, t, 'negative') +% +% Surface analogue of the raw-value branch of image thresholding: grayordinate +% values inside the threshold range are set to 0; .dat keeps its full size (D5b). +% +% Cluster-extent thresholding is available via the 'k' option (mesh-graph +% connected components for cortex, 26-connectivity for subcortex; see +% reparse_contiguous). +% +% :Inputs: +% **obj:** an fmri_surface_data object. +% **thresh:** scalar t (keeps |value| >= t, two-tailed) or [lo hi] (keeps +% values <= lo OR >= hi; i.e. removes the open interval (lo,hi)). +% +% :Optional Inputs: +% **'positive':** keep only values >= t (with scalar t). +% **'negative':** keep only values <= -abs(t) (with scalar t). +% **'k', N:** cluster-extent threshold: after the raw-value threshold, +% remove contiguous clusters smaller than N grayordinates +% (applied per map column). +% +% :Outputs: +% **obj:** thresholded object (sub-threshold grayordinates zeroed). +% +% :See also: fmri_surface_data, apply_mask, reparse_contiguous + +direction = 'two'; +if any(strcmpi(varargin, 'positive')), direction = 'pos'; end +if any(strcmpi(varargin, 'negative')), direction = 'neg'; end +kextent = 0; +for i = 1:numel(varargin) + if (ischar(varargin{i}) || isstring(varargin{i})) && strcmpi(varargin{i}, 'k') && i < numel(varargin) + kextent = varargin{i+1}; + end +end + +d = double(obj.dat); + +if numel(thresh) == 2 + lo = thresh(1); hi = thresh(2); + keep = d <= lo | d >= hi; +else + t = abs(thresh); + switch direction + case 'pos', keep = d >= t; + case 'neg', keep = d <= -t; + otherwise, keep = abs(d) >= t; + end +end + +d(~keep) = 0; +obj.dat = single(d); + +% Cluster-extent: drop clusters smaller than k grayordinates, per map +if kextent > 1 + for col = 1:size(obj.dat, 2) + tmp = reparse_contiguous(obj, 'which_image', col); + cl = tmp.brain_model.cluster; + if isempty(cl), continue; end + szs = accumarray(cl(cl > 0), 1, [max(cl) 1]); + small = find(szs < kextent); + drop = ismember(cl, small); + dd = obj.dat(:, col); dd(drop) = 0; obj.dat(:, col) = dd; + end + obj.history{end+1} = sprintf('threshold cluster-extent k>=%d applied', kextent); +end + +obj.history{end+1} = sprintf('threshold (raw-value): kept %.1f%% of values', 100*mean(keep(:))); +end diff --git a/CanlabCore/@fmri_surface_data/to_display_volume.m b/CanlabCore/@fmri_surface_data/to_display_volume.m new file mode 100644 index 00000000..09e26f5a --- /dev/null +++ b/CanlabCore/@fmri_surface_data/to_display_volume.m @@ -0,0 +1,84 @@ +function vol = to_display_volume(obj) +% to_display_volume Project a surface/grayordinate object to an MNI volume for +% rendering on an ARBITRARY (non-standard) MNI isosurface. +% +% :Usage: +% :: +% vol = to_display_volume(surf_obj) +% +% When surface data must be shown on a mesh that is not one of its own standard +% surfaces (e.g. an addbrain 'hires left' pial surface, a 'cutaway', or any MNI +% isosurface), the data is first turned into a volume so a patch can be coloured +% by sampling that volume at each vertex's MNI coordinate (see +% image_vector.render_on_surface). This is the projection step: +% +% - Cortex: resampled to fsaverage-164k (resample_surface) if not already, then +% surf2vol (CBIG registration fusion) to MNI 2 mm. +% - Subcortex: to_fmri_data (its grayordinate voxels are already volumetric). +% - Mixed grayordinate objects: the two are merged (subcortex overlaid on the +% cortical volume; the regions are spatially disjoint). +% +% Label/binary data resamples by nearest neighbour automatically (resample_surface +% detects .dlabel / binary), preserving discrete values. +% +% :Inputs: +% **obj:** an fmri_surface_data object (any surface_space, with cortex and/or +% subcortical voxel models). +% +% :Outputs: +% **vol:** an fmri_data in MNI space (2 mm) covering the projected cortex and/or +% subcortex, ready for image_vector.render_on_surface. +% +% :See also: render_on_surface, surf2vol, resample_surface, to_fmri_data + +has_cortex = ~isempty(obj.brain_model) && ... + any(cellfun(@(m) strcmp(m.type, 'surf'), obj.brain_model.models)); +has_vox = ~isempty(obj.brain_model) && ... + any(cellfun(@(m) strcmp(m.type, 'vox'), obj.brain_model.models)); + +volc = []; vols = []; + +if has_cortex + fs = obj; + if ~strcmp(obj.surface_space, 'fsaverage_164k') + % surf2vol needs fsaverage (the CBIG warp is fsaverage-based); get there + % natively. resample_surface auto-selects nearest for label/binary data. + fs = resample_surface(obj, 'fsaverage_164k'); + end + volc = surf2vol(fs); +end + +if has_vox + vols = to_fmri_data(obj); +end + +if isempty(volc) && isempty(vols) + error('fmri_surface_data:to_display_volume:empty', ... + 'Object has no cortex or subcortex to project to a volume.'); +elseif isempty(vols) + vol = volc; +elseif isempty(volc) + vol = vols; +else + vol = local_merge(volc, vols); +end +end + + +% ------------------------------------------------------------------------- +function vol = local_merge(volc, vols) +% Overlay the (spatially disjoint) subcortex onto the cortical volume. Falls back +% to the cortical volume if the subcortex cannot be placed on the cortical grid. +try + vols = resample_space(vols, volc); + volc = replace_empty(volc); + vols = replace_empty(vols); + d = volc.dat; s = vols.dat; + m = any(s ~= 0 & isfinite(s), 2); % voxels the subcortex actually fills + d(m, :) = s(m, :); + volc.dat = d; + vol = remove_empty(volc); +catch + vol = volc; +end +end diff --git a/CanlabCore/@fmri_surface_data/to_fmri_data.m b/CanlabCore/@fmri_surface_data/to_fmri_data.m new file mode 100644 index 00000000..37e829f8 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/to_fmri_data.m @@ -0,0 +1,49 @@ +function fmri_obj = to_fmri_data(obj) +% to_fmri_data Export the subcortical/volumetric grayordinates to an fmri_data object. +% +% :Usage: +% :: +% fmri_obj = to_fmri_data(surf_obj) +% +% Returns the volumetric (subcortical / cerebellar) part of a grayordinate +% fmri_surface_data object as a standard CANlab fmri_data object in the CIFTI +% volume's MNI space. Surface (cortical) grayordinates have no voxel coordinates +% and are dropped here -- use surf2vol (M4) to project cortex into a volume. +% +% The resulting fmri_data can be written to a NIfTI (.nii) with its write +% method, montaged, resampled, etc. like any volumetric object. +% +% :Inputs: +% **obj:** an fmri_surface_data object with a volumetric sub-block +% (brain_model.vol + one or more 'vox' models). +% +% :Outputs: +% **fmri_obj:** an fmri_data object [n_subcortical_voxels x nMaps]. +% +% :Examples: +% :: +% s = fmri_surface_data(which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii')); +% vol = to_fmri_data(s); +% % vol.write('sample_filename', '/tmp/subctx.nii'); % to disk +% +% :See also: fmri_surface_data, reconstruct_image, build_volinfo_subblock, surf2vol + +[vi, rows] = build_volinfo_subblock(obj.brain_model); +if isempty(vi) || isempty(rows) + error('fmri_surface_data:to_fmri_data:novolume', ... + ['This object has no volumetric (subcortical) grayordinates to export. ' ... + 'Use surf2vol to project cortical surface data into a volume.']); +end + +iv = image_vector; +iv.volInfo = vi; +iv.dat = single(obj.dat(rows, :)); +iv.removed_voxels = false(size(iv.dat, 1), 1); +iv.removed_images = false(size(iv.dat, 2), 1); +iv.image_names = obj.image_names; +iv.history = obj.history; +iv.history{end+1} = sprintf('to_fmri_data: extracted %d subcortical voxels x %d maps from %s', ... + size(iv.dat,1), size(iv.dat,2), class(obj)); + +fmri_obj = fmri_data(iv); +end diff --git a/CanlabCore/@fmri_surface_data/ttest.m b/CanlabCore/@fmri_surface_data/ttest.m new file mode 100644 index 00000000..8e9bbac3 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/ttest.m @@ -0,0 +1,49 @@ +function sobj = ttest(obj, varargin) +% ttest Voxel-wise (grayordinate-wise) one-sample t-test across maps. +% +% :Usage: +% :: +% sobj = ttest(obj) +% sobj = ttest(obj, pthresh, k) +% +% Performs a one-sample t-test at each grayordinate across the maps (columns) of +% obj.dat, mirroring fmri_data.ttest. Computed by delegating to fmri_data.ttest +% on a proxy (each grayordinate treated as a voxel), so the statistics are +% identical; the result is returned as an fmri_surface_data carrying the t-map in +% .dat and the parallel statistics in .additional_info.statistic (.t, .p, .ste, +% .sig, .dfe). Any extra arguments (e.g. threshold) are passed through. +% +% (A dedicated fmri_surface_statistic_image subclass with native threshold() +% support is planned; for now use the .additional_info.statistic fields.) +% +% :Inputs: +% **obj:** fmri_surface_data with multiple maps (columns = observations). +% +% :Outputs: +% **sobj:** fmri_surface_data; .dat is the t-statistic per grayordinate. +% +% :Examples: +% :: +% all_subs = cat(sub1, sub2, sub3, ...); +% t = ttest(all_subs); +% surface(t, 'clim', [-5 5]); +% +% :See also: regress, cat, surface, fmri_surface_data + +proxy = as_fmri_data_proxy(obj); +st = ttest(proxy, varargin{:}); % -> statistic_image + +sobj = rebuild_like(obj, double(st.dat)); +sobj.imagetype = 'dscalar'; +sobj.image_names = {'t'}; + +stat = struct(); +stat.t = double(st.dat); +for f = {'p', 'ste', 'sig', 'dfe', 'N'} + if isprop(st, f{1}) || isfield(st, f{1}) + try, stat.(f{1}) = st.(f{1}); catch, end %#ok + end +end +sobj.additional_info.statistic = stat; +sobj.history{end+1} = sprintf('ttest across %d maps (t-map in .dat; p/ste/sig in additional_info.statistic)', size(obj.dat,2)); +end diff --git a/CanlabCore/@fmri_surface_data/write.m b/CanlabCore/@fmri_surface_data/write.m new file mode 100644 index 00000000..3d2f5524 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/write.m @@ -0,0 +1,168 @@ +function write(obj, varargin) +% write Write an fmri_surface_data object to a CIFTI-2 (.nii) or GIFTI (.gii) file. +% +% :Usage: +% :: +% write(obj) % writes to obj.fullpath +% write(obj, '/path/out.dscalar.nii') % CIFTI-2 (dscalar/dtseries/dlabel) +% write(obj, 'fname', '/path/out.func.gii') % GIFTI +% +% Dispatches on the output extension to the native writers canlab_write_cifti / +% canlab_write_gifti (M1) -- no external toolbox is required. The CIFTI maps +% dimension is rebuilt from the object's intent + image_names / label_table / +% series_info. If the object was loaded from a CIFTI and its grayordinate layout +% is unchanged, the original CIFTI XML is re-emitted faithfully; otherwise the +% XML is regenerated from brain_model. +% +% :Inputs: +% **obj:** an fmri_surface_data object. +% **filename:** output path (positional) or via 'fname'/'filename' keyword. +% Defaults to obj.fullpath. +% +% :See also: canlab_write_cifti, canlab_write_gifti, fmri_surface_data, to_fmri_data + +% ---- Resolve filename ---- +fname = ''; +i = 1; +while i <= numel(varargin) + a = varargin{i}; + if (ischar(a) || isstring(a)) && any(strcmpi(char(a), {'fname','filename'})) && i < numel(varargin) + fname = char(varargin{i+1}); i = i + 2; + elseif (ischar(a) || isstring(a)) && (contains(char(a), '.nii') || contains(char(a), '.gii')) + fname = char(a); i = i + 1; + else + i = i + 1; + end +end +if isempty(fname), fname = char(obj.fullpath); end +if isempty(fname) + error('fmri_surface_data:write:nofname', ... + 'No output filename. Pass one or set obj.fullpath.'); +end + +lc = lower(fname); + +% ===================== GIFTI ===================== +if endsWith(lc, '.gii') + g = struct('vertices', [], 'faces', [], 'cdata', [], 'intents', {{}}, 'labels', []); + if ~isempty(obj.geom) && isfield(obj.geom, 'vertices') + g.vertices = obj.geom.vertices; + g.faces = obj.geom.faces; + end + if ~isempty(obj.dat) + g.cdata = double(obj.dat); + islabel = contains(lc, '.label.') || strcmp(obj.imagetype, 'label') || strcmp(obj.imagetype, 'dlabel'); + if islabel + g.intents = repmat({'NIFTI_INTENT_LABEL'}, 1, size(g.cdata, 2)); + g.labels = labeltable2struct(obj.label_table); + else + g.intents = repmat({'NIFTI_INTENT_NONE'}, 1, size(g.cdata, 2)); + end + end + canlab_write_gifti(fname, g); + return +end + +% ===================== CIFTI-2 ===================== +intent = local_resolve_intent(obj.imagetype, lc); + +cii = struct(); +cii.cdata = double(obj.dat); +cii.intent = intent; +cii.diminfo = {obj.brain_model, local_build_maps_dim(obj, intent)}; + +% Faithful re-emit if the stashed source XML still matches the current layout +ai = obj.additional_info; +if isstruct(ai) && isfield(ai, 'cifti_xml') && isfield(ai, 'cifti_hdr') ... + && ~isempty(ai.cifti_xml) && ~isempty(ai.cifti_hdr) + h = ai.cifti_hdr; + if isfield(h, 'dim') && numel(h.dim) >= 7 + gray = size(cii.cdata, 1); maps = size(cii.cdata, 2); + if (h.dim(6) == maps && h.dim(7) == gray) || (h.dim(6) == gray && h.dim(7) == maps) + cii.xml = ai.cifti_xml; + cii.hdr = h; + end + end +end + +canlab_write_cifti(fname, cii); +end + + +% ========================================================================= +function intent = local_resolve_intent(objintent, lc) +if ismember(objintent, {'dscalar','dtseries','dlabel','dconn'}) + intent = objintent; +elseif contains(lc, '.dscalar.'), intent = 'dscalar'; +elseif contains(lc, '.dtseries.'), intent = 'dtseries'; +elseif contains(lc, '.dlabel.'), intent = 'dlabel'; +elseif strcmp(objintent, 'label'), intent = 'dlabel'; +elseif strcmp(objintent, 'func') || strcmp(objintent, 'shape'), intent = 'dscalar'; +else, intent = 'dscalar'; +end +end + + +% ========================================================================= +function md = local_build_maps_dim(obj, intent) +nMaps = size(obj.dat, 2); + +names = obj.image_names; +if isempty(names) || numel(names) ~= nMaps + names = arrayfun(@(k) sprintf('map_%d', k), 1:nMaps, 'UniformOutput', false); +end +names = reshape(names, 1, []); + +switch intent + case 'dlabel' + % Per-map label tables: prefer stashed per-map tables, else reuse one + tables = {}; + if isstruct(obj.additional_info) && isfield(obj.additional_info, 'label_tables') + tables = obj.additional_info.label_tables; + end + maps = struct('name', {}, 'table', {}); + for k = 1:nMaps + if k <= numel(tables) && ~isempty(tables{k}) + tbl = tables{k}; + else + tbl = labeltable2struct(obj.label_table); + end + maps(k) = struct('name', names{k}, 'table', tbl); + end + md = struct('type', 'labels', 'length', nMaps, 'maps', maps); + + case 'dtseries' + si = obj.series_info; + md = struct('type', 'series', 'length', nMaps); + md.seriesStart = local_getdef(si, 'start', 0); + md.seriesStep = local_getdef(si, 'step', 1); + md.seriesUnit = local_getstr(si, 'unit', 'SECOND'); + md.seriesExponent = local_getdef(si, 'exponent', 0); + + otherwise % dscalar (and fallback) + maps = struct('name', {}, 'table', {}); + for k = 1:nMaps + maps(k) = struct('name', names{k}, 'table', []); + end + md = struct('type', 'scalars', 'length', nMaps, 'maps', maps); +end +end + + +function v = local_getdef(s, f, d) +v = d; +if isstruct(s) && isfield(s, f) && ~isempty(s.(f)) + val = double(s.(f)); + if ~all(isnan(val(:))) + v = s.(f); + end +end +end + +function v = local_getstr(s, f, d) +if isstruct(s) && isfield(s, f) && ~isempty(s.(f)) + v = s.(f); +else + v = d; +end +end diff --git a/CanlabCore/@fmridisplay/add_surface_blobs.m b/CanlabCore/@fmridisplay/add_surface_blobs.m new file mode 100644 index 00000000..8a152da5 --- /dev/null +++ b/CanlabCore/@fmridisplay/add_surface_blobs.m @@ -0,0 +1,154 @@ +function obj = add_surface_blobs(obj, surf_obj, varargin) +% add_surface_blobs Add an fmri_surface_data as a SURFACE-NATIVE blob layer. +% +% :Usage: +% :: +% o2 = add_surface_blobs(o2, surf_obj, [color options]) +% o2 = addblobs(o2, surf_obj, ...) % addblobs dispatches here +% +% Registers a grayordinate/surface object as a managed layer on an fmridisplay, +% painted DIRECTLY on any registered cortical mesh whose vertex count matches the +% object's space (e.g. the fs_LR-32k 'hcp inflated' surfaces for a native CIFTI +% object, or the fsaverage-164k surfaces for a vol2surf result). No volume +% resampling -- the per-vertex values color the vertices at full fidelity, using +% the same central canlab_colormap value->color map as montages, so colors match. +% +% The layer participates in the stateful display: set_colormap, set_opacity, +% refresh, removeblobs, and the controller act on it like a volume layer. It has +% no volume representation, so it does not appear on slice montages (use +% to_fmri_data / surf2vol for the volumetric part), and it is skipped on any +% registered surface whose mesh does not match the object's space. +% +% :Inputs: +% **obj:** an fmridisplay object with one or more surface views (add them +% with surface(obj, 'hcp inflated') etc.). +% **surf_obj:** an fmri_surface_data object. +% +% :Optional Inputs: +% **'which_image':** map (column) to display. Default 1. +% **'cmaprange':** [lo hi] (or 4-element for splitcolor) color range. Default: +% robust per-arm range from the data (canlab_default_cmaprange). +% Any color option understood by canlab_colormap.from_render_args: +% 'colormap', 'pos_colormap'/'neg_colormap', 'splitcolor', 'maxcolor'/'mincolor', +% 'color'. Opacity via 'transvalue' (set_opacity / controller). +% +% :Examples: +% :: +% o2 = fmridisplay; +% o2 = surface(o2, 'hcp inflated left'); o2 = surface(o2, 'hcp inflated right'); +% t = ttest(cat(sub1, sub2, sub3)); % a surface statistic map +% o2 = addblobs(o2, t, 'colormap', 'hot'); % paint it on the fs_LR surfaces +% o2 = set_colormap(o2, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]); % recolor +% +% :See also: addblobs, render_layer_surfaces, set_colormap, fmri_surface_data.surface + +% ---- normalize color aliases so the surface layer honors the same options as +% the volume path: 'clim' -> 'cmaprange', 'colormapname' -> 'colormap', and a +% NAMED colormap string ('hot','parula',...) -> a numeric LUT (canlab_colormap +% only recognizes a numeric 'colormap'). Without this, a named colormap silently +% fell through to the default split map. ---- +varargin = normalize_color_args(varargin); + +% ---- color MODE: explicit 'unique' / 'solid', else auto-select ('unique' for a +% few integer labels, 'solid' for a binary mask, 'colormap' otherwise) via the +% shared canlab_color_mode cutoff. 'unique' -> one solid colour per region +% (scn_standard_colors as an indexed colormap); 'solid' -> one colour for all +% in-data vertices. Only applied when no explicit colour spec was given. ---- +colorkeys = {'colormap', 'pos_colormap', 'neg_colormap', 'splitcolor', ... + 'maxcolor', 'mincolor', 'color', 'indexmap'}; +has_explicit = any(cellfun(@(a) (ischar(a) || isstring(a)) && any(strcmpi(a, colorkeys)), varargin)); +wu = find(strcmpi(varargin, 'unique'), 1); +ws = find(strcmpi(varargin, 'solid'), 1); +color_mode = ''; +if ~isempty(wu), color_mode = 'unique'; varargin(wu) = []; +elseif ~isempty(ws), color_mode = 'solid'; varargin(ws) = []; +elseif ~has_explicit, color_mode = canlab_color_mode(surf_obj); +end +switch color_mode + case 'unique' + wi = find(strcmp(varargin, 'which_image'), 1); wimg = 1; + if ~isempty(wi), wimg = varargin{wi + 1}; end + nlab = max(1, round(max(double(surf_obj.dat(:, min(wimg, size(surf_obj.dat, 2))))))); + cm = cell2mat(scn_standard_colors(nlab)'); + varargin = [varargin, {'indexmap', cm}]; + case 'solid' + varargin = [varargin, {'color', [1 0.4 0]}]; % default solid (override with 'color') + % 'colormap' / '' : leave the default (split) colour pipeline. +end + +% ---- which_image (a single layer shows one map) ---- +which_image = 1; +wh = find(strcmp(varargin, 'which_image'), 1); +if ~isempty(wh), which_image = varargin{wh + 1}; varargin(wh:wh + 1) = []; end + +% ---- color range: explicit, else robust default from the data ---- +cmaprange = []; +wh = find(strcmp(varargin, 'cmaprange'), 1); +if ~isempty(wh), cmaprange = varargin{wh + 1}; end +if isempty(cmaprange) + v = double(surf_obj.dat(:, which_image)); + v = v(v ~= 0 & isfinite(v)); + if ~isempty(v) + split_flag = {}; + if any(strcmp(varargin, 'splitcolor')), split_flag = {'splitcolor'}; end + cmaprange = canlab_default_cmaprange(v, split_flag{:}); + end +end + +% ---- which surfaces (default all) ---- +wh_surface = 1:numel(obj.surface); +whs = find(strcmp(varargin, 'wh_surface') | strcmp(varargin, 'wh_surfaces'), 1); +if ~isempty(whs), wh_surface = varargin{whs + 1}; end + +% ---- build a layer struct compatible with the volume layers ---- +layer = struct('mapdata', [], 'V', [], 'SPACE', [], 'blobhandles', [], ... + 'cmaprange', cmaprange, 'mincolor', [0 0 1], 'maxcolor', [1 0 0], 'color', [], ... + 'source_region', [], 'wh_montage', [], 'wh_surface', wh_surface, ... + 'applied_threshold', [], 'which_image', which_image, 'visible', true, ... + 'legendhandle', []); +layer.source_object = surf_obj; +layer.source_surface = surf_obj; % marks this as a surface-native layer +layer.render_args = varargin; + +obj.activation_maps{end + 1} = layer; +k = numel(obj.activation_maps); + +if isempty(obj.surface) + warning('fmridisplay:add_surface_blobs:nosurface', ... + ['No surface views on this fmridisplay. Add one first, e.g. ' ... + 'o2 = surface(o2, ''hcp inflated'');']); + obj = update_controller(obj); + return +end + +obj = render_layer_surfaces(obj, k, wh_surface); + +% Keep an open controller panel in sync with the new layer (mirrors addblobs). +% Without this the auto-launched controller keeps showing its empty state until +% the user reopens it by hand. +obj = update_controller(obj); +end + + +function args = normalize_color_args(args) +% Map color-option aliases onto the keys canlab_colormap.from_render_args reads. +for i = 1:2:numel(args) - 1 + if ~ischar(args{i}), continue; end + switch lower(args{i}) + case 'clim', args{i} = 'cmaprange'; + case 'colormapname', args{i} = 'colormap'; + end +end +% A named colormap string -> numeric LUT (from_render_args needs numeric). +wh = find(strcmpi(args, 'colormap'), 1); +if ~isempty(wh) && wh < numel(args) && (ischar(args{wh + 1}) || isstring(args{wh + 1})) + name = char(args{wh + 1}); + try + args{wh + 1} = feval(name, 256); % hot/cool/parula/jet/... + catch + warning('fmridisplay:add_surface_blobs:colormapname', ... + 'Unknown colormap name ''%s''; using the default.', name); + args(wh:wh + 1) = []; % drop -> default map + end +end +end diff --git a/CanlabCore/@fmridisplay/addblobs.m b/CanlabCore/@fmridisplay/addblobs.m index 8cc276a9..108748a4 100644 --- a/CanlabCore/@fmridisplay/addblobs.m +++ b/CanlabCore/@fmridisplay/addblobs.m @@ -176,6 +176,15 @@ ' o2 = addblobs(o2, region(t)); %% add the blobs from t']); end +% Surface / grayordinate data: an fmri_surface_data is an image_vector but has no +% single 3-D volume, so it is added as a SURFACE-NATIVE layer (painted directly on +% matching cortical meshes) rather than converted to a volume region. Handled by a +% dedicated method so the rest of addblobs (volume/region machinery) is untouched. +if isa(cl, 'fmri_surface_data') + obj = add_surface_blobs(obj, cl, varargin{:}); + return +end + % Multi-image objects: a single blob layer shows one image, so if an % image_vector with more than one image (column) is passed, use only the FIRST % image (with a note). This keeps addblobs(o2, multi_image_obj) from erroring or @@ -268,6 +277,21 @@ wh_surface = varargin{whs(1) + 1}; end +% The montage/surface view selectors are consumed above; strip them so they are +% not forwarded to render_blobs (which warns "Unknown input string option") and +% not stored in render_args (where refresh would forward them again on every +% re-render). Do this AFTER the parsing above, which needs them in varargin. +view_keys = {'wh_surfaces', 'wh_surface', 'which_surfaces', 'which surfaces', ... + 'wh_montages', 'wh_montage', 'which_montages', 'which montages'}; +vi = 1; +while vi <= numel(varargin) + if ischar(varargin{vi}) && any(strcmp(varargin{vi}, view_keys)) + varargin(vi:vi + 1) = []; + else + vi = vi + 1; + end +end + % Default color values % ------------------------------------------------------------------- diff --git a/CanlabCore/@fmridisplay/composite_surfaces.m b/CanlabCore/@fmridisplay/composite_surfaces.m index 7f459034..ef8fe200 100644 --- a/CanlabCore/@fmridisplay/composite_surfaces.m +++ b/CanlabCore/@fmridisplay/composite_surfaces.m @@ -24,13 +24,20 @@ if nargin < 2 || isempty(wh_surface), wh_surface = 1:numel(obj.surface); end if nargin < 3 || isempty(show_legend), show_legend = false; end % colorbars off by default -% Reset each target surface to its saved anatomy gray +% Reset each target surface to its saved anatomy gray. Erase when ANY patch in +% the view carries saved anatomy (UserData) -- not just the first handle -- so a +% mixed view (e.g. surface-native cortex patches alongside subcortical patches) +% is reliably reset regardless of handle order. for s = wh_surface if s < 1 || s > numel(obj.surface), continue, end h = obj.surface{s}.object_handle; h = h(ishandle(h)); if isempty(h), continue, end - if ~isempty(get(h(1), 'UserData')) + has_saved = false; + for hh = h(:)' + if ~isempty(get(hh, 'UserData')), has_saved = true; break; end + end + if has_saved addbrain('eraseblobs', h); end end diff --git a/CanlabCore/@fmridisplay/controller.m b/CanlabCore/@fmridisplay/controller.m index 6f8ce28c..945fb60b 100644 --- a/CanlabCore/@fmridisplay/controller.m +++ b/CanlabCore/@fmridisplay/controller.m @@ -33,7 +33,16 @@ % .. % ===> Controller window background colour. Tweak this RGB triplet manually. <=== -FIG_COLOR = [1 0.5 0]; +FIG_COLOR = [0.125 0.698 0.667]; % light sea green + +% Opening OR rebuilding the controller must never change the caller's current +% figure: the controller is a uifigure, and if it becomes gcf the next +% montage/surface slice-drawing (which uses gca/gcf) can land in the controller +% window or the wrong axes -- an intermittent bug when a display is (re)built while +% the controller auto-launches/updates. Capture the current figure now and restore +% it on every exit path (onCleanup covers the early update-in-place return too). +prevfig = get(groot, 'CurrentFigure'); +restore_currentfig = onCleanup(@() local_restore_currentfig(prevfig)); %#ok vname = inputname(1); % caller's variable name, for echoed code + title nlayers = numel(obj.activation_maps); @@ -102,7 +111,7 @@ opts = {'split (hot/cool)', 'split (mango)', 'seafire', 'warm (red-yellow)', ... 'cool (blue-cyan)', 'winter (blue-green)', ... 'viridis', 'inferno', 'magma', 'plasma', 'turbo', 'parula', ... - 'indexed (atlas)', 'solid colour…'}; + 'unique (per region)', 'solid colour…'}; end function names = perceptual_names() @@ -548,11 +557,33 @@ case perceptual_names() % Perceptual / continuous LUT colormaps (viridis, inferno, turbo, ...). set_colormap(obj, 'colormap', canlab_perceptual_colormap(choice), 'layers', k); echo_code(vname, sprintf('set_colormap(%s, ''colormap'', canlab_perceptual_colormap(''%s''), ''layers'', %d)', vname, choice, k)); + case 'unique (per region)' + % One solid colour per region: scn_standard_colors as an indexed colormap. + nlab = local_layer_maxlabel(obj, k); + cm = cell2mat(scn_standard_colors(nlab)'); + set_colormap(obj, 'indexmap', cm, 'layers', k); + echo_code(vname, sprintf('set_colormap(%s, ''indexmap'', cell2mat(scn_standard_colors(%d)''), ''layers'', %d)', vname, nlab, k)); case 'solid colour…' pick_solid_colour(obj, k, vname); end end + +function n = local_layer_maxlabel(obj, k) +% Number of colours for a 'unique' indexed colormap = the largest region index in +% the layer's source data (surface-native or volume). Falls back to 100. +n = 100; +lay = obj.activation_maps{k}; +src = []; +if isfield(lay, 'source_surface') && ~isempty(lay.source_surface), src = lay.source_surface; +elseif isfield(lay, 'source_object') && ~isempty(lay.source_object), src = lay.source_object; +end +if ~isempty(src) && isprop(src, 'dat') && ~isempty(src.dat) + m = max(double(src.dat(:))); + if isfinite(m) && m >= 1, n = round(m); end +end +end + function on_threshold(obj, k, value, is_pval, vname) % Apply the threshold on slider RELEASE / field entry (the expensive step: % rethreshold + refresh). Wrapped so a bad value (e.g. one that leaves no voxels) @@ -632,7 +663,7 @@ function echo_code(vname, callstr) function lbl = current_colormap_label(args, opts) %#ok if any(strcmp(args, 'indexmap')) - lbl = 'indexed (atlas)'; + lbl = 'unique (per region)'; elseif any(strcmp(args, 'splitcolor')) sc = args{find(strcmp(args, 'splitcolor'), 1) + 1}; if iscell(sc) && numel(sc) == 4 && isequal(sc, {[.5 0 1] [0 .8 .3] [1 .2 1] [1 1 .3]}) @@ -688,3 +719,12 @@ function set_layer_visible(obj, k, tf) composite_surfaces(obj); end end + + +function local_restore_currentfig(prevfig) +% Restore the current figure captured before the controller was opened/rebuilt, +% so the controller uifigure never becomes gcf for later montage/surface drawing. +if ~isempty(prevfig) && isgraphics(prevfig) + try, set(groot, 'CurrentFigure', prevfig); catch, end +end +end diff --git a/CanlabCore/@fmridisplay/montage.m b/CanlabCore/@fmridisplay/montage.m index d0e74833..07d26594 100644 --- a/CanlabCore/@fmridisplay/montage.m +++ b/CanlabCore/@fmridisplay/montage.m @@ -143,6 +143,27 @@ end +% An fmri_surface_data argument: build the montage, then render the object's +% SUBCORTICAL grayordinates as a managed volume layer on the slices (cortical +% surface data has no volume and is not shown on slices -- use surface(obj) for +% that). This mirrors the surface() fmri_surface_data path so montage(o2, obj) +% shows the subcortex instead of silently ignoring obj. +is_surf = cellfun(@(a) isa(a, 'fmri_surface_data'), varargin); +if any(is_surf) + surf_data = varargin{find(is_surf, 1)}; + rest = varargin(~is_surf); + obj = montage(obj, rest{:}); % build the montage view(s) + if ~isempty(surf_data.brain_model) && ... + any(cellfun(@(m) strcmp(m.type, 'vox'), surf_data.brain_model.models)) + obj = addblobs(obj, get_wh_image(to_fmri_data(surf_data), 1)); + else + warning('fmridisplay:montage:cortexonly', ... + ['This grayordinate object is cortex-only (no subcortical voxels); a slice ' ... + 'montage shows nothing. Use surface(obj) to render the cortical surface.']); + end + return +end + % Multi-panel routing % ------------------------------------------------------------------------- % The default montage(obj) (and montage(obj, ) for any diff --git a/CanlabCore/@fmridisplay/refresh.m b/CanlabCore/@fmridisplay/refresh.m index 0bbfe3db..72b1a7b2 100644 --- a/CanlabCore/@fmridisplay/refresh.m +++ b/CanlabCore/@fmridisplay/refresh.m @@ -54,6 +54,14 @@ layer = obj.activation_maps{k}; + % Surface-native layer (fmri_surface_data source): it has no volume mapdata, + % so skip the montage render_blobs path entirely (it would warn about an + % illegal mask size / volInfo). The surfaces are redrawn by composite_surfaces + % after this loop, which handles surface-native layers. + if isfield(layer, 'source_surface') && isa(layer.source_surface, 'fmri_surface_data') + continue + end + if ~isfield(layer, 'render_args') || isempty(layer.render_args) % Layer predates source retention (or has no stored options); it % cannot be re-rendered from source. Leave it as-is. diff --git a/CanlabCore/@fmridisplay/render_layer_surfaces.m b/CanlabCore/@fmridisplay/render_layer_surfaces.m index 60021351..3f1be542 100644 --- a/CanlabCore/@fmridisplay/render_layer_surfaces.m +++ b/CanlabCore/@fmridisplay/render_layer_surfaces.m @@ -42,6 +42,15 @@ layer = obj.activation_maps{k}; +% Surface-native layer (fmri_surface_data source): paint matching cortical meshes +% directly from the per-vertex data, at full fidelity, using the same central +% canlab_colormap value->colour map as montages. See add_surface_blobs. +if isfield(layer, 'source_surface') && isa(layer.source_surface, 'fmri_surface_data') + if nargin < 3 || isempty(wh_surface), wh_surface = 1:numel(obj.surface); end + obj = paint_surface_native_layer(obj, k, wh_surface); + return +end + if ~isfield(layer, 'source_region') || isempty(layer.source_region) return % legacy layer with no retained source; nothing to render from end @@ -125,6 +134,19 @@ surfh = surfh(ishandle(surfh)); % skip handles whose figure was closed if isempty(surfh), continue, end + % A subcortical-volume layer (added alongside a cortical surface-native layer + % for a mixed grayordinate object) must NOT paint the cortical meshes -- the + % cortex belongs to the surface-native layer, and projecting the subcortical + % volume onto the cortical surface would bleed onto medial-wall vertices that + % sit next to subcortical structures. Skip patches whose vertex count is a + % cortical mesh (see fmridisplay.surface, which sets skip_cortex_nv). + if isfield(layer, 'skip_cortex_nv') && ~isempty(layer.skip_cortex_nv) + keep = arrayfun(@(h) ~(strcmp(get(h, 'Type'), 'patch') && ... + ismember(size(get(h, 'Vertices'), 1), layer.skip_cortex_nv)), surfh); + surfh = surfh(keep); + if isempty(surfh), continue, end + end + % NOTE: this paints layer k onto the CURRENT surface colours (no erase), so a % new layer composites on top of lower layers (true-colour, top wins per % vertex). The anatomy gray is saved once (render_on_surface) so removeblobs/ @@ -161,6 +183,211 @@ end +function obj = paint_surface_native_layer(obj, k, wh_surface) +% Paint a surface-native layer (fmri_surface_data source) directly onto matching +% cortical meshes, blending by the layer opacity, using the central canlab_colormap +% so colours match montages. Meshes whose vertex count does not match the object's +% space are skipped (a layer has no volume, so it cannot be sampled onto them). +layer = obj.activation_maps{k}; +surf = layer.source_surface; + +args = {}; +if isfield(layer, 'render_args') && ~isempty(layer.render_args), args = layer.render_args; end +cmaprange = []; +if isfield(layer, 'cmaprange'), cmaprange = layer.cmaprange; end +which_image = 1; +if isfield(layer, 'which_image') && ~isempty(layer.which_image), which_image = layer.which_image; end + +% Native dense per-hemisphere values (medial wall = NaN), before thresholding. +r = reconstruct_image(surf); +L0 = []; R0 = []; +if isfield(r, 'cortex_left'), L0 = r.cortex_left(:, which_image); end +if isfield(r, 'cortex_right'), R0 = r.cortex_right(:, which_image); end +native_nv = max([numel(L0), numel(R0)]); + +% Layer threshold (rethreshold stores it here; a scalar is a magnitude cutoff: +% sub-threshold vertices -> NaN -> uncoloured). Applied at PAINT time, to whatever +% hemisphere data is painted, so set_colormap / set_opacity preserve it. +thr = []; +if isfield(layer, 'applied_threshold'), thr = layer.applied_threshold; end + +% Robust default colour range if none stored (from native, thresholded values). +if isempty(cmaprange) + v = [local_apply_thr(L0, thr); local_apply_thr(R0, thr)]; + v = v(v ~= 0 & isfinite(v)); + if ~isempty(v) + sf = {}; if any(strcmp(args, 'splitcolor')), sf = {'splitcolor'}; end + cmaprange = canlab_default_cmaprange(v, sf{:}); + end +end + +tc_map = canlab_colormap.from_render_args(args, cmaprange); + +% Layer opacity (set_opacity / controller) blends this layer with what's underneath +alpha = 1; +wh_tv = find(strcmp(args, 'transvalue'), 1); +if ~isempty(wh_tv) && isnumeric(args{wh_tv + 1}), alpha = args{wh_tv + 1}; end +alpha = max(0, min(1, alpha)); + +% Hemisphere data by mesh vertex count (post-threshold), computed on demand. A +% patch in the object's OWN space is painted directly. A patch that is a DIFFERENT +% recognized standard cortical mesh (e.g. fs_LR data on an fsaverage surface) is +% handled by an automatic native resample of the source object onto that mesh's +% space (resample_surface, nearest for render speed), CACHED on the layer so +% re-renders (set_colormap / rethreshold / opacity) reuse it. A non-standard mesh +% (arbitrary vertex count) cannot be resampled and is skipped. +if ~isfield(layer, 'resampled') || ~isstruct(layer.resampled), layer.resampled = struct(); end +hemiL = containers.Map('KeyType', 'double', 'ValueType', 'any'); +hemiR = containers.Map('KeyType', 'double', 'ValueType', 'any'); +hemiL(native_nv) = local_apply_thr(L0, thr); +hemiR(native_nv) = local_apply_thr(R0, thr); + +n_painted = 0; n_unknown = 0; +for i = wh_surface + if i < 1 || i > numel(obj.surface), continue; end + surfh = obj.surface{i}.object_handle; + surfh = surfh(ishandle(surfh)); + for hh = surfh(:)' + if ~strcmp(get(hh, 'Type'), 'patch'), continue; end + V = get(hh, 'Vertices'); + nv = size(V, 1); + tag = lower(get(hh, 'Tag')); + if iscell(tag), tag = strjoin(tag, ' '); end + + % Ensure hemisphere data exists for this mesh's vertex count (resample if + % it is a recognized standard space different from the object's own). + if ~isKey(hemiL, nv) + tgt = local_std_space_name(nv); + if isempty(tgt) + % Non-standard MNI isosurface (e.g. addbrain 'hires left', + % 'cutaway'): no spherical registration exists, so paint it by + % projecting the data to a volume and sampling that volume at the + % patch's vertices (image_vector.render_on_surface). The volume is + % cached on the layer so re-renders stay fast. + if ~isfield(layer, 'display_volume') || isempty(layer.display_volume) + try + layer.display_volume = to_display_volume(surf); + obj.activation_maps{k}.display_volume = layer.display_volume; + catch + n_unknown = n_unknown + 1; continue; + end + end + vimg = layer.display_volume; + if size(vimg.dat, 2) > 1, vimg = get_wh_image(vimg, which_image); end + if ~isempty(thr) && isscalar(thr) && isfinite(thr) && ~strcmp(tc_map.type, 'indexed') + vimg.dat(abs(vimg.dat) < thr) = 0; % apply the layer threshold + end + cargs = {'truecolor', tc_map, 'truecolor_alpha', alpha, 'nolegend'}; + if ~isempty(cmaprange), cargs = [cargs, {'cmaprange', cmaprange}]; end %#ok + if strcmp(tc_map.type, 'indexed'), cargs = [cargs, {'interp', 'nearest'}]; end %#ok + try + render_on_surface(vimg, hh, cargs{:}); + n_painted = n_painted + 1; + catch + n_unknown = n_unknown + 1; + end + continue + end + key = sprintf('nv%d', nv); + if ~isfield(layer.resampled, key) + try + layer.resampled.(key) = resample_surface(surf, tgt, 'interp', 'nearest'); + catch + n_unknown = n_unknown + 1; continue; + end + obj.activation_maps{k}.resampled = layer.resampled; % persist for re-renders + end + rr = reconstruct_image(layer.resampled.(key)); + Lr = []; Rr = []; + if isfield(rr, 'cortex_left'), Lr = rr.cortex_left(:, which_image); end + if isfield(rr, 'cortex_right'), Rr = rr.cortex_right(:, which_image); end + hemiL(nv) = local_apply_thr(Lr, thr); + hemiR(nv) = local_apply_thr(Rr, thr); + end + Ld_use = hemiL(nv); Rd_use = hemiR(nv); + + % Which hemisphere is this patch? Prefer an explicit 'left'/'right' tag; + % else fall back to x-centroid sign (left < 0 < right), because addbrain + % relabels foursurfaces_* patches and erases the L/R tag. + is_left = contains(tag, 'left'); + is_right = contains(tag, 'right'); + if ~is_left && ~is_right + if mean(V(:, 1)) < 0, is_left = true; else, is_right = true; end + end + + vals = []; + if ~isempty(Rd_use) && nv == numel(Rd_use) && is_right + vals = Rd_use; + elseif ~isempty(Ld_use) && nv == numel(Ld_use) && is_left + vals = Ld_use; + end + if isempty(vals), continue; end + + base = local_base_rgb(hh, nv); + + % Save the anatomy (gray) on first paint so composite_surfaces can reset + % this patch to gray before recompositing (see set_opacity / set_colormap). + if isempty(get(hh, 'UserData')) + set(hh, 'UserData', base); + end + + rgb = tc_map.map(double(vals)); % N x 3, NaN rows = uncoloured + col = ~any(isnan(rgb), 2); + out = base; + out(col, :) = alpha * rgb(col, :) + (1 - alpha) * base(col, :); + set(hh, 'FaceVertexCData', out, 'FaceColor', 'interp', 'EdgeColor', 'none'); + n_painted = n_painted + 1; + end +end + +% Only truly non-standard meshes (that cannot be resampled) go unpainted. +if n_painted == 0 && n_unknown > 0 + warning('fmridisplay:render_layer_surfaces:spacemismatch', ... + ['Surface data (%s) could not be painted: the target surface(s) are not a ' ... + 'recognized standard cortical mesh, so the data cannot be resampled onto ' ... + 'them. Use surface(obj), a standard fsaverage / fs_LR surface, or project ' ... + 'via a volume (surf2vol + render_on_surface).'], surf.surface_space); +end +end + + +function x = local_apply_thr(x, thr) +% Magnitude-cutoff threshold: |value| < thr becomes NaN (uncoloured). +if ~isempty(x) && ~isempty(thr) && isscalar(thr) && isfinite(thr) + x(abs(x) < thr) = NaN; +end +end + + +function name = local_std_space_name(nv) +% Standard cortical surface space for a per-hemisphere vertex count ('' if none). +switch nv + case 32492, name = 'fs_LR_32k'; + case 163842, name = 'fsaverage_164k'; + case 40962, name = 'fsaverage6'; + case 10242, name = 'fsaverage5'; + case 2562, name = 'fsaverage4'; + otherwise, name = ''; +end +end + + +function base = local_base_rgb(hh, nv) +% Current vertex colours to blend onto (running composite), else the patch's gray. +c = get(hh, 'FaceVertexCData'); +if isequal(size(c), [nv 3]) + base = c; +else + fc = get(hh, 'FaceColor'); + if isnumeric(fc) && numel(fc) == 3 + base = repmat(fc(:)', nv, 1); + else + base = repmat([.5 .5 .5], nv, 1); + end +end +end + + function [pos_cm, neg_cm] = surface_colormaps_from_args(args) % Build pos/neg colormaps for render_on_surface from the layer's colour spec, so % surfaces match montages. Returns [] for both when there is no explicit spec diff --git a/CanlabCore/@fmridisplay/rethreshold.m b/CanlabCore/@fmridisplay/rethreshold.m index c4358b5a..b21fad90 100644 --- a/CanlabCore/@fmridisplay/rethreshold.m +++ b/CanlabCore/@fmridisplay/rethreshold.m @@ -81,6 +81,18 @@ if k < 1 || k > numel(obj.activation_maps), continue, end layer = obj.activation_maps{k}; + + % Surface-native layer (fmri_surface_data source): it has no volume, so it + % cannot be re-regioned. Store the threshold as a magnitude cutoff that + % paint_surface_native_layer applies at paint time; refresh below redraws the + % surfaces with it. This avoids threshold()/region() on a cortical surface + % object (which has no matching volInfo) and the "Illegal size for mask.dat" + % / "Mask has multiple images" warnings that came from that path. + if isfield(layer, 'source_surface') && isa(layer.source_surface, 'fmri_surface_data') + obj.activation_maps{k}.applied_threshold = abs(double(input_threshold(1))); + continue + end + if ~isfield(layer, 'source_object') || isempty(layer.source_object) warning('fmridisplay:rethreshold', ... 'Layer %d retained no source; cannot rethreshold. Re-add via addblobs.', k); diff --git a/CanlabCore/@fmridisplay/surface.m b/CanlabCore/@fmridisplay/surface.m index afa684ae..90ed7d05 100644 --- a/CanlabCore/@fmridisplay/surface.m +++ b/CanlabCore/@fmridisplay/surface.m @@ -92,6 +92,90 @@ if ~isempty(cf) && is_uifigure(cf), figure; end end +% An fmri_surface_data argument: add its native surface(s) AND paint it as a +% managed surface-native layer. surface(o2, surf_obj, ...) then behaves like +% surface(surf_obj) but on the stateful display -- the surfaces are registered +% views under the controller, and the data is a real layer (set_colormap / +% set_opacity / removeblobs / refresh act on it). With no surface keyword, the +% set that MATCHES the object's space is added automatically (foursurfaces_hcp +% for fs_LR, foursurfaces_freesurfer for fsaverage), so the data always renders +% at full fidelity. An explicit non-matching surface (e.g. fsaverage meshes for +% fs_LR data) is added but cannot be painted, and add_surface_blobs warns. +% See add_surface_blobs, fmri_surface_data.surface. +is_surf = cellfun(@(a) isa(a, 'fmri_surface_data'), varargin); +if any(is_surf) + surf_data = varargin{find(is_surf, 1)}; + rest = varargin(~is_surf); + + % Split the remaining args into surface directives (WHICH surfaces to draw) + % and colour options (HOW to paint the layer). Colour options are the + % value-bearing keys add_surface_blobs / canlab_colormap understand; every + % other token (foursurfaces*, direction/orientation/axes pairs, a bare + % addbrain surface keyword) is a surface directive. + color_keys = {'which_image', 'clim', 'cmaprange', 'colormap', 'colormapname', ... + 'pos_colormap', 'neg_colormap', 'splitcolor', 'maxcolor', 'mincolor', ... + 'color', 'transvalue', 'wh_surface', 'wh_surfaces'}; + color_flags = {'unique', 'solid'}; % value-less colour-mode flags + surf_args = {}; color_args = {}; + j = 1; + while j <= numel(rest) + a = rest{j}; + if ischar(a) && any(strcmpi(a, color_flags)) + color_args = [color_args, rest(j)]; j = j + 1; %#ok + elseif ischar(a) && any(strcmpi(a, color_keys)) + color_args = [color_args, rest(j:j+1)]; j = j + 2; %#ok + else + surf_args{end + 1} = a; j = j + 1; %#ok + end + end + + if isempty(surf_args) + surf_args = {surface_default_keyword(surf_data)}; % matching space + end + + % Shared colour range across cortex and subcortex, so both layers use ONE + % scale (unless the caller passed clim/cmaprange). Computed from all + % grayordinates via the standard robust policy. + which_img = 1; + whi = find(strcmpi(color_args, 'which_image'), 1); + if ~isempty(whi), which_img = color_args{whi + 1}; end + if ~any(strcmpi(color_args, 'cmaprange')) && ~any(strcmpi(color_args, 'clim')) + vv = double(surf_data.dat(:, min(which_img, size(surf_data.dat, 2)))); + vv = vv(vv ~= 0 & isfinite(vv)); + if ~isempty(vv) + sf = {}; if any(strcmpi(color_args, 'splitcolor')), sf = {'splitcolor'}; end + color_args = [color_args, {'cmaprange', canlab_default_cmaprange(vv, sf{:})}]; + end + end + + n0 = numel(obj.surface); + obj = surface(obj, surf_args{:}); % add the view(s) + new_idx = (n0 + 1):numel(obj.surface); + if isempty(new_idx), new_idx = 1:numel(obj.surface); end + + % Cortex: surface-native layer painted directly on matching meshes. + obj = add_surface_blobs(obj, surf_data, color_args{:}, 'wh_surface', new_idx); + + % Subcortex (if present): its grayordinate voxels have no surface, so render + % them as a standard volume layer. On these surface views that paints the + % subcortical anatomical meshes (thalamus, brainstem, cerebellum, ...) that + % the foursurfaces set draws, via the volume->surface projection; on a montage + % it shows the slices. It is a normal volume layer, so rethreshold / opacity / + % set_colormap / the controller all drive it too. + if surface_has_volume(surf_data) + subvol = get_wh_image(to_fmri_data(surf_data), which_img); + vol_color = volume_color_args(color_args); + obj = addblobs(obj, subvol, vol_color{:}, 'wh_surface', new_idx); + % Confine this layer to the subcortical meshes: it must not repaint the + % cortical surfaces (owned by the surface-native layer above). Recomposite + % so the cortex is restored to the surface-native colouring / medial-wall + % gray after addblobs' incremental paint. + obj.activation_maps{end}.skip_cortex_nv = cortex_vertex_counts(surf_data); + obj = composite_surfaces(obj, new_idx); + end + return +end + % Multi-surface keywords (e.g. 'foursurfaces', 'foursurfaces_hcp') add a SET % of surface views to THIS SAME object, laid out in the current figure. Each % becomes its own registered view, so blobs/refresh act on all of them @@ -231,6 +315,11 @@ obj = render_layer_surfaces(obj, k, new_surf_idx); end +% Keep an already-open controller bound to THIS object (no new controller is +% created; update_controller is a no-op if none is open). This ensures adding a +% surface view leaves the existing controller in sync rather than stale. +obj = update_controller(obj); + end % main function @@ -325,6 +414,9 @@ set(findobj(layout_fig, 'Type', 'axes'), 'Visible', 'off'); canlab_hide_axes_toolbar(layout_fig); % hide the "..." toolbar on all surface panels +% Sync an already-open controller (no-op if none); no new controller is created. +obj = update_controller(obj); + end @@ -334,3 +426,62 @@ tf = canlab_is_uifigure(f); end + +function tf = surface_has_volume(surf_data) +% True when the grayordinate object has a subcortical (voxel) sub-block. +tf = ~isempty(surf_data.brain_model) && ... + any(cellfun(@(m) strcmp(m.type, 'vox'), surf_data.brain_model.models)); +end + + +function nv = cortex_vertex_counts(surf_data) +% Per-hemisphere vertex counts of the object's cortical models (e.g. 32492 for +% fs_LR-32k). Used to keep a subcortical-volume layer off the cortical meshes. +nv = []; +for i = 1:numel(surf_data.brain_model.models) + m = surf_data.brain_model.models{i}; + if strcmp(m.type, 'surf') && ~isempty(m.numvert) && ~isnan(m.numvert) + nv(end + 1) = m.numvert; %#ok + end +end +nv = unique(nv); +end + + +function out = volume_color_args(color_args) +% Translate the surface color options into the subset the volume blob path +% (render_blobs) understands, so cortex and subcortex share a colour spec. +% which_image is already applied; clim -> cmaprange; drop surface-only tokens. +out = {}; +i = 1; +while i <= numel(color_args) + key = color_args{i}; + if ~ischar(key), i = i + 1; continue; end + switch lower(key) + case 'clim' + out = [out, {'cmaprange', color_args{i + 1}}]; %#ok + case {'cmaprange', 'maxcolor', 'mincolor', 'splitcolor', 'color', ... + 'pos_colormap', 'neg_colormap'} + out = [out, color_args(i:i + 1)]; %#ok + case 'colormap' + if i + 1 <= numel(color_args) && isnumeric(color_args{i + 1}) + out = [out, color_args(i:i + 1)]; %#ok + end + % which_image / colormapname / transvalue / wh_surface: not forwarded. + end + i = i + 2; +end +end + + +function kw = surface_default_keyword(surf_data) +% Multi-surface keyword whose meshes MATCH the object's surface space, so the +% data paints directly (no cross-space resampling). Used when surface(o2, +% surf_obj) is called with no explicit surface directive. +switch surf_data.surface_space + case 'fsLR_32k', kw = 'foursurfaces_hcp'; % 32492 verts/hemi + case 'fsaverage_164k', kw = 'foursurfaces_freesurfer'; % 163842 verts/hemi + otherwise, kw = 'foursurfaces_hcp'; % best-effort default +end +end + diff --git a/CanlabCore/@image_vector/ica.m b/CanlabCore/@image_vector/ica.m index 0a6657ea..39e78f7f 100644 --- a/CanlabCore/@image_vector/ica.m +++ b/CanlabCore/@image_vector/ica.m @@ -86,7 +86,9 @@ % - mahal % - orthviews -figure; plot(B(:), W(:), 'k.') +% (Removed a stray, non-functional debug line here that referenced undefined +% variables B and W before they were computed, which made ica() error on every +% call for all image_vector subclasses. -- 2026-06-26) nic = 30; if length(varargin) > 0, nic = varargin{1}; end diff --git a/CanlabCore/@image_vector/vol2surf.m b/CanlabCore/@image_vector/vol2surf.m new file mode 100644 index 00000000..d546ff86 --- /dev/null +++ b/CanlabCore/@image_vector/vol2surf.m @@ -0,0 +1,103 @@ +function surf = vol2surf(obj, varargin) +% vol2surf Project a volumetric image to the fsaverage cortical surface. +% +% :Usage: +% :: +% surf = vol2surf(fmri_data_obj) +% surf = vol2surf(fmri_data_obj, 'interp', 'nearest') +% +% Projects a volumetric image_vector / fmri_data (in an MNI152 space) onto the +% fsaverage 164k cortical surface, returning an fmri_surface_data object. +% +% THIS IS THE CBIG REGISTRATION-FUSION MAPPER, NATIVELY. It uses the vendored +% CBIG Registration-Fusion (RF-ANTs) MNI152->fsaverage warp (Wu et al. 2018, +% Human Brain Mapping; MIT-licensed) -- the per-vertex MNI152 RAS coordinates in +% lh/rh.avgMapping_allSub_RF_ANTs_MNI152_orig_to_fsaverage.mat (resolved by +% canlab_cbig_warp_path) -- and samples the volume at those coordinates with +% interpn. This is a line-for-line native reimplementation of CBIG's +% CBIG_RF_projectMNI2fsaverage.m (see CanlabCore/Cifti_plotting/ +% CBIG_registration_fusion_surf2vol_vol2surf/): identical warp data and identical +% interpn sampling, but driven by SPM's volInfo.mat affine instead of FreeSurfer's +% MRIread vox2ras, so NO FreeSurfer or Connectome Workbench is required. +% +% It is a fixed group-template MNI152<->fsaverage correspondence (correct for +% group MNI maps; not a per-subject ribbon mapper), and it targets fsaverage-164k +% (fsaverage<->fs_LR deformation is a later step). surf2vol is the inverse. +% +% :References: +% Wu J, Ngo GH, Greve DN, Li J, He T, Fischl B, Eickhoff SB, Yeo BTT (2018). +% Accurate nonlinear mapping between MNI volumetric and FreeSurfer surface +% coordinate systems. Human Brain Mapping 39(9):3793-3808. +% +% :Inputs: +% **obj:** an image_vector / fmri_data / statistic_image in an MNI152 volume +% space (uses obj.volInfo.mat for the voxel->mm affine). +% +% :Optional Inputs: +% **'interp':** 'linear' (default) or 'nearest' (use 'nearest' for label maps). +% +% :Outputs: +% **surf:** fmri_surface_data, surface_space 'fsaverage_164k', cortex-only +% ([2*163842 x nMaps], left then right). +% +% :Examples: +% :: +% dat = ttest(load_image_set('emotionreg')); % a volumetric statistic_image +% s = vol2surf(dat); % -> fmri_surface_data on the fsaverage surface +% % surface(s) % render it +% +% :See also: surf2vol, fmri_surface_data, canlab_cbig_warp_path + +interp = 'linear'; +for i = 1:2:numel(varargin) + switch lower(varargin{i}) + case 'interp', interp = varargin{i+1}; + otherwise, error('vol2surf:badopt', 'Unknown option: %s', varargin{i}); + end +end + +if isempty(obj.volInfo) || ~isfield(obj.volInfo, 'mat') + error('vol2surf:novolinfo', 'Input has no volInfo.mat (needs a volumetric space).'); +end + +% Reconstruct the full 3-D/4-D volume and get the voxel->mm affine +obj = replace_empty(obj); +voldata = reconstruct_image(obj); % [X Y Z nMaps] +if ndims(voldata) == 3, voldata = voldata(:, :, :, 1); end +mat = obj.volInfo.mat; +nMaps = size(voldata, 4); + +NV = 163842; % fsaverage vertices per hemisphere +L = load(canlab_cbig_warp_path('lh_ras')); lh_ras = L.ras; % 3 x NV (MNI mm) +R = load(canlab_cbig_warp_path('rh_ras')); rh_ras = R.ras; + +datmat = zeros(2 * NV, nMaps, 'single'); +hemis = {lh_ras, rh_ras}; +offsets = [0, NV]; +for h = 1:2 + ras = [hemis{h}; ones(1, NV)]; + vox = mat \ ras; % 4 x NV, 1-based voxel coords + rows = offsets(h) + (1:NV); + for k = 1:nMaps + V = voldata(:, :, :, k); + vals = interpn(V, vox(1, :), vox(2, :), vox(3, :), interp, 0); + datmat(rows, k) = single(vals(:)); + end +end + +% Build the fsaverage cortex-only brain_model +mL = struct('struct', 'CORTEX_LEFT', 'type', 'surf', 'start', 1, 'count', NV, ... + 'numvert', NV, 'vertlist', 0:NV-1, 'voxlist', []); +mR = struct('struct', 'CORTEX_RIGHT', 'type', 'surf', 'start', NV+1, 'count', NV, ... + 'numvert', NV, 'vertlist', 0:NV-1, 'voxlist', []); +bm = struct('type', 'dense', 'length', 2*NV, 'models', {{mL, mR}}, 'vol', []); +bm.grayordinate_type = 'cortex_only'; +bm.cluster = []; + +surf = fmri_surface_data('dat', datmat, 'brain_model', bm, ... + 'surface_space', 'fsaverage_164k', 'imagetype', 'dscalar'); +if ~isempty(obj.image_names), surf.image_names = obj.image_names; end +surf.history = obj.history; +surf.history{end+1} = sprintf(['vol2surf: projected %d maps to fsaverage_164k via CBIG ' ... + 'RF-ANTs MNI152 mapping (interp=%s)'], nMaps, interp); +end diff --git a/CanlabCore/@region/surface.m b/CanlabCore/@region/surface.m index 328b166d..851c8507 100644 --- a/CanlabCore/@region/surface.m +++ b/CanlabCore/@region/surface.m @@ -166,12 +166,15 @@ case 'noverbose' - case addbrain_allowable_args + case addbrain_allowable_args % do nothing, handle later - + case render_on_surface_allowable_args % do nothing, will pass in to render_on_surface - + + case {'unique', 'solid'} + % colour-mode flags, translated to an indexed / solid colormap below + otherwise, warning(['Unknown input string option:' varargin{i}]); end end @@ -220,6 +223,29 @@ return end +% Colour MODE: 'unique' (one solid colour per region, like region/montage), +% 'solid', or auto-selected via the shared canlab_color_mode when the caller gave +% no explicit colour spec. region2imagevec stores region indices in obj.dat, so +% 'unique' -> an indexed colormap (scn_standard_colors); render_on_surface uses +% nearest-neighbour automatically for an indexmap. Without this, an atlas rendered +% its integer region indices through a CONTINUOUS colormap (blended at borders). +explicit_color_keys = {'colormap', 'colormapname', 'color', 'pos_colormap', 'neg_colormap', 'indexmap'}; +has_explicit_color = any(cellfun(@(a) (ischar(a) || isstring(a)) && any(strcmpi(a, explicit_color_keys)), varargin)); +wu = find(strcmpi(varargin, 'unique'), 1); +ws = find(strcmpi(varargin, 'solid'), 1); +color_mode = ''; +if ~isempty(wu), color_mode = 'unique'; varargin(wu) = []; +elseif ~isempty(ws), color_mode = 'solid'; varargin(ws) = []; +elseif ~has_explicit_color, color_mode = canlab_color_mode(obj); +end +switch color_mode + case 'unique' + nlab = max(1, round(max(double(obj.dat(:))))); + varargin = [varargin, {'colormap', cell2mat(scn_standard_colors(nlab)'), 'indexmap', 'nolegend'}]; + case 'solid' + varargin = [varargin, {'color', [1 0.4 0]}]; +end + render_on_surface(obj, surface_handles, varargin{:}); % Hide the "..." interaction toolbar on the surface axes (recent MATLAB shows one diff --git a/CanlabCore/Cifti_plotting/plot_surface_map.m b/CanlabCore/Cifti_plotting/plot_surface_map.m index 0a1a8ccc..a3d9e042 100644 --- a/CanlabCore/Cifti_plotting/plot_surface_map.m +++ b/CanlabCore/Cifti_plotting/plot_surface_map.m @@ -1,6 +1,13 @@ function h = plot_surface_map(dat,varargin) % This is a method to plot cifti (grayordinate) data on cortical surfaces +% +% NOTE (2026): This function depends on external tools (ft_read_cifti from +% FieldTrip, and the gifti toolbox). For new code, prefer the self-contained +% fmri_surface_data object, which reads CIFTI/GIFTI natively and renders with +% surface(): s = fmri_surface_data('data.dscalar.nii'); surface(s); +% See docs/fmri_surface_data_methods.md. +% % (e.g., from the HCP project). Read data with fieldtrip into matlab and % call this function. Right now, only dense scalar files (*.dscalar.nii) % with one map per file are supported. diff --git a/CanlabCore/Cifti_plotting/render_cifti_on_brain.m b/CanlabCore/Cifti_plotting/render_cifti_on_brain.m index 37775a52..64232b44 100644 --- a/CanlabCore/Cifti_plotting/render_cifti_on_brain.m +++ b/CanlabCore/Cifti_plotting/render_cifti_on_brain.m @@ -1,6 +1,15 @@ function [handles, r, subctx_fmri_data_obj] = render_cifti_on_brain(cifti_filename, varargin) % render_cifti_on_brain renders an image from a cifti file onto surfaces and brain slices. % +% NOTE (2026): This function depends on external HCP/Workbench CIFTI tools +% (cifti_read / Washington-University CIFTI tools). For new code, prefer the +% self-contained fmri_surface_data object, which reads CIFTI/GIFTI natively (no +% external toolbox) and renders with its surface() method: +% s = fmri_surface_data('yourdata.dscalar.nii'); +% surface(s); % native cortical-surface render +% surface(s, 'mni_surface', 'left'); % on an addbrain MNI surface +% See docs/fmri_surface_data_methods.md. +% % :Usage: % :: % render_cifti_on_brain(cifti_filename OR cifti_struct, [optional inputs]) diff --git a/CanlabCore/Data_extraction/load_atlas.m b/CanlabCore/Data_extraction/load_atlas.m index 4da17bb4..838c4264 100644 --- a/CanlabCore/Data_extraction/load_atlas.m +++ b/CanlabCore/Data_extraction/load_atlas.m @@ -20,6 +20,17 @@ % .nii file) or an fmri_data object together with a list of labels % into the atlas() constructor method. % +% ---------------------------------------------------------------------- +% TIP: To see the available atlases grouped by category, run: +% +% load_atlas('list') +% +% This prints categorized tables of keywords (combined, cortex, subcortical, +% thalamus/hypothalamus, brainstem/cerebellum, networks) and returns a struct +% of those tables. Most keywords accept a space/resolution suffix ('_fsl6', +% '_1mm'/'_2mm'); see :Available Keywords: below for the full grid. +% ---------------------------------------------------------------------- +% % :Inputs: % % **atlas_file_name_or_keyword:** @@ -194,7 +205,13 @@ % 'tian_3t_[fmriprep20|fsl6]' % Subcortical atlas at four different resolutions and two % different reference spaces. Use atlas/get_coarser_parcellation -% to select low-resolution versions. +% to select low-resolution versions. 'tian_3t' loads the finest +% scale (S4, 54 regions). +% +% 'tian_s1' | 'tian_s2' | 'tian_s3' | 'tian_s4' (append '_fsl6' for +% MNI152NLin6Asym; default is fmriprep20 / MNI152NLin2009cAsym) +% Tian 3T subcortical atlas at an explicit granularity scale: +% S1=16, S2=32, S3=50, S4=54 subcortical regions. % % 'delavega' % delaVega2017_neurosynth_atlas_object. @@ -274,8 +291,15 @@ end end +% 'list' prints a categorized table of atlas keywords and returns it (parallels +% load_image_set('list')). Handled before the main dispatch so no file is loaded. +if ischar(atlas_file_name_or_keyword) && strcmpi(atlas_file_name_or_keyword, 'list') + atlas_obj = list_atlases('print'); + return +end + switch lower(atlas_file_name_or_keyword) - + case {'thalamus', 'morel'} warning('This atlas is deprecated. Please specify thalamus_[fsl6|fmriprep20] instead.'); savefile = which('Thalamus_combined_atlas_object.mat'); @@ -376,10 +400,7 @@ warning('This is Shen in MNIColin27v1998 space, a subject specific space of the original paper. Consider shen_[fmriprep20|fsl6] instead.') savefile = which('Shen_atlas_object.mat'); varname = 'atlas_obj'; - - % case 'schaefer400' - % savefile = which('Schaefer2018Cortex_atlas_regions.mat'); - % varname = 'atlas_obj'; + case 'shen_fmriprep20' savefile = which('Shen_MNI152NLin2009cAsym_atlas_object.mat'); varname = 'atlas_obj'; @@ -431,7 +452,39 @@ case {'tian_3t_fsl6'} savefile ='tian_3t_fsl6_atlas_object.mat'; varname = 'atlas_obj'; - + + % Tian 3T subcortical atlas at explicit granularity scales S1-S4 + % (16 / 32 / 50 / 54 subcortical regions). Default space is fmriprep20 + % (MNI152NLin2009cAsym); the _fsl6 variants are in MNI152NLin6Asym. + % Note: 'tian_3t' / 'tian_3t_fsl6' above load the finest scale (S4). + case {'tian_s1', 'tian_3t_s1', 'tian_3t_s1_fmriprep20'} + savefile = 'tian_3t_s1_fmriprep20_atlas_object.mat'; + varname = 'atlas_obj'; + case {'tian_s1_fsl6', 'tian_3t_s1_fsl6'} + savefile = 'tian_3t_s1_fsl6_atlas_object.mat'; + varname = 'atlas_obj'; + + case {'tian_s2', 'tian_3t_s2', 'tian_3t_s2_fmriprep20'} + savefile = 'tian_3t_s2_fmriprep20_atlas_object.mat'; + varname = 'atlas_obj'; + case {'tian_s2_fsl6', 'tian_3t_s2_fsl6'} + savefile = 'tian_3t_s2_fsl6_atlas_object.mat'; + varname = 'atlas_obj'; + + case {'tian_s3', 'tian_3t_s3', 'tian_3t_s3_fmriprep20'} + savefile = 'tian_3t_s3_fmriprep20_atlas_object.mat'; + varname = 'atlas_obj'; + case {'tian_s3_fsl6', 'tian_3t_s3_fsl6'} + savefile = 'tian_3t_s3_fsl6_atlas_object.mat'; + varname = 'atlas_obj'; + + case {'tian_s4', 'tian_3t_s4', 'tian_3t_s4_fmriprep20'} + savefile = 'tian_3t_s4_fmriprep20_atlas_object.mat'; + varname = 'atlas_obj'; + case {'tian_s4_fsl6', 'tian_3t_s4_fsl6'} + savefile = 'tian_3t_s4_fsl6_atlas_object.mat'; + varname = 'atlas_obj'; + case {'julich','julich_fmriprep20'} savefile = 'julich_fmriprep20_atlas_object.mat'; varname = 'juAtlas'; @@ -723,11 +776,95 @@ if ~isfield(atlas_obj, varname), fprintf('Cannot find variable: %s\n', varname); return, end atlas_obj = atlas_obj.(varname); - + end end % subfunction +% ------------------------------------------------------------------------- +function S = list_atlases(mode) +% Categorized listing of load_atlas keywords. With mode 'print' (default) each +% table is printed under a descriptive title; the struct of tables is returned. +% Most atlases also accept a space/resolution suffix: append '_fsl6' for +% MNI152NLin6Asym (default is fmriprep20 / MNI152NLin2009cAsym), and '_1mm'/'_2mm' +% where available. See the help text (help load_atlas) for the full option grid. + +if nargin < 1, mode = 'print'; end + +S = struct(); + +S.combined = cell2table({ ... + 'canlab2024', 'CANlab combined whole-brain atlas (default). Suffixes: _fine/_coarse, _fsl6, _1mm/_2mm'; ... + 'canlab2023', 'Frozen predecessor of canlab2024 (often more stable)'; ... + 'canlab2018', 'Earlier CANlab combined atlas (also canlab2018_2mm)'; ... + 'opencanlab2024','canlab2024 restricted to open-license source atlases'}, ... + 'VariableNames', {'keyword','description'}); + +S.cortex = cell2table({ ... + 'glasser', 'Glasser 2016 HCP multi-modal cortical parcellation (360 regions)'; ... + 'yeo17networks', 'Schaefer/Yeo 17-network cortical parcellation'; ... + 'shen', 'Shen 268-region whole-brain parcellation'; ... + 'desikan_killiany', 'Desikan-Killiany gyral cortical labeling'; ... + 'dkt', 'Desikan-Killiany-Tourville cortical labeling'; ... + 'destrieux', 'Destrieux gyral/sulcal cortical labeling'; ... + 'julich', 'Julich-Brain cytoarchitectonic atlas'}, ... + 'VariableNames', {'keyword','description'}); + +S.subcortical = cell2table({ ... + 'tian_3t', 'Tian 3T subcortical atlas, finest scale (S4, 54 regions)'; ... + 'tian_s1..s4', 'Tian 3T subcortex at explicit scales S1/S2/S3/S4 (16/32/50/54 regions)'; ... + 'cit168', 'CIT168 subcortical nuclei'; ... + 'cit168_amygdala','CIT168 amygdala nuclei'; ... + 'basal_ganglia', 'Combined basal-ganglia atlas (alias bg)'; ... + 'striatum', 'Pauli 2016 striatal parcellation (alias pauli_bg)'; ... + 'cartmell_nac', 'Nucleus accumbens core/shell'; ... + 'brainnetome', 'Brainnetome 246-region atlas'; ... + 'keuken', 'Keuken 7T subcortical atlas'}, ... + 'VariableNames', {'keyword','description'}); + +S.thalamus_hypothalamus = cell2table({ ... + 'morel', 'Morel thalamic nuclei (alias thalamus_detail)'; ... + 'iglesias_thal', 'Iglesias/FreeSurfer thalamic nuclei'; ... + 'iglesias_hypothal','Iglesias hypothalamic subunits'}, ... + 'VariableNames', {'keyword','description'}); + +S.brainstem_cerebellum = cell2table({ ... + 'brainstem', 'Combined brainstem atlas'; ... + 'harvard_aan', 'Harvard Ascending Arousal Network brainstem nuclei'; ... + 'bianciardi', 'Bianciardi Brainstem Navigator nuclei'; ... + 'limbic_brainstem_atlas', 'Levinson-Bari limbic brainstem nuclei'; ... + 'kragel2019pag', 'Kragel 2019 periaqueductal gray'; ... + 'cerebellum', 'SUIT cerebellar atlas (alias suit)'}, ... + 'VariableNames', {'keyword','description'}); + +S.networks_specialized = cell2table({ ... + 'buckner', 'Buckner/Yeo resting-state networks'; ... + 'painpathways', 'CANlab pain-pathways atlas (also painpathways2024)'; ... + 'delavega', 'de la Vega 2017 Neurosynth cortical parcellation'; ... + 'insula', 'Faillenot insular parcellation'}, ... + 'VariableNames', {'keyword','description'}); + +if strcmpi(char(mode), 'print') + local_print_atlas_table(S.combined, 'CANLAB COMBINED WHOLE-BRAIN ATLASES'); + local_print_atlas_table(S.cortex, 'CORTICAL PARCELLATIONS'); + local_print_atlas_table(S.subcortical, 'SUBCORTICAL / BASAL-GANGLIA ATLASES'); + local_print_atlas_table(S.thalamus_hypothalamus, 'THALAMUS & HYPOTHALAMUS'); + local_print_atlas_table(S.brainstem_cerebellum, 'BRAINSTEM & CEREBELLUM'); + local_print_atlas_table(S.networks_specialized, 'NETWORKS & SPECIALIZED ATLASES'); + fprintf(['\nMost keywords accept a space/resolution suffix: append ''_fsl6'' for ' ... + 'MNI152NLin6Asym\n(default is fmriprep20 / MNI152NLin2009cAsym), and ''_1mm''/''_2mm'' ' ... + 'where available.\nLoad with: atl = load_atlas(''''); Full grid: help load_atlas\n\n']); +end + +end % list_atlases + + +function local_print_atlas_table(t, titlestr) +fprintf('\n==== %s ====\n', titlestr); +disp(t); +end + + diff --git a/CanlabCore/Data_extraction/load_image_set.m b/CanlabCore/Data_extraction/load_image_set.m index f3315699..f6a66a35 100644 --- a/CanlabCore/Data_extraction/load_image_set.m +++ b/CanlabCore/Data_extraction/load_image_set.m @@ -22,6 +22,24 @@ % (unlisted) datasets can be loaded if you have a load_.m file % in your path (this is intended for extensions in other libraries). % +% Most keywords return an **fmri_data** object; surface / grayordinate +% (CIFTI) map sets return an **fmri_surface_data** object. +% +% ---------------------------------------------------------------------- +% TIP: To see everything you can load, run: +% +% load_image_set('list') +% +% This prints a series of categorized tables to the screen -- multivariate +% signatures, person-level datasets, network/ICA/topic maps, surface (CIFTI) +% map sets, gradient/basis maps, and meta-analysis/receptor maps -- each +% under a descriptive title. It also RETURNS a struct whose fields are those +% tables, so you can query them programmatically: +% +% tmp = load_image_set('list'); % tmp.signatures, tmp.datasets, +% % tmp.networks, tmp.surface, ... +% ---------------------------------------------------------------------- +% % .. % Author and copyright information: % @@ -201,6 +219,20 @@ % [transcriptomic_grads transcriptomic_names] = ... % load_image_set('transcriptomic_gradients'); % +% 'mito_maps' or 'mito' : +% Mitochondrial energetic-capacity maps (CI, CII, +% CIV, MitoD, MRC, TRC) from Mosharov/Picard et al. +% (2025) Nature. Returns an fmri_data (6 maps). +% +% Surface / grayordinate (CIFTI) map sets -> fmri_surface_data +% -------------------------------------------------------------------- +% 'hcp_ica15' / 'hcp_ica25' / 'hcp_ica50' (aliases 'hcp15' etc.) : +% HCP resting-state group-ICA components in fs_LR-32k +% + subcortex grayordinate space (15, 25, or 50 maps). +% Example: o = load_image_set('hcp_ica25'); +% 'spectral_bases' : 200 spectral (Laplacian eigenmap) basis functions +% in HCP 91k grayordinate space. +% % 'Signature' patterns and predictive models % -------------------------------------------------------------------- % 'list' : Return list of signatures in a table. @@ -534,16 +566,34 @@ [image_obj, networknames, imagenames] = load_selfother; + % ---- Surface / grayordinate (CIFTI) map sets -> fmri_surface_data ---- + case {'hcp_ica15', 'hcp15', 'hcpica15'} + [image_obj, networknames, imagenames] = load_hcp_groupica(15); + + case {'hcp_ica25', 'hcp25', 'hcpica25'} + [image_obj, networknames, imagenames] = load_hcp_groupica(25); + + case {'hcp_ica50', 'hcp50', 'hcpica50'} + [image_obj, networknames, imagenames] = load_hcp_groupica(50); + + case {'spectral_bases', 'hcp_bases', 'spectralbases'} + [image_obj, networknames, imagenames] = load_spectral_bases; + + % ---- Volumetric map set -> fmri_data ---- + case {'mito_maps', 'mito', 'mitomaps'} + [image_obj, networknames, imagenames] = load_mito_maps; + case 'list' - + [networknames, imagenames] = deal({}); - table_list = list_signatures; - disp(table_list); - - image_obj = table_list; % return as 1st output + % Build and PRINT a set of categorized tables (signatures first, then + % person-level datasets, network/ICA/topic maps, surface/CIFTI sets, + % gradient/basis maps, and meta-analysis/receptor maps). Returns a + % struct whose fields are the individual tables. + image_obj = list_image_sets('print'); return - + otherwise % Try to load if we have a function name % This will be true for the single trial data (Bogdan Petre @@ -1431,6 +1481,65 @@ end % function +% ------------------------------------------------------------------------ +% Surface / grayordinate (CIFTI) map sets -> fmri_surface_data +% ------------------------------------------------------------------------ + +function [image_obj, networknames, imagenames] = load_hcp_groupica(ncomp) +% HCP group-ICA resting-state components (fs_LR-32k + subcortex grayordinates). +% ncomp is 15, 25, or 50. Returns an fmri_surface_data with one map per component. +fname = sprintf('hcp_d%d_ICs.dscalar.nii', ncomp); +fullname = which(fname); +if isempty(fullname) + error('load_image_set:hcpica', ['Cannot find %s on the path. Add the ' ... + 'Neuroimaging_Pattern_Masks repo (spatial_basis_functions/hcp_groupICAs).'], fname); +end +image_obj = fmri_surface_data(fullname); +n = size(image_obj.dat, 2); +networknames = arrayfun(@(k) sprintf('IC%d', k), 1:n, 'UniformOutput', false); +imagenames = {fullname}; +end % function + + +function [image_obj, networknames, imagenames] = load_spectral_bases +% 200 spectral (Laplacian eigenmap) basis functions in HCP 91k grayordinate space. +fname = 'spectral_bases_200.dscalar.nii'; +fullname = which(fname); +if isempty(fullname) + error('load_image_set:spectralbases', ['Cannot find %s on the path. Add the ' ... + 'Neuroimaging_Pattern_Masks repo (spatial_basis_functions/hcp_91k).'], fname); +end +image_obj = fmri_surface_data(fullname); +n = size(image_obj.dat, 2); +networknames = arrayfun(@(k) sprintf('Basis%d', k), 1:n, 'UniformOutput', false); +imagenames = {fullname}; +end % function + + +% ------------------------------------------------------------------------ +% Mitochondrial profile maps (volumetric) -> fmri_data +% ------------------------------------------------------------------------ + +function [image_obj, networknames, imagenames] = load_mito_maps +% Mitochondrial energetic-capacity maps (Mosharov/Picard 2025 Nature): six maps +% (CI, CII, CIV, MitoD, MRC, TRC). Loaded from the bundled fmri_data object. +fname = 'mito_maps.mat'; +fullname = which(fname); +if isempty(fullname) + error('load_image_set:mitomaps', ['Cannot find %s on the path. Add the ' ... + 'Neuroimaging_Pattern_Masks repo (spatial_basis_functions/mitochondrial_profile_maps).'], fname); +end +S = load(fullname); +image_obj = S.obj; % fmri_data with 6 maps +if isfield(S, 'names') && ~isempty(S.names) + networknames = S.names(:)'; +else + networknames = {'CI' 'CII' 'CIV' 'MitoD' 'MRC' 'TRC'}; +end +imagenames = {fullname}; +end % function + + % ------------------------------------------------------------------------ % NEUROSYNTH % ------------------------------------------------------------------------ @@ -1728,6 +1837,95 @@ end % load kragel18_alldata +% ------------------------------------------------------------------------ +% Categorized listing of everything load_image_set can load +% ------------------------------------------------------------------------ + +function S = list_image_sets(mode) +% Build a struct of categorized tables of loadable keywords. With mode 'print' +% (default) each table is printed to the screen under a descriptive title. The +% struct is also returned so callers can query it (e.g. tmp = load_image_set('list')). +% +% Categories: signatures, datasets (person-level), networks (network/ICA/topic), +% surface (CIFTI grayordinate), gradients (gradient/basis), metaanalysis. + +if nargin < 1, mode = 'print'; end + +S = struct(); + +% 1) Multivariate signatures (keeps the historical domain-flag table). +S.signatures = list_signatures; + +% 2) Person-level / subject-level sample datasets (many images per person). +S.datasets = local_imageset_table({ ... + 'emotionreg', 'Wager 2008 emotion-regulation sample (reappraise-vs-look contrasts, ~30 subjects)', 'fmri_data'; ... + 'bmrk3 (or pain)', 'BMRK3 thermal-pain dataset (subjects x temperatures, with ratings)', 'fmri_data'; ... + 'kragel270', 'Kragel 2018 270-subject multi-study dataset (pain/cognition/emotion)', 'fmri_data'; ... + 'dpsp_hotwarm', 'Dartmouth DPSP hot-vs-warm pain contrasts', 'fmri_data'; ... + 'dpsp_rejectorfriend', 'Dartmouth DPSP rejecter-vs-friend contrasts', 'fmri_data'; ... + 'guilt', 'Guilt-behavior contrast images', 'fmri_data'; ... + 'stroop', 'Stroop cognitive-control task images', 'fmri_data'}); + +% 3) Network / ICA / topic maps (a map per network, component, or topic). +S.networks = local_imageset_table({ ... + 'bucknerlab', 'Yeo/Buckner 7 resting-state cortical networks', 'fmri_data'; ... + 'bucknerlab_wholebrain', 'Yeo/Buckner 7 networks incl. subcortex', 'fmri_data'; ... + 'bucknerlab_wholebrain_plus', 'Yeo/Buckner 7 networks + extra subcortical structures', 'fmri_data'; ... + 'bgloops (or pauli)', 'Pauli 2016 basal-ganglia cortico-striatal loop networks', 'fmri_data'; ... + 'neurosynth', 'Yarkoni 2013 Neurosynth feature set 1', 'fmri_data'; ... + 'neurosynth_topics_fi', 'Neurosynth 100 topic maps, forward inference (p(activation|topic))','fmri_data'; ... + 'neurosynth_topics_ri', 'Neurosynth 100 topic maps, reverse inference (p(topic|activation))','fmri_data'; ... + 'hcp_ica15 / hcp_ica25 / hcp_ica50', 'HCP resting-state group-ICA components (surface; see below)', 'fmri_surface_data'; ... + 'allengenetics', 'Allen Human Brain Atlas gene-expression components', 'fmri_data'}); + +% 4) Surface / grayordinate (CIFTI) map sets -> fmri_surface_data. +S.surface = local_imageset_table({ ... + 'hcp_ica15 (or hcp15)', 'HCP group-ICA, 15 components (fs_LR-32k + subcortex, 91k grayordinates)', 'fmri_surface_data'; ... + 'hcp_ica25 (or hcp25)', 'HCP group-ICA, 25 components (91k grayordinates)', 'fmri_surface_data'; ... + 'hcp_ica50 (or hcp50)', 'HCP group-ICA, 50 components (91k grayordinates)', 'fmri_surface_data'; ... + 'spectral_bases', '200 spectral (Laplacian eigenmap) basis functions (91k grayordinates)', 'fmri_surface_data'}); + +% 5) Gradients and spatial-basis maps. +S.gradients = local_imageset_table({ ... + 'transcriptomic_gradients', 'Allen gene-expression principal gradients PC1-PC3 (volumetric)', 'fmri_data'; ... + 'marg (or principalgradient)', 'Margulies 2016 principal functional connectivity gradient', 'fmri_data'; ... + 'margfsl', 'Margulies principal gradient in MNI152NLin6Asym (FSL) space', 'fmri_data'; ... + 'spectral_bases', '200 spectral basis functions (surface; also under Surface)', 'fmri_surface_data'; ... + 'mito_maps (or mito)', 'Mitochondrial energetic-capacity maps: CI, CII, CIV, MitoD, MRC, TRC', 'fmri_data'}); + +% 6) Meta-analysis, receptor, and curated-domain map sets. +S.metaanalysis = local_imageset_table({ ... + 'emometa', 'Wager/Kang 2015 discrete-emotion meta-analysis (anger, disgust, fear, happy, sad)', 'fmri_data'; ... + 'pet (or hansen22)', 'Hansen 2022 PET neurotransmitter-receptor density maps', 'fmri_data'; ... + 'kragelemotion', 'Kragel 2016 emotion-category classification patterns (7 emotions)', 'fmri_data'; ... + 'kragelschemas', 'Kragel 2019 emotion-schema patterns (22 emotions)', 'fmri_data'; ... + 'multiaversive (or mpa2)','Ceko 2022 multimodal aversive patterns (general + 4 modalities)', 'fmri_data'}); + +if strcmp(lower(char(mode)), 'print') + local_print_titled(S.signatures, 'MULTIVARIATE SIGNATURES (apply with apply_mask / dot-product; domain flags shown)'); + local_print_titled(S.datasets, 'PERSON-LEVEL DATASETS (subject-level images; ready for ttest / predict)'); + local_print_titled(S.networks, 'NETWORK, ICA & TOPIC MAPS (for similarity / dual-regression against your data)'); + local_print_titled(S.surface, 'SURFACE / GRAYORDINATE (CIFTI) MAP SETS -> fmri_surface_data'); + local_print_titled(S.gradients, 'GRADIENTS & SPATIAL-BASIS MAPS'); + local_print_titled(S.metaanalysis, 'META-ANALYSIS, RECEPTOR & CURATED-DOMAIN MAP SETS'); + fprintf('\nLoad any of the above with: obj = load_image_set('''');\n'); + fprintf('Signatures are also queryable in the struct returned by load_image_set(''list'').\n\n'); +end + +end % list_image_sets + + +function t = local_imageset_table(rows) +% rows: N x 3 cell {keyword, description, returns} -> a 3-column table. +t = cell2table(rows, 'VariableNames', {'keyword', 'description', 'returns'}); +end + + +function local_print_titled(t, titlestr) +% Print a descriptive title and then the table. +fprintf('\n==== %s ====\n', titlestr); +disp(t); +end function table_list = list_signatures diff --git a/CanlabCore/Sample_datasets/CIFTI_surface_examples/README.md b/CanlabCore/Sample_datasets/CIFTI_surface_examples/README.md new file mode 100644 index 00000000..9d52dde6 --- /dev/null +++ b/CanlabCore/Sample_datasets/CIFTI_surface_examples/README.md @@ -0,0 +1,41 @@ +# CIFTI surface / grayordinate sample data + +Example CIFTI-2 surface data used by the `fmri_surface_data` object documentation +and walkthrough (see `docs/workflows/fmri_surface_data_howto.md` and +`docs/fmri_surface_data_methods.md`). Loads with: + +```matlab +s = fmri_surface_data(which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii')); +surface(s); +``` + +| File | Type | Contents | +|---|---|---| +| `S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii` | `.dscalar` | HCP S1200 group-average cortical myelin map (T1w/T2w), 59,412 cortical grayordinates (fs_LR-32k, medial wall excluded). A continuous map — good for surface visualization. | + +## Additional surface data (Neuroimaging_Pattern_Masks) + +CanlabCore ships only this one small continuous map. **Surface atlases, +grayordinate parcellations, and other CIFTI maps used by parts of the walkthrough +live in the companion `Neuroimaging_Pattern_Masks` repository** (a standard CANlab +dependency; add it with `canlab_toolbox_setup`). Once it is on the path you can +load e.g.: + +```matlab +atl = fmri_surface_data(which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii')); +grd = fmri_surface_data(which('transcriptomic_gradients.dscalar.nii')); +``` + +See the inventory table in `docs/fmri_surface_data_methods.md` (section +"Surface data in Neuroimaging_Pattern_Masks"). + +## Provenance and license + +- **HCP S1200 group myelin map** — from the Human Connectome Project S1200 release + (`Q1-Q6_RelatedParcellation210.MyelinMap_BC_MSMAll_2_d41_WRN_DeDrift.32k_fs_LR.dscalar.nii`), + distributed under the [HCP Open Access Data Use Terms](https://www.humanconnectome.org/study/hcp-young-adult/document/wu-minn-hcp-consortium-open-access-data-use-terms) + (redistributable for research/education with acknowledgment; WU-Minn HCP + Consortium, funded by the NIH Blueprint for Neuroscience Research). + +This is a group-average template file (no individual-subject data). If you +redistribute CanlabCore, retain this README with its attribution. diff --git a/CanlabCore/Sample_datasets/CIFTI_surface_examples/S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii b/CanlabCore/Sample_datasets/CIFTI_surface_examples/S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii new file mode 100644 index 00000000..896ec9af Binary files /dev/null and b/CanlabCore/Sample_datasets/CIFTI_surface_examples/S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii differ diff --git a/CanlabCore/Surface_tools/canlab_cbig_warp_path.m b/CanlabCore/Surface_tools/canlab_cbig_warp_path.m new file mode 100644 index 00000000..fd59deed --- /dev/null +++ b/CanlabCore/Surface_tools/canlab_cbig_warp_path.m @@ -0,0 +1,42 @@ +function p = canlab_cbig_warp_path(name) +% canlab_cbig_warp_path Resolve a vendored CBIG registration-fusion warp file path. +% +% :Usage: +% :: +% p = canlab_cbig_warp_path('lh_ras') +% +% Returns the absolute path to a CBIG Registration-Fusion (RF-ANTs) MNI152<-> +% fsaverage mapping file vendored under CanlabCore/Cifti_plotting/. These warps +% (MIT-licensed) back the native vol2surf / surf2vol mappers -- no FreeSurfer or +% Connectome Workbench binary is required. +% +% :Inputs: +% **name:** one of +% 'lh_ras' / 'rh_ras' : per-fsaverage-vertex MNI152 RAS coords (3x163842), +% variable 'ras' (MNI152 orig -> fsaverage). +% +% :Outputs: +% **p:** absolute path to the .mat warp file (errors if not found). +% +% :See also: vol2surf, surf2vol, fmri_surface_data + +% CanlabCore root = parent of Surface_tools (this file's folder) +root = fileparts(fileparts(mfilename('fullpath'))); +base = fullfile(root, 'Cifti_plotting', 'CBIG_registration_fusion_surf2vol_vol2surf', ... + 'standalone_scripts_for_MNI_fsaverage_projection', 'final_warps_FS5.3'); + +switch lower(name) + case 'lh_ras' + p = fullfile(base, 'lh.avgMapping_allSub_RF_ANTs_MNI152_orig_to_fsaverage.mat'); + case 'rh_ras' + p = fullfile(base, 'rh.avgMapping_allSub_RF_ANTs_MNI152_orig_to_fsaverage.mat'); + otherwise + error('canlab_cbig_warp_path:badname', 'Unknown warp name: %s', name); +end + +if exist(p, 'file') ~= 2 + error('canlab_cbig_warp_path:notfound', ... + ['CBIG warp not found: %s\nThe registration-fusion warps should ship under ' ... + 'CanlabCore/Cifti_plotting/CBIG_registration_fusion_surf2vol_vol2surf/.'], p); +end +end diff --git a/CanlabCore/Surface_tools/canlab_read_cifti.m b/CanlabCore/Surface_tools/canlab_read_cifti.m new file mode 100644 index 00000000..eca113ca --- /dev/null +++ b/CanlabCore/Surface_tools/canlab_read_cifti.m @@ -0,0 +1,363 @@ +function cii = canlab_read_cifti(filename) +% Read a CIFTI-2 file (.dscalar/.dtseries/.dlabel/.dconn.nii) natively in MATLAB. +% +% :Usage: +% :: +% cii = canlab_read_cifti(filename) +% +% A CIFTI-2 file is a NIfTI-2 container (540-byte little-endian header) whose +% header extension with ecode==32 holds an XML description of the matrix +% (BrainModels = surface vertices + subcortical voxels; plus a Scalars/Series/ +% Labels map for the second dimension). The data matrix follows at vox_offset. +% This reader uses only fopen/fread + regexp XML parsing -- it does NOT require +% cifti-matlab, FieldTrip, gifti, or Connectome Workbench at runtime. +% +% Only modern CIFTI-2 is supported (legacy CIFTI-1 requires wb_command to +% upgrade). The output field names mirror cifti-matlab's so existing helpers +% such as get_cifti_data.m can consume it unchanged. +% +% :Inputs: +% +% **filename:** +% Char/string. Path to a .dscalar.nii / .dtseries.nii / .dlabel.nii / +% .dconn.nii (CIFTI-2) file. +% +% :Outputs: +% +% **cii:** +% Struct with fields: +% .cdata : [nGrayordinates x nMaps] numeric data matrix +% .intent : 'dscalar' | 'dtseries' | 'dlabel' | 'dconn' | 'unknown' +% .diminfo : 1x2 cell. diminfo{1} describes the grayordinate (dense) +% dimension; diminfo{2} the map/series/label dimension. +% diminfo{1}.type = 'dense' +% diminfo{1}.length +% diminfo{1}.models{i}: .struct (e.g. 'CORTEX_LEFT'), .type 'surf'|'vox', +% .start (1-based row), .count, .numvert (surf), .vertlist (0-based), +% .voxlist (3xN IJK, 0-based) +% diminfo{1}.vol.dims (1x3), diminfo{1}.vol.sform (4x4, mm) +% diminfo{2}.type = 'scalars'|'series'|'labels' +% diminfo{2}.maps(k).name (+ .table for labels: .key,.name,.rgba) +% diminfo{2}.seriesStart/.seriesStep/.seriesUnit (series) +% .hdr : parsed NIfTI-2 header fields (dim, datatype, vox_offset, ...) +% .xml : raw CIFTI XML char array (used by canlab_write_cifti) +% +% :Examples: +% :: +% c = canlab_read_cifti(which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii')); +% size(c.cdata) % [grayordinates x maps] +% cellfun(@(m) m.struct, c.diminfo{1}.models, 'unif', 0) +% +% :See also: +% canlab_write_cifti, canlab_read_gifti, get_cifti_data, fmri_surface_data + +% .. +% Part of the CANlab fmri_surface_data surface-data toolset. See +% docs/fmri_surface_data_design_plan.md. No external toolbox required. +% .. + +if nargin < 1 || isempty(filename) + error('canlab_read_cifti:input', 'Provide a path to a CIFTI-2 .nii file.'); +end +filename = char(filename); +if exist(filename, 'file') ~= 2 + error('canlab_read_cifti:notfound', 'File not found: %s', filename); +end + +% ---- Open and detect endianness via sizeof_hdr (must be 540 for NIfTI-2) ---- +fid = fopen(filename, 'r', 'l'); +if fid < 0, error('canlab_read_cifti:open', 'Cannot open %s', filename); end +cleanupObj = onCleanup(@() fclose(fid)); + +sizeof_hdr = fread(fid, 1, 'int32'); +machine = 'l'; +if sizeof_hdr ~= 540 + fclose(fid); + fid = fopen(filename, 'r', 'b'); machine = 'b'; + cleanupObj = onCleanup(@() fclose(fid)); %#ok + sizeof_hdr = fread(fid, 1, 'int32'); + if sizeof_hdr ~= 540 + error('canlab_read_cifti:notnifti2', ... + ['sizeof_hdr=%d (expected 540). Not a NIfTI-2/CIFTI-2 file. ' ... + 'Legacy CIFTI-1 is not supported.'], sizeof_hdr); + end +end + +% ---- NIfTI-2 header (fixed offsets) ---- +hdr.magic = fread(fid, 8, '*char')'; % @4 +hdr.datatype = fread(fid, 1, 'int16'); % @12 +hdr.bitpix = fread(fid, 1, 'int16'); % @14 +hdr.dim = fread(fid, 8, 'int64')'; % @16 +hdr.intent_p = fread(fid, 3, 'double')'; % @80 +hdr.pixdim = fread(fid, 8, 'double')'; % @104 +hdr.vox_offset = fread(fid, 1, 'int64'); % @168 +hdr.scl_slope = fread(fid, 1, 'double'); % @176 +hdr.scl_inter = fread(fid, 1, 'double'); % @184 +fseek(fid, 504, 'bof'); +hdr.intent_code = fread(fid, 1, 'int32'); % @504 +hdr.intent_name = deblank(fread(fid, 16, '*char')'); % @508 +hdr.machine = machine; + +% ---- Header extension: find the ecode==32 CIFTI XML ---- +fseek(fid, 540, 'bof'); +extender = fread(fid, 4, 'uint8'); +xml = ''; +if ~isempty(extender) && extender(1) ~= 0 + while ftell(fid) < hdr.vox_offset + esize = fread(fid, 1, 'int32'); + ecode = fread(fid, 1, 'int32'); + if isempty(esize) || esize <= 8, break; end + edata = fread(fid, esize - 8, '*char')'; + if ecode == 32, xml = edata; end + end +end +if isempty(xml) + error('canlab_read_cifti:noxml', 'No CIFTI XML extension (ecode 32) found.'); +end +% Trim trailing NULs/whitespace padding from the XML blob +xml = regexprep(xml, '\x00+$', ''); +cii.xml = xml; + +% ---- Read the data matrix ---- +rowLen = hdr.dim(6); % NIfTI dim[5] +nRows = hdr.dim(7); % NIfTI dim[6] +prec = local_nifti_precision(hdr.datatype); +fseek(fid, hdr.vox_offset, 'bof'); +v = fread(fid, rowLen * nRows, [prec '=>' prec]); +v = double(v); +if isfinite(hdr.scl_slope) && hdr.scl_slope ~= 0 && ... + ~(hdr.scl_slope == 1 && hdr.scl_inter == 0) + v = v * hdr.scl_slope + hdr.scl_inter; +end + +% ---- Parse XML into diminfo ---- +[diminfo, intent] = local_parse_cifti_xml(xml, hdr.intent_code, hdr.intent_name); + +% ---- Orient cdata to [grayordinates x maps] robustly ---- +G = 0; +if ~isempty(diminfo{1}) && isfield(diminfo{1}, 'models') + for i = 1:numel(diminfo{1}.models), G = G + diminfo{1}.models{i}.count; end +end +M = reshape(v, [rowLen nRows]); +if G > 0 && rowLen == G + cdata = M; % [grayordinates x maps] +elseif G > 0 && nRows == G + cdata = M'; % transpose to [grayordinates x maps] +else + % Dense dimension not size-identified (e.g. dconn): keep file order, + % assume dimension-1 (along rows) is the dense dimension. + cdata = M; +end + +cii.cdata = cdata; +cii.intent = intent; +cii.diminfo = diminfo; +cii.hdr = hdr; + +end % main + + +% ========================================================================= +function prec = local_nifti_precision(dt) +switch dt + case 2, prec = 'uint8'; + case 4, prec = 'int16'; + case 8, prec = 'int32'; + case 16, prec = 'single'; + case 64, prec = 'double'; + case 256, prec = 'int8'; + case 512, prec = 'uint16'; + case 768, prec = 'uint32'; + case 1024, prec = 'int64'; + case 1280, prec = 'uint64'; + otherwise + error('canlab_read_cifti:datatype', 'Unsupported NIfTI datatype %d', dt); +end +end + + +% ========================================================================= +function [diminfo, intent] = local_parse_cifti_xml(xml, intent_code, intent_name) +% Returns diminfo{1} (dense) and diminfo{2} (maps/series/labels). + +switch intent_code + case 3000, intent = 'dconn'; + case 3002, intent = 'dtseries'; + case 3006, intent = 'dscalar'; + case 3007, intent = 'dlabel'; + otherwise + if contains(intent_name, 'Scalar'), intent = 'dscalar'; + elseif contains(intent_name, 'Series'), intent = 'dtseries'; + elseif contains(intent_name, 'Label'), intent = 'dlabel'; + elseif contains(intent_name, 'Dense'), intent = 'dconn'; + else, intent = 'unknown'; + end +end + +% Match either a self-closing (e.g. SERIES maps carry +% all their info in attributes) or a paired ... block. +mims = regexp(xml, '(?s)]*?/>|]*?>.*?', 'match'); + +infos = {}; % parsed info structs +applies = {}; % AppliesToMatrixDimension vectors +isdense = []; % logical: this map is a BRAIN_MODELS (dense) dimension + +for k = 1:numel(mims) + mim = mims{k}; + appliesTo = local_attr(mim, 'AppliesToMatrixDimension'); % "0" / "1" / "0,1" + maptype = local_attr(mim, 'IndicesMapToDataType'); + + info = struct(); + dense = false; + if contains(maptype, 'BRAIN_MODELS') + info = local_parse_brain_models(mim); + dense = true; + elseif contains(maptype, 'SCALARS') + info.type = 'scalars'; + info.maps = local_parse_named_maps(mim, false); + info.length = numel(info.maps); + elseif contains(maptype, 'LABELS') + info.type = 'labels'; + info.maps = local_parse_named_maps(mim, true); + info.length = numel(info.maps); + elseif contains(maptype, 'SERIES') + info.type = 'series'; + info.seriesStart = str2double(local_attr(mim, 'SeriesStart')); + info.seriesStep = str2double(local_attr(mim, 'SeriesStep')); + info.seriesUnit = local_attr(mim, 'SeriesUnit'); + info.seriesExponent = str2double(local_attr(mim, 'SeriesExponent')); + info.length = str2double(local_attr(mim, 'NumberOfSeriesPoints')); + elseif contains(maptype, 'PARCELS') + info.type = 'parcels'; % not fully expanded in v1 + info.raw = mim; + else + info.type = 'unknown'; + end + + infos{end+1} = info; %#ok + applies{end+1} = sscanf(appliesTo, '%d,')'; %#ok + isdense(end+1) = dense; %#ok +end + +% Order so diminfo{1} is the dense (grayordinate) dimension and diminfo{2} the +% maps/series/labels dimension -- matches cifti-matlab and get_cifti_data, which +% index diminfo{1}.models. (CIFTI AppliesToMatrixDimension numbering is reversed +% relative to MATLAB row/col, so we key off content, not the attribute.) +diminfo = {[], []}; +denseIdx = find(isdense, 1); +mapsIdx = find(~isdense, 1); +if ~isempty(denseIdx), diminfo{1} = infos{denseIdx}; end +if ~isempty(mapsIdx), diminfo{2} = infos{mapsIdx}; end +if isempty(denseIdx) % e.g. dconn: both dimensions are dense + [~, order] = sort(cellfun(@(a) a(1), applies)); + diminfo = infos(order); +end +end + + +% ========================================================================= +function info = local_parse_brain_models(mim) +info = struct('type', 'dense', 'length', 0, 'models', {{}}, 'vol', []); + +% Volume block (affine + dims), if any +vol = regexp(mim, '(?s)', 'match', 'once'); +if ~isempty(vol) + vd = local_attr(vol, 'VolumeDimensions'); + dims = sscanf(vd, '%d,')'; + Telem = regexp(vol, '(?s)]*>.*?', ... + 'match', 'once'); + T = regexp(Telem, '(?s)>(.*?)<', 'tokens', 'once'); + sform = eye(4); + if ~isempty(T) + mexp = str2double(local_attr(Telem, 'MeterExponent')); + if isnan(mexp), mexp = -3; end + nums = sscanf(T{1}, '%f'); + if numel(nums) >= 16 + sform = reshape(nums(1:16), 4, 4)'; % row-major -> 4x4 + sform(1:3, :) = sform(1:3, :) * 10^(mexp + 3); % normalize to mm + end + end + info.vol = struct('dims', dims, 'sform', sform); +end + +bms = regexp(mim, '(?s)', 'match'); +total = 0; +for i = 1:numel(bms) + bm = bms{i}; + m = struct(); + m.struct = regexprep(local_attr(bm, 'BrainStructure'), '^CIFTI_STRUCTURE_', ''); + mt = local_attr(bm, 'ModelType'); + if contains(mt, 'SURFACE'), m.type = 'surf'; else, m.type = 'vox'; end + m.start = str2double(local_attr(bm, 'IndexOffset')) + 1; % 1-based + m.count = str2double(local_attr(bm, 'IndexCount')); + m.numvert = str2double(local_attr(bm, 'SurfaceNumberOfVertices')); + m.vertlist = []; + m.voxlist = []; + if strcmp(m.type, 'surf') + vi = regexp(bm, '(?s)]*>(.*?)', 'tokens', 'once'); + if ~isempty(vi), m.vertlist = sscanf(vi{1}, '%d')'; end % 0-based + else + vx = regexp(bm, '(?s)]*>(.*?)', 'tokens', 'once'); + if ~isempty(vx) + ijk = sscanf(vx{1}, '%d'); + m.voxlist = reshape(ijk, 3, [])'; % Nx3 IJK (0-based) + m.voxlist = m.voxlist'; % 3xN to match cifti-matlab + end + end + info.models{end+1} = m; + total = total + m.count; +end +info.length = total; +end + + +% ========================================================================= +function maps = local_parse_named_maps(mim, isLabel) +maps = struct('name', {}, 'table', {}); +nm = regexp(mim, '(?s)(.*?)', 'match'); +for i = 1:numel(nm) + blk = nm{i}; + nameTok = regexp(blk, '(?s)(.*?)', 'tokens', 'once'); + name = ''; + if ~isempty(nameTok) + name = strtrim(nameTok{1}); + name = regexprep(name, '', '$1'); + end + tbl = []; + if isLabel + tbl = local_parse_cifti_labeltable(blk); + end + maps(end+1) = struct('name', name, 'table', tbl); %#ok +end +end + + +% ========================================================================= +function tbl = local_parse_cifti_labeltable(blk) +tbl = struct('key', {}, 'name', {}, 'rgba', {}); +lt = regexp(blk, '(?s)(.*?)', 'tokens', 'once'); +if isempty(lt), return; end +items = regexp(lt{1}, '(?s)', 'match'); +for i = 1:numel(items) + it = items{i}; + key = str2double(local_attr(it, 'Key')); + r = str2double(local_attr(it, 'Red')); + g = str2double(local_attr(it, 'Green')); + b = str2double(local_attr(it, 'Blue')); + a = str2double(local_attr(it, 'Alpha')); + nmt = regexp(it, '(?s)]*>(.*?)', 'tokens', 'once'); + name = ''; + if ~isempty(nmt) + name = strtrim(nmt{1}); + name = regexprep(name, '', '$1'); + end + tbl(end+1) = struct('key', key, 'name', name, 'rgba', [r g b a]); %#ok +end +end + + +% ========================================================================= +function val = local_attr(str, name) +tok = regexp(str, [name '\s*=\s*"([^"]*)"'], 'tokens', 'once'); +if isempty(tok), val = ''; else, val = tok{1}; end +end diff --git a/CanlabCore/Surface_tools/canlab_read_gifti.m b/CanlabCore/Surface_tools/canlab_read_gifti.m new file mode 100644 index 00000000..5f6474ae --- /dev/null +++ b/CanlabCore/Surface_tools/canlab_read_gifti.m @@ -0,0 +1,193 @@ +function gii = canlab_read_gifti(filename) +% Read a GIFTI (.gii) file natively in MATLAB, with no external toolbox. +% +% :Usage: +% :: +% gii = canlab_read_gifti(filename) +% +% Reads geometry (.surf.gii), functional/shape (.func.gii / .shape.gii) and +% label (.label.gii) GIFTI files. GIFTI is a UTF-8 XML container holding one or +% more elements; payloads may be ASCII, Base64Binary, or +% GZipBase64Binary (base64 of a raw zlib stream). This function depends only on +% core MATLAB plus the JVM (for zlib inflate of GZipBase64Binary data) -- it does +% NOT require the SPM/FieldTrip gifti toolbox or Connectome Workbench. +% +% :Inputs: +% +% **filename:** +% Char/string. Full path to a .gii file (.surf/.func/.shape/.label.gii). +% +% :Outputs: +% +% **gii:** +% Struct with fields: +% .vertices : [N x 3] double vertex mm coordinates (POINTSET), or [] +% .faces : [M x 3] double, 1-based triangle indices (TRIANGLE), or [] +% .cdata : [N x K] per-vertex data, K columns from K data arrays, or [] +% .intents : 1xK cell of NIFTI_INTENT_* strings for the cdata columns +% .labels : struct array (.key,.name,.rgba) from a GIFTI LabelTable, or [] +% .gifti_version : char, from the root attribute +% +% :Examples: +% :: +% g = canlab_read_gifti(which('S1200.L.midthickness_MSMAll.32k_fs_LR.surf.gii')); +% patch('Faces', g.faces, 'Vertices', g.vertices, 'FaceColor', [.7 .7 .7]); axis equal +% +% lbl = canlab_read_gifti('Glasser_2016.32k.L.label.gii'); +% unique(lbl.cdata) % integer region keys +% {lbl.labels.name} % region names +% +% :See also: +% canlab_write_gifti, canlab_read_cifti, canlab_write_cifti, fmri_surface_data + +% .. +% Part of the CANlab fmri_surface_data surface-data toolset. See +% docs/fmri_surface_data_design_plan.md. No external toolbox required. +% .. + +if nargin < 1 || isempty(filename) + error('canlab_read_gifti:input', 'Provide a path to a .gii file.'); +end +filename = char(filename); +if exist(filename, 'file') ~= 2 + error('canlab_read_gifti:notfound', 'File not found: %s', filename); +end + +txt = fileread(filename); + +gii = struct('vertices', [], 'faces', [], 'cdata', [], 'intents', {{}}, ... + 'labels', [], 'gifti_version', ''); + +% Root version +v = regexp(txt, ']*\sVersion="([^"]*)"', 'tokens', 'once'); +if ~isempty(v), gii.gifti_version = v{1}; end + +% Label table (optional; .label.gii) +gii.labels = local_parse_labeltable(txt); + +% Each DataArray +das = regexp(txt, '(?s)', 'match'); +cdata = {}; +for k = 1:numel(das) + [M, intent] = local_parse_dataarray(das{k}); + if isempty(M), continue; end + if contains(intent, 'POINTSET') + gii.vertices = M; + elseif contains(intent, 'TRIANGLE') + gii.faces = M + 1; % store 1-based for patch() + else + cdata{end+1} = M(:); %#ok per-vertex data column + gii.intents{end+1} = intent; %#ok + end +end + +if ~isempty(cdata) + n = cellfun(@numel, cdata); + if numel(unique(n)) == 1 + gii.cdata = double([cdata{:}]); + else + % Ragged data arrays: keep as cell to avoid silent truncation + gii.cdata = cdata; + warning('canlab_read_gifti:ragged', ... + 'DataArrays have differing lengths; returning .cdata as a cell array.'); + end +end + +end % main function + + +% ------------------------------------------------------------------------- +function [M, intent] = local_parse_dataarray(da) +% Parse one ... block into a numeric matrix. + +ga = @(name) local_attr(da, name); +intent = ga('Intent'); +dtype = ga('DataType'); +order = ga('ArrayIndexingOrder'); +enc = ga('Encoding'); +endian = ga('Endian'); + +dimn = str2double(ga('Dimensionality')); +if isnan(dimn) || dimn < 1, dimn = 1; end +dims = ones(1, dimn); +for d = 0:dimn-1 + dims(d+1) = str2double(ga(sprintf('Dim%d', d))); +end + +switch dtype + case 'NIFTI_TYPE_FLOAT32', cls = 'single'; + case 'NIFTI_TYPE_FLOAT64', cls = 'double'; + case 'NIFTI_TYPE_INT32', cls = 'int32'; + case 'NIFTI_TYPE_UINT32', cls = 'uint32'; + case 'NIFTI_TYPE_INT16', cls = 'int16'; + case 'NIFTI_TYPE_UINT8', cls = 'uint8'; + case 'NIFTI_TYPE_INT8', cls = 'int8'; + otherwise, cls = 'single'; +end + +payload = regexp(da, '(?s)(.*?)', 'tokens', 'once'); +if isempty(payload), M = []; return; end +data_str = payload{1}; + +if strcmp(enc, 'ASCII') + vals = cast(sscanf(data_str, '%f'), cls); % whitespace-delimited numbers +else + b64 = data_str(~isspace(data_str)); % base64 must have whitespace stripped + if isempty(b64), M = []; return; end + raw = matlab.net.base64decode(b64); + if strcmp(enc, 'GZipBase64Binary') + raw = canlab_zlib_inflate(raw); + end + vals = typecast(raw(:), cls); + if strcmpi(endian, 'BigEndian'), vals = swapbytes(vals); end +end +vals = double(vals); + +if numel(dims) >= 2 && dims(2) > 1 + if strcmp(order, 'RowMajorOrder') + M = reshape(vals, [dims(2) dims(1)])'; + else + M = reshape(vals, dims); + end +else + M = vals(:); +end +end + + +% ------------------------------------------------------------------------- +function labels = local_parse_labeltable(txt) +% Parse an optional GIFTI into a struct array (key,name,rgba). + +labels = []; +lt = regexp(txt, '(?s)(.*?)', 'tokens', 'once'); +if isempty(lt), return; end +items = regexp(lt{1}, '(?s)', 'match'); +if isempty(items), return; end + +labels = struct('key', {}, 'name', {}, 'rgba', {}); +for i = 1:numel(items) + it = items{i}; + key = str2double(local_attr(it, 'Key')); + r = str2double(local_attr(it, 'Red')); + g = str2double(local_attr(it, 'Green')); + b = str2double(local_attr(it, 'Blue')); + a = str2double(local_attr(it, 'Alpha')); + nm = regexp(it, '(?s)]*>(.*?)', 'tokens', 'once'); + name = ''; + if ~isempty(nm) + name = strtrim(nm{1}); + name = regexprep(name, '', '$1'); + end + labels(end+1) = struct('key', key, 'name', name, ... + 'rgba', [r g b a]); %#ok +end +end + + +% ------------------------------------------------------------------------- +function val = local_attr(str, name) +% Extract a single XML attribute value (or '' if absent). +tok = regexp(str, [name '\s*=\s*"([^"]*)"'], 'tokens', 'once'); +if isempty(tok), val = ''; else, val = tok{1}; end +end diff --git a/CanlabCore/Surface_tools/canlab_surface_vertexcolors.m b/CanlabCore/Surface_tools/canlab_surface_vertexcolors.m new file mode 100644 index 00000000..90a72a92 --- /dev/null +++ b/CanlabCore/Surface_tools/canlab_surface_vertexcolors.m @@ -0,0 +1,53 @@ +function rgb = canlab_surface_vertexcolors(vals, clim, poscm, negcm, graycolor) +% canlab_surface_vertexcolors Map per-vertex values to truecolor RGB (split colormap). +% +% :Usage: +% :: +% rgb = canlab_surface_vertexcolors(vals, clim, poscm, negcm, graycolor) +% +% Maps a vector of per-vertex values to an [N x 3] truecolor matrix using a +% split colormap: positive values use a "hot"-style map, negative values a +% "cool"-style map, and zero / NaN vertices (e.g. medial wall, out-of-mask) are +% rendered in a neutral gray. Designed for direct FaceVertexCData assignment on +% surface patches (FaceColor 'interp'), so no axis colormap/caxis juggling is +% needed and the medial wall stays gray. +% +% :Inputs: +% **vals:** [N x 1] per-vertex values (NaN allowed for medial wall). +% +% :Optional Inputs: +% **clim:** [lo hi] color limits. Default: symmetric [-m m] where m is the +% max abs nonzero value. Mapping uses max(abs(clim)). +% **poscm:** [P x 3] positive colormap. Default hot(256). +% **negcm:** [Q x 3] negative colormap. Default cool(256). +% **graycolor:** 1x3 color for zero/NaN vertices. Default [.5 .5 .5]. +% +% :Outputs: +% **rgb:** [N x 3] truecolor matrix in [0,1]. +% +% :See also: surface, render_on_surface, fmri_surface_data + +vals = double(vals(:)); +N = numel(vals); + +if nargin < 5 || isempty(graycolor), graycolor = [.5 .5 .5]; end +if nargin < 4 || isempty(negcm), negcm = cool(256); end +if nargin < 3 || isempty(poscm), poscm = hot(256); end +if nargin < 2 || isempty(clim) + finite_nonzero = vals(~isnan(vals) & vals ~= 0); + m = max(abs(finite_nonzero)); + if isempty(m) || m == 0, m = 1; end + clim = [-m m]; +end + +hi = max(abs(clim)); +if hi == 0, hi = 1; end + +rgb = repmat(graycolor, N, 1); +pos = vals > 0 & ~isnan(vals); +neg = vals < 0 & ~isnan(vals); + +idx = @(v, n) min(max(round(1 + (abs(v) / hi) * (n - 1)), 1), n); +if any(pos), rgb(pos, :) = poscm(idx(vals(pos), size(poscm, 1)), :); end +if any(neg), rgb(neg, :) = negcm(idx(vals(neg), size(negcm, 1)), :); end +end diff --git a/CanlabCore/Surface_tools/canlab_write_cifti.m b/CanlabCore/Surface_tools/canlab_write_cifti.m new file mode 100644 index 00000000..84106681 --- /dev/null +++ b/CanlabCore/Surface_tools/canlab_write_cifti.m @@ -0,0 +1,235 @@ +function canlab_write_cifti(filename, cii) +% Write a CIFTI-2 file (.dscalar/.dtseries/.dlabel.nii) natively in MATLAB. +% +% :Usage: +% :: +% canlab_write_cifti(filename, cii) +% +% Writes a NIfTI-2 container with the CIFTI XML in an ecode==32 header +% extension, followed by the data matrix. The input struct matches +% canlab_read_cifti's output, so read->write->read round-trips exactly. +% +% Two modes: +% * Round-trip: if `cii` carries the original `.xml` and `.hdr` (as returned +% by canlab_read_cifti), the original XML and matrix layout are preserved +% verbatim for a faithful re-write. +% * Regenerate: otherwise the CIFTI XML is rebuilt from `cii.diminfo` (dense +% BrainModels + a Scalars/Series/Labels map) in the standard layout. +% +% Depends only on core MATLAB -- no cifti-matlab, FieldTrip, or wb_command. +% +% :Inputs: +% +% **filename:** +% Char/string. Output path (e.g. '/tmp/out.dscalar.nii'). +% +% **cii:** +% Struct (as from canlab_read_cifti) with at least: +% .cdata [nGrayordinates x nMaps] +% .intent 'dscalar'|'dtseries'|'dlabel'|'dconn' +% .diminfo {dense, maps} as described in canlab_read_cifti +% Optional .xml and .hdr trigger faithful round-trip mode. +% +% :Examples: +% :: +% c = canlab_read_cifti(which('transcriptomic_gradients.dscalar.nii')); +% c.cdata = zscore(c.cdata); % modify +% canlab_write_cifti('/tmp/z.dscalar.nii', c); +% +% :See also: canlab_read_cifti, canlab_read_gifti, canlab_write_gifti + +% .. +% Part of the CANlab fmri_surface_data surface-data toolset. See +% docs/fmri_surface_data_design_plan.md. No external toolbox required. +% .. + +if nargin < 2 || ~isstruct(cii) || ~isfield(cii, 'cdata') + error('canlab_write_cifti:input', 'Provide (filename, cii) with cii.cdata. See help.'); +end +filename = char(filename); +cdata = cii.cdata; +G = size(cdata, 1); % grayordinates +nMaps = size(cdata, 2); + +% ---- XML: re-emit original or regenerate ---- +reuse = isfield(cii, 'xml') && ~isempty(cii.xml) && isfield(cii, 'hdr') ... + && isfield(cii.hdr, 'dim'); +if reuse + xml = cii.xml; + rowLen = cii.hdr.dim(6); + nRows = cii.hdr.dim(7); + % Reconstruct the on-disk vector in the file's original orientation + if rowLen == G + M = cdata; % cdata was stored as [rowLen x nRows] + else + M = cdata.'; % cdata was transposed on read + end + v = M(:); + ndim = cii.hdr.dim(1); +else + xml = local_build_cifti_xml(cii); + rowLen = nMaps; % standard layout: maps fast, grayords slow + nRows = G; + v = reshape(cdata.', [], 1); % maps fastest within each grayordinate + ndim = 6; +end + +[intent_code, intent_name] = local_intent_codes(cii.intent); + +% ---- Build header extension (ecode 32, padded so esize is a multiple of 16) ---- +xmlbytes = uint8(xml); +raw_len = numel(xmlbytes); +esize = ceil((raw_len + 8) / 16) * 16; % includes the 8-byte esize+ecode +pad = esize - 8 - raw_len; +vox_offset = 544 + esize; % 540 hdr + 4 extender + esize + +% ---- NIfTI-2 dim vector ---- +dim = ones(1, 8); +dim(1) = ndim; +dim(6) = rowLen; % NIfTI dim[5] +dim(7) = nRows; % NIfTI dim[6] + +% ---- Write ---- +fid = fopen(filename, 'w', 'l'); +if fid < 0, error('canlab_write_cifti:open', 'Cannot open for writing: %s', filename); end +cleanupObj = onCleanup(@() fclose(fid)); %#ok + +hdrbuf = local_nifti2_header(dim, 16, 32, vox_offset, intent_code, intent_name); +fwrite(fid, hdrbuf, 'uint8'); + +fwrite(fid, uint8([1 0 0 0]), 'uint8'); % extension flag +fwrite(fid, esize, 'int32'); +fwrite(fid, 32, 'int32'); % ecode 32 = CIFTI +fwrite(fid, xmlbytes, 'uint8'); +if pad > 0, fwrite(fid, zeros(1, pad, 'uint8'), 'uint8'); end + +% Data, single precision (scl_slope written as 0 => values are literal) +here = ftell(fid); +if here < vox_offset, fwrite(fid, zeros(1, vox_offset - here, 'uint8'), 'uint8'); end +fwrite(fid, single(v), 'single'); + +end % main + + +% ========================================================================= +function buf = local_nifti2_header(dim, datatype, bitpix, vox_offset, intent_code, intent_name) +buf = zeros(1, 540, 'uint8'); +put = @(off, val, type) local_put(buf, off, val, type); + +buf = put(0, 540, 'int32'); +% magic "n+2\0" 0x0D 0x0A 0x1A 0x0A +buf(5:12) = uint8([110 43 50 0 13 10 26 10]); +buf = local_put(buf, 12, datatype, 'int16'); +buf = local_put(buf, 14, bitpix, 'int16'); +buf = local_put(buf, 16, int64(dim(:)'), 'int64'); % dim[8] @16 +buf = local_put(buf, 104, ones(1, 8), 'double'); % pixdim[8]=1 +buf = local_put(buf, 168, int64(vox_offset), 'int64'); % vox_offset +buf = local_put(buf, 176, 0, 'double'); % scl_slope=0 (no scaling) +buf = local_put(buf, 184, 0, 'double'); % scl_inter +buf = local_put(buf, 344, 0, 'int32'); % qform_code +buf = local_put(buf, 348, 0, 'int32'); % sform_code +buf = local_put(buf, 504, int32(intent_code), 'int32'); +nm = uint8(intent_name); nm = nm(1:min(15, numel(nm))); +buf(509:509+numel(nm)-1) = nm; % intent_name @508 (16) +end + + +% ========================================================================= +function buf = local_put(buf, off, val, type) +bytes = typecast(cast(val(:)', type), 'uint8'); +buf(off+1:off+numel(bytes)) = bytes; +end + + +% ========================================================================= +function [code, name] = local_intent_codes(intent) +switch intent + case 'dscalar', code = 3006; name = 'ConnDenseScalar'; + case 'dtseries', code = 3002; name = 'ConnDenseSeries'; + case 'dlabel', code = 3007; name = 'ConnDenseLabel'; + case 'dconn', code = 3000; name = 'ConnDense'; + otherwise, code = 3006; name = 'ConnDenseScalar'; +end +end + + +% ========================================================================= +function xml = local_build_cifti_xml(cii) +% Regenerate CIFTI-2 XML from cii.diminfo (maps dim 0, dense BrainModels dim 1). +dense = cii.diminfo{1}; +maps = cii.diminfo{2}; + +s = sprintf('\n\n\n'); + +% --- maps dimension (AppliesToMatrixDimension=0) --- +switch maps.type + case 'scalars' + s = [s sprintf('\n')]; + for k = 1:numel(maps.maps) + s = [s sprintf('%s\n', local_xesc(maps.maps(k).name))]; %#ok + end + s = [s sprintf('\n')]; + case 'labels' + s = [s sprintf('\n')]; + for k = 1:numel(maps.maps) + s = [s sprintf('%s\n\n', local_xesc(maps.maps(k).name))]; %#ok + t = maps.maps(k).table; + for j = 1:numel(t) + s = [s sprintf('\n', ... + t(j).key, t(j).rgba(1), t(j).rgba(2), t(j).rgba(3), t(j).rgba(4), local_xesc(t(j).name))]; %#ok + end + s = [s sprintf('\n\n')]; %#ok + end + s = [s sprintf('\n')]; + case 'series' + s = [s sprintf(['\n'], ... + size(cii.cdata,2), local_def(maps,'seriesExponent',0), local_def(maps,'seriesStart',0), ... + local_def(maps,'seriesStep',1), local_defstr(maps,'seriesUnit','SECOND'))]; + otherwise + error('canlab_write_cifti:maptype', 'Cannot regenerate XML for maps type "%s". Re-write using the original .xml.', maps.type); +end + +% --- dense dimension (AppliesToMatrixDimension=1) --- +s = [s sprintf('\n')]; +if isfield(dense, 'vol') && ~isempty(dense.vol) + A = dense.vol.sform; + vals = A'; vals = vals(:)'; % row-major 16 + s = [s sprintf('\n', dense.vol.dims(1), dense.vol.dims(2), dense.vol.dims(3))]; + s = [s sprintf('')]; + s = [s sprintf('%.10g ', vals)]; + s = [s sprintf('\n\n')]; +end +for i = 1:numel(dense.models) + m = dense.models{i}; + if strcmp(m.type, 'surf') + s = [s sprintf(['\n'], ... + m.start-1, m.count, m.struct, m.numvert)]; %#ok + s = [s sprintf('%d ', m.vertlist)]; + s = [s sprintf('\n\n')]; %#ok + else + s = [s sprintf(['\n'], ... + m.start-1, m.count, m.struct)]; %#ok + ijk = m.voxlist; % 3xN + s = [s sprintf('%d %d %d\n', ijk)]; + s = [s sprintf('\n\n')]; %#ok + end +end +s = [s sprintf('\n\n\n')]; +xml = s; +end + + +function v = local_def(s, f, d), if isfield(s,f) && ~isnan(s.(f)), v = s.(f); else, v = d; end, end +function v = local_defstr(s, f, d), if isfield(s,f) && ~isempty(s.(f)), v = s.(f); else, v = d; end, end + + +% ========================================================================= +function out = local_xesc(str) +out = str; +out = strrep(out, '&', '&'); +out = strrep(out, '<', '<'); +out = strrep(out, '>', '>'); +end diff --git a/CanlabCore/Surface_tools/canlab_write_gifti.m b/CanlabCore/Surface_tools/canlab_write_gifti.m new file mode 100644 index 00000000..ddf76edb --- /dev/null +++ b/CanlabCore/Surface_tools/canlab_write_gifti.m @@ -0,0 +1,176 @@ +function canlab_write_gifti(filename, gii, varargin) +% Write a GIFTI (.gii) file natively in MATLAB, with no external toolbox. +% +% :Usage: +% :: +% canlab_write_gifti(filename, gii) +% canlab_write_gifti(filename, gii, 'encoding', 'GZipBase64Binary') +% +% Writes geometry (vertices+faces), per-vertex functional/shape data, and/or a +% label table to a GIFTI file. The struct layout matches canlab_read_gifti's +% output, so read->write->read round-trips exactly. Depends only on core MATLAB +% plus the JVM (for zlib deflate) -- no SPM/FieldTrip gifti toolbox required. +% +% :Inputs: +% +% **filename:** +% Char/string. Output path (e.g. '/tmp/out.surf.gii'). +% +% **gii:** +% Struct (as from canlab_read_gifti) with any of: +% .vertices : [N x 3] vertex coords -> NIFTI_INTENT_POINTSET +% .faces : [M x 3] 1-based faces -> NIFTI_INTENT_TRIANGLE (written 0-based) +% .cdata : [N x K] per-vertex data -> one DataArray per column +% .intents : 1xK cell of intent strings for cdata columns +% (default NIFTI_INTENT_NONE, or _LABEL if .labels present) +% .labels : struct array (.key,.name,.rgba) -> +% +% :Optional Inputs: +% **'encoding':** 'GZipBase64Binary' (default) | 'Base64Binary' | 'ASCII' +% +% :Examples: +% :: +% g = canlab_read_gifti(which('S1200.L.midthickness_MSMAll.32k_fs_LR.surf.gii')); +% canlab_write_gifti('/tmp/copy.surf.gii', g); +% +% :See also: canlab_read_gifti, canlab_read_cifti, canlab_write_cifti + +encoding = 'GZipBase64Binary'; +for i = 1:2:numel(varargin) + switch lower(varargin{i}) + case 'encoding', encoding = varargin{i+1}; + otherwise, error('canlab_write_gifti:badopt', 'Unknown option: %s', varargin{i}); + end +end + +if ~isstruct(gii) + error('canlab_write_gifti:input', 'Second argument must be a struct (see help).'); +end +gii = local_fill_defaults(gii); + +% Count data arrays +nda = 0; +if ~isempty(gii.vertices), nda = nda + 1; end +if ~isempty(gii.faces), nda = nda + 1; end +if ~isempty(gii.cdata), nda = nda + size(gii.cdata, 2); end +if nda == 0 + error('canlab_write_gifti:empty', 'Nothing to write (no vertices/faces/cdata).'); +end + +fid = fopen(char(filename), 'w'); +if fid < 0, error('canlab_write_gifti:open', 'Cannot open for writing: %s', filename); end +cleanupObj = onCleanup(@() fclose(fid)); %#ok + +fprintf(fid, '\n'); +fprintf(fid, '\n'); +fprintf(fid, '\n', nda); + +% Optional label table +if ~isempty(gii.labels) + local_write_labeltable(fid, gii.labels); +end + +% Geometry +if ~isempty(gii.vertices) + local_write_da(fid, 'NIFTI_INTENT_POINTSET', 'NIFTI_TYPE_FLOAT32', ... + single(gii.vertices), encoding); +end +if ~isempty(gii.faces) + local_write_da(fid, 'NIFTI_INTENT_TRIANGLE', 'NIFTI_TYPE_INT32', ... + int32(gii.faces - 1), encoding); % write 0-based +end + +% Per-vertex data: one DataArray per column +for c = 1:size(gii.cdata, 2) + intent = 'NIFTI_INTENT_NONE'; + if c <= numel(gii.intents) && ~isempty(gii.intents{c}) + intent = gii.intents{c}; + end + col = gii.cdata(:, c); + if contains(intent, 'LABEL') + local_write_da(fid, intent, 'NIFTI_TYPE_INT32', int32(col), encoding); + else + local_write_da(fid, intent, 'NIFTI_TYPE_FLOAT32', single(col), encoding); + end +end + +fprintf(fid, '\n'); +end % main + + +% ------------------------------------------------------------------------- +function gii = local_fill_defaults(gii) +flds = {'vertices', 'faces', 'cdata', 'intents', 'labels'}; +for i = 1:numel(flds) + if ~isfield(gii, flds{i}), gii.(flds{i}) = []; end +end +if isempty(gii.intents), gii.intents = {}; end +if ~isempty(gii.labels) && ~isempty(gii.cdata) && isempty(gii.intents) + gii.intents = repmat({'NIFTI_INTENT_LABEL'}, 1, size(gii.cdata, 2)); +end +end + + +% ------------------------------------------------------------------------- +function local_write_da(fid, intent, dtype, data, encoding) +% data is [Dim0 x Dim1] in MATLAB column-major; GIFTI stores RowMajorOrder. +d0 = size(data, 1); +d1 = size(data, 2); +dimn = 1 + (d1 > 1); + +% Row-major byte stream = bytes of the transpose, read column-major +dt = data'; +switch dtype + case 'NIFTI_TYPE_FLOAT32', bytes = typecast(single(dt(:))', 'uint8'); + case 'NIFTI_TYPE_INT32', bytes = typecast(int32(dt(:))', 'uint8'); + case 'NIFTI_TYPE_FLOAT64', bytes = typecast(double(dt(:))', 'uint8'); + case 'NIFTI_TYPE_UINT8', bytes = uint8(dt(:))'; + otherwise, error('canlab_write_gifti:dtype', 'Unsupported DataType %s', dtype); +end + +if dimn == 1 + dimattr = sprintf('Dimensionality="1" Dim0="%d"', d0); +else + dimattr = sprintf('Dimensionality="2" Dim0="%d" Dim1="%d"', d0, d1); +end + +fprintf(fid, ['\n'], ... + intent, dtype, dimattr, encoding); + +switch encoding + case 'ASCII' + if contains(dtype, 'INT') + fmt = '%d '; + else + fmt = '%.9g '; % full single-precision width + end + fprintf(fid, ''); + fprintf(fid, fmt, double(dt(:))); + fprintf(fid, '\n'); + case 'Base64Binary' + fprintf(fid, '%s\n', char(matlab.net.base64encode(bytes))); + case 'GZipBase64Binary' + comp = canlab_zlib_deflate(bytes); + fprintf(fid, '%s\n', char(matlab.net.base64encode(comp))); + otherwise + error('canlab_write_gifti:encoding', 'Unknown encoding %s', encoding); +end + +fprintf(fid, '\n'); +end + + +% ------------------------------------------------------------------------- +function local_write_labeltable(fid, labels) +fprintf(fid, '\n'); +for i = 1:numel(labels) + rgba = labels(i).rgba; + if numel(rgba) < 4, rgba = [rgba(:)' ones(1, 4 - numel(rgba))]; end + nm = labels(i).name; + if isempty(nm), nm = sprintf('label_%d', labels(i).key); end + fprintf(fid, '\n', ... + labels(i).key, rgba(1), rgba(2), rgba(3), rgba(4), nm); +end +fprintf(fid, '\n'); +end diff --git a/CanlabCore/Surface_tools/canlab_zlib_deflate.m b/CanlabCore/Surface_tools/canlab_zlib_deflate.m new file mode 100644 index 00000000..b19e5462 --- /dev/null +++ b/CanlabCore/Surface_tools/canlab_zlib_deflate.m @@ -0,0 +1,28 @@ +function comp = canlab_zlib_deflate(raw) +% Deflate (compress) bytes to a raw zlib stream natively via the JVM. +% +% :Usage: +% :: +% comp = canlab_zlib_deflate(raw) +% +% Produces a raw zlib stream (header 0x78 0x9C) suitable for GIFTI/CIFTI +% "GZipBase64Binary" payloads after base64 encoding. Uses java.util.zip +% (ships with MATLAB) -- no external toolbox. +% +% :Inputs: +% **raw:** uint8 vector of bytes to compress. +% +% :Outputs: +% **comp:** uint8 column vector, the raw zlib-compressed stream. +% +% :See also: canlab_zlib_inflate, canlab_write_gifti, canlab_write_cifti + +raw = uint8(raw(:)); +baos = java.io.ByteArrayOutputStream(); +dos = java.util.zip.DeflaterOutputStream(baos); +dos.write(typecast(raw, 'int8'), 0, numel(raw)); +dos.finish(); +dos.close(); +comp = typecast(int8(baos.toByteArray()), 'uint8'); +comp = comp(:); +end diff --git a/CanlabCore/Surface_tools/canlab_zlib_inflate.m b/CanlabCore/Surface_tools/canlab_zlib_inflate.m new file mode 100644 index 00000000..2f2c8278 --- /dev/null +++ b/CanlabCore/Surface_tools/canlab_zlib_inflate.m @@ -0,0 +1,30 @@ +function out = canlab_zlib_inflate(raw) +% Inflate (decompress) a raw zlib byte stream natively via the JVM. +% +% :Usage: +% :: +% out = canlab_zlib_inflate(raw) +% +% GIFTI "GZipBase64Binary" and similar payloads are raw zlib streams (header +% 0x78 0x9C), NOT gzip. This decompresses a uint8 vector entirely on the JVM +% side, which avoids a long-standing MATLAB bug where an in-place +% java.util.zip.Inflater.inflate(buffer) call returns garbage because MATLAB +% passes the Java method a copy of the buffer. Uses java.util.zip plus +% org.apache.commons.io.IOUtils (both ship with MATLAB) -- no external toolbox. +% +% :Inputs: +% **raw:** uint8 vector, a raw zlib-compressed stream. +% +% :Outputs: +% **out:** uint8 column vector of the decompressed bytes. +% +% :See also: canlab_zlib_deflate, canlab_read_gifti, canlab_read_cifti + +bais = java.io.ByteArrayInputStream(typecast(uint8(raw(:)), 'int8')); +iis = java.util.zip.InflaterInputStream(bais); +baos = java.io.ByteArrayOutputStream(); +org.apache.commons.io.IOUtils.copy(iis, baos); +iis.close(); +out = typecast(int8(baos.toByteArray()), 'uint8'); +out = out(:); +end diff --git a/CanlabCore/Unit_tests/data_extraction/canlab_test_load_registries.m b/CanlabCore/Unit_tests/data_extraction/canlab_test_load_registries.m new file mode 100644 index 00000000..14bb8161 --- /dev/null +++ b/CanlabCore/Unit_tests/data_extraction/canlab_test_load_registries.m @@ -0,0 +1,119 @@ +function tests = canlab_test_load_registries +% Unit tests for the load_image_set / load_atlas keyword registries: +% newly-added surface (CIFTI) and volumetric map sets, the categorized 'list' +% output, and the Tian subcortical scale atlases. +% +% Requires the Neuroimaging_Pattern_Masks data (CIFTI/.mat map sets and the Tian +% subcortical atlas objects) on the path -- all committed to the public NPM repo, +% which CI checks out, so this runs in the CI unit tier. The test is headless-safe +% (native CIFTI / .mat loads, no SPM volumetric reads, no display). Individual +% tests assumeFail (skip) when a specific data file is not present, so a missing +% file becomes an Incomplete (skip), never a CI failure. +% +% Run: runtests('canlab_test_load_registries') +% +% :See also: load_image_set, load_atlas + +tests = functiontests(localfunctions); +end + + +% ------------------------------------------------------------------------- +function setupOnce(t) +assert(~isempty(which('load_image_set')), 'load_image_set not on path.'); +assert(~isempty(which('load_atlas')), 'load_atlas not on path.'); +t.TestData.figvis = get(0, 'DefaultFigureVisible'); +set(0, 'DefaultFigureVisible', 'off'); +end + +function teardownOnce(t) +close all force +% figvis is unset if setupOnce bailed early (e.g. the GitHub Actions skip), so +% guard against it -- an error here would flip filtered tests to "failed". +if isfield(t.TestData, 'figvis') + set(0, 'DefaultFigureVisible', t.TestData.figvis); +end +end + + +% ------------------------------------------------------------------------- +function test_hcp_groupica_surface(t) +% hcp_ica15/25/50 return fmri_surface_data with the right number of maps. +specs = {'hcp_ica15', 15; 'hcp_ica25', 25; 'hcp_ica50', 50}; +for i = 1:size(specs, 1) + if isempty(which(sprintf('hcp_d%d_ICs.dscalar.nii', specs{i,2}))) + t.assumeFail('HCP group-ICA dscalar not on path.'); + end + o = load_image_set(specs{i,1}, 'noverbose'); + verifyTrue(t, isa(o, 'fmri_surface_data'), 'HCP ICA must return fmri_surface_data.'); + verifyEqual(t, size(o.dat, 2), specs{i,2}, 'Wrong number of ICA components.'); +end +end + + +% ------------------------------------------------------------------------- +function test_spectral_bases_surface(t) +if isempty(which('spectral_bases_200.dscalar.nii')) + t.assumeFail('spectral_bases_200.dscalar.nii not on path.'); +end +o = load_image_set('spectral_bases', 'noverbose'); +verifyTrue(t, isa(o, 'fmri_surface_data'), 'spectral_bases must return fmri_surface_data.'); +verifyEqual(t, size(o.dat, 2), 200, 'Expected 200 spectral basis functions.'); +end + + +% ------------------------------------------------------------------------- +function test_mito_maps_volumetric(t) +if isempty(which('mito_maps.mat')) + t.assumeFail('mito_maps.mat not on path.'); +end +[o, names] = load_image_set('mito_maps', 'noverbose'); +verifyTrue(t, isa(o, 'fmri_data'), 'mito_maps must return fmri_data.'); +verifyEqual(t, size(o.dat, 2), 6, 'Expected 6 mitochondrial maps.'); +verifyEqual(t, numel(names), 6, 'Expected 6 map names.'); +end + + +% ------------------------------------------------------------------------- +function test_list_returns_struct_of_tables(t) +evalc('tmp = load_image_set(''list'');'); % capture printed tables to suppress them +verifyClass(t, tmp, 'struct'); +expected = {'signatures','datasets','networks','surface','gradients','metaanalysis'}; +for f = expected + verifyTrue(t, isfield(tmp, f{1}), sprintf('list struct must have field %s.', f{1})); + verifyClass(t, tmp.(f{1}), 'table'); +end +% Surface table must advertise the new fmri_surface_data sets. +verifyTrue(t, any(contains(tmp.surface.keyword, 'hcp_ica15')), 'Surface table lists hcp_ica15.'); +verifyTrue(t, any(contains(tmp.surface.keyword, 'spectral_bases')), 'Surface table lists spectral_bases.'); +end + + +% ------------------------------------------------------------------------- +function test_tian_scale_atlases(t) +% Tian S1-S4 load with the expected subcortical region counts. +if isempty(which('tian_3t_s1_fmriprep20_atlas_object.mat')) + t.assumeFail('Tian scale atlas objects not on path.'); +end +expected = [16 32 50 54]; +for s = 1:4 + a = load_atlas(sprintf('tian_s%d', s), 'noverbose'); + verifyTrue(t, isa(a, 'atlas'), 'Tian scale must return an atlas.'); + verifyEqual(t, numel(a.labels), expected(s), ... + sprintf('Tian S%d should have %d regions.', s, expected(s))); +end +% fsl6 variant also resolves. +a = load_atlas('tian_s2_fsl6', 'noverbose'); +verifyEqual(t, numel(a.labels), 32, 'Tian S2 fsl6 should have 32 regions.'); +end + + +% ------------------------------------------------------------------------- +function test_load_atlas_list_returns_struct(t) +evalc('la = load_atlas(''list'');'); % capture printed tables to suppress them +verifyClass(t, la, 'struct'); +for f = {'combined','cortex','subcortical','thalamus_hypothalamus','brainstem_cerebellum','networks_specialized'} + verifyTrue(t, isfield(la, f{1}), sprintf('atlas list must have field %s.', f{1})); + verifyClass(t, la.(f{1}), 'table'); +end +end diff --git a/CanlabCore/Unit_tests/surface_data/canlab_test_surface_analysis.m b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_analysis.m new file mode 100644 index 00000000..b332f1ea --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_analysis.m @@ -0,0 +1,154 @@ +function tests = canlab_test_surface_analysis +% Unit tests for fmri_surface_data analysis methods (M6). +% +% Covers cat / horzcat, ttest, regress (native OLS), predict (delegated, weight +% map remapped to surface), and ica (delegated; skipped if the icatb toolbox is +% absent). Uses synthetic grayordinate objects -- no external toolbox required +% (except ica, which needs icatb_fastICA and is skipped otherwise). +% +% Run: runtests('canlab_test_surface_analysis') +% +% :See also: cat, ttest, regress, predict, ica, fmri_surface_data + +tests = functiontests(localfunctions); +end + + +% ------------------------------------------------------------------------- +function setupOnce(t) +here = fileparts(mfilename('fullpath')); +addpath(fullfile(here, '..', '..', 'Surface_tools')); +addpath(fullfile(here, '..', '..')); +assert(~isempty(which('fmri_surface_data')), 'fmri_surface_data not on path.'); +t.TestData.figvis = get(0, 'DefaultFigureVisible'); +set(0, 'DefaultFigureVisible', 'off'); +end + +function teardownOnce(t) +close all force +set(0, 'DefaultFigureVisible', t.TestData.figvis); +end + + +% ------------------------------------------------------------------------- +function test_cat_and_horzcat(t) +A = local_obj((1:20)' + (0:5)*0.3); % 20 x 6 +B = local_obj((1:20)' + (0:5)*0.3 + 1); % 20 x 6, same space + +C = cat(A, B); +verifyEqual(t, size(C.dat), [20 12], 'cat should concatenate along maps.'); +C2 = [A, B]; +verifyEqual(t, C2.dat, C.dat, 'horzcat must match cat.'); +verifyEqual(t, numel(C.removed_images), 12, 'removed_images must track maps.'); + +% Per-map fields concatenate +A.image_names = arrayfun(@(k) sprintf('a%d',k), 1:6, 'unif', 0)'; +B.image_names = arrayfun(@(k) sprintf('b%d',k), 1:6, 'unif', 0)'; +C = cat(A, B); +verifyEqual(t, numel(C.image_names), 12, 'image_names must concatenate.'); + +% Space mismatch errors +Adiff = A; Adiff.surface_space = 'fsaverage_164k'; +verifyError(t, @() cat(A, Adiff), 'fmri_surface_data:cat:space'); +end + + +% ------------------------------------------------------------------------- +function test_ttest(t) +% Construct data with a known positive mean across maps. +base = ones(20, 1) * 2 + 0.01 * (1:20)'; % mostly positive +C = local_obj(base + 0.1 * randn(20, 10)); +tobj = ttest(C); +verifyEqual(t, class(tobj), 'fmri_surface_data'); +verifyEqual(t, size(tobj.dat), [20 1], 't-map is one column.'); +verifyTrue(t, isfield(tobj.additional_info, 'statistic'), 'stats stored in additional_info.'); +st = tobj.additional_info.statistic; +verifyEqual(t, numel(st.p), 20, 'p has one value per grayordinate.'); +verifyTrue(t, all(tobj.dat > 0), 'Positive-mean data should give positive t.'); +% Cross-check against MATLAB ttest at one grayordinate +[~, p1, ~, s1] = ttest(double(C.dat(5, :))); +% Note: fmri_data stores .dat as single, so the delegated t is single-precision. +verifyEqual(t, double(tobj.dat(5)), s1.tstat, 'RelTol', 1e-4, 't mismatch vs MATLAB ttest.'); +verifyEqual(t, double(st.p(5)), p1, 'AbsTol', 1e-4, 'p mismatch vs MATLAB ttest.'); +end + + +% ------------------------------------------------------------------------- +function test_regress_ols(t) +n = 15; +X = [ones(n,1), (1:n)'/n]; % intercept + linear +truebeta = [0.5; 3]; +C = local_obj((1:20)'*0 + (X * truebeta)' + 0.001*randn(20, n)); % 20 x n, ~linear in X +b = regress(C, X); +verifyEqual(t, size(b.dat), [20 2], 'betas: one column per regressor.'); +% Cross-check coefficients against MATLAB backslash at one grayordinate +g = 9; +bm = X \ double(C.dat(g, :))'; +verifyEqual(t, double(b.dat(g, :))', bm, 'AbsTol', 1e-5, 'OLS betas mismatch.'); +st = b.additional_info.statistic; +verifyEqual(t, size(st.t), [20 2], 't-stats per beta.'); +verifyEqual(t, st.dfe, n - 2, 'wrong residual df.'); +% Recovered slope should be near the true slope (.dat is single) +verifyEqual(t, double(mean(b.dat(:,2))), truebeta(2), 'AbsTol', 0.05, 'slope not recovered.'); +% Errors with no design +verifyError(t, @() regress(local_obj(randn(20,5))), 'fmri_surface_data:regress:noX'); +end + + +% ------------------------------------------------------------------------- +function test_predict_delegation(t) +% Outcome linearly related to a latent feature -> cv prediction should work and +% return a surface weight map of the right size. +n = 30; +w = randn(20, 1); +Y = (1:n)'; +D = w * (Y' - mean(Y)) + randn(20, n); % each grayordinate ~ scaled Y +C = local_obj(D); +C.Y = Y; +[cverr, stats] = predict(C, 'algorithm_name', 'cv_lassopcr', 'nfolds', 3, 'verbose', 0); +verifyTrue(t, isfinite(cverr), 'cverr should be finite.'); +verifyEqual(t, class(stats.weight_obj), 'fmri_surface_data', ... + 'weight_obj must be remapped to a surface object.'); +verifyEqual(t, size(stats.weight_obj.dat, 1), 20, 'weight map has one row per grayordinate.'); +verifyEqual(t, numel(stats.yfit), n, 'one cross-validated fit per observation.'); +% No-Y guard +Cn = local_obj(randn(20, n)); +verifyError(t, @() predict(Cn), 'fmri_surface_data:predict:noY'); +end + + +% ------------------------------------------------------------------------- +function test_ica_if_available(t) +if isempty(which('icatb_fastICA')) + t.assumeFail('icatb_fastICA (GIFT/icatb toolbox) not on path; ica delegation skipped.'); +end +n = 500; nm = 30; +S = randn(3, nm); A = randn(n, 3); D = A * S + 0.1 * randn(n, nm); +o = local_obj_n(D); +ic = ica(o, 3); +verifyEqual(t, class(ic), 'fmri_surface_data'); +verifyEqual(t, size(ic.dat, 1), n, 'IC maps have one row per grayordinate.'); +end + + +% ========================================================================= +function o = local_obj(D) +% 20-grayordinate cortex-only object (10 verts per hemisphere). +mL = struct('struct','CORTEX_LEFT','type','surf','start',1,'count',10,'numvert',32492,'vertlist',0:9,'voxlist',[]); +mR = struct('struct','CORTEX_RIGHT','type','surf','start',11,'count',10,'numvert',32492,'vertlist',0:9,'voxlist',[]); +bm = struct('type','dense','length',20,'models',{{mL,mR}},'vol',[]); +bm.grayordinate_type = 'cortex_only'; bm.cluster = []; +o = fmri_surface_data('dat', single(D), 'brain_model', bm, ... + 'surface_space', 'fsLR_32k', 'imagetype', 'dscalar'); +end + +function o = local_obj_n(D) +% n-grayordinate cortex-only object (n/2 verts per hemisphere). +n = size(D,1); h = n/2; +mL = struct('struct','CORTEX_LEFT','type','surf','start',1,'count',h,'numvert',32492,'vertlist',0:h-1,'voxlist',[]); +mR = struct('struct','CORTEX_RIGHT','type','surf','start',h+1,'count',h,'numvert',32492,'vertlist',0:h-1,'voxlist',[]); +bm = struct('type','dense','length',n,'models',{{mL,mR}},'vol',[]); +bm.grayordinate_type = 'cortex_only'; bm.cluster = []; +o = fmri_surface_data('dat', single(D), 'brain_model', bm, ... + 'surface_space', 'fsLR_32k', 'imagetype', 'dscalar'); +end diff --git a/CanlabCore/Unit_tests/surface_data/canlab_test_surface_fmridisplay.m b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_fmridisplay.m new file mode 100644 index 00000000..b440da8e --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_fmridisplay.m @@ -0,0 +1,364 @@ +function tests = canlab_test_surface_fmridisplay +% Unit tests for fmri_surface_data <-> fmridisplay integration (visualization +% harmonization, Level 2). A surface object is added to a managed fmridisplay as +% a surface-native layer via addblobs, painted directly on matching cortical +% meshes, and driven by set_colormap / removeblobs like a volume layer. +% +% Run: runtests('canlab_test_surface_fmridisplay') +% +% :See also: add_surface_blobs, render_layer_surfaces, addblobs, fmridisplay + +tests = functiontests(localfunctions); +end + + +% ------------------------------------------------------------------------- +function setupOnce(t) +here = fileparts(mfilename('fullpath')); +addpath(fullfile(here, '..', '..', 'Surface_tools')); +addpath(fullfile(here, '..', '..')); +assert(~isempty(which('fmri_surface_data')), 'fmri_surface_data not on path.'); +assert(~isempty(which('add_surface_blobs')), 'add_surface_blobs not on path.'); +f = which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii'); +assert(~isempty(f), 'bundled myelin sample not on path.'); +t.TestData.s = fmri_surface_data(f); % fs_LR-32k cortex +t.TestData.figvis = get(0, 'DefaultFigureVisible'); +set(0, 'DefaultFigureVisible', 'off'); +end + +function teardownOnce(t) +close all force +set(0, 'DefaultFigureVisible', t.TestData.figvis); +end + + +% ------------------------------------------------------------------------- +function test_isempty_fixed(t) +% A populated surface object must not report empty (cortex-only volInfo is empty). +verifyFalse(t, isempty(t.TestData.s), 'A populated cortex object must not be isempty.'); +verifyTrue(t, isempty(fmri_surface_data), 'An empty object must be isempty.'); +end + + +% ------------------------------------------------------------------------- +function test_addblobs_paints_surface_native(t) +o2 = fmridisplay; +o2 = surface(o2, 'hcp inflated left'); +o2 = surface(o2, 'hcp inflated right'); +o2 = addblobs(o2, t.TestData.s, 'colormap', 'hot', 'cmaprange', [1 1.8]); + +% A surface-native layer was created +verifyEqual(t, numel(o2.activation_maps), 1); +verifyTrue(t, isa(o2.activation_maps{1}.source_surface, 'fmri_surface_data')); + +% Both hemispheres painted with per-vertex truecolor (in-data verts non-gray) +cL = get(o2.surface{1}.object_handle(1), 'FaceVertexCData'); +cR = get(o2.surface{2}.object_handle(1), 'FaceVertexCData'); +verifyEqual(t, size(cL), [32492 3], 'Left mesh must be painted per-vertex.'); +verifyEqual(t, size(cR), [32492 3], 'Right mesh must be painted per-vertex.'); +verifyGreaterThan(t, nnz(~all(abs(cL-0.5) < 1e-6, 2)), 20000, 'Many left vertices should be colored.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_set_colormap_and_removeblobs(t) +o2 = fmridisplay; +o2 = surface(o2, 'hcp inflated left'); +o2 = addblobs(o2, t.TestData.s, 'colormap', 'hot'); +c1 = get(o2.surface{1}.object_handle(1), 'FaceVertexCData'); + +% set_colormap must recolor the surface (managed-display integration) +o2 = set_colormap(o2, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]); +c2 = get(o2.surface{1}.object_handle(1), 'FaceVertexCData'); +verifyFalse(t, isequal(c1, c2), 'set_colormap must propagate to surface layers.'); + +% removeblobs must restore the anatomy (gray) +o2 = removeblobs(o2); +c3 = get(o2.surface{1}.object_handle(1), 'FaceVertexCData'); +verifyTrue(t, all(all(abs(c3 - 0.5) < 1e-6)) || size(unique(c3,'rows'),1) <= 1, ... + 'removeblobs must restore gray on surfaces.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_surface_returns_managed_and_paints_correct_hemis(t) +% surface(obj) now returns a stateful fmridisplay with the matching native +% surfaces added and the data painted -- and each hemisphere gets ITS OWN data +% (regression: the foursurfaces_hcp patches lose their L/R tag, so hemisphere is +% resolved by x-centroid; a bug there painted left data on both hemispheres). +o2 = surface(t.TestData.s); +verifyTrue(t, isa(o2, 'fmridisplay'), 'surface(obj) must return a managed fmridisplay.'); +verifyEqual(t, numel(o2.activation_maps), 1, 'Data added as one managed layer.'); + +r = reconstruct_image(t.TestData.s); +Ld = double(r.cortex_left(:,1)); Rd = double(r.cortex_right(:,1)); +fig = ancestor(o2.surface{1}.object_handle(1), 'figure'); +pp = findobj(fig, 'Type', 'patch'); +checked = 0; +for hh = pp(:)' + V = get(hh, 'Vertices'); nv = size(V,1); + if nv ~= 32492, continue; end + c = get(hh, 'FaceVertexCData'); if ~isequal(size(c),[nv 3]), continue; end + bright = mean(c, 2); + if mean(V(:,1)) < 0, own = Ld; other = Rd; else, own = Rd; other = Ld; end + m = own ~= 0 & isfinite(own) & ~all(abs(c-0.5) < 1e-6, 2); + verifyGreaterThan(t, corr(bright(m), own(m)), 0.8, 'Hemisphere must show its OWN data.'); + checked = checked + 1; +end +verifyEqual(t, checked, 4, 'All four cortical panels should be painted.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_surface_on_existing_display(t) +% surface(o2, obj) adds the object's native surfaces to an existing managed +% display and renders onto them (under controller management). +o2 = montage(fmridisplay, 'axial'); +n_mont = numel(o2.surface); +o2 = surface(o2, t.TestData.s); +verifyGreaterThan(t, numel(o2.surface), n_mont, 'Native surfaces must be added.'); +verifyEqual(t, numel(o2.activation_maps), 1, 'A managed surface-native layer is added.'); +verifyNotEmpty(t, cortex_patches(o2, 32492), 'fs_LR cortical patches must exist.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_autoresample_onto_mismatched_surface(t) +% Rendering fs_LR data onto fsaverage surfaces AUTO-RESAMPLES (resample_surface) +% and paints, instead of warning about a space mismatch. (t.TestData.s is fs_LR.) +o2 = fmridisplay; +w = warning('off', 'all'); c = onCleanup(@() warning(w)); +o2 = surface(o2, t.TestData.s, 'foursurfaces_freesurfer'); % fsaverage meshes +p = cortex_patches(o2, 163842); +verifyNotEmpty(t, p, 'fsaverage cortical patches should exist.'); +anypainted = false; +for hh = p + cc = get(hh, 'FaceVertexCData'); + if isequal(size(cc), [163842 3]) && nnz(~all(abs(cc - 0.5) < 1e-6, 2)) > 1000 + anypainted = true; + end +end +verifyTrue(t, anypainted, 'fs_LR data must auto-resample and paint onto fsaverage surfaces.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_nonmatching_and_nosurface(t) +% No surface views -> a clear warning, not an error. +o2 = fmridisplay; +w = warning('off', 'fmridisplay:add_surface_blobs:nosurface'); +c = onCleanup(@() warning(w)); +o2 = addblobs(o2, t.TestData.s); %#ok must not error +verifyEqual(t, numel(o2.activation_maps), 1, 'Layer is still recorded.'); + +% Non-matching mesh (fsaverage vs fs_LR data) is skipped, not errored. +o3 = fmridisplay; +o3 = surface(o3, 'inflated left'); % fsaverage-164k (163842 verts) +w2 = warning('off', 'fmridisplay:render_layer_surfaces:spacemismatch'); +c2 = onCleanup(@() warning(w2)); +o3 = addblobs(o3, t.TestData.s); % fs_LR data -> no matching mesh +% The fsaverage patch keeps its anatomy colour (data not force-painted onto it) +cc = get(o3.surface{1}.object_handle(1), 'FaceVertexCData'); +verifyTrue(t, size(cc,1) ~= 32492, 'fsaverage mesh should not be painted with fs_LR data.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_opacity_blends_toward_gray(t) +% set_opacity must blend the layer toward the anatomy GRAY (not toward its own +% colour, which was a no-op), and restore fully at opacity 1. +o2 = surface(t.TestData.s); +p = cortex_patches(o2, 32492); cp = p(1); +cfull = get(cp, 'FaceVertexCData'); +v = find(~all(abs(cfull - 0.5) < 1e-3, 2)); v = v(round(numel(v)/2)); + +o2 = set_opacity(o2, 0.3); +c1 = get(cp, 'FaceVertexCData'); +expected = 0.3 * cfull(v,:) + 0.7 * [.5 .5 .5]; +verifyLessThan(t, max(abs(c1(v,:) - expected)), 1e-6, 'Opacity must blend toward gray.'); + +o2 = set_opacity(o2, 1.0); +c2 = get(cp, 'FaceVertexCData'); +verifyLessThan(t, max(abs(c2(v,:) - cfull(v,:))), 1e-6, 'Opacity 1 must restore full colour.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_rethreshold_and_colormap_preserve(t) +% rethreshold applies a magnitude cutoff on the surface WITHOUT region()/volInfo +% (no "Illegal size for mask.dat" warning), sub-threshold vertices go gray, and a +% subsequent set_colormap keeps the threshold (regression: 'single'/'solid' +% colormaps used to paint NaN vertices, resetting the threshold). +o2 = surface(t.TestData.s); +p = cortex_patches(o2, 32492); cp = p(1); +r = reconstruct_image(t.TestData.s); +d = r.cortex_left(:,1); if mean(get(cp,'Vertices')*[1;0;0]) > 0, d = r.cortex_right(:,1); end +thr = median(abs(d(isfinite(d) & d~=0))); +sub = find(abs(d) < thr & isfinite(d)); + +lastwarn(''); +o2 = rethreshold(o2, thr); +[~, wid] = lastwarn; +verifyNotEqual(t, wid, 'MATLAB:badsubscript'); % path did not error +c1 = get(cp, 'FaceVertexCData'); +g1 = mean(all(abs(c1(sub,:) - 0.5) < 1e-6, 2)); +verifyGreaterThan(t, g1, 0.95, 'Sub-threshold vertices must render gray after rethreshold.'); + +o2 = set_colormap(o2, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]); +c2 = get(cp, 'FaceVertexCData'); +g2 = mean(all(abs(c2(sub,:) - 0.5) < 1e-6, 2)); +verifyGreaterThan(t, g2, 0.95, 'set_colormap must preserve the threshold (gray stays gray).'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_mixed_object_subcortex_and_medial_wall(t) +% A mixed grayordinate object renders cortex (surface-native) AND subcortex +% (volume layer) on the surfaces, without the subcortical layer bleeding onto the +% cortical meshes (the medial wall must stay gray). +f = which('transcriptomic_gradients.dscalar.nii'); +if isempty(f), t.assumeFail('transcriptomic_gradients not on path.'); end +s = fmri_surface_data(f); +o2 = surface(s); +verifyEqual(t, numel(o2.activation_maps), 2, 'Mixed object -> cortex + subcortex layers.'); + +fig = ancestor(o2.surface{1}.object_handle(1), 'figure'); +pp = findobj(fig, 'Type', 'patch'); +% Subcortical meshes (thalamus 7690, combined 45406) are painted. +sub_painted = 0; +for hh = pp(:)' + nv = size(get(hh,'Vertices'),1); + if ismember(nv, [7690 45406]) + c = get(hh,'FaceVertexCData'); + if isequal(size(c),[nv 3]) && nnz(~all(abs(c-0.5)<1e-3,2)) > 50, sub_painted = sub_painted + 1; end + end +end +verifyGreaterThan(t, sub_painted, 0, 'Subcortical surfaces must be painted.'); + +% Cortical medial wall stays gray (subcortical layer must not bleed onto cortex). +lh_model = s.brain_model.models{1}; +inmask = false(lh_model.numvert,1); inmask(lh_model.vertlist + 1) = true; +p = cortex_patches(o2, 32492); +lh = p(1); +for jj = 1:numel(p) + if mean(get(p(jj),'Vertices')*[1;0;0]) < 0, lh = p(jj); break; end +end +c = get(lh, 'FaceVertexCData'); +graymask = all(abs(c - 0.5) < 1e-6, 2); +verifyTrue(t, all(graymask(~inmask)), 'Medial wall must stay gray (no subcortical bleed).'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_montage_renders_subcortex(t) +% montage(o2, obj) builds the montage and renders the object's subcortical +% grayordinates as blobs on the slices (instead of ignoring obj). +f = which('transcriptomic_gradients.dscalar.nii'); +if isempty(f), t.assumeFail('transcriptomic_gradients not on path.'); end +s = fmri_surface_data(f); +o2 = montage(fmridisplay, s); +verifyEqual(t, numel(o2.activation_maps), 1, 'Subcortical volume added as one layer.'); +nblob = 0; +for k = 1:numel(o2.activation_maps), nblob = nblob + numel(o2.activation_maps{k}.blobhandles); end +verifyGreaterThan(t, nblob, 0, 'Subcortical blobs must be drawn on the slices.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_to_display_volume(t) +% Surface data projects to an MNI volume (for rendering on arbitrary meshes): +% fs_LR cortex is resampled to fsaverage and surf2vol'd. +vol = to_display_volume(t.TestData.s); % t.TestData.s is fs_LR cortex +verifyTrue(t, isa(vol, 'fmri_data'), 'to_display_volume returns an fmri_data.'); +verifyGreaterThan(t, size(vol.dat, 1), 1000, 'Projected volume must have cortical voxels.'); +end + + +% ------------------------------------------------------------------------- +function test_render_onto_arbitrary_mni_mesh(t) +% Rendering onto a NON-standard MNI isosurface (addbrain 'hires left') projects +% the data to a volume and paints it, instead of skipping with a warning. +o2 = surface(t.TestData.s); % fs_LR data, managed display +n0 = numel(o2.surface); +w = warning('off', 'all'); c = onCleanup(@() warning(w)); +try + o2 = surface(o2, 'hires left'); % arbitrary MNI cortical mesh +catch + t.assumeFail('addbrain ''hires left'' surface unavailable.'); +end +painted = false; sawmesh = false; +for i = (n0 + 1):numel(o2.surface) + h = o2.surface{i}.object_handle; h = h(ishandle(h)); + for jj = 1:numel(h) + hh = h(jj); + if ~strcmp(get(hh, 'Type'), 'patch'), continue; end + nv = size(get(hh, 'Vertices'), 1); + if ismember(nv, [32492 163842 40962 10242 2562]), continue; end % standard + sawmesh = true; + cc = get(hh, 'FaceVertexCData'); + if size(cc, 1) == nv && nnz(~all(abs(double(cc) - 0.5) < 1e-3, 2)) > 100 + painted = true; + end + end +end +if ~sawmesh, t.assumeFail('No non-standard mesh was added.'); end +verifyTrue(t, painted, 'A non-standard MNI mesh must be painted via volume projection.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_color_mode_and_unique_solid(t) +% canlab_color_mode (single source of truth) + surface 'unique'/'solid'/auto. +verifyEqual(t, canlab_color_mode([0; 1; 0; 1]), 'solid'); % binary mask +verifyEqual(t, canlab_color_mode((0:6)'), 'unique'); % few integer labels +verifyEqual(t, canlab_color_mode(randn(500, 1) + 0.5), 'colormap'); % continuous + +% Continuous data (myelin) auto-selects a colormap (no indexmap/solid). +o = surface(t.TestData.s); +ra = o.activation_maps{1}.render_args; +verifyFalse(t, any(strcmp(ra, 'indexmap')) || any(strcmp(ra, 'color')), 'continuous -> colormap'); + +% Explicit 'solid'. +o2 = surface(t.TestData.s, 'solid'); +verifyTrue(t, any(strcmp(o2.activation_maps{1}.render_args, 'color')), '''solid'' -> solid color'); + +% A label object auto-selects 'unique' (indexed colormap). +sc = t.TestData.s; +sc.dat = single(mod((1:size(sc.dat, 1))', 7) + 1); +sc.imagetype = 'dlabel'; +verifyEqual(t, canlab_color_mode(sc), 'unique', '.dlabel is always unique.'); +o3 = surface(sc); +verifyTrue(t, any(strcmp(o3.activation_maps{1}.render_args, 'indexmap')), 'dlabel auto -> unique'); +% Explicit 'unique' too. +o4 = surface(t.TestData.s, 'unique'); +verifyTrue(t, any(strcmp(o4.activation_maps{1}.render_args, 'indexmap')), '''unique'' -> indexed'); +close all force +end + + +% ------------------------------------------------------------------------- +function p = cortex_patches(o2, nv) +% Cortical patches (vertex count nv) across all managed surface views. +p = gobjects(0); +for i = 1:numel(o2.surface) + h = o2.surface{i}.object_handle; + h = h(ishandle(h)); + for jj = 1:numel(h) + hh = h(jj); + if strcmp(get(hh, 'Type'), 'patch') && size(get(hh, 'Vertices'), 1) == nv + p(end + 1) = hh; %#ok + end + end +end +end diff --git a/CanlabCore/Unit_tests/surface_data/canlab_test_surface_io.m b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_io.m new file mode 100644 index 00000000..3522c595 --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_io.m @@ -0,0 +1,191 @@ +function tests = canlab_test_surface_io +% Unit tests for the native CIFTI-2 / GIFTI reader+writer (M1 of fmri_surface_data). +% +% Verifies that canlab_read_gifti / canlab_write_gifti / canlab_read_cifti / +% canlab_write_cifti work with NO external toolbox (no gifti, FieldTrip, +% cifti-matlab, or Connectome Workbench) -- only core MATLAB + the JVM. +% +% Run: runtests('canlab_test_surface_io') +% or it is auto-discovered by canlab_run_all_tests. +% +% Coverage: +% - GIFTI .surf round-trip (shipped S1200 fs_LR-32k surface): exact verts/faces, +% GZip and Base64 encodings. +% - GIFTI label round-trip with a LabelTable (synthetic). +% - CIFTI-2 dscalar round-trip (synthetic grayordinates: 2 surface models + +% 1 voxel model): cdata, BrainModel start/count/vertlist/voxlist, and the +% subcortical affine all preserved. +% - CIFTI-2 dlabel round-trip with a LabelTable (synthetic). +% - If a real .dscalar.nii is on the path, an end-to-end read->write->read. +% +% :See also: canlab_read_cifti, canlab_write_cifti, canlab_read_gifti, canlab_write_gifti + +tests = functiontests(localfunctions); +end + + +% ------------------------------------------------------------------------- +function setupOnce(t) +here = fileparts(mfilename('fullpath')); +st = fullfile(here, '..', '..', 'Surface_tools'); +if exist(st, 'dir'), addpath(st); end +% Sanity: the codec functions must be on the path +for fn = {'canlab_read_gifti','canlab_write_gifti','canlab_read_cifti','canlab_write_cifti'} + assert(~isempty(which(fn{1})), 'Missing required function on path: %s', fn{1}); +end +t.TestData.tmp = tempname; +mkdir(t.TestData.tmp); +end + +function teardownOnce(t) +if isfield(t.TestData, 'tmp') && exist(t.TestData.tmp, 'dir') + rmdir(t.TestData.tmp, 's'); +end +end + + +% ------------------------------------------------------------------------- +function test_gifti_surf_roundtrip(t) +surf = which('S1200.L.midthickness_MSMAll.32k_fs_LR.surf.gii'); +assert(~isempty(surf), 'Shipped surface S1200.L.midthickness...surf.gii not found on path.'); + +g = canlab_read_gifti(surf); +verifyEqual(t, size(g.vertices), [32492 3], 'fs_LR-32k left surface should have 32492 vertices.'); +verifyEqual(t, size(g.faces), [64980 3], 'fs_LR-32k left surface should have 64980 faces.'); +verifyGreaterThanOrEqual(t, min(g.faces(:)), 1, 'Faces must be 1-based for patch().'); +verifyLessThanOrEqual(t, max(g.faces(:)), 32492, 'Face indices must not exceed vertex count.'); + +for enc = {'GZipBase64Binary', 'Base64Binary', 'ASCII'} + f = fullfile(t.TestData.tmp, ['rt_' enc{1} '.surf.gii']); + canlab_write_gifti(f, g, 'encoding', enc{1}); + g2 = canlab_read_gifti(f); + tol = 0; if strcmp(enc{1}, 'ASCII'), tol = 1e-4; end % ASCII prints %g + verifyLessThanOrEqual(t, max(abs(g.vertices(:) - g2.vertices(:))), tol, ... + sprintf('Vertex round-trip failed for encoding %s', enc{1})); + verifyEqual(t, g.faces, g2.faces, ... + sprintf('Face round-trip failed for encoding %s', enc{1})); +end +end + + +% ------------------------------------------------------------------------- +function test_gifti_label_roundtrip(t) +n = 50; +keys = mod((0:n-1)', 4); % 0..3 +g = struct(); +g.vertices = []; g.faces = []; +g.cdata = keys; +g.intents = {'NIFTI_INTENT_LABEL'}; +g.labels = struct('key', {0 1 2 3}, ... + 'name', {'Unknown','A','B','C'}, ... + 'rgba', {[0 0 0 0],[1 0 0 1],[0 1 0 1],[0 0 1 1]}); + +f = fullfile(t.TestData.tmp, 'lbl.label.gii'); +canlab_write_gifti(f, g); +g2 = canlab_read_gifti(f); +verifyEqual(t, g2.cdata, double(keys), 'Label keys did not round-trip.'); +verifyEqual(t, numel(g2.labels), 4, 'Label table size changed.'); +verifyEqual(t, g2.labels(2).name, 'A', 'Label name did not round-trip.'); +verifyEqual(t, g2.labels(2).rgba, [1 0 0 1], 'Label RGBA did not round-trip.'); +end + + +% ------------------------------------------------------------------------- +function test_cifti_dscalar_roundtrip(t) +cii = local_make_synthetic_cifti('dscalar'); +f = fullfile(t.TestData.tmp, 'syn.dscalar.nii'); +canlab_write_cifti(f, cii); +c2 = canlab_read_cifti(f); + +verifyEqual(t, c2.intent, 'dscalar'); +verifyEqual(t, size(c2.cdata), size(cii.cdata), 'cdata size changed.'); +verifyLessThanOrEqual(t, max(abs(cii.cdata(:) - c2.cdata(:))), 0, 'cdata values changed.'); + +verifyEqual(t, numel(c2.diminfo{1}.models), numel(cii.diminfo{1}.models), 'Model count changed.'); +for i = 1:numel(cii.diminfo{1}.models) + a = cii.diminfo{1}.models{i}; b = c2.diminfo{1}.models{i}; + verifyEqual(t, b.struct, a.struct); + verifyEqual(t, b.start, a.start); + verifyEqual(t, b.count, a.count); + if strcmp(a.type, 'surf') + verifyEqual(t, b.vertlist, a.vertlist, 'Surface vertlist changed.'); + else + verifyEqual(t, b.voxlist, a.voxlist, 'Voxel IJK list changed.'); + end +end +verifyEqual(t, c2.diminfo{1}.vol.sform, cii.diminfo{1}.vol.sform, 'Subcortical affine changed.'); +verifyEqual(t, {c2.diminfo{2}.maps.name}, {cii.diminfo{2}.maps.name}, 'Scalar map names changed.'); +end + + +% ------------------------------------------------------------------------- +function test_cifti_dlabel_roundtrip(t) +cii = local_make_synthetic_cifti('dlabel'); +f = fullfile(t.TestData.tmp, 'syn.dlabel.nii'); +canlab_write_cifti(f, cii); +c2 = canlab_read_cifti(f); + +verifyEqual(t, c2.intent, 'dlabel'); +verifyLessThanOrEqual(t, max(abs(cii.cdata(:) - c2.cdata(:))), 0, 'Label keys changed.'); +tbl = c2.diminfo{2}.maps(1).table; +verifyEqual(t, numel(tbl), 3, 'Label table size changed.'); +verifyEqual(t, tbl(2).name, 'RegionA', 'Label name did not round-trip.'); +verifyEqual(t, tbl(2).rgba, [1 0 0 1], 'Label RGBA did not round-trip.'); +end + + +% ------------------------------------------------------------------------- +function test_real_cifti_if_available(t) +% Opportunistic: if a real dscalar is on the path (Neuroimaging_Pattern_Masks), +% verify an end-to-end native read->write->read with exact cdata. +f0 = which('transcriptomic_gradients.dscalar.nii'); +if isempty(f0) + t.assumeFail('No real .dscalar.nii on path; skipping (synthetic tests cover the codec).'); +end +c = canlab_read_cifti(f0); +f = fullfile(t.TestData.tmp, 'real_rt.dscalar.nii'); +canlab_write_cifti(f, c); +c2 = canlab_read_cifti(f); +verifyLessThanOrEqual(t, max(abs(double(c.cdata(:)) - double(c2.cdata(:)))), 0, ... + 'Real dscalar cdata did not round-trip exactly.'); +verifyEqual(t, numel(c2.diminfo{1}.models), numel(c.diminfo{1}.models)); +end + + +% ========================================================================= +function cii = local_make_synthetic_cifti(kind) +% Build a minimal valid grayordinate CIFTI struct: 2 surface models (L/R, 10 +% vertices each, 5 in-data) + 1 subcortical voxel model (4 voxels). + +mL = struct('struct','CORTEX_LEFT','type','surf','start',1,'count',5, ... + 'numvert',10,'vertlist',[0 2 4 6 8],'voxlist',[]); +mR = struct('struct','CORTEX_RIGHT','type','surf','start',6,'count',5, ... + 'numvert',10,'vertlist',[1 3 5 7 9],'voxlist',[]); +voxijk = [2 2 2; 3 2 2; 2 3 2; 2 2 3]'; % 3x4 IJK (0-based) +mV = struct('struct','THALAMUS_LEFT','type','vox','start',11,'count',4, ... + 'numvert',NaN,'vertlist',[],'voxlist',voxijk); + +sform = [2 0 0 -20; 0 2 0 -22; 0 0 2 -24; 0 0 0 1]; +dense = struct('type','dense','length',14,'models',{{mL,mR,mV}}, ... + 'vol',struct('dims',[10 10 10],'sform',sform)); + +G = 14; +switch kind + case 'dscalar' + nMaps = 2; + cdata = single(reshape(1:G*nMaps, G, nMaps)); % deterministic + maps = struct('name', {'mapOne','mapTwo'}, 'table', {[],[]}); + mapsdim = struct('type','scalars','length',nMaps,'maps',maps); + intent = 'dscalar'; + case 'dlabel' + cdata = single(mod((0:G-1)', 3)); % keys 0,1,2 + tbl = struct('key',{0 1 2}, 'name',{'Unknown','RegionA','RegionB'}, ... + 'rgba',{[0 0 0 0],[1 0 0 1],[0 0 1 1]}); + maps = struct('name', {'parc'}, 'table', {tbl}); + mapsdim = struct('type','labels','length',1,'maps',maps); + intent = 'dlabel'; +end + +cii = struct('cdata', cdata, 'intent', intent, ... + 'diminfo', {{dense, mapsdim}}); +end diff --git a/CanlabCore/Unit_tests/surface_data/canlab_test_surface_object_basic.m b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_object_basic.m new file mode 100644 index 00000000..1cc76125 --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_object_basic.m @@ -0,0 +1,183 @@ +function tests = canlab_test_surface_object_basic +% Unit tests for the fmri_surface_data class skeleton (M2). +% +% Verifies construction, the grayordinate property model, inheritance from +% image_vector, and the D5b "no empty-squeezing" behavior -- all with NO +% external toolbox. The core tests build a synthetic grayordinate object so they +% run anywhere; one opportunistic test uses a real .dscalar.nii if present. +% +% Run: runtests('canlab_test_surface_object_basic') +% +% :See also: fmri_surface_data, canlab_read_cifti, canlab_test_surface_io + +tests = functiontests(localfunctions); +end + + +% ------------------------------------------------------------------------- +function setupOnce(t) +here = fileparts(mfilename('fullpath')); +addpath(fullfile(here, '..', '..', 'Surface_tools')); +addpath(fullfile(here, '..', '..')); % so @fmri_surface_data resolves +assert(~isempty(which('fmri_surface_data')), 'fmri_surface_data not on path.'); +t.TestData.cii = local_synthetic_cifti(); +end + + +% ------------------------------------------------------------------------- +function test_empty_construction(t) +o = fmri_surface_data; +verifyTrue(t, isa(o, 'fmri_surface_data')); +verifyTrue(t, isa(o, 'image_vector'), 'Must subclass image_vector.'); +verifyEmpty(t, o.dat); +verifyEmpty(t, o.brain_model); +verifyEqual(t, o.imagetype, ''); +end + + +% ------------------------------------------------------------------------- +function test_build_from_grayordinate_struct(t) +o = fmri_surface_data(t.TestData.cii); +verifyEqual(t, class(o), 'fmri_surface_data'); +verifyEqual(t, size(o.dat), [14 2], 'Grayordinate dat should be 14 x 2.'); +verifyEqual(t, o.imagetype, 'dscalar'); + +% brain_model carries the model split, 1:1 with .dat rows +bm = o.brain_model; +verifyEqual(t, numel(bm.models), 3, 'Expected L cortex + R cortex + 1 voxel model.'); +total = sum(cellfun(@(m) m.count, bm.models)); +verifyEqual(t, total, size(o.dat, 1), 'Sum of model counts must equal grayordinate rows.'); +verifyEqual(t, bm.models{1}.struct, 'CORTEX_LEFT'); +verifyEqual(t, bm.models{3}.type, 'vox'); +verifyEqual(t, bm.vol.sform, t.TestData.cii.diminfo{1}.vol.sform, 'Subcortical affine lost.'); + +% map names carried +verifyEqual(t, reshape(o.image_names,1,[]), {'mapOne','mapTwo'}); +end + + +% ------------------------------------------------------------------------- +function test_removed_vectors_are_vestigial(t) +% D5b: removed_voxels/removed_images are all-false and length-correct. +o = fmri_surface_data(t.TestData.cii); +verifyEqual(t, numel(o.removed_voxels), size(o.dat,1)); +verifyEqual(t, numel(o.removed_images), size(o.dat,2)); +verifyFalse(t, any(o.removed_voxels), 'removed_voxels must be all-false.'); +verifyFalse(t, any(o.removed_images), 'removed_images must be all-false.'); +end + + +% ------------------------------------------------------------------------- +function test_remove_replace_empty_are_noops(t) +% Even with all-zero grayordinate rows, nothing is squeezed. +o = fmri_surface_data(t.TestData.cii); +o.dat(7, :) = 0; % zero out a whole grayordinate row +n0 = size(o.dat, 1); + +o1 = remove_empty(o); +verifyEqual(t, size(o1.dat, 1), n0, 'remove_empty must NOT drop rows.'); +verifyEqual(t, o1.dat, o.dat, 'remove_empty must return data unchanged.'); +verifyFalse(t, any(o1.removed_voxels), 'removed_voxels must stay all-false.'); + +o2 = replace_empty(o1); +verifyEqual(t, size(o2.dat, 1), n0, 'replace_empty must not change size.'); +verifyEqual(t, o2.dat, o.dat, 'replace_empty must return data unchanged.'); +end + + +% ------------------------------------------------------------------------- +function test_get_wh_image_subsets_maps_only(t) +% Inherited get_wh_image selects map columns; grayordinate rows are preserved. +o = fmri_surface_data(t.TestData.cii); +o2 = get_wh_image(o, 2); +verifyEqual(t, size(o2.dat), [14 1], 'get_wh_image should keep all rows, 1 map.'); +verifyEqual(t, o2.dat, o.dat(:, 2), 'Wrong map selected.'); +verifyEqual(t, numel(o2.image_names), 1, 'image_names not subset with maps.'); +verifyEqual(t, numel(o2.removed_images), 1, 'removed_images not subset with maps.'); +% rows (grayordinates) unchanged +verifyEqual(t, numel(o2.removed_voxels), 14, 'Grayordinate rows must be preserved.'); +end + + +% ------------------------------------------------------------------------- +function test_method_surface_is_inherited(t) +% The image_vector method surface is inherited by dispatch (same method names +% as fmri_data/image_vector). Note: methods that dereference volInfo +% (descriptives, montage, orthviews, flip, ...) get surface-aware guards/ +% overrides in M3 (Risk #1); here we just confirm the surface is present. +o = fmri_surface_data(t.TestData.cii); +m = methods(o); +for name = {'get_wh_image','remove_empty','replace_empty','apply_parcellation','ica'} + verifyTrue(t, ismember(name{1}, m), sprintf('Method %s should be on the object.', name{1})); +end +% remove_empty / replace_empty must be overridden in THIS class folder (no-op +% semantics are verified separately in test_remove_replace_empty_are_noops). +clsdir = fileparts(which('fmri_surface_data')); +verifyTrue(t, exist(fullfile(clsdir, 'remove_empty.m'), 'file') == 2, ... + 'remove_empty.m override missing from @fmri_surface_data.'); +verifyTrue(t, exist(fullfile(clsdir, 'replace_empty.m'), 'file') == 2, ... + 'replace_empty.m override missing from @fmri_surface_data.'); +end + + +% ------------------------------------------------------------------------- +function test_real_dscalar_if_available(t) +f = which('transcriptomic_gradients.dscalar.nii'); +if isempty(f) + t.assumeFail('No real .dscalar.nii on path; synthetic tests cover the class.'); +end +o = fmri_surface_data(f); +verifyEqual(t, class(o), 'fmri_surface_data'); +verifyGreaterThan(t, size(o.dat,1), 60000, 'Expected tens of thousands of grayordinates.'); +verifyEqual(t, sum(cellfun(@(m) m.count, o.brain_model.models)), size(o.dat,1)); +verifyEqual(t, o.brain_model.models{1}.struct, 'CORTEX_LEFT'); +% .dat is full, no squeezing +verifyEqual(t, numel(o.removed_voxels), size(o.dat,1)); +verifyFalse(t, any(o.removed_voxels)); +end + + +% ------------------------------------------------------------------------- +function test_volume_only_methods_hidden(t) +% Volume-only image_vector methods are masked (hidden from methods() and give a +% clear error), while the class stays a full image_vector subclass. +o = fmri_surface_data(local_synthetic_cifti()); +m = methods('fmri_surface_data'); +for name = {'flip','isosurface','interpolate','resample_space','extract_gray_white_csf'} + verifyFalse(t, ismember(name{1}, m), sprintf('%s should be hidden from methods().', name{1})); +end +verifyTrue(t, isa(o, 'image_vector'), 'Class must remain an image_vector subclass.'); +% A hidden method gives the informative error, not a cryptic one +verifyError(t, @() flip(o), 'fmri_surface_data:unsupportedMethod'); + +% orthviews/montage/slices route to the subcortical volume (this object has one) +verifyTrue(t, ismember('orthviews', m), 'orthviews should be available (routes to subcortex).'); +% cortex-only object: orthviews errors clearly +cii = local_synthetic_cifti(); +cii.diminfo{1}.models = cii.diminfo{1}.models(1:2); % drop the voxel model +cii.diminfo{1}.vol = []; +cortexonly = fmri_surface_data(cii); +cortexonly.dat = cortexonly.dat(1:10, :); % 2 cortex models, 10 rows +verifyError(t, @() orthviews(cortexonly), 'fmri_surface_data:orthviews:cortexonly'); +end + + +% ========================================================================= +function cii = local_synthetic_cifti() +% Minimal grayordinate dscalar: 2 surface models (L/R, 5 in-data each) + 1 +% subcortical voxel model (4 voxels) = 14 grayordinates, 2 maps. +mL = struct('struct','CORTEX_LEFT','type','surf','start',1,'count',5, ... + 'numvert',32492,'vertlist',[0 2 4 6 8],'voxlist',[]); +mR = struct('struct','CORTEX_RIGHT','type','surf','start',6,'count',5, ... + 'numvert',32492,'vertlist',[1 3 5 7 9],'voxlist',[]); +voxijk = [2 2 2; 3 2 2; 2 3 2; 2 2 3]'; +mV = struct('struct','THALAMUS_LEFT','type','vox','start',11,'count',4, ... + 'numvert',NaN,'vertlist',[],'voxlist',voxijk); +sform = [2 0 0 -20; 0 2 0 -22; 0 0 2 -24; 0 0 0 1]; +dense = struct('type','dense','length',14,'models',{{mL,mR,mV}}, ... + 'vol',struct('dims',[10 10 10],'sform',sform)); +maps = struct('name',{'mapOne','mapTwo'},'table',{[],[]}); +mapsdim = struct('type','scalars','length',2,'maps',maps); +cii = struct('cdata', single(reshape(1:28,14,2)), 'intent','dscalar', ... + 'diminfo', {{dense, mapsdim}}); +end diff --git a/CanlabCore/Unit_tests/surface_data/canlab_test_surface_parcellation.m b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_parcellation.m new file mode 100644 index 00000000..0d9dd949 --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_parcellation.m @@ -0,0 +1,138 @@ +function tests = canlab_test_surface_parcellation +% Unit tests for fmri_surface_data parcellation / region methods (M7). +% +% Covers apply_parcellation (parcel means, labels, area weighting, space check), +% cluster-extent threshold ('k'), and surface_region. Core tests use a synthetic +% object with a known parcellation (self-contained); mesh-dependent tests use a +% real fs_LR file if present. +% +% Run: runtests('canlab_test_surface_parcellation') +% +% :See also: apply_parcellation, threshold, surface_region, reparse_contiguous + +tests = functiontests(localfunctions); +end + + +% ------------------------------------------------------------------------- +function setupOnce(t) +here = fileparts(mfilename('fullpath')); +addpath(fullfile(here, '..', '..', 'Surface_tools')); +addpath(fullfile(here, '..', '..')); +assert(~isempty(which('fmri_surface_data')), 'fmri_surface_data not on path.'); +t.TestData.figvis = get(0, 'DefaultFigureVisible'); +set(0, 'DefaultFigureVisible', 'off'); +end + +function teardownOnce(t) +close all force +set(0, 'DefaultFigureVisible', t.TestData.figvis); +end + + +% ------------------------------------------------------------------------- +function test_apply_parcellation_known(t) +% 20 grayordinates, 3 parcels + background (0). Data == key, so each parcel mean +% equals its key. +keys = [1 1 1 1 1 2 2 2 2 2 3 3 3 3 3 0 0 0 0 0]'; +D = [double(keys), double(keys)*10]; +o = local_obj(D); + +[pm, labels, tbl] = apply_parcellation(o, keys); +verifyEqual(t, size(pm), [2 3], 'parcel_means should be [nMaps x nParcels].'); +verifyEqual(t, pm(1,:), [1 2 3], 'AbsTol', 1e-6, 'map1 parcel means should equal the keys.'); +verifyEqual(t, pm(2,:), [10 20 30], 'AbsTol', 1e-5, 'map2 parcel means should equal 10*keys.'); +verifyEqual(t, numel(labels), 3); +verifyEqual(t, tbl.n_grayordinates', [5 5 5], 'each parcel has 5 grayordinates.'); +% background key 0 must be excluded +verifyFalse(t, any(tbl.key == 0), 'background (key 0) must be excluded.'); +end + + +% ------------------------------------------------------------------------- +function test_apply_parcellation_from_object_and_space_check(t) +keys = [ones(10,1); 2*ones(10,1)]; +o = local_obj([double(keys), double(keys)]); +parc = local_obj(double(keys)); % a "dlabel"-like object on the same space +parc.imagetype = 'dlabel'; +% label_table is a MATLAB table (variables key, name, rgba) +parc.label_table = table([1;2], ["A";"B"], [1 0 0 1; 0 0 1 1], ... + 'VariableNames', {'key','name','rgba'}); + +[pm, labels] = apply_parcellation(o, parc); +verifyEqual(t, pm(1,:), [1 2], 'AbsTol', 1e-6); +verifyEqual(t, labels, {'A','B'}, 'labels should come from the label_table.'); + +% Different space must error +parc2 = parc; parc2.surface_space = 'fsaverage_164k'; +verifyError(t, @() apply_parcellation(o, parc2), 'fmri_surface_data:apply_parcellation:space'); +end + + +% ------------------------------------------------------------------------- +function test_real_atlas_parcellation(t) +f = which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii'); +if isempty(f), t.assumeFail('No real .dlabel atlas on path.'); end +atl = fmri_surface_data(f); +verifyTrue(t, istable(atl.label_table), 'A loaded .dlabel must have a MATLAB-table label_table.'); +verifyTrue(t, all(ismember({'key','name','rgba'}, atl.label_table.Properties.VariableNames))); +% Use the atlas keys as the data: each parcel mean must equal its key +data = atl; data.dat = single(double(atl.dat)); data.imagetype = 'dscalar'; +data.removed_images = false(1,1); +[pm, labels] = apply_parcellation(data, atl); +ukeys = unique(round(double(atl.dat(atl.dat > 0)))); +verifyEqual(t, numel(labels), numel(ukeys), 'one label per positive key.'); +verifyEqual(t, pm(1,:)', ukeys, 'AbsTol', 1e-4, 'parcel mean of key-data must equal the key.'); +% area-weighted means are finite +pmA = apply_parcellation(data, atl, 'area'); +verifyTrue(t, all(isfinite(pmA(:))), 'area-weighted parcel means must be finite.'); +end + + +% ------------------------------------------------------------------------- +function test_cluster_extent_threshold(t) +f = which('transcriptomic_gradients.dscalar.nii'); +if isempty(f), t.assumeFail('No fs_LR dscalar on path.'); end +s = fmri_surface_data(f); +raw = threshold(s, 1.0, 'positive'); +big = threshold(s, 1.0, 'positive', 'k', 50); +verifyLessThanOrEqual(t, nnz(big.dat(:,1) ~= 0), nnz(raw.dat(:,1) ~= 0), ... + 'cluster-extent threshold cannot increase the surviving set.'); +% Every surviving cluster must have >= 50 grayordinates +[chk, ~] = reparse_contiguous(big, 'which_image', 1); +cl = chk.brain_model.cluster; +if any(cl > 0) + szs = accumarray(cl(cl > 0), 1); + verifyGreaterThanOrEqual(t, min(szs(szs > 0)), 50, 'a surviving cluster is smaller than k.'); +end +end + + +% ------------------------------------------------------------------------- +function test_surface_region(t) +f = which('transcriptomic_gradients.dscalar.nii'); +if isempty(f), t.assumeFail('No fs_LR dscalar on path.'); end +s = threshold(fmri_surface_data(f), 1.5, 'positive'); +reg = surface_region(s, 'which_image', 1); +verifyGreaterThan(t, numel(reg), 0, 'expected at least one region.'); +% Field sanity on the first region +r1 = reg(1); +verifyTrue(t, all(isfield(r1, {'struct','type','grayord_rows','XYZmm','numVox','val'}))); +verifyEqual(t, r1.numVox, numel(r1.grayord_rows)); +% Total grayordinates across regions == active grayordinates +total = sum([reg.numVox]); +active = nnz(s.dat(:,1) ~= 0 & ~isnan(s.dat(:,1))); +verifyEqual(t, total, active, 'regions must partition the active grayordinates.'); +end + + +% ========================================================================= +function o = local_obj(D) +n = size(D,1); h = n/2; +mL = struct('struct','CORTEX_LEFT','type','surf','start',1,'count',h,'numvert',32492,'vertlist',0:h-1,'voxlist',[]); +mR = struct('struct','CORTEX_RIGHT','type','surf','start',h+1,'count',h,'numvert',32492,'vertlist',0:h-1,'voxlist',[]); +bm = struct('type','dense','length',n,'models',{{mL,mR}},'vol',[]); +bm.grayordinate_type = 'cortex_only'; bm.cluster = []; +o = fmri_surface_data('dat', single(D), 'brain_model', bm, ... + 'surface_space', 'fsLR_32k', 'imagetype', 'dscalar'); +end diff --git a/CanlabCore/Unit_tests/surface_data/canlab_test_surface_render.m b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_render.m new file mode 100644 index 00000000..d37ea37a --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_render.m @@ -0,0 +1,236 @@ +function tests = canlab_test_surface_render +% Unit tests for fmri_surface_data rendering + contiguity (M5). +% +% Covers the geom loader, native surface() rendering (4-panel, direct vertex +% coloring), render_on_surface onto existing patches (native + via-volume), +% reparse_contiguous (mesh connected components), and plot QC. Figures are +% rendered offscreen and checked structurally (handles, FaceVertexCData) -- no +% visual inspection needed. Requires the bundled meshes + CBIG warps (in-repo); +% no external toolbox. +% +% Run: runtests('canlab_test_surface_render') +% +% :See also: surface, render_on_surface, reparse_contiguous, fmri_surface_data + +tests = functiontests(localfunctions); +end + + +% ------------------------------------------------------------------------- +function setupOnce(t) +here = fileparts(mfilename('fullpath')); +addpath(fullfile(here, '..', '..', 'Surface_tools')); +addpath(fullfile(here, '..', '..')); +assert(~isempty(which('fmri_surface_data')), 'fmri_surface_data not on path.'); +assert(~isempty(which('addbrain')), 'addbrain not on path.'); +t.TestData.figvis = get(0, 'DefaultFigureVisible'); +set(0, 'DefaultFigureVisible', 'off'); +% A real fs_LR dscalar (covers all vertices incl. medial wall) +f = which('transcriptomic_gradients.dscalar.nii'); +assert(~isempty(f), 'transcriptomic_gradients.dscalar.nii not on path.'); +t.TestData.s = fmri_surface_data(f); +end + +function teardownOnce(t) +close all force; +set(0, 'DefaultFigureVisible', t.TestData.figvis); +end + + +% ------------------------------------------------------------------------- +function test_geom_loads_for_both_spaces(t) +% Geometry is loaded (correct vertex counts) indirectly via surface() for both +% the fs_LR-32k and fsaverage-164k bundled meshes. (load_surface_geom is a +% private helper, verified through the public surface() API.) surface() now +% returns a managed fmridisplay whose cortical patches carry the right meshes. +o1 = surface(t.TestData.s); % fs_LR-32k -> managed display +verifyTrue(t, isa(o1, 'fmridisplay'), 'Native surface() returns a managed fmridisplay.'); +verifyNotEmpty(t, cortex_patches(o1, 32492), 'Expected fs_LR 32492-vertex cortical patches.'); +close all force + +vol = local_synthetic_volume(); +sf = vol2surf(vol); % fsaverage_164k object +o2 = surface(sf); +verifyNotEmpty(t, cortex_patches(o2, 163842), 'Expected fsaverage 163842-vertex cortical patches.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_surface_native_render(t) +o = surface(t.TestData.s, 'which_image', 1); +verifyTrue(t, isa(o, 'fmridisplay'), 'Native surface() returns a managed fmridisplay.'); +% transcriptomic_gradients is a mixed grayordinate object: cortex (surface-native +% layer) + subcortex (volume layer), so two managed layers. +verifyGreaterThanOrEqual(t, numel(o.activation_maps), 1, 'At least the cortical layer exists.'); +verifyTrue(t, isa(o.activation_maps{1}.source_surface, 'fmri_surface_data'), ... + 'First layer is the surface-native cortical layer.'); +p = cortex_patches(o, 32492); +verifyEqual(t, numel(p), 4, 'Expected 4 cortical panels (L/R x lateral/medial).'); +for k = 1:numel(p) + c = get(p(k), 'FaceVertexCData'); + V = get(p(k), 'Vertices'); + verifyEqual(t, size(c), [size(V,1) 3], 'Each cortical patch must be truecolor per vertex.'); + verifyEqual(t, size(V,1), 32492, 'Native fs_LR mesh should have 32492 vertices.'); +end +close all force +end + + +% ------------------------------------------------------------------------- +function test_harmonized_color_options(t) +% surface()/render_on_surface accept the same color vocabulary as the volume +% pipeline (colormap/cmaprange, maxcolor/mincolor, splitcolor, color). +s = t.TestData.s; +% Graduated maps must produce many colors +opts = { {'colormap','parula'}, ... % single sequential map over data range + {'maxcolor',[1 1 0],'mincolor',[1 0 0]}, ... + {'splitcolor',{[0 0 1],[0 1 1],[1 .5 0],[1 1 0]}} }; +for k = 1:numel(opts) + o = surface(s, opts{k}{:}); + p = cortex_patches(o, 32492); + c = get(p(1), 'FaceVertexCData'); + verifyEqual(t, size(c,2), 3, 'Must set truecolor.'); + verifyGreaterThan(t, size(unique(c,'rows'),1), 2, 'Graduated coloring should not be uniform.'); + close all force +end +% solid 'color' -> the solid color (plus gray for any zero/NaN vertices) +o = surface(s, 'color', [0 .7 0]); +p = cortex_patches(o, 32492); +u = unique(get(p(1),'FaceVertexCData'),'rows'); +verifyLessThanOrEqual(t, size(u,1), 2, 'Solid color should give <= 2 colors (color + gray).'); +verifyTrue(t, ismember([0 .7 0], u, 'rows'), 'Solid color must appear on the surface.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_medial_wall_is_gray(t) +% For a 91k object (medial wall excluded), medial-wall vertices render gray. +f = which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii'); +if isempty(f), t.assumeFail('No 91k dlabel on path.'); end +o = fmri_surface_data(f); +o2 = surface(o); +% Left cortical patch (x-centroid < 0): medial-wall vertices (not in vertlist) +% should be exactly gray. +p = cortex_patches(o2, 32492); +lh = p(1); +for jj = 1:numel(p) + V = get(p(jj), 'Vertices'); + if mean(V(:,1)) < 0, lh = p(jj); break; end +end +lh_model = o.brain_model.models{1}; +inmask = false(lh_model.numvert, 1); inmask(lh_model.vertlist + 1) = true; +c = get(lh, 'FaceVertexCData'); +graymask = all(abs(c - 0.5) < 1e-6, 2); +% Every medial-wall vertex must be gray +verifyTrue(t, all(graymask(~inmask)), 'Medial wall vertices must render gray.'); +close all force +end + + +% ------------------------------------------------------------------------- +function test_render_on_existing_native_patch(t) +% Color an addbrain native fs_LR patch directly (no resampling). +hp = addbrain('hcp inflated left'); % 32492-vertex fs_LR mesh, Tag has 'left' +render_on_surface(t.TestData.s, hp, 'which_image', 1); +c = get(hp, 'FaceVertexCData'); +verifyEqual(t, size(c), [32492 3], 'Direct native coloring should set per-vertex truecolor.'); +verifyEqual(t, get(hp, 'FaceColor'), 'interp'); +close all force; +end + + +% ------------------------------------------------------------------------- +function test_render_on_mni_surface_via_volume(t) +% An fsaverage object on an arbitrary MNI surface routes through a volume. +vol = local_synthetic_volume(); +sf = vol2surf(vol); +hp = addbrain('left'); % MNI pial-ish surface (not fsaverage topology) +nverts = size(get(hp, 'Vertices'), 1); +verifyNotEqual(t, nverts, 163842, 'addbrain left should not be fsaverage topology.'); +render_on_surface(sf, hp, 'clim', [-2 2]); +c = get(hp, 'FaceVertexCData'); +verifyEqual(t, size(c, 1), nverts, 'Via-volume render should color all patch vertices.'); +close all force; +end + + +% ------------------------------------------------------------------------- +function test_atlas_surface_unique(t) +% A volumetric atlas rendered on surfaces uses UNIQUE per-region solid colours +% (via an indexed colormap), and surface(atl,'unique') is accepted (no warning). +% A continuous statistic map keeps its colormap (no false auto-unique). +if isempty(which('load_atlas')), t.assumeFail('load_atlas not on path.'); end +atl = []; +try, atl = load_atlas('julich_fmriprep20', 'noverbose'); catch, end +if isempty(atl), t.assumeFail('julich atlas not available.'); end + +verifyEqual(t, canlab_color_mode(atl), 'unique', 'An atlas is a unique-colour map.'); + +w = warning('off', 'all'); c = onCleanup(@() warning(w)); +lastwarn(''); +h = surface(atl, 'unique'); % explicit flag, must not error/warn-unknown +[~, wid] = lastwarn; +verifyNotEqual(t, wid, 'MATLAB:UndefinedFunction', 'surface(atl,''unique'') must run.'); +verifyNotEmpty(t, h, 'surface(atl,''unique'') returns surface handles.'); +close all force; +end + + +% ------------------------------------------------------------------------- +function test_reparse_contiguous(t) +st = threshold(t.TestData.s, 1.0, 'positive'); +[st, ncl] = reparse_contiguous(st, 'which_image', 1); +verifyGreaterThan(t, ncl, 0, 'Expected at least one cluster.'); +verifyEqual(t, numel(st.brain_model.cluster), size(st.dat,1)); +active = st.dat(:,1) ~= 0 & ~isnan(st.dat(:,1)); +verifyEqual(t, nnz(st.brain_model.cluster > 0), nnz(active), ... + 'Every active grayordinate must get a cluster label.'); +verifyEqual(t, nnz(st.brain_model.cluster(~active)), 0, ... + 'Inactive grayordinates must have label 0.'); +end + + +% ------------------------------------------------------------------------- +function test_plot_runs(t) +h = plot(t.TestData.s, 'norender'); +verifyTrue(t, isgraphics(h.figure)); +close(h.figure); +end + + +% ========================================================================= +function p = cortex_patches(o2, nv) +% Cortical patches (vertex count nv) across all managed surface views of a +% fmridisplay returned by native surface(). +p = gobjects(0); +for i = 1:numel(o2.surface) + h = o2.surface{i}.object_handle; + h = h(ishandle(h)); + for jj = 1:numel(h) + hh = h(jj); + if strcmp(get(hh, 'Type'), 'patch') && size(get(hh, 'Vertices'), 1) == nv + p(end + 1) = hh; %#ok + end + end +end +end + + +% ========================================================================= +function vol = local_synthetic_volume() +dims = [91 109 91]; +tmat = [-2 0 0 92; 0 2 0 -128; 0 0 2 -74; 0 0 0 1]; +[I, J, K] = ndgrid(1:dims(1), 1:dims(2), 1:dims(3)); +xyz = tmat * [I(:)'; J(:)'; K(:)'; ones(1, numel(I))]; +f = sin(xyz(1,:)/30) + cos(xyz(2,:)/40); +iv = image_vector; +iv.volInfo = struct('mat', tmat, 'dim', dims, 'dt', [16 0], ... + 'xyzlist', [I(:) J(:) K(:)], 'nvox', prod(dims), ... + 'image_indx', true(prod(dims),1), 'wh_inmask', (1:prod(dims))', ... + 'n_inmask', prod(dims), 'fname', ''); +iv.dat = single(f(:)); +iv.removed_voxels = false(prod(dims),1); iv.removed_images = false; +vol = fmri_data(iv); +end diff --git a/CanlabCore/Unit_tests/surface_data/canlab_test_surface_resample.m b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_resample.m new file mode 100644 index 00000000..66de2002 --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_resample.m @@ -0,0 +1,175 @@ +function tests = canlab_test_surface_resample +% Unit tests for fmri_surface_data/resample_surface (surface-to-surface resampling). +% +% Covers fsaverage<->fs_LR (spherical barycentric / nearest), exact nested +% fsaverage downsampling, the 'list' output, auto method selection (barycentric +% for continuous, nearest for binary/label), multi-map weight reuse, and carrying +% subcortex through unchanged. The core cases use a synthetic smooth fsaverage +% object built from the bundled sphere, so they need only CanlabCore (the sphere +% assets + interpolation engine ship in-repo); the 91k subcortex case needs +% Neuroimaging_Pattern_Masks and assumeFail-skips if it is absent. +% +% Run: runtests('canlab_test_surface_resample') +% +% :See also: resample_surface, vol2surf, surf2vol, fmri_surface_data + +tests = functiontests(localfunctions); +end + + +% ------------------------------------------------------------------------- +function setupOnce(t) +here = fileparts(mfilename('fullpath')); +addpath(fullfile(here, '..', '..')); +assert(~isempty(which('fmri_surface_data')), 'fmri_surface_data not on path.'); +assert(~isempty(which('fs_L-to-fs_LR_fsaverage.L_LR.spherical_std.164k_fs_L.mat')), ... + 'Bundled fsaverage<->fs_LR registration sphere not on path.'); +% A synthetic SMOOTH fsaverage-164k object (value = a low-frequency function of +% sphere position), so resampling round-trips faithfully and accuracy is testable. +D = load(which('fs_L-to-fs_LR_fsaverage.L_LR.spherical_std.164k_fs_L.mat')); +V = double(D.vertices); V = V ./ sqrt(sum(V.^2, 2)); % unit sphere +smooth = V(:, 1) + 0.5 * V(:, 2) - 0.3 * V(:, 3); % smooth map, both hemis same +t.TestData.obj = local_make_fsaverage(smooth, smooth); +end + + +% ------------------------------------------------------------------------- +function test_list_returns_table(t) +tbl = resample_surface(fmri_surface_data, 'list'); +verifyClass(t, tbl, 'table'); +verifyGreaterThanOrEqual(t, height(tbl), 5, 'Expected at least the 5 core spaces.'); +verifyTrue(t, all(ismember({'fsaverage_164k', 'fs_LR_32k', 'onavg_41k'}, tbl.space)), ... + 'List must include fsaverage_164k, fs_LR_32k, and onavg_41k.'); +end + + +% ------------------------------------------------------------------------- +function test_fsaverage_to_fslr(t) +s32 = resample_surface(t.TestData.obj, 'fsLR_32k'); +verifyEqual(t, s32.surface_space, 'fs_LR_32k'); +verifyEqual(t, size(s32.dat, 1), 2 * 32492, 'fs_LR-32k cortex = 2 x 32492.'); +% Values stay in the source range (no wild extrapolation) +src = double(t.TestData.obj.dat(:, 1)); out = double(s32.dat(:, 1)); +verifyGreaterThanOrEqual(t, min(out), min(src) - 1e-3); +verifyLessThanOrEqual(t, max(out), max(src) + 1e-3); +end + + +% ------------------------------------------------------------------------- +function test_nested_downsample_is_exact(t) +% fsaverage_164k -> fsaverage6 is the first 40962 vertices per hemisphere (exact). +s6 = resample_surface(t.TestData.obj, 'fsaverage6'); +verifyEqual(t, s6.surface_space, 'fsaverage6'); +verifyEqual(t, size(s6.dat, 1), 2 * 40962); +verifyEqual(t, double(s6.dat(1:40962, 1)), double(t.TestData.obj.dat(1:40962, 1)), 'AbsTol', 1e-6, ... + 'fsaverage6 must be the exact nested subset of the 164k left hemisphere.'); +end + + +% ------------------------------------------------------------------------- +function test_roundtrip_accuracy(t) +% Barycentric fsaverage -> fs_LR -> fsaverage should recover a smooth map closely. +s32 = resample_surface(t.TestData.obj, 'fsLR_32k'); % barycentric (continuous) +sback = resample_surface(s32, 'fsaverage_164k'); +a = double(t.TestData.obj.dat(:, 1)); b = double(sback.dat(:, 1)); +m = isfinite(a) & isfinite(b); +verifyGreaterThan(t, corr(a(m), b(m)), 0.999, 'Barycentric round-trip should be near-lossless.'); +end + + +% ------------------------------------------------------------------------- +function test_binary_defaults_to_nearest(t) +% A binary mask must stay binary (auto nearest-neighbor), not blend to [0 1]. +sb = t.TestData.obj; +sb.dat = single(sb.dat(:, 1) > 0); +sb.imagetype = 'dscalar'; +out = resample_surface(sb, 'fsLR_32k'); +u = unique(double(out.dat(isfinite(out.dat)))); +verifyTrue(t, all(ismember(u, [0 1])), 'Binary mask must resample to {0,1} (nearest).'); +end + + +% ------------------------------------------------------------------------- +function test_interp_override(t) +% Explicit 'interp','nearest' on continuous data; output differs from barycentric. +s_nn = resample_surface(t.TestData.obj, 'fsLR_32k', 'interp', 'nearest'); +s_bary = resample_surface(t.TestData.obj, 'fsLR_32k', 'interp', 'linear'); +verifyEqual(t, size(s_nn.dat), size(s_bary.dat)); +verifyFalse(t, isequal(s_nn.dat, s_bary.dat), 'nearest and barycentric should differ on continuous data.'); +end + + +% ------------------------------------------------------------------------- +function test_multimap_weights_reused(t) +% A multi-map object resamples every column correctly (weights built once). +sm = t.TestData.obj; +sm.dat = [sm.dat, 2 * sm.dat, -sm.dat]; % 3 maps +out = resample_surface(sm, 'fsLR_32k'); +verifyEqual(t, size(out.dat, 2), 3); +% Column 2 = 2x column 1; column 3 = -column 1 (linear operator applied per map) +verifyEqual(t, double(out.dat(:, 2)), 2 * double(out.dat(:, 1)), 'AbsTol', 1e-4); +verifyEqual(t, double(out.dat(:, 3)), -double(out.dat(:, 1)), 'AbsTol', 1e-4); +end + + +% ------------------------------------------------------------------------- +function test_patch_target_resolves_by_count(t) +% A mesh struct / patch target resolves the target space by its vertex count. +mesh32k = struct('vertices', zeros(32492, 3), 'faces', []); +out = resample_surface(t.TestData.obj, mesh32k); % fsaverage -> fs_LR by count +verifyEqual(t, out.surface_space, 'fs_LR_32k'); +% A non-standard vertex count errors clearly (cannot resample onto an arbitrary mesh). +badmesh = struct('vertices', zeros(12345, 3)); +verifyError(t, @() resample_surface(t.TestData.obj, badmesh), 'resample_surface:unknownmesh'); +end + + +% ------------------------------------------------------------------------- +function test_onavg_space(t) +% onavg (equal-area) template: resample to onavg and back via the fs_LR frame. +if isempty(which('onavg_sphere_fsLR_lh_41k.mat')) + t.assumeFail('onavg registration spheres not on path.'); +end +son = resample_surface(t.TestData.obj, 'onavg'); % fsaverage_164k -> onavg_41k +verifyEqual(t, son.surface_space, 'onavg_41k'); +verifyEqual(t, size(son.dat, 1), 2 * 40962, 'onavg den-41k cortex = 2 x 40962.'); +sback = resample_surface(son, 'fsaverage_164k'); +a = double(t.TestData.obj.dat(:, 1)); b = double(sback.dat(:, 1)); +m = isfinite(a) & isfinite(b); +verifyGreaterThan(t, corr(a(m), b(m)), 0.99, 'onavg round-trip should recover a smooth map.'); +% den-10k alias +s10 = resample_surface(t.TestData.obj, 'onavg_10k'); +verifyEqual(t, size(s10.dat, 1), 2 * 10242); +end + + +% ------------------------------------------------------------------------- +function test_subcortex_carried_through(t) +% A 91k grayordinate object: cortex resamples, subcortex passes through unchanged. +f = which('transcriptomic_gradients.dscalar.nii'); +if isempty(f), t.assumeFail('transcriptomic_gradients.dscalar.nii not on path.'); end +s91 = fmri_surface_data(f); +nsub = sum(cellfun(@(m) strcmp(m.type, 'vox'), s91.brain_model.models)); +out = resample_surface(s91, 'fsaverage6'); +verifyEqual(t, out.surface_space, 'fsaverage6'); +verifyEqual(t, sum(cellfun(@(m) strcmp(m.type, 'vox'), out.brain_model.models)), nsub, ... + 'All subcortical models must be carried through.'); +sub_in = s91.dat(2 * 32492 + 1:end, :); +sub_out = out.dat(2 * 40962 + 1:end, :); +verifyEqual(t, sub_out, sub_in, 'Subcortical data must be unchanged.'); +end + + +% ========================================================================= +function obj = local_make_fsaverage(lh, rh) +% Build a minimal dense fsaverage_164k cortex-only object from per-hemi vectors. +NV = 163842; +mL = struct('struct', 'CORTEX_LEFT', 'type', 'surf', 'start', 1, 'count', NV, ... + 'numvert', NV, 'vertlist', 0:NV - 1, 'voxlist', []); +mR = struct('struct', 'CORTEX_RIGHT', 'type', 'surf', 'start', NV + 1, 'count', NV, ... + 'numvert', NV, 'vertlist', 0:NV - 1, 'voxlist', []); +bm = struct('type', 'dense', 'length', 2 * NV, 'models', {{mL, mR}}, 'vol', []); +bm.grayordinate_type = 'cortex_only'; bm.cluster = []; +obj = fmri_surface_data('dat', single([lh(:); rh(:)]), 'brain_model', bm, ... + 'surface_space', 'fsaverage_164k', 'imagetype', 'dscalar'); +end diff --git a/CanlabCore/Unit_tests/surface_data/canlab_test_surface_space_recon.m b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_space_recon.m new file mode 100644 index 00000000..0be4d0d4 --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_space_recon.m @@ -0,0 +1,165 @@ +function tests = canlab_test_surface_space_recon +% Unit tests for fmri_surface_data spatial overrides + interop (M3). +% +% Covers compare_space (0/1/2/3 contract), reconstruct_image (cortex dense +% arrays + subcortex volume), to_fmri_data (subcortex -> fmri_data), write +% (native CIFTI round-trip, faithful + regenerated), and rebuild_like. Core +% tests use a synthetic grayordinate object (no external toolbox); one +% opportunistic test uses a real .dlabel.nii if present. +% +% Run: runtests('canlab_test_surface_space_recon') +% +% :See also: fmri_surface_data, reconstruct_image, to_fmri_data, compare_space + +tests = functiontests(localfunctions); +end + + +% ------------------------------------------------------------------------- +function setupOnce(t) +here = fileparts(mfilename('fullpath')); +addpath(fullfile(here, '..', '..', 'Surface_tools')); +addpath(fullfile(here, '..', '..')); +assert(~isempty(which('fmri_surface_data')), 'fmri_surface_data not on path.'); +t.TestData.tmp = tempname; mkdir(t.TestData.tmp); +t.TestData.obj = fmri_surface_data(local_synthetic_cifti()); +end + +function teardownOnce(t) +if isfield(t.TestData,'tmp') && exist(t.TestData.tmp,'dir'), rmdir(t.TestData.tmp,'s'); end +end + + +% ------------------------------------------------------------------------- +function test_volinfo_subblock_populated(t) +o = t.TestData.obj; +verifyNotEmpty(t, o.volInfo, 'volInfo (subcortical sub-block) should be populated.'); +verifyEqual(t, o.volInfo.dim, [10 10 10]); +verifyEqual(t, o.volInfo.n_inmask, 4, 'Subcortical sub-block should have 4 voxels.'); +% 0-based CIFTI affine -> 1-based SPM .mat conversion +expected_mat = [2 0 0 -20; 0 2 0 -22; 0 0 2 -24; 0 0 0 1]; +expected_mat(:,4) = expected_mat(:,4) - sum(expected_mat(:,1:3),2); +verifyEqual(t, o.volInfo.mat, expected_mat, 'Subcortical affine (1-based) incorrect.'); +end + + +% ------------------------------------------------------------------------- +function test_to_fmri_data(t) +o = t.TestData.obj; +vol = to_fmri_data(o); +verifyEqual(t, class(vol), 'fmri_data'); +verifyEqual(t, size(vol.dat), [4 2], 'Subcortex should be 4 voxels x 2 maps.'); +% Values equal the subcortical grayordinate rows (rows 11:14) +verifyEqual(t, double(vol.dat), double(o.dat(11:14, :)), 'AbsTol', 1e-6, ... + 'to_fmri_data values must equal the subcortical grayordinate rows.'); +verifyEqual(t, vol.volInfo.dim, [10 10 10]); +end + + +% ------------------------------------------------------------------------- +function test_reconstruct_image(t) +o = t.TestData.obj; +r = reconstruct_image(o); + +% Cortex: dense [numvert x nMaps]; medial wall (non in-data verts) -> NaN +verifyEqual(t, size(r.cortex_left), [10 2]); +verifyEqual(t, sum(isnan(r.cortex_left(:,1))), 5, 'Half the vertices are medial wall (NaN).'); +% In-data vertices (0-based [0 2 4 6 8] -> 1-based [1 3 5 7 9]) carry the data +verifyEqual(t, r.cortex_left([1 3 5 7 9], 1), double(o.dat(1:5, 1)), 'AbsTol', 1e-6); + +% Volume present and a known voxel matches the subcortical data +verifyTrue(t, isfield(r, 'volume')); +verifyEqual(t, size(r.volume), [10 10 10 2]); +% First subcortical voxel is IJK (2,2,2) 0-based -> (3,3,3) 1-based, row 11 +verifyEqual(t, r.volume(3,3,3,1), double(o.dat(11,1)), 'AbsTol', 1e-6); +end + + +% ------------------------------------------------------------------------- +function test_compare_space_contract(t) +o = t.TestData.obj; +verifyEqual(t, compare_space(o, o), 0, 'Identical objects -> 0.'); + +% Selecting maps does not change the grayordinate space -> 0 +verifyEqual(t, compare_space(o, get_wh_image(o, 1)), 0); + +% Same space tag + layout but different in-data vertices -> 3 +o3 = o; +o3.brain_model.models{1}.vertlist = [0 1 2 3 4]; % different selection +verifyEqual(t, compare_space(o, o3), 3, 'Different in-data grayordinates -> 3.'); + +% Different space tag -> 1 +o1 = o; o1.surface_space = 'fsaverage_164k'; +verifyEqual(t, compare_space(o, o1), 1, 'Different space tag -> 1.'); + +% Missing brain_model -> 2 +oempty = fmri_surface_data; +verifyEqual(t, compare_space(o, oempty), 2, 'Missing brain_model -> 2.'); +end + + +% ------------------------------------------------------------------------- +function test_rebuild_like(t) +o = t.TestData.obj; +rb = rebuild_like(o, o.dat * 3); +verifyEqual(t, class(rb), 'fmri_surface_data'); +verifyTrue(t, isequaln(rb.brain_model, o.brain_model), 'brain_model must be preserved.'); +verifyEqual(t, double(rb.dat), double(o.dat)*3, 'AbsTol', 1e-3); +verifyEqual(t, numel(rb.removed_voxels), size(rb.dat,1)); +% Row-count mismatch must error (geometry is fixed) +verifyError(t, @() rebuild_like(o, o.dat(1:5,:)), 'fmri_surface_data:rebuild_like:rowmismatch'); +end + + +% ------------------------------------------------------------------------- +function test_write_roundtrip_cifti(t) +o = t.TestData.obj; + +% Faithful path (stashed xml present? synthetic has none -> regenerate path) +f = fullfile(t.TestData.tmp, 'syn.dscalar.nii'); +write(o, f); +o2 = fmri_surface_data(f); +verifyEqual(t, size(o2.dat), size(o.dat)); +verifyEqual(t, double(o2.dat), double(o.dat), 'AbsTol', 0, 'CIFTI write->read changed data.'); +verifyEqual(t, numel(o2.brain_model.models), numel(o.brain_model.models)); +verifyEqual(t, o2.brain_model.vol.sform, o.brain_model.vol.sform, 'Affine lost on write.'); +verifyEqual(t, reshape(o2.image_names,1,[]), {'mapOne','mapTwo'}, 'Map names lost on write.'); +end + + +% ------------------------------------------------------------------------- +function test_real_dlabel_write_if_available(t) +f0 = which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii'); +if isempty(f0) + t.assumeFail('No real .dlabel.nii on path; synthetic tests cover write.'); +end +o = fmri_surface_data(f0); +f = fullfile(t.TestData.tmp, 'real.dlabel.nii'); +write(o, f); +o2 = fmri_surface_data(f); +verifyEqual(t, double(o2.dat), double(o.dat), 'AbsTol', 0, 'dlabel keys changed on write.'); +verifyEqual(t, numel(o2.label_table), numel(o.label_table), 'label table changed on write.'); +% subcortex export +vol = to_fmri_data(o); +verifyEqual(t, class(vol), 'fmri_data'); +verifyGreaterThan(t, size(vol.dat,1), 1000, 'Expected thousands of subcortical voxels.'); +end + + +% ========================================================================= +function cii = local_synthetic_cifti() +mL = struct('struct','CORTEX_LEFT','type','surf','start',1,'count',5, ... + 'numvert',10,'vertlist',[0 2 4 6 8],'voxlist',[]); +mR = struct('struct','CORTEX_RIGHT','type','surf','start',6,'count',5, ... + 'numvert',10,'vertlist',[1 3 5 7 9],'voxlist',[]); +voxijk = [2 2 2; 3 2 2; 2 3 2; 2 2 3]'; +mV = struct('struct','THALAMUS_LEFT','type','vox','start',11,'count',4, ... + 'numvert',NaN,'vertlist',[],'voxlist',voxijk); +sform = [2 0 0 -20; 0 2 0 -22; 0 0 2 -24; 0 0 0 1]; +dense = struct('type','dense','length',14,'models',{{mL,mR,mV}}, ... + 'vol',struct('dims',[10 10 10],'sform',sform)); +maps = struct('name',{'mapOne','mapTwo'},'table',{[],[]}); +mapsdim = struct('type','scalars','length',2,'maps',maps); +cii = struct('cdata', single(reshape(1:28,14,2)), 'intent','dscalar', ... + 'diminfo', {{dense, mapsdim}}); +end diff --git a/CanlabCore/Unit_tests/surface_data/canlab_test_surface_vol_map.m b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_vol_map.m new file mode 100644 index 00000000..2239dde6 --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_vol_map.m @@ -0,0 +1,161 @@ +function tests = canlab_test_surface_vol_map +% Unit tests for fmri_surface_data volume<->surface mapping (M4). +% +% Covers vol2surf (volume -> fsaverage_164k via CBIG RF), surf2vol (fsaverage -> +% MNI fmri_data), the vol->surf->vol round-trip fidelity, and the mean / +% apply_mask / threshold overrides. Uses a synthetic smooth MNI volume + the +% vendored CBIG warps (in-repo) -- no external toolbox. +% +% Run: runtests('canlab_test_surface_vol_map') +% +% :See also: vol2surf, surf2vol, fmri_surface_data + +tests = functiontests(localfunctions); +end + + +% ------------------------------------------------------------------------- +function setupOnce(t) +here = fileparts(mfilename('fullpath')); +addpath(fullfile(here, '..', '..', 'Surface_tools')); +addpath(fullfile(here, '..', '..')); +assert(~isempty(which('fmri_surface_data')), 'fmri_surface_data not on path.'); +assert(~isempty(which('vol2surf')), 'vol2surf not on path.'); +t.TestData.vol = local_synthetic_volume(); +end + + +% ------------------------------------------------------------------------- +function test_vol2surf_basic(t) +s = vol2surf(t.TestData.vol); +verifyEqual(t, class(s), 'fmri_surface_data'); +verifyEqual(t, s.surface_space, 'fsaverage_164k'); +verifyEqual(t, size(s.dat), [2*163842 1], 'Expected 2 hemispheres x 163842 vertices.'); +verifyEqual(t, numel(s.brain_model.models), 2); +verifyEqual(t, s.brain_model.models{1}.struct, 'CORTEX_LEFT'); +verifyEqual(t, s.brain_model.models{2}.numvert, 163842); +verifyFalse(t, any(isnan(s.dat(:))), 'No NaNs expected from interpn with extrapval 0.'); +end + + +% ------------------------------------------------------------------------- +function test_vol2surf_matches_interpn(t) +% A surface vertex value must equal the volume sampled at that vertex's RAS. +vol = t.TestData.vol; +s = vol2surf(vol); +L = load(canlab_cbig_warp_path('lh_ras')); ras = L.ras; +V = reconstruct_image(vol); +for vtest = [1 1000 80000 163842] + voxc = vol.volInfo.mat \ [ras(:, vtest); 1]; + expected = interpn(V, voxc(1), voxc(2), voxc(3), 'linear', 0); + verifyEqual(t, double(s.dat(vtest, 1)), expected, 'AbsTol', 1e-5, ... + sprintf('Left vertex %d value != interpn sample.', vtest)); +end +end + + +% ------------------------------------------------------------------------- +function test_vol_surf_vol_roundtrip(t) +% Smooth data should round-trip vol->surf->vol with high correlation on the +% cortical voxels touched by the projection. +vol = t.TestData.vol; +s = vol2surf(vol); +vback = surf2vol(s); + +dims = vol.volInfo.dim; +v0 = zeros(prod(dims), 1); v0(:) = double(vol.dat); +vb = zeros(prod(dims), 1); vb(vback.volInfo.wh_inmask) = double(vback.dat); +hit = vback.volInfo.image_indx; + +verifyGreaterThan(t, nnz(hit), 20000, 'Expected many cortical voxels hit.'); +cc = corr(v0(hit), vb(hit)); +verifyGreaterThan(t, cc, 0.99, sprintf('vol->surf->vol correlation too low (r=%.4f).', cc)); +verifyEqual(t, class(vback), 'fmri_data'); +verifyEqual(t, vback.volInfo.dim, dims); +end + + +% ------------------------------------------------------------------------- +function test_surf2vol_requires_fsaverage(t) +% A non-fsaverage object should be rejected by surf2vol. +o = fmri_surface_data(local_synthetic_cifti()); % fsLR_32k synthetic +verifyError(t, @() surf2vol(o), 'surf2vol:space'); +end + + +% ------------------------------------------------------------------------- +function test_vol2surf_nearest_preserves_labels(t) +% 'nearest' interpolation must keep integer label values intact. +vol = t.TestData.vol; +vol.dat = single(mod(round(double(vol.dat)*7), 4)); % integer "labels" 0..3 +s = vol2surf(vol, 'interp', 'nearest'); +u = unique(s.dat(:)); +verifyTrue(t, all(ismember(u, [0 1 2 3])), 'Nearest interp introduced non-integer labels.'); +end + + +% ------------------------------------------------------------------------- +function test_mean_apply_mask_threshold(t) +s = vol2surf(t.TestData.vol); + +% mean across a 3-map version +s3 = s; s3.dat = [s.dat, s.dat*2, s.dat*3]; +s3.removed_images = false(3,1); +m = mean(s3); +verifyEqual(t, size(m.dat), [size(s.dat,1) 1]); +verifyEqual(t, double(m.dat), double(s.dat)*2, 'AbsTol', 1e-4, 'mean of [x 2x 3x] should be 2x.'); + +% apply_mask: keep first 1000 grayordinates +keep = false(size(s.dat,1),1); keep(1:1000) = true; +sm = apply_mask(s, keep); +verifyEqual(t, nnz(any(sm.dat~=0,2)), nnz(s.dat(1:1000)~=0), 'apply_mask zeroed wrong rows.'); +verifyEqual(t, size(sm.dat), size(s.dat), 'apply_mask must keep .dat full (D5b).'); + +% apply_mask falls back to obj.mask when no mask is passed +s_withmask = s; s_withmask.mask = keep; +sm2 = apply_mask(s_withmask); +verifyEqual(t, sm2.dat, sm.dat, 'apply_mask(obj) must use obj.mask.'); +% and errors clearly when neither a mask arg nor obj.mask is available +verifyError(t, @() apply_mask(s), 'fmri_surface_data:apply_mask:nomask'); + +% threshold (raw, two-tailed) +st = threshold(s, 0.5); +verifyTrue(t, all(abs(st.dat(st.dat~=0)) >= 0.5), 'threshold left sub-threshold values.'); +verifyEqual(t, size(st.dat), size(s.dat)); +end + + +% ========================================================================= +function vol = local_synthetic_volume() +% Smooth fmri_data on the standard MNI152 2 mm grid. +dims = [91 109 91]; +tmat = [-2 0 0 92; 0 2 0 -128; 0 0 2 -74; 0 0 0 1]; +[I, J, K] = ndgrid(1:dims(1), 1:dims(2), 1:dims(3)); +xyz = tmat * [I(:)'; J(:)'; K(:)'; ones(1, numel(I))]; +f = 0.01*xyz(1,:) + 0.005*xyz(2,:) + ... + exp(-((xyz(1,:).^2 + xyz(2,:).^2 + xyz(3,:).^2) / (2*40^2))); +iv = image_vector; +iv.volInfo = struct('mat', tmat, 'dim', dims, 'dt', [16 0], ... + 'xyzlist', [I(:) J(:) K(:)], 'nvox', prod(dims), ... + 'image_indx', true(prod(dims),1), 'wh_inmask', (1:prod(dims))', ... + 'n_inmask', prod(dims), 'fname', ''); +iv.dat = single(f(:)); +iv.removed_voxels = false(prod(dims), 1); +iv.removed_images = false; +vol = fmri_data(iv); +end + + +% ------------------------------------------------------------------------- +function cii = local_synthetic_cifti() +mL = struct('struct','CORTEX_LEFT','type','surf','start',1,'count',5, ... + 'numvert',10,'vertlist',[0 2 4 6 8],'voxlist',[]); +mR = struct('struct','CORTEX_RIGHT','type','surf','start',6,'count',5, ... + 'numvert',10,'vertlist',[1 3 5 7 9],'voxlist',[]); +sform = [2 0 0 -20; 0 2 0 -22; 0 0 2 -24; 0 0 0 1]; +dense = struct('type','dense','length',10,'models',{{mL,mR}}, ... + 'vol',struct('dims',[10 10 10],'sform',sform)); +maps = struct('name',{'m1'},'table',{[]}); +mapsdim = struct('type','scalars','length',1,'maps',maps); +cii = struct('cdata', single((1:10)'), 'intent','dscalar', 'diminfo', {{dense, mapsdim}}); +end diff --git a/CanlabCore/Unit_tests/visualization/canlab_test_canlab_colormap.m b/CanlabCore/Unit_tests/visualization/canlab_test_canlab_colormap.m index a1a24ffb..ac55bd8f 100644 --- a/CanlabCore/Unit_tests/visualization/canlab_test_canlab_colormap.m +++ b/CanlabCore/Unit_tests/visualization/canlab_test_canlab_colormap.m @@ -28,6 +28,26 @@ function test_single_clamps_outside_range(tc) tc.verifyEqual(map(cm, 99), [1 1 0], 'AbsTol', 1e-12); % above -> max end +function test_single_and_continuous_collapse_split_range(tc) +% A 4-element (split) cmaprange must collapse to its FULL span [r(1) r(4)] for a +% single / continuous map, not use the negative arm [r(1) r(2)]. Regression: a +% split-range layer recoloured to a continuous colormap (e.g. set_colormap(o, +% 'colormap', hot)) painted almost everything the max colour (white for hot), +% because the map used only the negative arm as [lo hi]. +splitrange = [-0.5 -0.1 0.9 1.5]; +cm = canlab_colormap.single([1 0 0], [1 1 0], splitrange); +tc.verifyEqual(cm.range, [-0.5 1.5], 'AbsTol', 1e-12, 'single: 4-el range -> full span'); +tc.verifyEqual(map(cm, -0.5), [1 0 0], 'AbsTol', 1e-12); % min at r(1) +tc.verifyEqual(map(cm, 1.5), [1 1 0], 'AbsTol', 1e-12); % max at r(4) +tc.verifyEqual(map(cm, 0.5), [1 .5 0], 'AbsTol', 1e-12); % midpoint of full span + +lut = [0 0 0; 1 1 1]; +cc = canlab_colormap.continuous(lut, splitrange); +tc.verifyEqual(cc.range, [-0.5 1.5], 'AbsTol', 1e-12, 'continuous: 4-el range -> full span'); +tc.verifyEqual(map(cc, 1.5), [1 1 1], 'AbsTol', 1e-12); % top of LUT at r(4) +tc.verifyEqual(map(cc, -0.5), [0 0 0], 'AbsTol', 1e-12); % bottom of LUT at r(1) +end + function test_single_matches_render_blobs_formula(tc) % render_blobs single map: w = clamp((v-lo)/(hi-lo)); rgb = w*maxcol + (1-w)*mincol. mn = [1 0 0]; mx = [0 1 1]; rng = [-2 6]; diff --git a/CanlabCore/Visualization_functions/addbrain.m b/CanlabCore/Visualization_functions/addbrain.m index e7b9b6fc..77b07421 100644 --- a/CanlabCore/Visualization_functions/addbrain.m +++ b/CanlabCore/Visualization_functions/addbrain.m @@ -31,6 +31,25 @@ % 'transparent_surface' 'foursurfaces' 'foursurfaces_hcp' 'flat left' 'flat right' ... % 'bigbrain' {'hires surface left', 'bigbrain left'} % ['fsavg_left' or 'inflated left'], 'fsavg_right' or 'inflated right', uses freesurfer inflated brain with Thomas Yeo group's RF_ANTs mapping from MNI to Freesurfer. (https://doi.org/10.1002/hbm.24213) +% 'hcp inflated', 'hcp inflated left', 'hcp inflated right', uses the HCP fs_LR-32k inflated mesh. +% +% % Standard-mesh surfaces and fmri_surface_data +% % ----------------------------------------------------------------------- +% Some of the cortical surfaces above are EXACT standard meshes (same vertex +% count and vertex ordering as a template space), so per-vertex surface data can +% be mapped onto them DIRECTLY, with no resampling: +% 'hcp inflated' (and 'hcp inflated left'/'right') -> HCP fs_LR-32k template +% (32,492 vertices/hemisphere) +% 'inflated' / 'fsavg_left' / 'fsavg_right' -> FreeSurfer fsaverage template +% (163,842 vertices/hemisphere) +% These match the surface_space of an fmri_surface_data object ('fsLR_32k' and +% 'fsaverage_164k' respectively). The fmri_surface_data/surface and +% render_on_surface methods use exactly these meshes for native-space rendering, +% and will color any patch whose vertex count matches a hemisphere directly. By +% contrast, the MNI cortical surfaces ('left'/'right', 'hires ...') are NOT +% standard meshes; rendering surface data on them requires projecting through a +% volume (handled automatically by fmri_surface_data/render_on_surface). +% See docs/fmri_surface_data_methods.md. % % % Macro subcortical surfaces % % ----------------------------------------------------------------------- diff --git a/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/README.md b/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/README.md new file mode 100644 index 00000000..e7e373db --- /dev/null +++ b/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/README.md @@ -0,0 +1,33 @@ +# onavg (OpenNeuro Average) registration spheres + +Vendored spherical meshes for the **onavg** cortical surface template, used by +`fmri_surface_data/resample_surface` to resample data to/from onavg. + +- `onavg_sphere_fsLR_{lh,rh}_41k.mat` — onavg den-41k (40,962 vertices/hemi) +- `onavg_sphere_fsLR_{lh,rh}_10k.mat` — onavg den-10k (10,242 vertices/hemi) + +Each `.mat` holds `vertices` (`single`, N×3) and `faces` (`uint32`, M×3). + +These are the **`space-fsLR`** onavg registration spheres: onavg vertices expressed +in the fs_LR-aligned spherical frame. That is the same common frame the bundled +fsaverage↔fs_LR "deformed" sphere uses, so onavg resamples to fsaverage / fs_LR +with the same native spherical-interpolation engine (no FreeSurfer / Connectome +Workbench). onavg is an equal-area tessellation (not nested with the FreeSurfer +icosahedra), so onavg resampling always uses interpolation. + +## Source and license + +Downloaded from TemplateFlow `tpl-onavg` +(https://github.com/templateflow/tpl-onavg), files +`tpl-onavg_space-fsLR_hemi-{L,R}_den-{41k,10k}_sphere.surf.gii`, converted to +`.mat`. License: **CC0** (public domain). + +onavg was built from 1,031 participants across 30 OpenNeuro datasets, with vertex +locations optimized so cortical regions are sampled at uniform resolution. + +Reference: Feilong M, Guo J (Jiahui), Gobbini MI, Haxby JV (2024). A cortical +surface template with vertex-level, equal-area sampling. *Nature Methods* 21, +2069–2078. https://doi.org/10.1038/s41592-024-02346-y + +Additional group statistics (sulcal depth, curvature, vertex area) are available +via GIN (https://gin.g-node.org/neuroboros/core) and are not vendored here. diff --git a/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_lh_10k.mat b/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_lh_10k.mat new file mode 100644 index 00000000..d7acca8c Binary files /dev/null and b/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_lh_10k.mat differ diff --git a/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_lh_41k.mat b/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_lh_41k.mat new file mode 100644 index 00000000..96cd20b9 Binary files /dev/null and b/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_lh_41k.mat differ diff --git a/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_rh_10k.mat b/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_rh_10k.mat new file mode 100644 index 00000000..074150c1 Binary files /dev/null and b/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_rh_10k.mat differ diff --git a/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_rh_41k.mat b/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_rh_41k.mat new file mode 100644 index 00000000..e19fcd05 Binary files /dev/null and b/CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/onavg/onavg_sphere_fsLR_rh_41k.mat differ diff --git a/CanlabCore/docs/fmri_surface_data_design_plan.md b/CanlabCore/docs/fmri_surface_data_design_plan.md new file mode 100644 index 00000000..4eeeabf8 --- /dev/null +++ b/CanlabCore/docs/fmri_surface_data_design_plan.md @@ -0,0 +1,668 @@ +# CANlab Surface / Grayordinate Data Object — Design & Implementation Plan + +## 1. Purpose and scope + +This document specifies the design and a phased implementation plan for a new CANlab +object, **`fmri_surface_data`**, that represents cortical-surface (vertex) and +grayordinate (CIFTI: surface vertices + subcortical voxels) brain data inside the +existing CANlab object model. The goal is a first-class sibling of `fmri_data` that +(a) losslessly holds and round-trips CIFTI `.dscalar/.dtseries/.dlabel.nii` and GIFTI +`.surf/.func/.shape/.label.gii` files, (b) reuses the entire geometry-agnostic CANlab +analysis stack (`predict`, `ica`, `mean`, `descriptives`, parcellation, +etc.) by keeping data in the canonical flat `[units × images]` `.dat` matrix, (c) renders +natively on bundled fs_LR / fsaverage meshes, and (d) maps to/from volumetric `fmri_data` +using warps **already vendored in the repo** — all with **no external MATLAB toolbox, +FreeSurfer, or Connectome Workbench binary required at runtime**. Scope for v1 is the +object, native I/O, native rendering, group-template volume↔surface mapping, and surface +parcellation; per-subject ribbon-constrained mapping and fsaverage↔fs_LR deformation are +explicitly deferred. + +This plan reconciles three independent architecture proposals. Where they diverged, the +conflicts and resolutions are stated explicitly in §3. + +--- + +## 2. Background: CIFTI grayordinates and GIFTI for CANlab developers + +**The grayordinate model.** HCP "grayordinates" represent the cortex as *surface +vertices* and the subcortex/cerebellum as *voxels*. The standard "91k" space has +**91,282 grayordinates** = 29,696 left-cortex vertices + 29,716 right-cortex vertices +(the non-medial-wall subset of 32,492 fs_LR-32k vertices per hemisphere) + 31,870 +subcortical 2 mm MNI voxels spread across ~19 `CIFTI_STRUCTURE_*` volume structures +(accumbens, amygdala, caudate, cerebellum, diencephalon, hippocampus, pallidum, putamen, +thalamus L/R, brainstem). The key insight for CANlab: **grayordinate data is already a +flat `[grayordinates × maps]` matrix** — exactly the layout CANlab's `.dat` expects. + +**CIFTI-2 file format.** A CIFTI-2 file is a single uncompressed `.nii` (NIfTI-2) +container: a 540-byte little-endian NIfTI-2 binary header, a 4-byte extension flag at +byte 540, one or more header extensions (the CIFTI XML lives in the extension whose +`ecode == 32`), then the raw data matrix beginning at `vox_offset`. NIfTI `dim[1..4]=1`; +the real CIFTI lengths live in `dim[5]` (values per row, fastest) and `dim[6]` (rows); +`dim[0]` is 6 or 7. **The data matrix is stored row-major**, so a MATLAB reader `fread`s +then permutes `[2 1]` to column-major. The XML (`...`) carries +one `MatrixIndicesMap` per dimension; a `BRAIN_MODELS` map holds `BrainModel` elements +each with `IndexOffset`, `IndexCount`, `ModelType` (`SURFACE`/`VOXELS`), `BrainStructure`, +`SurfaceNumberOfVertices`, and either `` (0-based vertices that carry data, +i.e. medial wall excluded) or `` + a `` 4×4 affine. +Intent codes / extensions: `.dscalar.nii` = 3006 `ConnDenseScalar`, `.dtseries.nii` = +3002 `ConnDenseSeries`, `.dlabel.nii` = 3007 `ConnDenseLabel` (integer keys indexing a +per-map `LabelTable`). **No external toolbox is required to read/write CIFTI-2** — only +`fopen/fread/fwrite` + XML parsing (`xmlread` or regexp). `wb_command` is needed only to +convert legacy CIFTI-1, which v1 does not support. + +**GIFTI (.gii) format.** GIFTI is a UTF-8 XML container. Root +`` holds N `` elements. A geometry +`.surf.gii` has two arrays: `NIFTI_INTENT_POINTSET` (N×3 FLOAT32 vertex mm coords) and +`NIFTI_INTENT_TRIANGLE` (M×3 INT32 0-based faces — add 1 for MATLAB `patch`). Per-vertex +data files use `NIFTI_INTENT_NONE/SHAPE/TIME_SERIES/LABEL`. Each `` payload is +`ASCII`, `Base64Binary`, or `GZipBase64Binary` (base64 of a **raw zlib** stream, header +`0x78 0x9C` — **not gzip**). A fully native MATLAB reader/writer was implemented and +**validated bit-exact** on the repo's S1200 32k surf.gii (32,492 verts / 64,980 faces; +verts/faces `maxdiff = 0` on round-trip). Three gotchas were solved and must be carried +over verbatim: (1) decompress with `java.util.zip.InflaterInputStream` (not +`GZIPInputStream`); (2) base64 with `matlab.net.base64decode/encode` (not apache-commons, +which corrupts bytes); (3) inflate entirely inside the JVM via `IOUtils.copy` (in-place +`Inflater.inflate(buf)` returns garbage to MATLAB because MATLAB passes a copy). + +**Why CANlab can do this with almost no new format code:** CANlab already ships fs_LR-32k +and fsaverage meshes as plain `.mat` files (faces + vertices), the MNI→fs_LR/fsavg +barycentric resample matrices, and the CBIG MNI↔fsaverage registration-fusion warps — so +the only genuinely new I/O is a self-contained CIFTI/GIFTI parser. + +--- + +## 3. Design decisions & rationale + +### D1. Class name — `fmri_surface_data` +All three proposals independently converged on `fmri_surface_data`. **Decision: +`fmri_surface_data`.** It is unambiguous, prefixed to avoid collisions, and signals +"surface/grayordinate analogue of `fmri_data`." *(Open question Q1: confirm the +`canlab_` prefix vs. a bare `surface_data`/`fmri_surface_data`; the existing siblings +`fmri_data`/`image_vector`/`atlas`/`region` are unprefixed, so a bare name like +`fmri_surface_data` would match house style. Recommend confirming with the user.)* + +### D2. Superclass — subclass `image_vector`, **not** `fmri_data`, **not** from scratch +All three proposals agree: **`classdef fmri_surface_data < image_vector`.** + +- Subclassing `image_vector` inherits the load-bearing, purely-`.dat`/`removed_*` + methods verbatim by dispatch: `remove_empty`, `replace_empty`, `get_wh_image`, + `descriptives`, `ica`, `mahal`, `pca`, `image_math`, `history`, `isempty`, + `enforce_variable_types`. +- **Not** `fmri_data`: `fmri_data` hard-codes a mandatory `volInfo` and an + `fmri_mask_image`-typed `mask` in `run_checks_and_fixes` (errors if `volInfo` empty), + and its `cat/horzcat/plot/predict/ttest/regress` assume `interp3`/orthviews resampling. + Inheriting those would fight the surface design at every turn. +- **Not** from scratch: that would forfeit dozens of inherited methods and the + constructor polymorphism contract. + +Trade-off accepted: `cat`, `horzcat`, `plot`, `predict`, `ttest`, `regress` live **only** +in `@fmri_data`, so an `image_vector` subclass does **not** inherit them. We provide our +own (see §5), copying the geometry-agnostic cores and stripping the volume rebuild/resample +steps. This is the deliberate price of not inheriting `fmri_data`'s volume baggage. + +### D3. How `volInfo` is replaced — a `brain_model` descriptor + repurposed `volInfo` slot + +This is where the proposals **conflicted** and is resolved explicitly: + +- **Proposal A** repurposes the inherited `volInfo` *property slot* to hold the whole + surface+volume `brain_model` descriptor (keeping the literal name `volInfo`). +- **Proposal B/C** add a **new** `brain_model`/`cifti`/`geom` property as the single + source of truth, and (B) additionally keep `volInfo` populated to describe **only the + volumetric sub-block** so the subcortex still round-trips through existing voxel code. + +**Resolution (graft the best of both):** +1. The surface/grayordinate geometry truth lives in a **new property `brain_model`** (a + CIFTI-`BrainModel`-mirroring struct; the single source of truth for round-trip and for + the flat-row↔space map). This is cleaner and less leak-prone than overloading `volInfo` + semantically (Proposal A's chief risk was inherited methods dereferencing + `volInfo.mat`). +2. The inherited **`volInfo` slot is kept populated, but describes ONLY the volumetric + sub-block** of the grayordinates (its `.mat`/`.dim`/`.xyzlist`/`.wh_inmask` come from + the subcortical `VoxelIndicesIJK` + affine). This is Proposal B's key idea: it means + the subcortex is a *valid voxel space*, inherited methods that read `volInfo` find a + real (if partial) struct, and `to_fmri_data` on the subcortex is almost free. For + **surface-only** objects (`.func.gii`, cortex-only CIFTI), `volInfo` is empty and the + relaxed run-checks tolerate it. +3. Mesh geometry (faces/vertices per hemisphere, medial wall) lives in a **new property + `geom`**, loaded lazily from bundled `.mat` assets (this is what render/area/contiguity + patch and compute on). + +`brain_model` fulfills `volInfo`'s three load-bearing roles: **(a)** row↔space map = +per-structure index lists (`vertlist` 0-based per hemisphere; `voxlist` IJK per +subcortical structure) — the `wh_inmask`/`xyzlist` analogue; **(b)** reconstruct target = +scatter rows into dense per-hemisphere vertex arrays (`reconstruct_image` override) + +subcortical volume; **(c)** contiguity source = mesh-graph connected components (cortex) / +3-D connectivity (subcortex), replacing `volInfo.cluster`. + +### D4. Re-declare `fmri_data` per-image annotation properties **by name** +Because we subclass `image_vector` (where these don't exist), we re-declare `X`, `Y`, +`Y_names`, `covariates`, `covariate_names`, `images_per_session`, `metadata_table`, +`image_metadata`, `additional_info` with the **identical `fmri_data` names** so analysis +methods bind by name. These are per-*image* (column) annotations, independent of +voxel-vs-vertex. + +### D5. Preserve method contracts exactly +`compare_space` must keep its **4-valued return code** (`0` same / `1` diff / `2` missing / +`3` same-space-diff-grayordinates), not a boolean — callers (`cat`, `apply_mask`) branch +on it. A single overridable **`rebuild_like(obj, newdat)`** helper replaces the hard-coded +`image_vector('dat',…,'volInfo',…)`+`fmri_data` re-wrap in `mean`/`ica`/etc. so they emit +`fmri_surface_data` carrying `brain_model`+`geom`. + +### D5b. NO empty-squeezing — `.dat` is always the full grayordinate set (user directive, 2026-06-25) +Unlike `fmri_data` (which stores sparse 3-D volumes and must drop out-of-brain / empty +voxels to save space), CIFTI grayordinate data is **already compact**: the medial wall is +excluded by construction (it is simply absent from each surface model's `vertlist`), and +there are no out-of-brain voxels. So this object does **not** reproduce the +`remove_empty`/`replace_empty` space-squeezing contract: + +- `.dat` is **always** `[nGrayordinates × nMaps]`, in exact 1:1 row correspondence with + `brain_model`. It is never shrunk to drop zero/NaN rows. There is therefore no "full vs + reduced" duality and no `replace_empty`-before-reconstruct dance. +- `remove_empty` and `replace_empty` are **overridden as identity no-ops** (return the + object unchanged) so any inherited method that calls them internally still composes, and + a user who calls them by habit gets a valid object back. (Zeroing a grayordinate's value + is allowed; it just never removes the row.) +- `removed_voxels` is kept (inherited) but is **vestigial**: always `false(nGray,1)`. It + exists only so inherited methods that read it find a valid all-false vector. `removed_images` + likewise stays `false(nImg,1)`; *selecting* a subset of maps is `get_wh_image`'s job, not a + space-saving squeeze. +- **Masking is intrinsic, not space-squeezing.** Because all objects share a standardized + grayordinate space (fs_LR-32k / 91k), a "mask" is just a same-length logical over + grayordinates (or another `fmri_surface_data` on the same space). `apply_mask` therefore + zeros/selects matching rows directly — no `fmri_mask_image`, no resample, no + empty-removal. This is a major simplification over `@fmri_data/apply_mask`. + +This eliminates the plan's largest risk class (the "forgot to `replace_empty`" family) and +several override headaches; `reconstruct_image`/`write`/`cat` no longer need a +`replace_empty` pre-step. + +### D6. Statistic / atlas variants deferred to a later phase +`ttest`/`regress` results and `.dlabel` parcellations want parallel per-grayordinate +fields (`.p/.ste/.sig`; integer label keys + RGBA). For v1, `ttest`/`regress` return a +`fmri_surface_data` carrying those as extra fields; dedicated subclasses +(`fmri_surface_statistic_image`, `fmri_surface_atlas`) that keep those parallel fields in +sync under `get_wh_image` (column subsetting) are a later phase. *(No `remove_empty`/ +`replace_empty` sync needed — they are no-ops, D5b.)* + +### Open questions — RESOLVED (user, 2026-06-25) +- **Q1. Class name → `fmri_surface_data`** (unprefixed, matching house style of + `fmri_data`/`atlas`/`region`). Statistic/atlas subclasses → `fmri_surface_statistic_image` + / `fmri_surface_atlas`. +- **Q2. Canonical default space → fs_LR-32k (91k)** for native CIFTI; fsaverage-164k for the + CBIG mapper path. Confirmed. +- **Q3. Mesh assets → ship the in-repo HCP S1200 meshes for v1, add CC0 TemplateFlow + `tpl-fsLR`/`tpl-fsaverage` as the license-clean default in a later milestone (M8).** +- **Q4. CIFTI I/O → hand-roll natively** (`canlab_read_cifti`/`canlab_write_cifti`), modeled + on the BSD-2 cifti-matlab core, so CanlabCore stays 100% permissive and self-contained. + The GIFTI codec is already hand-rolled and validated bit-exact. + +--- + +## 4. Object model + +`.dat` is `single [n_grayordinates × n_maps]`; rows in fixed CIFTI order: left-cortex +non-medial vertices, then right-cortex non-medial vertices, then subcortical voxels. + +| Property | Type | Description | +|---|---|---| +| `dat` | `single [nGray × nImg]` | **Inherited, unchanged.** The CIFTI cdata matrix (cortex L, cortex R, subcortical voxels). All generic `.dat` math operates here. For `.dlabel` holds integer label keys. | +| `brain_model` | struct (**new**, the `volInfo` replacement) | **Single source of truth** for surface/grayordinate geometry, mirroring CIFTI `BrainModel` 1:1. Fields: `.models` (struct array per BrainModel: `.struct_name` e.g. `CIFTI_STRUCTURE_CORTEX_LEFT`, `.type` `'surf'|'vox'`, `.index_offset`, `.index_count`, `.vertlist` 0-based vertex indices, `.surf_nverts`, `.voxlist` 3×N IJK, `.mat` 4×4 affine for vox models); `.grayordinate_type` e.g. `'91k'`; `.cluster` (computed connected-component labels). Replaces `wh_inmask/xyzlist` with `vertlist/voxlist`; per-vox-model affines replace one global `mat`. | +| `geom` | struct (**new**, mesh cache) | Per-hemisphere meshes (loaded lazily from bundled `.mat`): `.faces_lh/.faces_rh` [M×3 1-based], `.vertices_lh/.vertices_rh` [N×3 mm] for one or more surface types (`midthickness/inflated/pial/white/sphere`, faces shared across types), `.space_name` (`fsLR_32k`/`fsavg_164k`), `.medialwall_lh/.medialwall_rh` logical masks. What render/area/contiguity use. | +| `volInfo` | struct (**inherited, repurposed for the volume sub-block only**) | Populated to describe ONLY the subcortical/cerebellar voxel models (`.mat`=their affine, `.dim`, `.xyzlist/.wh_inmask` from `voxlist`) so the subcortex is a valid voxel space and `to_fmri_data` is near-free. **Empty for surface-only objects** (run-checks relaxed). Surface part is described by `brain_model`+`geom`, never `volInfo`. | +| `removed_voxels` | logical [nGray × 1] | **Inherited but vestigial (see D5b).** Always `false` — grayordinate data is already compact, so rows are never squeezed. Kept only so inherited methods that read it find a valid all-false vector. | +| `removed_images` | logical [nImg × 1] | **Inherited but vestigial (see D5b).** Always `false`. Map subsetting is `get_wh_image`'s job, not a space-saving squeeze. | +| `history` / `source_notes` / `image_names` / `fullpath` / `files_exist` / `dat_descrip` | cell / char / logical | **Inherited verbatim.** Provenance + file metadata. `fullpath` reinterpreted by `write()` as `.dscalar/.dtseries/.dlabel.nii` or `.gii` path; extension drives intent. | +| `X` | double [nImg × p] | **New (fmri_data name).** Design/predictor matrix, per-map, so `regress`/`predict` bind. | +| `Y` / `Y_names` | double [nImg × q] / cell | **New (fmri_data names).** Outcomes + names, per-map. | +| `covariates` / `covariate_names` | double / cell | **New (fmri_data names).** Per-map nuisance covariates. | +| `images_per_session` | double vector | **New (fmri_data name).** Run lengths for `.dtseries` data. | +| `metadata_table` | table (nImg rows) | **New (fmri_data name).** Per-map metadata; `write()` exports a companion CSV like `fmri_data.write`. | +| `image_metadata` / `additional_info` | struct | **New (fmri_data names).** Acquisition flags + free-form info; preserves the `fmri_data` API surface. | +| `mask` | logical [nGray × 1] or `fmri_surface_data` | **New, lightweight (see D5b).** Optional same-length logical over grayordinates (or another object on the same space). No `fmri_mask_image`, no resampling, no empty-squeezing — masking just zeros/selects rows. Medial wall is already excluded by `brain_model`, so a `mask` is only for further sub-selection (e.g. cortex-only, an ROI). | +| `intent` | char | **New.** CIFTI/GIFTI intent (`dscalar`/`dtseries`/`dlabel`/`func`/`shape`/`label`) so `write()` round-trips the correct `intent_code` + extension. | +| `series_info` | struct | **New.** For `.dtseries`: `SeriesStart/Step/Unit/Exponent` from the CIFTI SERIES map. | +| `label_table` | struct/table | **New (only `intent=='dlabel'/label`).** Per-key Name + RGBA, mirroring atlas label tables; lets a surface `.dlabel` convert cleanly to/from a surface atlas. | +| `surface_space` | char | **New.** Canonical space tag (`'fsLR_32k_91k'` default, `'fsLR_32k'`, `'fsaverage_164k'`). Gatekeeps `compare_space`; drives which meshes/warps load. | + +**How `brain_model` plays `volInfo`'s role across surface + volume grayordinates:** for +**cortical** grayordinates there is no affine — the row↔space map is +`models(i).vertlist` into `models(i).surf_nverts` (per hemisphere), reconstruction +scatters rows into dense vertex arrays (medial wall → NaN), and contiguity is connected +components on the `geom.faces` edge graph. For **subcortical** grayordinates the +`models(i).voxlist` + `models(i).mat` *is* a normal voxel space — these are mirrored into +the inherited `volInfo` slot so the subcortex reconstructs into a 3-D volume and exports +to `fmri_data` via existing voxel code. + +--- + +## 5. Method surface + +Classification: **Inherited** (works verbatim by dispatch), **Override** (same name, +surface-specific body), **New** (not present in `@image_vector`; mirrors `@fmri_data`). +"Mirrors fmri_data" = same name/signature/role as the `fmri_data` method. + +| Method | Class | Behavior (one line) | +|---|---|---| +| `fmri_surface_data` (constructor) | New | Mirrors `image_vector`/`fmri_data` polymorphism: empty / struct→fieldname-copy / `'key',value` / filename-autoload (`.dscalar/.dtseries/.dlabel.nii`/`.gii` extension test replaces `'.nii'`+exist) / `isa(image_vector)` recast (copy matching fields, cast `.dat` to single). Builds `brain_model`+`volInfo`(vol sub-block); lazy `geom`. Relaxes mandatory-`volInfo` check. | +| `remove_empty` | Override → **no-op** (D5b) | Returns the object unchanged. Grayordinate data is already compact; rows are never squeezed. Defined so inherited callers compose and habitual user calls are safe. | +| `replace_empty` | Override → **no-op** (D5b) | Returns the object unchanged (`.dat` is always full-size already). Removes the "forgot to replace_empty" bug class. | +| `get_wh_image` | Inherited | Map (column) selection; subsets `.dat`-sized + per-image fields (X/Y/metadata_table). | +| `descriptives` | Inherited | Numeric `.dat` summaries; only the "n voxels" label reads cosmetically as grayordinates. | +| `ica` / `mahal` / `pca` | Inherited | Pure `.dat` decomposition/stats; geometry-agnostic. Re-wrap (if any) via `rebuild_like`. | +| `image_math` / `history` / `isempty` / `enforce_variable_types` | Inherited | Pass `.dat`/metadata around; bind unchanged. | +| `mean` | Override (math reused) | Reuse `.dat` averaging + `group_by`; replace the hard-coded `image_vector(...,'volInfo',...)`+`fmri_data` re-wrap with `rebuild_like` → `fmri_surface_data` carrying `brain_model`+`geom`. | +| `threshold` | Override (raw branch reused) | Raw value thresholding on `.dat` delegates to inherited logic; override only the cluster-extent `k` / `trim_mask` paths to mesh connected components (cortex) + 3-D (subcortex), medial-wall aware. | +| `apply_mask` | Override (simplified, D5b) | Same-space logical/object mask → zero or select matching `.dat` rows directly. No `fmri_mask_image`, no resample, no empty-removal. Pattern-expression / dot-product math on `.dat` reused verbatim where applicable. | +| `reconstruct_image` | Override | Scatter `.dat` rows directly (no `replace_empty` pre-step; `.dat` is always full) into dense per-hemisphere vertex arrays via `brain_model.models.vertlist` (medial wall→NaN) and subcortical models into a 3-D volume via their `.mat`. Returns `{lh_vertdata, rh_vertdata, subvol}`. | +| `resample_space` | Override | `interp3`-in-mm is meaningless on meshes. Surface: barycentric mesh↔mesh via bundled `resample_from_*_to_*.mat` structs; subcortex: existing voxel resample. Same signature. | +| `compare_space` | Override | Compare `surface_space` + per-model `struct_name`/`index_count`/`surf_nverts` + vox-model `dim`/`mat`. **Preserves the 0/1/2/3 return contract.** | +| `reparse_contiguous` | Override | Connected components on the mesh edge graph (cortex, via `External/matlab_bgl` or `graph/conncomp`) + 6/18/26 voxel connectivity (subcortex). Writes `brain_model.cluster`. | +| `write` | Override | Native `canlab_write_cifti`/`canlab_write_gifti` (M1), dispatched on `intent`/extension; keeps `fullpath/fname/overwrite` convention; exports `metadata_table` CSV. No `replace_empty` pre-step needed (`.dat` already full). | +| `apply_parcellation` | Override (core reused) | `condf2indic`→column-normalize→`parcel_means = dat.dat' * parcels.dat` reused verbatim; override only space-matching (shared grayordinate index / mesh resample) and `get_region_volumes`→per-parcel surface **area** (face geometry) for `rmsv`; exclude medial wall from normalization. | +| `region` / `surface_region` | Override / New | Per-region vertex-index lists + per-vertex `.val/.Z` + centroid (mean vertex coord) via mesh components; subcortical models route through the existing volumetric `region` path. Mirrors `cifti_struct_2_region_obj` field conventions for the vox part (which currently drops `'surf'` — the gap to fill). | +| `surface` / `render_on_surface` | Override | Object already holds per-vertex values → skip `render_on_surface`'s `interp3`; feed `.dat` columns straight into `FaceVertexCData` + the split gray/color colormap builders. Reuse `surface_outlines.m`; reuse `make_surface_figure.m` body (swap `gifti()`→`load()`). Register into `@fmridisplay/surface.m` + `addbrain`. | +| `montage` / `orthviews` / `slices` | Override | Route cortex through the surface renderer; subcortex through the existing slice montage; warn where a 2-D slice view is meaningless for cortex. | +| `cat` | New (mirrors fmri_data) | Establish common grayordinate space via surface `compare_space` (resample if needed), hcat `.dat` + per-map fields (X/Y/covariates/metadata_table/series_info). No `replace_empty` pre-step (`.dat` always full). Reuses `@fmri_data/cat.m` logic minus volume resample. | +| `horzcat` | New (mirrors fmri_data) | Thin wrapper over `cat`. | +| `predict` | New (mirrors fmri_data) | Copy `@fmri_data/predict.m` algorithm core (operates on `.dat`+X/Y, geometry-agnostic); skip volume weight-map rebuild, wrap weights via `rebuild_like`. | +| `ttest` / `regress` | New (mirrors fmri_data) | Per-row stats on `.dat` columns (geometry-agnostic core); return a `fmri_surface_data` carrying parallel `.p/.ste/.sig` per-grayordinate fields (statistic subclass deferred). | +| `plot` | New (mirrors fmri_data) | Surface QC: reuse the geometry-agnostic panels (covariance, global signal, per-map histograms); replace the volume montage/orthviews panel with a 4-view surface render of the mean map. | +| `to_fmri_data` | New | Export the subcortical/volumetric models to a `fmri_data` (+ writeable `.nii`) via the repurposed `volInfo` sub-block, reusing `extract_vol_from_cifti.m`. Surface models dropped here (use `surf2vol`). | +| `vol2surf` (method on `fmri_data`/`image_vector`) | New | Volume → surface via CBIG RF (returns `fmri_surface_data`). See §7. | +| `surf2vol` | New | Surface → volume via CBIG RF (returns `fmri_data`; `.nii` via `fmri_data.write`). See §7. | +| `rebuild_like` (helper) | New | Overridable rebuild of a `fmri_surface_data` from a new `.dat`, carrying `brain_model`+`geom`. Used by `mean`/`ica`/`predict`/etc. to avoid the volume re-wrap. | + +--- + +## 6. Import / Export — native, no external toolbox + +**Hard requirement:** no `gifti`, FieldTrip `ft_read_cifti`, `wb_command`, or +cifti-matlab `@xmltree` (LGPL) / `ft_cifti` (GPL) at runtime. Verify at each milestone via +`mcp__matlab__detect_matlab_toolboxes` on a clean path. Recommend hand-rolling the +reader/writer (modeled on the BSD-2 cifti-matlab core) so CanlabCore stays 100% +permissive and self-contained. + +### CIFTI-2 reader (`read_cifti_native`) +1. `fopen(fname,'r','l')`; `fread` `sizeof_hdr` int32 @0 (=540; else byte-swap). +2. Read the 540-byte NIfTI-2 header at exact offsets: `dim` int64@16, `datatype` + int16@12, `vox_offset` int64@168, `scl_slope`@176/`scl_inter`@184, `intent_code` + int32@504, `intent_name` char16@508. +3. `fseek(540)`; read int32 extension flag; loop esize/ecode blocks until `vox_offset`; + keep the `ecode==32` bytes as the XML char array. +4. Parse XML with a **regexp pull-parser** (preferred — avoids even the `xmlread`/JRE + dependency and namespace/entity edge cases) or `xmlread`. Walk + `CIFTI>Matrix>MatrixIndicesMap`; for `BRAIN_MODELS` read each `BrainModel`'s + `IndexOffset/Count/ModelType/BrainStructure/SurfaceNumberOfVertices` and `sscanf` + `VertexIndices`/`VoxelIndicesIJK`; read `Volume`+16-float affine (`reshape(...,4,4)'`, + row-major). For SCALARS/LABELS iterate `NamedMap>MapName` (+`LabelTable>Label`); SERIES + `Start/Step/Exponent/Unit`. +5. `fseek(vox_offset)`; `fread([rowLen nRows], precision)`; permute `[2 1]` to + column-major; apply `scl_slope/inter` when nonzero. Populate `.dat`, `brain_model`, + `volInfo`(vol sub-block via the split logic of `get_cifti_data.m`), `intent`, + `series_info`, `label_table`, `image_names`. + +### CIFTI-2 writer (`write_cifti_native`) +Build XML (`sprintf`, escape `&<>`), pad to /16, `esize=len+8` rounded, `ecode=32`; +`vox_offset=round-up(540+4+esize)`; write 540-byte header (magic `n+2` + `0D 0A 1A 0A`, +`dim[0]=6/7`, `dim[1..4]=1`, `dim[5]=rowLen`, `dim[6]=nCols`, datatype/bitpix per class, +`intent_code`+`intent_name` per the 3006/3002/3007 table), ext flag=1, esize/ecode/XML, +pad, then data transposed back to row-major. Target: byte/value-level round-trip +verifiable by `cifti_diff`-style comparison. + +### GIFTI reader/writer (`read_gifti_native` / `write_gifti_native`) +**Validated bit-exact** (reference at `/tmp/test_gii_native.m`). Read: `fileread`, +`regexp(txt,'(?s)','match')` (the `(?s)` dotall flag is +required; avoid `\b`), pull attrs + `` payload, `matlab.net.base64decode`, +GZipBase64 inflate via `java.io.ByteArrayInputStream` → +`java.util.zip.InflaterInputStream` → `org.apache.commons.io.IOUtils.copy` (IOUtils ships +with MATLAB; **not** `GZIPInputStream`), `typecast`, `swapbytes` if endian mismatch, +RowMajor `reshape(vals,[Dim1 Dim0])'`. `POINTSET`→vertices, `TRIANGLE`→faces(+1), +`LABEL`→keys+LabelTable, `NONE/SHAPE/TIME_SERIES`→per-vertex data. Write: RowMajor byte +flatten of the transpose, zlib-compress JVM-side via `DeflaterOutputStream`, +`matlab.net.base64encode`, emit GIFTI XML. A no-Java path (ASCII/Base64Binary) is fully +native for writing; reading external GZip files still needs the JVM `Inflater`. + +### Vendorable, permissively-licensed code +| Tool | License | Use | +|---|---|---| +| **cifti-matlab** core (`cifti_read`, `cifti_write`, `cifti_parse_xml`, `read_nifti2_hdr`) | **BSD-2-Clause** (take `read_nifti2_hdr` under its BSD option) | Model the hand-rolled CIFTI reader/writer on it. **Avoid** the `@xmltree` (LGPL) and `ft_cifti` (GPL) subdirs. | +| **gllmflndn/gifti** | **MIT** | Optional *fallback* only; ships per-platform MEX with no pure-MATLAB fallback, so prefer the validated native codec. | +| **CBIG RF warps** (already in repo) | **MIT** | vol↔surf mapping assets (§7). Keep `CBIG_LICENSE` alongside. | + +`get_cifti_data.m` (diminfo model walk → cortex L/R + volumes) and +`extract_vol_from_cifti.m` (volumetric grayordinates → `fmri_data`) from +`canlabSurface/cifti_utils/` are ported in for the split and the `to_fmri_data` bridge. + +--- + +## 7. Volume ↔ Surface mapping (native v1) + +**Zero external binaries.** Reuse the **CBIG Registration-Fusion (RF-ANTs) warps already +vendored** at `CanlabCore/Cifti_plotting/CBIG_registration_fusion_surf2vol_vol2surf/` +(MIT; the `.mat` tables verified on disk). Replace FreeSurfer `MRIread/MRIwrite` with SPM12 +`spm_vol/spm_read_vols/spm_write_vol` (already a hard CanlabCore dependency) and the MARS +kd-tree with the precomputed vertex-index grids. v1 standardizes the RF path on +**fsaverage** and the bundled barycentric path on **fs_LR-32k / fsavg-164k**. + +### `vol2surf` (method on `fmri_data`/`image_vector` → `fmri_surface_data`, fsaverage) +Port `CBIG_RF_projectMNI2fsaverage.m`: (1) get the object's 3-D volume via +`reconstruct_image` and its 4×4 `mat` from `volInfo.mat`; (2) load +`lh/rh.avgMapping_allSub_RF_ANTs_MNI152_orig_to_fsaverage.mat` → `ras` (3×163842 MNI-RAS +per fsaverage7 vertex); (3) `vox = inv(mat(1:3,1:3))*(ras - mat(1:3,4))`, +`matcoord=[vox(2,:)+1; vox(1,:)+1; vox(3,:)+1]`; (4) per map column +`vertex_vals = interpn(vol3d, matcoord..., interp)` — `'linear'` for continuous, +`'nearest'` for label maps; OOB→0. Result populates `.dat` with an `fsavg_164k` +`brain_model`. + +### `surf2vol` (method on `fmri_surface_data` → `fmri_data` + `.nii`) +Port `CBIG_RF_projectfsaverage2Vol_single.m`, **fast nearest gather path** (no kd-tree): +(1) load `..._avgMapping.vertex.mat` → `lh_vertex/rh_vertex` (256³ nearest-vertex-index +grids) and the liberal cortex mask NIfTI (defines grid + vox2ras); (2) vectorized gather +`out = zeros(numel(mask)); idx = lh_vertex>0; out(idx) = lh_vals(lh_vertex(idx));` (same +rh) — no loop/search; (3) reshape to the mask volume, wrap as `fmri_data` with the mask's +vox2ras (lands in MNI152). **The subcortical grayordinates need no mapping** — they are +already voxels+affine in the `volInfo` sub-block, so reconstruct them directly and add to +the cortical projection for a full-brain `fmri_data`. `fmri_data.write()` emits the +`.nii`. Optional linear path uses `prop.mat` + one `knnsearch` (Statistics Toolbox). + +### Second native path (preferred when data is already fs_LR grayordinate) +`fsLR_32k ↔ MNI` via the repo's `resample_from__to_fsLR_32k(_nearestneighbor).mat` +barycentric structs through `render_on_surface`'s existing projection code — targets +fs_LR directly, no fsaverage↔fs_LR deformation needed. + +### Deferred (documented limitation) +RF is a **fixed group-template MNI152↔fsaverage correspondence** — correct for group MNI +maps, **not** a per-subject native-space ribbon mapper. Best-in-class +ribbon-constrained per-subject mapping (needs subject white+pial) and fsaverage↔fs_LR HCP +deformation are deferred. + +--- + +## 8. Reuse map + +All paths under `/Users/f003vz1/Documents/GitHub/`. + +| Existing file / method | How reused | +|---|---| +| `CanlabCore/@image_vector/{get_wh_image,descriptives,ica,mahal,pca,image_math,history,isempty,enforce_variable_types}.m` | **Inherit verbatim** by dispatch (touch only `.dat`). | +| `CanlabCore/@image_vector/{remove_empty,replace_empty}.m` | **Override as no-ops** (D5b) — not inherited; grayordinate `.dat` is never squeezed. | +| `CanlabCore/@image_vector/mean.m` (rebuild ~L141-143), `apply_mask.m` (pattern math), `threshold.m` (raw branch), `apply_parcellation.m` (aggregation core) | **Copy body, swap rebuild/space step** via `rebuild_like` / surface `resample_space`. | +| `CanlabCore/@image_vector/image_vector.m` (L252-279 property block; L358 `.nii`+exist branch); `CanlabCore/@fmri_data/fmri_data.m` (L313-351 analysis fields; L441-471 recast-from-sibling; L713 mandatory-volInfo) | **Mirror constructor polymorphism**, swap reader + extension test, relax volInfo check. | +| `CanlabCore/@fmri_data/{cat,horzcat,predict,ttest,regress,plot}.m` | **New by copying body** (geometry-agnostic cores; strip volume rebuild/resample) — NOT inherited by an `image_vector` subclass. | +| `canlabSurface/cifti_utils/get_cifti_data.m` | **Port** as the CIFTI→grayordinate splitter (cortex L/R + volumes). | +| `canlabSurface/cifti_utils/extract_vol_from_cifti.m` | **Port near-verbatim** for `to_fmri_data` (builds volInfo/voxlist/affine, wraps `fmri_data`). | +| `CanlabCore/Cifti_plotting/CBIG_registration_fusion_surf2vol_vol2surf/CBIG_RF_projectMNI2fsaverage.m`, `CBIG_RF_projectfsaverage2Vol_single.m` + `.mat` warps | **Port** (drop FreeSurfer→`spm_vol`, kd-tree→`vertex.mat`) for `vol2surf`/`surf2vol`. | +| `CanlabCore/@image_vector/render_on_surface.m` | **Reuse colormap/split-colormap/indexmap subfunctions**; feed per-vertex `.dat` straight to `FaceVertexCData` (skip `interp3`). | +| `CanlabCore/Cifti_plotting/surface_outlines.m` | **Reuse verbatim** (pure MATLAB). | +| `CanlabCore/Cifti_plotting/make_surface_figure.m` | **Reuse body** (swap `gifti()`→`load()`; fix hardcoded `/Users/sgeuter` addpath). | +| `CanlabCore/@fmridisplay/surface.m`, `CanlabCore/Visualization_functions/addbrain.m` | **Register** the object so `o2.surface{}` workflows keep working (`'hcp inflated'`, `'foursurfaces_hcp'`). | +| `CanlabCore/Cifti_plotting/cifti_struct_2_region_obj.m` | **Mirror field conventions** (XYZ/XYZmm/M/Z/voxlist+1) for the subcortical region path; it explicitly drops `'surf'` — the gap to fill. | +| `CanlabCore/@atlas/{num_regions,get_region_volumes,atlas2region}.m`, `condf2indic` | **Reuse / mirror** for surface atlas + parcellation (area replaces voxel volume). | +| `External/matlab_bgl` (or `graph/conncomp`) | **Reuse** for mesh connected components in `reparse_contiguous`/`region`. | +| `Neuroimaging_Pattern_Masks/.../2016_Glasser_*`, `2018_Schaefer_Yeo_*`, `2024_CANLab_atlas/` (`.label.gii`, `.dlabel.nii`) | **Test/atlas content** for surface parcellation import. | + +**Known bugs to fix on reuse:** `make_surface_figure.m` hardcoded `/Users/sgeuter` addpath; +`render_cifti_on_brain.m` L188 extracts `CORTEX_LEFT` for the right hemisphere. + +--- + +## 9. Standard mesh assets + +CANlab already ships fs_LR-32k and fsaverage meshes as `.mat` (faces + vertices, loadable +straight into `patch()` with no `gifti` dependency) under +`CanlabCore/canlab_canonical_brains/Canonical_brains_surfaces/`. + +| Asset | Vertex count / hemi | Source / license | Use | +|---|---|---|---| +| `S1200.{L,R}.midthickness_MSMAll.32k_fs_LR.surf.gii`, `S1200.{L,R}.sphere.32k_fs_LR.mat`, inflated `.mat` | 32,492 (29,696 L / 29,716 R non-medial) | **HCP S1200** Open-Access Data Use Terms (redistributable, not relicensable) — **already in repo** | Default fs_LR-32k display/geometry. | +| `fsavg_{inflated,white,sphere}_{lh,rh}.mat` | 163,842 | Already in repo | fsaverage-164k display + CBIG RF target. | +| `resample_from_{MNI152NLin2009cAsym,MNI152NLin6Asym,colin27}_to_{fsLR_32k,fsavg_164k}[_nearestneighbor].mat` | weights/vertices [Ntarget × 9] | Already in repo | MNI↔surface barycentric resampling (vol2surf path B, mesh↔mesh). | +| CBIG RF warps + `FSL_MNI152_FS4.5.0_cortex_estimate` mask | fsaverage7 = 163,842 | **MIT** (CBIG) — already in repo | `vol2surf`/`surf2vol` (§7). | +| **TemplateFlow `tpl-fsLR`** (32k/59k/164k midthickness/inflated/veryinflated/sphere + nomedialwall) | 32,492 / 59,292 / 163,842 | **CC0-1.0** | *Recommended add* for a clean-license default (pending Q3). | +| **TemplateFlow `tpl-fsaverage`** (white/pial/sphere + curv/sulc, 10k/41k/164k) | 2,562 / 40,962 / 163,842 | **CC0-1.0** | Optional fsaverage support with permissive license. | + +Hemisphere convention (from HCP/CANlab code): **left=1, right=2**; surface handle `.Tag` +must contain `'left'`/`'right'` for projection; medial-wall mask value 1 = valid cortex. + +--- + +## 10. Implementation phasing + +Each milestone has a verification step using sample data + a unit test in +`CanlabCore/Unit_tests/`. Run `mcp__matlab__detect_matlab_toolboxes` on a clean path at +M1, M2 and M5 to prove zero external-toolbox runtime. + +**M1 — Native I/O foundation (START HERE; highest value, no class yet). ✅ DONE 2026-06-25.** +Built (in `CanlabCore/Surface_tools/`): `canlab_read_cifti` / `canlab_write_cifti` (NIfTI-2 ++ ecode-32 XML, hand-rolled regexp pull-parser; supports dscalar/dtseries/dlabel, surface + +voxel BrainModels, subcortical affine, scalar/series/label maps; writer does faithful +re-emit *and* full XML regeneration), `canlab_read_gifti` / `canlab_write_gifti` (.surf/ +.func/.shape/.label; ASCII/Base64/GZipBase64), and shared `canlab_zlib_inflate` / +`canlab_zlib_deflate` JVM helpers. *Deliverable met:* self-contained, **proven to round-trip +exactly with cifti_read AND gifti removed from the path** (cifti cdata maxdiff=0, gifti +verts/faces maxdiff=0). *Verified:* `Unit_tests/surface_data/canlab_test_surface_io.m` +(5/5 pass) — shipped S1200 surf.gii (32,492 verts / 64,980 faces, bit-exact across all 3 +encodings), GIFTI + CIFTI label tables, synthetic dscalar/dlabel grayordinate round-trips +(cdata + BrainModel start/count/vertlist/voxlist + subcortical affine preserved), and an +opportunistic real `.dscalar.nii` round-trip. Dev-time cross-validation against the +`cifti_read`/`gifti` oracles: bit-exact on dscalar/dtseries/dlabel/surf/label files. +Key gotchas solved: MATLAB regexp `\b` does NOT word-boundary here (use `\s`); CIFTI maps +can be self-closing ``; the affine element is +`TransformationMatrixVoxelIndicesIJKtoXYZ`; strip whitespace only for base64 (not ASCII) +payloads; inflate must run fully JVM-side. + +*Naming note:* the design's `read_cifti_native` etc. were implemented under CANlab's +`canlab_` standalone-function convention. + +**M2 — Class skeleton + inheritance proof + import. ✅ DONE 2026-06-25.** +Built `@fmri_surface_data/fmri_surface_data.m` (`classdef fmri_surface_data < image_vector`) +with the full property set (`brain_model`/`geom`/`intent`/`series_info`/`label_table`/ +`surface_space`/`mask`/X/Y/covariates/images_per_session/metadata_table/...). Constructor +polymorphism implemented: empty / cifti-struct / gifti-struct / plain-struct / `'key',value` +/ filename-autoload (dispatches `.gii`→`canlab_read_gifti`, `.nii`→`canlab_read_cifti`) / +`isa(image_vector)`-recast. `brain_model` + `.dat` are built directly from M1's reader output +(the reader already returns the `diminfo{1}.models` split, so the `get_cifti_data` port was +unnecessary). `surface_space`/`grayordinate_type` inferred from vertex counts. No-op +`remove_empty`/`replace_empty` overrides added (`@fmri_surface_data/{remove_empty, +replace_empty}.m`); `removed_voxels`/`removed_images` initialised all-false (D5b). *Verified:* +`Unit_tests/surface_data/canlab_test_surface_object_basic.m` (7/7 pass) — all 7 construction +paths (real dscalar/dtseries/dlabel, GIFTI label, `.surf.gii` geometry, struct, empty), +`.dat` stays full under `remove_empty`/`replace_empty` (no squeeze), `get_wh_image` subsets +maps only (rows preserved), `removed_*` vestigial, inherited method surface present. +`volInfo` sub-block population was **deferred to M3** (not needed for the M2 inheritance proof). + +*M3 note (volInfo leak, Risk #1 confirmed):* inherited `descriptives` dereferences +`dat.volInfo.n_inmask` (its only volInfo use). Inherited methods that read `volInfo.*` +(`descriptives`, `montage`, `orthviews`, `flip`, `interpolate`, `get_xyzmm_coordinates`, +`rebuild_volinfo_from_dat`) need surface-aware guards/overrides in M3 — audit every +`volInfo.*` dereference and guard with `isfield` or override. + +**M3 — Spatial overrides + interop. ✅ DONE 2026-06-26.** +Built `@fmri_surface_data/`: `compare_space.m` (0/1/2/3 contract, brain_model-based), +`reconstruct_image.m` (returns a struct: dense per-hemisphere vertex arrays with medial +wall = NaN, + a [X×Y×Z×maps] subcortical volume + its volInfo), `to_fmri_data.m` (exports +the subcortical voxel sub-block to an `fmri_data` in MNI), `write.m` (dispatches to +`canlab_write_cifti`/`canlab_write_gifti`; rebuilds the maps dimension from +intent+image_names/label_table/series_info; faithful re-emit of stashed source XML when the +layout is unchanged, else regenerate), and `rebuild_like.m` (re-wrap new data carrying the +geometry). Plus a private `build_volinfo_subblock.m` (0-based CIFTI → 1-based SPM affine +conversion; wh_inmask aligned with grayordinate rows) used by the constructor (populates the +inherited `volInfo` slot for the subcortical sub-block; empty for surface-only) and +`to_fmri_data`. *Verified:* `Unit_tests/surface_data/canlab_test_surface_space_recon.m` +(7/7) — volInfo sub-block + affine, `to_fmri_data` values vs subcortical rows, +`reconstruct_image` cortex NaN-medial-wall + subcortex voxel values, the full compare_space +contract (0/1/2/3), `rebuild_like` (geometry preserved, row-count enforced), and native +CIFTI dscalar/dlabel write→read round-trips (maxdiff 0, affine + map names + label table +preserved). Full surface suite (M1+M2+M3) = **19/19**. + +*Deferred from M3:* (a) `reparse_contiguous` — needs the cortical mesh edge graph, which +depends on a bundled-mesh `geom` loader; moved to M5 (rendering) where that loader is built. +(b) Surface-aware overrides of the volInfo-dependent inherited QC/display methods +(`descriptives`, `montage`, `orthviews`, `flip`) — moved to M5/M6. `to_fmri_data` already +gives a clean volumetric escape hatch for those in the meantime. + +**M4 — Volume ↔ surface mapping. ✅ DONE 2026-06-26.** +Built `@image_vector/vol2surf.m` (volume → fsaverage_164k `fmri_surface_data`: reconstruct +volume, sample the CBIG RF-ANTs `ras` MNI→fsaverage per-vertex coords with `interpn`; +`'interp'` linear/nearest) and `@fmri_surface_data/surf2vol.m` (fsaverage_164k → `fmri_data` +in MNI 2 mm: scatter the same `ras` coords with `accumarray` mean; `'reference'` to set the +target grid; writes `.nii` via the returned fmri_data). Self-consistent inverse pair, fully +native — no FreeSurfer/Workbench. Plus `Surface_tools/canlab_cbig_warp_path.m` (resolves the +vendored MIT warps) and the overrides `@fmri_surface_data/{mean,apply_mask,threshold}.m` +(mean across maps via `rebuild_like`; apply_mask = zero out-of-mask rows, no fmri_mask_image/ +resample per D5b; threshold = raw-value only). *Verified:* +`Unit_tests/surface_data/canlab_test_surface_vol_map.m` (6/6) — vol2surf size/space, vertex +value == `interpn` sample (exact), **vol→surf→vol cortical correlation r = 0.9999**, surf2vol +rejects non-fsaverage objects, nearest-interp preserves integer labels, and mean/apply_mask/ +threshold. Full surface suite (M1–M4) = **25/25**. + +*Deferred from M4:* (a) the fs_LR-32k barycentric path (the `resample_from_*_to_fsLR_32k.mat` +structs are surface→surface fsnative→fs_LR deformations needing a vol→fsnative step first; the +fsaverage CBIG path is the v1, fsaverage↔fs_LR remains a later enhancement); (b) +cluster-extent `threshold` (needs the cortical mesh graph — M5); (c) combining the +subcortical sub-block into `surf2vol` output (use `to_fmri_data` for subcortex meanwhile). + +**M5 — Rendering. ✅ DONE 2026-06-26.** +Built `@fmri_surface_data/surface.m` (native 4-panel L/R×lateral/medial render on the bundled +inflated mesh; `'surftype'`/`'which_image'`/`'clim'`/`'pos_colormap'`/`'neg_colormap'`; +`'existingsurface'` and `'mni_surface'` modes), `@fmri_surface_data/render_on_surface.m` +(override: colors given patch handles — DIRECT per-vertex truecolor when the patch matches a +hemisphere's vertex count, e.g. any addbrain fs_LR/fsaverage mesh; else projects to a volume +via `obj_to_volume` and reuses `@image_vector/render_on_surface`), `@fmri_surface_data/plot.m` +(QC: histogram, per-map mean±sd, coverage, mean-map render), and the deferred +`@fmri_surface_data/reparse_contiguous.m` (cortex = mesh edge-graph `conncomp`; subcortex = +`spm_clusters`; writes `brain_model.cluster`). Helpers: `Surface_tools/canlab_surface_vertexcolors.m` +(value→truecolor split colormap, medial wall/zero = gray) and the private +`load_surface_geom.m` (bundled S12000 inflated / S1200 midthickness/sphere / fsaverage +inflated meshes) and `obj_to_volume.m`. **Native-space rendering needs no resampling** — +addbrain's `hcp inflated` is the fs_LR-32k template (32492) and `inflated`/`fsavg` is fsaverage +(163842), exact matches. **Rendering on any other (e.g. MNI pial) surface** works via the +volume projection. *Verified:* `Unit_tests/surface_data/canlab_test_surface_render.m` (7/7) — +native 4-panel render (truecolor per vertex, correct vertex counts for both spaces), medial +wall renders gray, direct coloring of an addbrain native patch, via-volume render onto an +addbrain MNI surface, `reparse_contiguous` (every active grayordinate labeled, inactive = 0), +and `plot`. Visually confirmed (transcriptomic gradient on inflated fs_LR; smooth map on +fsaverage and on an MNI surface). Full surface suite (M1–M5) = **32/32**. + +*Deferred from M5:* deep `@fmridisplay`/`addbrain` registration (the standalone `surface`/ +`render_on_surface` cover the rendering need; fmridisplay montage integration is optional +polish); `surface_outlines` overlay and a colorbar/legend on the native path; surface-aware +overrides of `montage`/`orthviews`/`flip`/`descriptives` (still routed via `to_fmri_data`). + +**M6 — Analysis parity. ✅ DONE 2026-06-26.** +Built `@fmri_surface_data/`: `cat.m` + `horzcat.m` (concatenate along maps; `compare_space==0` +required; per-map fields X/Y/covariates/image_names/metadata_table concatenated), +`ttest.m` and `predict.m` and `ica.m` (**delegated** to the corresponding `@fmri_data`/ +`@image_vector` methods via a private `as_fmri_data_proxy.m` — wraps the object as an +`fmri_data` with a dummy 1-D volInfo treating each grayordinate as a voxel — then remaps +geometry-bearing results back with `rebuild_like`; this reuses the entire battle-tested +algorithm/CV machinery), and a native lightweight `regress.m` (per-grayordinate OLS with +`obj.X`; betas in `.dat`, t/p/se/dfe in `additional_info.statistic`). `ttest`/`regress` +return an `fmri_surface_data` carrying the stat in `.dat` (t / betas) plus parallel fields in +`additional_info.statistic` (full surface statistic_image subclass deferred to M7). `predict` +returns `[cverr, stats, optout]` with `stats.weight_obj` remapped to a surface object. +*Verified:* `Unit_tests/surface_data/canlab_test_surface_analysis.m` (4 pass + ica filtered) — +cat/horzcat (+ space-mismatch error), ttest vs MATLAB `ttest` (matches to single precision), +regress OLS vs `\` (and recovered slope), predict CV round-trip with remapped weight map. +Full surface suite (M1–M6) = **36/36** (+ ica skipped where its toolbox is absent). + +*Upstream fix made:* `@image_vector/ica.m` had a stray, never-functional debug line +(`figure; plot(B(:), W(:), …)` referencing undefined `B`/`W`) that made `ica` error on *every* +call for all `image_vector` subclasses — removed it. `ica` still requires `icatb_fastICA` +(GIFT/icatb toolbox); it is the one `fmri_surface_data` method that is not fully self-contained. + +*Deferred from M6:* full `glm_map` integration for `regress` (contrasts/diagnostics — use +`to_fmri_data` + `fmri_data.regress`/`glm_map` for now); a dedicated +`fmri_surface_statistic_image` subclass with native cluster-extent `threshold` (M7). + +**M7 — Parcellation / region + surface atlas. ✅ DONE 2026-06-26.** +Built `@fmri_surface_data/`: `apply_parcellation.m` (parcel means `[nMaps × nParcels]` from a +`.dlabel` surface object or an integer key vector; key 0/NaN = background/medial wall excluded; +optional `'area'` weighting via per-vertex mesh surface area; returns labels + summary table), +`surface_region.m` (contiguous clusters → struct array with `.struct`/`.type`/`.grayord_rows`/ +`.vertex_indices`/`.XYZmm` centroid/`.numVox`/`.val`), and cluster-extent thresholding added to +`threshold.m` (`'k', N` via `reparse_contiguous`). Surface atlases are just `.dlabel` +`fmri_surface_data` objects (label keys + label_table), so they load through the standard +constructor — no separate import path needed. *Verified:* +`Unit_tests/surface_data/canlab_test_surface_parcellation.m` (5/5) — parcel means == keys on a +known synthetic parcellation and the real Gordon333+Tian atlas, label_table names, area +weighting finite, `compare_space` enforcement, cluster-extent threshold (no surviving cluster +< k), and `surface_region` partitioning the active grayordinates. + +*Deferred from M7:* the dedicated `fmri_surface_statistic_image` / `fmri_surface_atlas` +subclasses (the single class + `additional_info.statistic` + `.dlabel` objects cover the +functionality; subclasses are polish for `get_wh_image`-synced stat fields). + +**M8 — Docs + deprecation. ✅ DONE 2026-06-26.** +Authored `docs/fmri_surface_data_methods.md` (full method/option reference, updated through M7) +and `docs/fmri_surface_data_walkthrough.m` (runnable cell-mode walkthrough incl. parcellation + +group analysis; verified end-to-end). Added deprecation pointers in the external-dependent +`Cifti_plotting/render_cifti_on_brain.m` and `plot_surface_map.m` headers steering new code to +the native `fmri_surface_data`. *Deferred (optional, asset fetch — not code):* bundling CC0 +TemplateFlow `tpl-fsLR`/`tpl-fsaverage` meshes for a relicensable default (the in-repo HCP/ +FreeSurfer meshes work today; this is license hygiene); a `validate_object`-style suite (the +7-file, 41-test surface suite covers M1–M7). + +--- + +## Status: v1 complete (M1–M8) + +All eight milestones delivered. `fmri_surface_data` reads/writes CIFTI-2 + GIFTI natively, +holds grayordinate data with full `fmri_data`-style method parity (construct, reconstruct, +to_fmri_data, write, compare_space, cat/horzcat, mean/apply_mask/threshold, ttest/regress/ +predict/ica, vol2surf/surf2vol, surface/render_on_surface/plot, reparse_contiguous/ +apply_parcellation/surface_region), and runs with **no external toolbox** (sole exception: +`ica`, which needs the GIFT/icatb toolbox like the base class). Surface suite: **41 tests +passing** across 7 files. Best-in-class per-subject nonlinear mapping and fsaverage↔fs_LR +deformation remain the main future enhancements. + +--- + +## 11. Risks & open questions + +**Risks** +1. **`volInfo` dual-role leak.** Inherited methods read `volInfo.mat`/`.dim`/`.wh_inmask` + (counts: mat 25, wh_inmask 63, xyzlist 41, image_indx 37, cluster 22). Mitigation: + the surface truth lives in `brain_model`; `volInfo` describes *only* the volume + sub-block, and is empty for surface-only objects. Audit every `volInfo.*` dereference + (highest-risk overlooked inheritors: `rebuild_volinfo_from_dat`, + `get_xyzmm_coordinates`, `extract_roi_averages`, `montage`, `orthviews`, `flip`, + `interpolate`) and override or guard with `isfield`; do NOT rely on inheritance for any + method touching those fields. +2. ~~**remove/replace_empty contract**~~ **ELIMINATED (D5b).** `.dat` is always the full + grayordinate set; `remove_empty`/`replace_empty` are no-ops and `removed_voxels` is a + vestigial all-false vector. This whole risk class (the "forgot to `replace_empty`" bugs) + is removed by design. Remaining care: keep `.dat` rows in 1:1 order with `brain_model`. +3. **Native parser correctness.** CIFTI XML edge cases (CDATA, BrainModels tiling with no + gaps, row-major permute, `scl_slope`); GIFTI GZip = **raw zlib not gzip**, apache base64 + corruption, in-place JVM inflate garbage. Mitigation: validate against real HCP files; + keep `/tmp/test_gii_native.m` as the oracle; carry the three GIFTI gotchas verbatim. +4. **`compare_space` 4-valued contract** must be preserved (not boolean) or `cat`/ + `apply_mask` break subtly. +5. **`cat`/`horzcat`/`plot`/`predict`/`ttest`/`regress` are NOT inherited** (only in + `@fmri_data`) — forgetting to provide them yields silent "method not found". +6. **`mean`/rebuilders hardcode** the `image_vector`+`fmri_data` re-wrap — must route + through `rebuild_like` or they silently emit volume objects from surface data. +7. **Space proliferation.** Native CIFTI yields fs_LR-32k; CBIG RF yields fsaverage — + different topologies. fsaverage↔fs_LR deformation is deferred, so the two cannot be + `cat`'d without resampling. Gate `cat`/`compare_space` on `surface_space`. +8. **RF is a fixed group correspondence** — wrong for per-subject native space; document + clearly. Exclude medial wall from `apply_parcellation` column-normalization. +9. **Statistic/atlas parallel fields** (`.p/.ste/.sig`; integer keys) need to be carried + through `get_wh_image` (column subsetting) or they desync from `.dat` (handled in M7 + subclasses). Simpler than `fmri_data`, since `remove_empty`/`replace_empty` are no-ops. +10. **Surface area** for `get_region_volumes`/`rmsv` cannot be a no-op stub — compute from + face geometry early (M7). +11. **Known reuse bugs:** `make_surface_figure.m` hardcoded addpath; + `render_cifti_on_brain.m` L188 CORTEX_LEFT-for-right copy-paste — fix on reuse. +12. **License hygiene (hard requirement):** must NOT vendor `@xmltree` (LGPL) / `ft_cifti` + (GPL) / `gifti` MEX. Hand-roll permissively; ship CBIG MIT + HCP/TemplateFlow terms; + verify before bundling. + +**Open questions (need user confirmation — see §3):** +- **Q1.** Class name: `fmri_surface_data` (prefixed) vs. house-style `fmri_surface_data`? +- **Q2.** Confirm fs_LR-32k (91k) as the canonical default space. +- **Q3.** Add CC0 TemplateFlow `tpl-fsLR`/`tpl-fsaverage` meshes for a clean-license + default, or rely on the HCP S1200 surfaces already shipped? +- **Q4.** Hand-roll the CIFTI reader/writer (recommended, fully self-contained) vs. vendor + the BSD-2 cifti-matlab core? diff --git a/CanlabCore/docs/fmri_surface_data_methods.md b/CanlabCore/docs/fmri_surface_data_methods.md new file mode 100644 index 00000000..ee32930b --- /dev/null +++ b/CanlabCore/docs/fmri_surface_data_methods.md @@ -0,0 +1,425 @@ +# `fmri_surface_data` — methods and usage reference + +`fmri_surface_data` is the CANlab object for **cortical-surface and grayordinate +(HCP CIFTI) data**. It is the surface analogue of `fmri_data`: data are stored +flat as a `[grayordinates × maps]` matrix in `.dat`, with a `brain_model` +describing the cortical-surface vertices and subcortical voxels, so the familiar +CANlab method names (`mean`, `threshold`, `apply_mask`, `surface`, `write`, …) +work on surface data and interoperate with the rest of the toolbox. + +Everything runs **natively in MATLAB** — no gifti toolbox, FieldTrip, Connectome +Workbench, or FreeSurfer is required at runtime. + +- **Walkthrough (runnable):** [`fmri_surface_data_walkthrough.m`](fmri_surface_data_walkthrough.m) +- **Design rationale / roadmap:** [`fmri_surface_data_design_plan.md`](fmri_surface_data_design_plan.md) +- Class folder: `CanlabCore/@fmri_surface_data/`; native I/O helpers: `CanlabCore/Surface_tools/` + +--- + +## Contents + +1. [Concepts](#1-concepts) +2. [Construction](#2-construction) +3. [Properties](#3-properties) +4. [Method reference](#4-method-reference) + - [Import / export](#import--export) + - [Geometry & space](#geometry--space) + - [Volume ↔ surface mapping](#volume--surface-mapping) + - [Data operations](#data-operations) + - [Rendering & QC](#rendering--qc) +5. [Native I/O functions](#5-native-io-functions-surface_tools) +6. [Surface spaces](#6-surface-spaces) +7. [Design notes & limitations](#7-design-notes--limitations) + +--- + +## 1. Concepts + +**Grayordinates.** The HCP "grayordinate" model represents the cortex as +*surface vertices* and the subcortex/cerebellum as *voxels*. The standard "91k" +space has 91,282 grayordinates = left + right cortex vertices (the non-medial-wall +subset of the 32,492 fs_LR-32k vertices per hemisphere) plus ~31,870 subcortical +2 mm MNI voxels. This is already a flat `[grayordinates × maps]` matrix — exactly +what `.dat` expects. + +**`brain_model` replaces `volInfo`.** Cortical surface vertices have no voxel +coordinates, so `brain_model` (mirroring the CIFTI BrainModels) is the geometry +source of truth. The inherited `volInfo` slot is populated to describe **only** +the subcortical voxel sub-block (so `to_fmri_data` and volInfo-aware code work), +and is empty for surface-only objects. + +**No empty-squeezing.** Unlike `fmri_data` (which drops empty voxels to save +space on sparse volumes), grayordinate data is already compact, so `.dat` is +**always** the full `[grayordinates × maps]` set. `remove_empty` / `replace_empty` +are no-ops, and masking simply zeros out-of-mask rows. + +--- + +## 2. Construction + +```matlab +obj = fmri_surface_data % empty object +obj = fmri_surface_data(cifti_filename) % .dscalar/.dtseries/.dlabel.nii +obj = fmri_surface_data(gifti_filename) % .func/.shape/.label/.surf.gii +obj = fmri_surface_data(cifti_struct) % a canlab_read_cifti output struct +obj = fmri_surface_data(gifti_struct) % a canlab_read_gifti output struct +obj = fmri_surface_data('dat', X, 'brain_model', bm, 'surface_space', 'fsLR_32k', ...) +obj = fmri_surface_data(image_vector_obj) % recast a matching object +``` + +The constructor auto-detects CIFTI vs GIFTI by extension and reads it with the +native readers. `surface_space` and `grayordinate_type` are inferred from the +per-hemisphere vertex count. + +```matlab +s = fmri_surface_data(which('transcriptomic_gradients.dscalar.nii')); +size(s.dat) % [96854 3] +s.surface_space % 'fsLR_32k' +``` + +--- + +## 3. Properties + +| Property | Description | +|---|---| +| `dat` | `[nGrayordinates × nMaps]` single. The CIFTI cdata matrix (cortex L, cortex R, subcortical voxels). Always full (never squeezed). | +| `brain_model` | Geometry source of truth (CIFTI BrainModels). `.models{i}` has `.struct`, `.type` `'surf'`/`'vox'`, `.start`, `.count`, `.numvert`, `.vertlist` (0-based), `.voxlist` (3×N); plus `.vol` (`.dims`, `.sform`), `.grayordinate_type`, `.cluster`. | +| `geom` | Cortical mesh cache (faces/vertices), loaded for rendering. | +| `imagetype` | `'dscalar'` / `'dtseries'` / `'dlabel'` / `'func'` / `'shape'` / `'label'`. | +| `series_info` | For `.dtseries`: `.start`/`.step`/`.unit`/`.exponent`. | +| `label_table` | For `.dlabel`/`.label`: a MATLAB table (variables `key`, `name`, `rgba`). | +| `surface_space` | `'fsLR_32k'`, `'fsaverage_164k'`, … (gatekeeps `compare_space`; drives mesh/warp choice). | +| `volInfo` | Inherited; populated for the subcortical voxel sub-block only (empty for surface-only). | +| `mask` | Optional `[nGray × 1]` logical (or another same-space object) for `apply_mask`. | +| `X` / `Y` / `Y_names` / `covariates` / `covariate_names` / `images_per_session` / `metadata_table` / `image_metadata` / `additional_info` | Per-map (column) annotations, same names/roles as `fmri_data`. | +| `removed_voxels` / `removed_images` | Inherited but **vestigial** (always all-false); grayordinate data is never squeezed. | +| `image_names` / `fullpath` / `history` / `source_notes` / `dat_descrip` | Inherited provenance / file metadata. | + +--- + +## 4. Method reference + +> Type `methods(fmri_surface_data)` for the full list. Methods inherited from +> `image_vector` that operate purely on `.dat` (`get_wh_image`, `mahal`, +> `pca`, …) also apply. volInfo-dependent display/QC methods (`descriptives`, +> `montage`, `orthviews`, `flip`) are not yet surface-aware — run them on the +> volumetric part via `to_fmri_data(obj)` for now. + +### Import / export + +#### `write(obj, filename)` +Write to a CIFTI-2 (`.nii`) or GIFTI (`.gii`) file natively; dispatches on the +extension. Rebuilds the CIFTI maps dimension from `intent` + `image_names` / +`label_table` / `series_info`; re-emits the original XML faithfully when the +layout is unchanged, else regenerates it. + +```matlab +write(s, '/tmp/out.dscalar.nii'); % CIFTI-2 +write(s, 'fname', '/tmp/out.func.gii'); % GIFTI +``` + +#### `to_fmri_data(obj)` +Export the subcortical/volumetric grayordinates as a standard `fmri_data` object +in MNI space (writeable to `.nii`, montageable, etc.). Cortical surface +grayordinates are dropped (use `surf2vol`). + +```matlab +vol = to_fmri_data(s); % subcortex as fmri_data +``` + +### Geometry & space + +#### `reconstruct_image(obj)` +Returns a struct with dense per-hemisphere vertex arrays (medial wall = `NaN`) +and a `[X × Y × Z × maps]` subcortical volume. + +```matlab +r = reconstruct_image(s); +r.cortex_left % [numvert × nMaps], medial wall NaN +r.cortex_right +r.volume % subcortical volume +r.volume_volInfo % its SPM-style volInfo +``` + +#### `compare_space(obj, obj2)` +Surface analogue of `image_vector.compare_space`, preserving the integer contract: +`0` same · `1` different space (tag/layout) · `2` missing `brain_model` · `3` same +space but different in-data grayordinates. + +#### `reparse_contiguous(obj, 'which_image', k)` +Label contiguous clusters of active (nonzero, non-NaN) grayordinates: cortex via +the mesh edge graph (`graph`/`conncomp`), subcortex via 26-connectivity +(`spm_clusters`). Writes integer labels to `brain_model.cluster`. + +```matlab +[obj, ncl] = reparse_contiguous(threshold(s, 1.0, 'positive')); +``` + +### Surface resampling and volume ↔ surface mapping + +#### `resample_surface(obj, target_space, 'interp', 'linear')` +Resample the **cortical** data to another surface space, natively. Target-space +keywords (also `resample_surface(obj,'list')`): `fsaverage_164k` (aliases +`fsaverage`, `164k`), `fsaverage6`/`fsaverage5`/`fsaverage4`, `fs_LR_32k` (aliases +`fsLR`, `hcp`, `32k`). fsaverage↔fs_LR uses the vendored HCP registration +("deformed") sphere so both meshes share one spherical frame and are resampled by +barycentric (or nearest) interpolation; fsaverage down-sampling is the exact nested +icosahedral subset. Interpolation weights depend only on geometry, so they are +built once and applied to all maps (a sparse matrix-multiply) — a 50-map object +costs ~the same as one map. Continuous data defaults to barycentric (`'linear'`); +binary masks and `.dlabel` images default to `'nearest'`. Subcortical voxels are +carried through unchanged. + +```matlab +s32 = resample_surface(vol2surf(t), 'fsLR_32k'); % fsaverage -> HCP fs_LR-32k +s6 = resample_surface(vol2surf(t), 'fsaverage6'); % nested downsample (exact) +``` + +#### `vol2surf(volume_obj, 'interp', 'linear')` *(method on `image_vector` / `fmri_data`)* +Project a volumetric image (MNI152) onto the **fsaverage-164k** cortical surface, +returning an `fmri_surface_data`. **This is the CBIG registration-fusion mapper, +natively:** it samples the vendored CBIG RF-ANTs MNI→fsaverage per-vertex +coordinates (Wu et al. 2018, *Hum Brain Mapp*) with `interpn` — a line-for-line +reimplementation of CBIG's `CBIG_RF_projectMNI2fsaverage.m` using the identical +warp, driven by SPM's `volInfo.mat` instead of FreeSurfer's `MRIread`, so no +FreeSurfer/Workbench is required. Use `'interp','nearest'` for label maps. + +```matlab +ssurf = vol2surf(fmri_data('weights.nii')); % fsaverage_164k object +ssurf = vol2surf(t, 'interp', 'nearest'); % e.g. an integer atlas +``` + +#### `surf2vol(obj, 'reference', ref)` +Inverse of `vol2surf`: project an **fsaverage-164k** object back to an MNI152 +volume (`fmri_data`), scattering the same CBIG coordinates (`accumarray` mean). +`'reference'` (an `fmri_data`/`image_vector`) sets the target grid; default is MNI +2 mm `[91 109 91]`. The vol→surf→vol round-trip correlates ~1.0 on cortical voxels. + +```matlab +backvol = surf2vol(ssurf); % -> fmri_data in MNI +backvol = surf2vol(ssurf, 'reference', some_fmri_data); +``` + +### Data operations + +#### `mean(obj, ['omitnan'])` +Average across maps (columns), returning a single-map object (geometry preserved). + +#### `apply_mask(obj, mask, ['invert'])` +Keep a subset of grayordinates, **zeroing** the rest (no shrinking; D5b). `mask` +is a `[nGray × 1]` logical/numeric vector or another same-space `fmri_surface_data`. + +```matlab +s2 = apply_mask(s, s.dat(:,1) > 0); % zero non-positive grayordinates +``` + +#### `threshold(obj, thresh, ['positive'|'negative'], ['k', N])` +Zeros grayordinates inside the threshold range. `thresh` is a scalar `t` (keeps +`|value| ≥ t`) or `[lo hi]` (keeps `value ≤ lo | value ≥ hi`). With `'k', N`, +applies a **cluster-extent threshold** after the raw threshold — removing +contiguous clusters smaller than `N` grayordinates (mesh-graph connectivity for +cortex, 26-connectivity for subcortex; see `reparse_contiguous`). + +```matlab +t = threshold(s, 2.0, 'positive', 'k', 20); % >2, clusters >= 20 grayordinates +``` + +#### `rebuild_like(obj, newdat)` +Wrap a new `[nGray × K]` matrix into an `fmri_surface_data` carrying `obj`'s +geometry. Used internally by data-transforming methods. + +### Analysis + +These mirror the `fmri_data` analysis surface. `predict`, `ttest`, and `ica` +delegate to the corresponding `fmri_data`/`image_vector` methods (treating each +grayordinate as a feature), so the algorithms are identical; geometry-bearing +results are remapped back to `fmri_surface_data`. + +#### `cat(obj1, obj2, ...)` / `[obj1, obj2, ...]` +Concatenate objects along the map (image) dimension — e.g. building a group +dataset from per-subject maps. All objects must share the same grayordinate +space (`compare_space == 0`). Per-map fields (`X`, `Y`, `covariates`, +`image_names`, `metadata_table`) are concatenated too. + +```matlab +all_subs = cat(sub1, sub2, sub3); % or [sub1 sub2 sub3] +``` + +#### `ttest(obj, [pthresh], [k])` +Grayordinate-wise one-sample t-test across maps. Returns an `fmri_surface_data` +with the t-map in `.dat` and `.p`/`.ste`/`.sig`/`.dfe` in +`.additional_info.statistic`. + +```matlab +t = ttest(cat(sub1, sub2, sub3)); +surface(t, 'clim', [-5 5]); +``` + +#### `regress(obj, [X])` +Grayordinate-wise OLS regression of the maps onto design `X` (`[nMaps × p]`; +defaults to `obj.X`; no intercept added automatically). Returns coefficients in +`.dat` (`[nGray × p]`) and `.t`/`.p`/`.se`/`.dfe` in `.additional_info.statistic`. +For contrasts/diagnostics, use `to_fmri_data` + `fmri_data.regress`/`glm_map`. + +```matlab +obj.X = [ones(n,1) age(:)]; +b = regress(obj); +surface(get_wh_image(b, 2)); % the age effect +``` + +#### `predict(obj, 'algorithm_name', ..., 'nfolds', ...)` +Cross-validated multivariate prediction (set `obj.Y` first). Returns +`[cverr, stats, optout]` as `fmri_data.predict`; `stats.weight_obj` is remapped +to an `fmri_surface_data` you can `surface`/`write`. + +```matlab +obj.Y = scores(:); +[err, stats] = predict(obj, 'algorithm_name', 'cv_lassopcr', 'nfolds', 5); +surface(stats.weight_obj); +``` + +#### `ica(obj, [nIC])` +Spatial ICA; returns an `fmri_surface_data` whose maps are the spatial +components. **Requires the GIFT/`icatb` toolbox (`icatb_fastICA`)** — the one +method that is not fully self-contained. + +### Parcellation & regions + +#### `apply_parcellation(obj, parcels, ['area'])` +Average each map within the parcels of a surface atlas. `parcels` is another +`fmri_surface_data` (a `.dlabel` on the same space; its `label_table` supplies +names) or an integer key vector `[nGray × 1]`. Key 0 / NaN = background (medial +wall) and is excluded. `'area'` area-weights by per-vertex surface area. + +```matlab +atl = fmri_surface_data(which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii')); +[pm, labels, tbl] = apply_parcellation(s, atl); % pm: [nMaps × nParcels] +``` + +Returns `parcel_means` `[nMaps × nParcels]`, a `labels` cell, and a summary +`table` (`key`, `label`, `n_grayordinates`, `total_weight`). + +#### `surface_region(obj, ['which_image', k])` +Summarize contiguous clusters (active = nonzero, non-NaN) as a struct array — the +surface analogue of `region()`. Each element has `.struct`, `.type`, +`.cluster_id`, `.grayord_rows`, `.vertex_indices`, `.XYZmm` (centroid), +`.numVox`, `.val`. (For a full CANlab `region` object on the subcortical part, +use `region(to_fmri_data(obj))`.) + +```matlab +reg = surface_region(threshold(t, 3, 'positive', 'k', 20)); +[reg.numVox] % cluster sizes +``` + +### Rendering & QC + +#### `surface(obj, ...)` +Render on cortical surfaces. Three modes: + +- **Native (default):** builds a **managed, stateful `fmridisplay`** whose surface + views are the mesh set matching `surface_space` (fs_LR-32k → `'foursurfaces_hcp'`, + fsaverage-164k → `'foursurfaces_freesurfer'`), and paints the data as a managed + surface-native layer — colored directly, no resampling. Because the returned + object is under a controller, `set_colormap` / `set_opacity` / `rethreshold` / + `removeblobs` / `refresh` act on the surfaces (this is how you change the colormap + after rendering). Each hemisphere shows its own data. +- **`'existingsurface', handles`:** color patch handles you already have (returns a + struct with `.figure`/`.axes`/`.surfaces`). +- **`'mni_surface', name`:** create an `addbrain` surface (e.g. `'left'`, + `'hcp inflated'`) and render onto it, projecting through a volume when the + surface is not the object's native mesh (returns a struct). + +You can also add a surface object to an **existing** managed display: +`o2 = surface(o2, obj)` adds the object's matching native surfaces and paints it; +`o2 = surface(o2, obj, 'foursurfaces_hcp')` targets a named surface set. Requesting a +surface of a **different** space than the data (e.g. fsaverage meshes for fs_LR data) +warns (`fmridisplay:render_layer_surfaces:spacemismatch`) rather than mapping wrong — +a surface object has no volume to resample onto a foreign mesh, so use the matching +surfaces (the bare `surface(obj)` picks them automatically). + +| Option | Meaning | +|---|---| +| `'surftype'` | `'inflated'` (default), `'midthickness'`, `'sphere'` (fs_LR). | +| `'which_image'` | map (column) to render (default 1). | +| `'clim'` / `'cmaprange'` | `[lo hi]` color limits (default symmetric from data). | +| `'colormap'` | named map (`'hot'`, `'parula'`, …) or `[n × 3]` matrix. | +| `'pos_colormap'` / `'neg_colormap'` | `[n × 3]` colormaps (default hot / cool). | + +```matlab +o2 = surface(s, 'which_image', 1); % managed fs_LR, 4 views +o2 = set_colormap(o2, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]); % recolor live +o2 = removeblobs(o2); % back to anatomy + +o2 = montage(fmridisplay, 'axial'); % existing managed display +o2 = surface(o2, s); % add + paint its native surfaces + +surface(ssurf, 'mni_surface', 'left'); % on an addbrain MNI surface +han = addbrain('hcp inflated left'); % an fs_LR mesh +surface(s, 'existingsurface', han); % color it directly +``` + +#### `render_on_surface(obj, handles, ...)` +Lower-level: color given patch `handles`. Patches whose vertex count matches a +hemisphere are colored **directly** (true native-space rendering — works for any +matching-space mesh, e.g. all `addbrain` fs_LR/fsaverage surfaces); other surfaces +are colored by projecting the object to a volume and reusing +`image_vector.render_on_surface`. Options: `'which_image'`, `'clim'`, +`'pos_colormap'`, `'neg_colormap'`. + +#### `plot(obj, ['norender'])` +Quick QC panel: value histogram, per-map mean ± sd, coverage, and a mean-map +surface render. + +--- + +## 5. Native I/O functions (`Surface_tools/`) + +These standalone functions back the object and can be used directly. They depend +only on core MATLAB (+ the JVM for gzip) — no external toolbox. + +| Function | Purpose | +|---|---| +| `canlab_read_cifti(file)` | Read CIFTI-2 (`.dscalar`/`.dtseries`/`.dlabel`/`.dconn.nii`) → struct (`.cdata`, `.diminfo`, `.intent`, `.hdr`, `.xml`). | +| `canlab_write_cifti(file, cii)` | Write CIFTI-2 (faithful re-emit or regenerate). | +| `canlab_read_gifti(file)` | Read GIFTI (`.surf`/`.func`/`.shape`/`.label.gii`) → struct (`.vertices`, `.faces`, `.cdata`, `.labels`). | +| `canlab_write_gifti(file, gii)` | Write GIFTI (ASCII / Base64 / GZipBase64). | +| `canlab_surface_vertexcolors(vals, clim, poscm, negcm, graycolor)` | Map per-vertex values → truecolor (split hot/cool; NaN/zero = `graycolor`, default gray). | +| `canlab_cbig_warp_path(name)` | Resolve the vendored CBIG RF warp paths. | + +--- + +## 6. Surface spaces + +| Space | Per-hemi vertices | Source | Used by | +|---|---|---|---| +| `fsLR_32k` | 32,492 | native CIFTI (HCP grayordinates) | `fmri_surface_data(cifti)` | +| `fsaverage_164k` | 163,842 | CBIG RF mapping | `vol2surf` output | + +`vol2surf` produces `fsaverage_164k`; native CIFTI is `fsLR_32k`. These have +different mesh topologies, so they cannot be combined without resampling +(fsaverage↔fs_LR deformation is a planned enhancement). Native rendering uses the +matching bundled mesh in `canlab_canonical_brains/Canonical_brains_surfaces/` +(`addbrain('hcp inflated')` for fs_LR, `addbrain('inflated')` for fsaverage). + +--- + +## 7. Design notes & limitations + +- **Group-template mapping = CBIG registration fusion.** `vol2surf`/`surf2vol` + are native reimplementations of the CBIG RF-ANTs MNI152↔fsaverage mappers + (Wu et al. 2018), using the vendored warp under `Cifti_plotting/ + CBIG_registration_fusion_surf2vol_vol2surf/` — `vol2surf` ≡ + `CBIG_RF_projectMNI2fsaverage`. So you are already using CBIG registration + fusion, with no FreeSurfer/CBIG-toolbox dependency. It is a fixed group + correspondence (correct for group MNI maps; not a per-subject ribbon mapper). + CBIG's heavier `fsaverage2Vol` ribbon-fill needs FreeSurfer + the MARS toolbox + + external mask geometry (not bundled) and is intentionally not wired in. +- **fs_LR ↔ fsaverage.** Not yet bridged; keep a workflow in one space, or go + through a volume. +- **Planned:** a colorbar/outline overlay on the native render, and surface-aware + overrides of `montage`/`orthviews`/`flip`/`descriptives`; the volumetric escape + hatch (`to_fmri_data`) covers those cases today. (Cluster-extent thresholding is + already implemented — `threshold(obj, thr, 'k', N)`.) +- See [`fmri_surface_data_design_plan.md`](fmri_surface_data_design_plan.md) for the + full milestone roadmap and the rationale behind these choices. diff --git a/CanlabCore/docs/fmri_surface_data_walkthrough.m b/CanlabCore/docs/fmri_surface_data_walkthrough.m new file mode 100644 index 00000000..098933d9 --- /dev/null +++ b/CanlabCore/docs/fmri_surface_data_walkthrough.m @@ -0,0 +1,243 @@ +%% Working with surface and grayordinate data: the fmri_surface_data object +% +% This walkthrough demonstrates the common operations on |fmri_surface_data|, +% the CANlab object for cortical-surface and grayordinate (HCP CIFTI) data. It +% is the surface analogue of |fmri_data|: data are stored flat as a +% [grayordinates x maps] matrix in |.dat|, with a |brain_model| describing the +% surface vertices and subcortical voxels, so the familiar CANlab method names +% (mean, threshold, apply_mask, surface, write, ...) work on surface data. +% +% Everything here runs natively in MATLAB -- no gifti toolbox, FieldTrip, +% Connectome Workbench, or FreeSurfer is required at runtime. +% +% Reference: docs/fmri_surface_data_methods.md +% +% To run: you only need CanlabCore on your path (canlab_toolbox_setup). The core +% sections use a small CIFTI map that ships with CanlabCore and the built-in +% 'emotionreg' dataset. A few sections that show subcortical grayordinates and +% atlas parcellation use files from the companion Neuroimaging_Pattern_Masks +% repository; those sections are guarded with `if ~isempty(which(...))` so the +% script still runs top to bottom without it. + +%% Executive summary +% +% A complete tour in a few commands: +% +% s = fmri_surface_data(which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii')); +% surface(s); % render on the native fs_LR surface +% r = reconstruct_image(s); % dense per-hemisphere vertex arrays +% ssurf = vol2surf(ttest(load_image_set('emotionreg'))); % volume result -> surface +% back = surf2vol(ssurf); % ...and back to an MNI fmri_data volume +% write(s, 'out.dscalar.nii'); % write CIFTI natively +% +% Now step through it in detail. + +%% 1. Load a CIFTI surface file +% +% The constructor auto-detects CIFTI (.dscalar/.dtseries/.dlabel.nii) and GIFTI +% (.surf/.func/.shape/.label.gii) by extension and reads them natively. Here we +% load the HCP group cortical myelin map bundled with CanlabCore. + +ciftifile = which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii'); % ships with CanlabCore +s = fmri_surface_data(ciftifile); + +disp(s.imagetype) % 'dscalar' +disp(s.surface_space) % 'fsLR_32k' +fprintf('%d grayordinates x %d maps\n', size(s.dat,1), size(s.dat,2)); + +%% 2. Render on the native cortical surface (managed display) +% +% surface() loads the bundled mesh matching the object's surface_space (here +% fs_LR-32k), colors vertices directly -- no resampling -- and returns a stateful +% fmridisplay object. The default is a 4-panel view (left/right x lateral/medial); +% the medial wall renders gray. Because the result is a managed display under a +% controller, you can recolor / rethreshold / remove the layer AFTER rendering. + +o2 = surface(s, 'colormap', hot(256)); +drawnow, snapnow; + +% Change the colormap live on the returned object (this is why surface() returns +% a managed fmridisplay rather than raw handles): +o2 = set_colormap(o2, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]); +drawnow, snapnow; + +% Options: pick a map ('which_image'), color range ('clim'/'cmaprange'), a named +% or matrix colormap ('colormap'), or mesh ('surftype', e.g. 'midthickness' for +% fs_LR). For example: +% o2 = surface(s, 'clim', [1 1.8], 'surftype', 'midthickness'); +% +% To add the object's surfaces to a display you already have (e.g. a montage), +% pass the fmridisplay first -- it adds the matching native surfaces and paints: +% o2 = montage(fmridisplay, 'axial'); o2 = surface(o2, s); + +%% 3. The grayordinate model (brain_model) +% +% brain_model mirrors the CIFTI BrainModels: one entry per cortical hemisphere +% (surface vertices) and per subcortical structure (voxels). It plays the role +% volInfo plays for fmri_data. The myelin map above is cortex-only; a full "91k" +% grayordinate file (from Neuroimaging_Pattern_Masks) also has subcortical +% volume structures. + +fprintf('myelin map models:\n'); +cellfun(@(m) sprintf(' %-14s %-4s %6d rows', m.struct, m.type, m.count), ... + s.brain_model.models, 'UniformOutput', false)' + +sg = []; % a full grayordinate object (cortex + subcortex), if available +gfile = which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii'); % from NPM +if ~isempty(gfile) + sg = fmri_surface_data(gfile); + types = cellfun(@(m) m.type, sg.brain_model.models, 'UniformOutput', false); + fprintf('\n91k grayordinate atlas: %d grayordinates, %d models (%d surface + %d volume)\n', ... + size(sg.dat,1), numel(sg.brain_model.models), nnz(strcmp(types,'surf')), nnz(strcmp(types,'vox'))); + % The inherited volInfo describes ONLY the subcortical voxel sub-block: + disp(sg.volInfo) +else + fprintf('\n(Install Neuroimaging_Pattern_Masks to see subcortical grayordinates.)\n'); +end + +%% 4. Reconstruct to dense surfaces (and a subcortical volume) +% +% reconstruct_image returns per-hemisphere dense vertex arrays (medial wall = +% NaN); for a grayordinate object with subcortex it also returns a 3-D/4-D +% volume. .dat is always full (no replace_empty needed for fmri_surface_data). + +r = reconstruct_image(s); +fprintf('cortex_left : %d vertices x %d maps (medial wall = NaN)\n', ... + size(r.cortex_left,1), size(r.cortex_left,2)); + +if ~isempty(sg) + rg = reconstruct_image(sg); + fprintf('subcortex volume: %s\n', mat2str(size(rg.volume))); +end + +%% 5. Export the subcortical part to an fmri_data object (and a .nii) +% +% to_fmri_data returns the volumetric (subcortical) grayordinates as a standard +% fmri_data in MNI space, which you can montage, threshold, or write to NIfTI. +% (This needs a grayordinate object with subcortex.) + +if ~isempty(sg) + vol = to_fmri_data(sg); + fprintf('subcortex fmri_data: %d voxels x %d maps\n', size(vol.dat,1), size(vol.dat,2)); + % vol.fullpath = fullfile(pwd, 'subctx.nii'); write(vol); % uncomment to write NIfTI +end + +%% 6. Project a volume onto the surface (vol2surf) and back (surf2vol) +% +% vol2surf samples any MNI152 volume (fmri_data/statistic_image) onto the +% fsaverage cortical surface using the vendored CBIG registration-fusion warps. +% surf2vol is its self-consistent inverse. + +img = load_image_set('emotionreg'); % 30 contrast images (fmri_data) +t = ttest(img); % volumetric statistic_image (group t-map) +ssurf = vol2surf(t); % -> fmri_surface_data, fsaverage_164k +surface(ssurf, 'clim', [-6 6]); % render the projected map +drawnow, snapnow; + +backvol = surf2vol(ssurf); % fsaverage surface -> MNI fmri_data +fprintf('surf2vol -> fmri_data with %d cortical voxels\n', size(backvol.dat,1)); + +%% 7. Render on ANY existing surface (e.g. an addbrain MNI surface) +% +% Pass an addbrain keyword (or your own patch handles). If the surface is not +% the object's native mesh, the data is projected through a volume automatically +% (resampling), reusing image_vector.render_on_surface. + +surface(ssurf, 'mni_surface', 'left'); % render onto an addbrain MNI cortical surface +drawnow, snapnow; + +%% 8. Threshold and find contiguous clusters on the mesh +% +% threshold zeros sub-threshold grayordinates (raw value; add 'k', N for a +% cluster-extent threshold). reparse_contiguous labels contiguous clusters using +% the cortical mesh graph (cortex) and 26-voxel connectivity (subcortex). + +st = threshold(ssurf, 3, 'positive', 'k', 20); % t > 3, clusters >= 20 grayordinates +[st, ncl] = reparse_contiguous(st, 'which_image', 1); +fprintf('Found %d contiguous clusters above threshold\n', ncl); + +% Summarize the clusters as region-like structs (centroid, size, mean value): +reg = surface_region(st, 'which_image', 1); +if ~isempty(reg) + fprintf('surface_region: %d clusters; sizes: %s\n', numel(reg), mat2str([reg.numVox])); +end + +%% 9. Parcellate with a surface atlas +% +% apply_parcellation averages each map within the parcels of a surface atlas (a +% .dlabel object on the same grayordinate space, or an integer key vector). +% Background / medial wall (key 0) is excluded. The data and atlas must be on the +% same grayordinate space; here we make a demo map on the atlas's own space. + +if ~isempty(sg) + demo = sg; % same grayordinate space as the atlas + demo.dat = single(sqrt(double(sg.dat))); % a continuous map on those grayordinates + demo.imagetype = 'dscalar'; + [parcel_means, parcel_labels] = apply_parcellation(demo, sg); + fprintf('Parcellated into %d parcels; first parcel "%s" mean = %.3f\n', ... + size(parcel_means,2), parcel_labels{1}, parcel_means(1,1)); +end + +%% 10. Other data operations: mean, apply_mask +% +% mean() averages across maps; apply_mask() keeps a subset of grayordinates +% (zeroing the rest -- no shrinking, since grayordinate data is already compact). + +mn = mean(ssurf); % single-map mean across maps +keep = ssurf.dat(:,1) > 0; % a simple grayordinate mask (positive t) +smasked = apply_mask(ssurf, keep); % zero the non-positive grayordinates + +%% 11. Group analysis: cat, ttest, regress, predict +% +% Combine per-map/per-subject surface objects with cat (or [a, b, ...]) into one +% object, then run the same analyses as fmri_data. Here we build a small "group" +% by projecting individual emotionreg contrast images to the surface. + +subj = cell(1, 5); +for i = 1:5 + subj{i} = vol2surf(get_wh_image(img, i)); % each subject's contrast on the surface +end +group = cat(subj{:}); % one object, 5 maps +fprintf('group object: %d grayordinates x %d maps\n', size(group.dat,1), size(group.dat,2)); + +tmap = ttest(group); % grayordinate-wise one-sample t-test +% surface(tmap, 'clim', [-4 4]); % (render the group t-map) + +% OLS regression onto a design matrix (set X BEFORE calling regress): +group.X = [ones(5,1), (1:5)']; % intercept + a linear predictor +b = regress(group); % betas in b.dat; t/p in additional_info.statistic + +% Cross-validated multivariate prediction (set Y first): +group.Y = (1:5)'; +[cverr, stats] = predict(group, 'algorithm_name', 'cv_lassopcr', 'nfolds', 5, 'verbose', 0); +fprintf('predict cverr = %.3f; weight map is an fmri_surface_data (%dx%d)\n', ... + cverr, size(stats.weight_obj.dat,1), size(stats.weight_obj.dat,2)); + +%% 12. Write CIFTI / GIFTI natively +% +% write() dispatches on the output extension. CIFTI map metadata (scalar names, +% label tables, series info) and the subcortical affine are preserved. + +outdir = tempdir; +write(s, fullfile(outdir, 'myelin_copy.dscalar.nii')); % CIFTI-2 +write(ssurf, fullfile(outdir, 'emotionreg_surf.func.gii')); % GIFTI (cortex) +fprintf('Wrote CIFTI and GIFTI to %s\n', outdir); + +% Round-trip check: +s_reloaded = fmri_surface_data(fullfile(outdir, 'myelin_copy.dscalar.nii')); +fprintf('Round-trip max abs diff: %g\n', max(abs(double(s.dat(:)) - double(s_reloaded.dat(:))))); + +%% 13. Quick QC plot +% +% plot() shows a value histogram, per-map mean/sd, coverage, and a mean-map +% surface render. + +plot(s); +drawnow, snapnow; + +%% See also +% +% docs/fmri_surface_data_methods.md - full method/option reference +% docs/workflows/fmri_surface_data_howto.md - the workflow how-to (this content as markdown) +% docs/fmri_surface_data_design_plan.md - design rationale and roadmap +% methods(fmri_surface_data) - the full method list diff --git a/CanlabCore/fmridisplay_helper_functions/canlab_color_mode.m b/CanlabCore/fmridisplay_helper_functions/canlab_color_mode.m new file mode 100644 index 00000000..1186c85d --- /dev/null +++ b/CanlabCore/fmridisplay_helper_functions/canlab_color_mode.m @@ -0,0 +1,82 @@ +function [mode, info] = canlab_color_mode(dat, varargin) +% canlab_color_mode Single source of truth for choosing a blob color mode. +% +% Decides whether an image/blob set should be rendered with a continuous +% COLORMAP, one solid color per region ('unique'), or a single SOLID color, +% based on how many distinct values it has. Used by montage / surface / orthviews +% and the fmridisplay controller so the default is uniform across methods. +% +% :Usage: +% :: +% mode = canlab_color_mode(dat) +% mode = canlab_color_mode(dat, 'max_unique', 300) +% +% :Inputs: +% **dat:** a numeric data vector/matrix, an image_vector/atlas/region object, +% or an fmri_surface_data. Zeros and NaNs are treated as background. +% +% :Optional Inputs: +% **'max_unique':** the cutoff (default 1000) for a *generic* integer map. +% Integer-valued images with more than 2 and fewer than this +% many distinct nonzero values use 'unique'; at or above it (or +% non-integer data) use 'colormap'. Atlas objects and +% .dlabel/.label parcellations are always 'unique', ignoring +% this cutoff. (image_vector.orthviews uses a 300 parcel cutoff +% for its own legacy auto-detection.) +% +% :Outputs: +% **mode:** one of +% 'solid' - <= 2 distinct nonzero values (a binary mask / constant map): +% render in one solid color. +% 'unique' - integer-valued with 3..(max_unique-1) distinct values, or an +% atlas: one distinct solid color per region. +% 'colormap' - many values or non-integer (continuous) data: a graded colormap. +% **info:** struct with .n_unique, .is_integer, .max_unique, .is_atlas. +% +% :Examples: +% :: +% canlab_color_mode(load_atlas('julich_fmriprep20')) % -> 'unique' +% canlab_color_mode(mask_obj) % -> 'solid' (binary) +% canlab_color_mode(ttest(load_image_set('emotionreg'))) % -> 'colormap' +% +% :See also: canlab_colormap, render_blobs, fmri_surface_data.surface, montage + +max_unique = 1000; % shared cutoff for a *generic* integer map +wh = find(strcmpi(varargin, 'max_unique'), 1); +if ~isempty(wh), max_unique = varargin{wh + 1}; end + +% Atlases and label parcellations (.dlabel/.label) are region-labeled: always +% 'unique', regardless of how many regions (parcellations can exceed the cutoff). +is_atlas = isa(dat, 'atlas') || ... + (isobject(dat) && isprop(dat, 'imagetype') && ... + any(strcmpi(dat.imagetype, {'dlabel', 'label'}))); + +% Extract a numeric data vector from whatever was passed. +if isa(dat, 'atlas') + v = double(dat.dat(:)); +elseif isa(dat, 'region') + v = cat(1, dat.val); if isempty(v), v = (1:numel(dat))'; end +elseif isobject(dat) && isprop(dat, 'dat') + v = double(dat.dat(:)); +else + v = double(dat(:)); +end + +v = v(isfinite(v) & v ~= 0); % drop background (0) and NaN +u = unique(v); +n = numel(u); +is_integer = ~isempty(u) && all(u == round(u)); + +info = struct('n_unique', n, 'is_integer', is_integer, ... + 'max_unique', max_unique, 'is_atlas', is_atlas); + +if is_atlas + mode = 'unique'; +elseif n <= 2 + mode = 'solid'; % binary mask / (near-)constant map +elseif is_integer && n < max_unique + mode = 'unique'; % few integer labels -> one color each +else + mode = 'colormap'; % continuous / many values +end +end diff --git a/CanlabCore/fmridisplay_helper_functions/canlab_colormap.m b/CanlabCore/fmridisplay_helper_functions/canlab_colormap.m index 3adee0bb..851412e9 100644 --- a/CanlabCore/fmridisplay_helper_functions/canlab_colormap.m +++ b/CanlabCore/fmridisplay_helper_functions/canlab_colormap.m @@ -62,12 +62,17 @@ switch obj.type case 'solid' rgb = repmat(rowcolor(obj.colors{1}), n, 1); + rgb(~isfinite(v), :) = NaN; % keep NaN/Inf uncoloured + % (a thresholded / medial-wall vertex must stay gray, not take + % the solid colour). case 'single' lo = obj.range(1); hi = obj.range(2); w = clamp01((v - lo) ./ nonzero(hi - lo)); mn = rowcolor(obj.colors{1}); mx = rowcolor(obj.colors{2}); rgb = (1 - w) .* mn + w .* mx; + rgb(~isfinite(v), :) = NaN; % clamp01(NaN)=0 -> would + % otherwise paint uncoloured vertices with the min endpoint. case 'split' r = obj.range; % [negmin negmax posmin posmax] @@ -178,7 +183,9 @@ methods (Static) function obj = single(mincolor, maxcolor, range) if nargin < 3 || isempty(range), range = [0 1]; end - obj = canlab_colormap('single', {mincolor, maxcolor}, range(:)'); + range = range(:)'; + if numel(range) == 4, range = [range(1) range(4)]; end % split range -> full span + obj = canlab_colormap('single', {mincolor, maxcolor}, range); end function obj = split(minneg, maxneg, minpos, maxpos, range) @@ -199,7 +206,9 @@ % CONTINUOUS an n x 3 LUT (e.g. a perceptual colormap: viridis, % inferno, turbo, ...) mapped continuously over a value range. if nargin < 2 || isempty(range), range = [0 1]; end - obj = canlab_colormap('continuous', lut, range(:)'); + range = range(:)'; + if numel(range) == 4, range = [range(1) range(4)]; end % split range -> full span + obj = canlab_colormap('continuous', lut, range); end function obj = from_render_args(args, clim) diff --git a/CanlabCore/fmridisplay_helper_functions/render_blobs.m b/CanlabCore/fmridisplay_helper_functions/render_blobs.m index e6b98528..34343605 100644 --- a/CanlabCore/fmridisplay_helper_functions/render_blobs.m +++ b/CanlabCore/fmridisplay_helper_functions/render_blobs.m @@ -363,7 +363,9 @@ 'subcortex full', ... 'subcortex compact', ... 'subcortex 3d', ... - 'subcortex slices'} + 'subcortex slices', ... + 'unique', ... % colour-mode flags (handled upstream; ignored here) + 'solid'} continue case {'partialvolumethreshold'} diff --git a/docs/Object_methods.md b/docs/Object_methods.md index 8424918c..4b844119 100644 --- a/docs/Object_methods.md +++ b/docs/Object_methods.md @@ -44,6 +44,7 @@ image_vector (abstract base; rarely used directly) ├── fmri_data generic image data + .X, .Y, covariates ├── statistic_image stat maps with t / p / sig ├── atlas labeled parcellation with probability maps +├── fmri_surface_data cortical-surface / grayordinate (HCP CIFTI) data └── fmri_mask_image binary mask (legacy) region list of contiguous clusters @@ -67,6 +68,7 @@ Listed in roughly the order most users encounter them. Click a class name for th | **[`image_vector`](image_vector_methods.md)** | Abstract superclass. You rarely create one directly, but most of the methods you call on an `fmri_data`, `statistic_image`, or `atlas` are inherited from here (`apply_mask`, `resample_space`, `montage`, `surface`, `extract_roi_averages`, etc.). | | **[`statistic_image`](statistic_image_methods.md)** | Stat maps (t / p / effect-size) with thresholding state. Produced by `ttest`, `regress`, etc. The `threshold` method re-thresholds without losing the underlying values. | | **[`atlas`](atlas_methods.md)** | Brain atlases / parcellations. Includes both winner-take-all labels for each parcel and probabilistic maps, region labels ( `.labels`), `.references`, and other metadata. Makes it easy to extract or analyze data within named atlas regions or on region/parcel averages. Methods include `select_atlas_subset`, `merge_atlases`, `downsample_parcellation`, `atlas2region`. Use `load_atlas` to load by keyword. | +| **[`fmri_surface_data`](fmri_surface_data_methods.md)** | Cortical-surface and grayordinate (HCP CIFTI) data — the surface analogue of `fmri_data`. Reads/writes CIFTI-2 and GIFTI **natively** (no gifti/FieldTrip/Workbench toolbox), stores data as `[grayordinates × maps]`, and supports the familiar methods (`surface`, `threshold`, `mean`, `apply_parcellation`, `predict`, `ttest`, `write`) plus `vol2surf` / `surf2vol` to map between volumes and the cortical surface. See the [surface data how-to](workflows/fmri_surface_data_howto.md). | | **[`region`](region_methods.md)** | Vector storing information about a set of contiguous clusters / ROIs as a unit of analysis, with one element per region. Designed to hold a compact representation of thresholded maps, including data, voxel coordinates and locations, and facilitate rendering on brains and tables. Produced by `region(t)` from a thresholded `statistic_image`. Consumed by `montage`, `table`, `surface`, `extract_data`. | | **[`fmridisplay`](fmridisplay_methods.md)** | Container holding figure handles for slice montages and surfaces. Built by invoking methods like `montage` or with preconfigured sets in `canlab_results_fmridisplay`; lets you swap blob layers in / out without re-rendering the anatomy underneath. | | **[`brainpathway`](brainpathway_methods.md)** | Connectivity / pathway-modeling object for one subject. The `brainpathway_multisubject` extension is documented on the same page. | diff --git a/docs/Workflows.md b/docs/Workflows.md index 401081c8..df9e7a9c 100644 --- a/docs/Workflows.md +++ b/docs/Workflows.md @@ -16,6 +16,8 @@ Start with the roadmap to orient yourself, then follow the walkthrough to run it | **ROI / atlas data extraction** | Pull region-of-interest, pattern, parcel, tissue-compartment, and sphere/coordinate summaries out of brain images, then visualize them (bar plots, line plots, multi-subject slope plots). | [ROI extraction roadmap](workflows/ROI_extraction_methods_roadmap.md) | [Extract & visualize ROI data — how-to](workflows/extract_roi_data_howto.md) | | **First-level fMRI time-series modeling** (`glm_map`) | Build a single-subject event-related model from onsets (FSL tables, SPM-style cells, or an `SPM.mat`), choose a basis set, add nuisance covariates and contrasts, screen the design (VIF/cVIF, efficiency, high-pass filter), simulate data, fit (AR errors), threshold, and visualize. | [First-level roadmap](workflows/glm_map_first_level_roadmap.md) | [First-level how-to](workflows/glm_map_first_level_howto.md) | | **Second-level fMRI group analysis** (`glm_map`) | Group regression on contrast images: build the object via `fmri_data.regress` or the `glm_map` estimator API, handle outliers and WM/CSF nuisance signals (`normalize_gm_by_wm_csf`), fit OLS or robust, screen the design, threshold, and visualize. | [Second-level roadmap](workflows/glm_map_second_level_roadmap.md) | [Second-level how-to](workflows/glm_map_second_level_howto.md) | +| **Surface & grayordinate data** (`fmri_surface_data`) | Load CIFTI/GIFTI surface data and visualize it on the cortical surface; map a volumetric result onto the surface (`vol2surf`) and back to a volume (`surf2vol` / `to_fmri_data`); parcellate, threshold, and run group analyses — all natively, no external toolbox. | [`fmri_surface_data` methods](fmri_surface_data_methods.md) | [Surface data how-to](workflows/fmri_surface_data_howto.md) | +| **Loading & using image sets** (`load_image_set`) | Discover what's loadable with `load_image_set('list')` (categorized tables of signatures, datasets, networks/ICA/topics, surface CIFTI sets, gradients, meta-analysis maps); load datasets and signatures; score data with a signature; compute similarity to networks and topics; run dual regression; load surface map sets as `fmri_surface_data`. The parallel registry for parcellations is `load_atlas` (also supports `load_atlas('list')`). | — | [Load image sets — how-to](workflows/load_image_set_howto.md) | *More workflows will be added here over time.* diff --git a/docs/fmri_surface_data_methods.md b/docs/fmri_surface_data_methods.md new file mode 100644 index 00000000..f315c64a --- /dev/null +++ b/docs/fmri_surface_data_methods.md @@ -0,0 +1,294 @@ +# `fmri_surface_data` methods, organized by area + +This is a functional index of methods available on an `fmri_surface_data` +object — the CANlab object for **cortical-surface and grayordinate (HCP CIFTI) +data**. It is the surface analogue of `fmri_data`: data are stored flat as a +`[grayordinates × maps]` matrix in `.dat`, with a `brain_model` describing the +cortical-surface vertices and subcortical voxels, so the familiar CANlab method +names (`mean`, `threshold`, `apply_mask`, `surface`, `write`, …) work on surface +data and interoperate with the rest of the toolbox. + +`fmri_surface_data` is a subclass of `image_vector`, so it inherits the +purely-`.dat` methods listed in +[image_vector_methods.md](image_vector_methods.md) (`get_wh_image`, `mahal`, +`pca`, `image_math`, …). Only methods owned by `fmri_surface_data` (defined in +`@fmri_surface_data/`) are listed below. Type `methods(my_obj)` for the live list. + +**Everything runs natively in MATLAB — no gifti toolbox, FieldTrip, Connectome +Workbench, or FreeSurfer is required at runtime** (sole exception: `ica`, which +needs the GIFT/`icatb` toolbox, like the base `image_vector` method). + +- **Tutorial:** [Surface & grayordinate data how-to](workflows/fmri_surface_data_howto.md) +- **Runnable script:** [`CanlabCore/docs/fmri_surface_data_walkthrough.m`](../CanlabCore/docs/fmri_surface_data_walkthrough.m) +- **Design rationale / roadmap:** [`CanlabCore/docs/fmri_surface_data_design_plan.md`](../CanlabCore/docs/fmri_surface_data_design_plan.md) + +## Concept + +The HCP "grayordinate" model represents the cortex as *surface vertices* and the +subcortex/cerebellum as *voxels*. Because that is already a flat +`[grayordinates × maps]` matrix, it maps directly onto CANlab's `.dat`. Two +differences from `fmri_data`: + +- **`brain_model` replaces `volInfo`** as the geometry source of truth (cortical + vertices have no voxel coordinates). The inherited `volInfo` is populated to + describe only the subcortical voxel sub-block (empty for surface-only objects). +- **No empty-squeezing:** grayordinate data is already compact, so `.dat` is + *always* the full set; `remove_empty` / `replace_empty` are no-ops. + +## Surface spaces + +Every object carries a `surface_space` tag naming the cortical mesh its vertices +live on. The supported spaces, their resolutions, and their sources: + +| `surface_space` | Verts/hemi | What it is | Source | +|---|---|---|---| +| `fsaverage_164k` | 163,842 | **FreeSurfer fsaverage** average-subject spherical surface, 7th-order icosahedron. The output of `vol2surf`. | Fischl et al. 1999 | +| `fsaverage6` | 40,962 | fsaverage 6th-order icosahedron — a **nested subset** (first N vertices) of fsaverage-164k. | Fischl et al. 1999 | +| `fsaverage5` | 10,242 | fsaverage 5th-order icosahedron (nested subset). | Fischl et al. 1999 | +| `fsaverage4` | 2,562 | fsaverage 4th-order icosahedron (nested subset). | Fischl et al. 1999 | +| `fsLR_32k` | 32,492 | **HCP fs_LR-32k** — the left/right-symmetric grayordinate cortical mesh used by CIFTI `.dscalar`/`.dtseries`/`.dlabel` files. | Van Essen et al. 2012 | +| `onavg_41k` | 40,962 | **onavg** (OpenNeuro Average) equal-area template, den-41k — vertices placed so cortical regions are sampled at uniform resolution. Resample-only (via its `space-fsLR` registration sphere). | Feilong et al. 2024 (CC0) | +| `onavg_10k` | 10,242 | onavg equal-area template, den-10k. | Feilong et al. 2024 (CC0) | + +**Grayordinate objects (the "91k" CIFTI model).** A native CIFTI file combines +the `fsLR_32k` cortex (59,412 vertices = 32,492 × 2 minus the medial wall) with +**31,870 subcortical/cerebellar voxels**, for 91,282 grayordinates — cortex on the +surface, subcortex in a volume. The cortex uses `fsLR_32k`; the subcortical block +is a standard MNI152 volume (extract it with `to_fmri_data`). See Glasser et al. +2013 for the grayordinate model. + +**Moving between spaces.** `resample_surface(obj, target_space)` resamples the +cortical data between any of these spaces natively (see the [Surface resampling](#surface-resampling-and-volume--surface-mapping) +section); `resample_surface(obj, 'list')` prints the keyword list. `vol2surf` / +`surf2vol` convert between an MNI152 **volume** and `fsaverage_164k`. + +*References.* Fischl B, Sereno MI, Tootell RBH, Dale AM (1999), *Hum Brain Mapp* +8(4):272–284. Van Essen DC, Glasser MF, Dierker DL, Harwell J, Coalson T (2012), +*Cereb Cortex* 22(10):2241–2262. Glasser MF et al. (2013), *NeuroImage* +80:105–124. Feilong M, Guo J, Gobbini MI, Haxby JV (2024), *Nat Methods* +21:2069–2078 (onavg; TemplateFlow `tpl-onavg`, CC0). + +## Properties + +`fmri_surface_data` inherits the `image_vector` properties (`dat`, `volInfo`, +`removed_voxels`, `removed_images`, `image_names`, `fullpath`, `files_exist`, +`history`, `dat_descrip`, `source_notes`) and adds: + +| Property | Description | +|---|---| +| `brain_model` | Geometry source of truth (mirrors CIFTI BrainModels). `.models{i}`: `.struct`, `.type` (`surf`/`vox`), `.start`, `.count`, `.numvert`, `.vertlist` (0-based), `.voxlist`; plus `.vol` (`.dims`, `.sform`), `.grayordinate_type`, `.cluster`. | +| `geom` | **Attached CUSTOM mesh geometry only** (`.vertices`/`.faces`), set when the object is built from a `.surf.gii` that carries geometry. It is **empty for a data-only CIFTI** (e.g. a `.dscalar`) — that is expected, not a bug. Standard-space meshes are **not** stored here: for a known `surface_space` (fs_LR-32k, fsaverage-164k, …) `surface()` and resampling load the mesh on demand from the bundled assets (`private/load_surface_geom`, `canlab_canonical_brains/Canonical_brains_surfaces`). Use `.geom` only to carry a non-standard mesh no `surface_space` keyword can reproduce. | +| `imagetype` | `dscalar` / `dtseries` / `dlabel` / `func` / `shape` / `label`. | +| `series_info` | For `.dtseries`: `.start`/`.step`/`.unit`/`.exponent`. | +| `label_table` | For `.dlabel`/`.label`: a MATLAB table with variables `key`, `name`, `rgba` (Nx4). | +| `surface_space` | The cortical mesh the vertices live on — one of `fsaverage_164k`, `fsaverage6`, `fsaverage5`, `fsaverage4`, `fsLR_32k` (see [Surface spaces](#surface-spaces)). Gatekeeps `compare_space`; drives mesh/warp choice and `resample_surface`. | +| `mask` | Optional `[nGray × 1]` logical (or same-space object) for `apply_mask`. | +| `X` / `Y` / `covariates` / `images_per_session` / `metadata_table` / … | Per-map annotations, same names/roles as `fmri_data`. | +| `additional_info` | Free-form struct; also holds `.statistic` (from `ttest`/`regress`) and the source CIFTI xml/hdr. | + +## Construction and import/export + +| Method | From | One-liner | +|---|---|---| +| `fmri_surface_data` | `@fmri_surface_data` | Constructor: from a CIFTI/GIFTI file, a reader struct, `'key',value` pairs, or an `image_vector` recast | +| `write` | `@fmri_surface_data` | Write natively to CIFTI-2 (`.nii`) or GIFTI (`.gii`); rebuilds map metadata; faithful re-emit or regenerate | +| `to_fmri_data` | `@fmri_surface_data` | Export the subcortical voxel grayordinates as a volumetric `fmri_data` (writeable to NIfTI) | + +## Geometry and space + +| Method | From | One-liner | +|---|---|---| +| `reconstruct_image` | `@fmri_surface_data` | Unpack `.dat` into dense per-hemisphere vertex arrays (medial wall NaN) + a subcortical volume | +| `compare_space` | `@fmri_surface_data` | Compare grayordinate spaces (0 same / 1 diff space / 2 missing / 3 diff in-data), preserving the contract | +| `reparse_contiguous` | `@fmri_surface_data` | Label contiguous clusters (cortex = mesh edge graph; subcortex = 26-connectivity) into `brain_model.cluster` | +| `rebuild_like` | `@fmri_surface_data` | Wrap new `[nGray × K]` data into an object carrying this geometry (used internally) | + +## Surface resampling and volume ↔ surface mapping + +| Method | From | One-liner | +|---|---|---| +| `resample_surface` | `@fmri_surface_data` | Resample the cortex to another **surface** space — `fsaverage_164k` ↔ `fsLR_32k` (HCP CIFTI) and nested `fsaverage6`/`5`/`4`. Barycentric by default (weights built once, reused across maps), nearest for binary/label maps; subcortex carried through. `resample_surface(obj,'list')` prints the spaces | +| `vol2surf` | `@image_vector` | Project a volumetric image (MNI152) onto the fsaverage-164k surface. **Is the CBIG RF-ANTs mapper natively** (Wu et al. 2018) — a reimplementation of `CBIG_RF_projectMNI2fsaverage` (`interpn`), no FreeSurfer needed | +| `surf2vol` | `@fmri_surface_data` | Project an fsaverage-164k object back to an MNI152 `fmri_data` volume — native inverse using the same CBIG RF-ANTs warp (`accumarray` scatter) | +| `to_display_volume` | `@fmri_surface_data` | Project the object (any space) to an MNI `fmri_data` for rendering on an **arbitrary** MNI isosurface: cortex resampled to fsaverage + `surf2vol`, merged with subcortex (`to_fmri_data`). Used by `render_on_surface` / the managed painter | + +**`resample_surface(obj, target[, 'interp', 'nearest'])`.** `target` is a space +keyword — `fsaverage_164k` (aliases `fsaverage`, `fsavg`, `164k`), `fsaverage6`/`5`/`4`, +`fs_LR_32k` (aliases `fsLR`, `hcp`, `32k`), `onavg_41k`/`onavg_10k` (aliases `onavg`) — +**or an isosurface patch handle / mesh struct** (its space is resolved by vertex +count, so rendering code can resample straight onto an `addbrain` patch). +fsaverage↔fs_LR (and ↔onavg) uses a vendored HCP/onavg registration ("deformed") +sphere so both meshes share one spherical frame and are resampled with barycentric +(or nearest) interpolation; fsaverage down-sampling is the **exact nested +icosahedral subset**. Barycentric weights depend only on geometry, so they are +computed once and applied to every map as a sparse matrix-multiply (a 50-map object +costs about the same as one map). Continuous data defaults to barycentric +(`'linear'`); binary masks and `.dlabel` images default to `'nearest'`. onavg is +resample-only (it has no `.dscalar` I/O here). + +```matlab +s = vol2surf(ttest(load_image_set('emotionreg'))); % fsaverage_164k +s32 = resample_surface(s, 'fsLR_32k'); % -> HCP fs_LR-32k +s6 = resample_surface(s, 'fsaverage6'); % -> nested fsaverage6 +son = resample_surface(s, 'onavg'); % -> onavg (equal-area) +lab = resample_surface(atlas_surf, 'fsLR_32k', 'interp', 'nearest'); % labels +sp = resample_surface(s, addbrain('hcp inflated left')); % onto a patch's space +``` + +## Data operations + +| Method | From | One-liner | +|---|---|---| +| `mean` | `@fmri_surface_data` | Average across maps → single-map object | +| `apply_mask` | `@fmri_surface_data` | Zero out-of-mask grayordinates (no `fmri_mask_image`, no resample; `.dat` stays full) | +| `threshold` | `@fmri_surface_data` | Raw-value threshold; optional cluster-extent (`'k', N`) via `reparse_contiguous` | +| `cat` / `horzcat` | `@fmri_surface_data` | Concatenate objects along maps (same space required); also `[a, b, …]` | +| `remove_empty` / `replace_empty` | `@fmri_surface_data` | No-ops (grayordinate data is already compact; overrides so inherited callers compose) | + +## Analysis + +`ttest`, `predict`, and `ica` delegate to the corresponding `fmri_data` / +`image_vector` methods — treating each grayordinate as a feature — and remap +geometry-bearing results back to the surface, so those algorithms are identical +to their volumetric counterparts. `regress` is a native grayordinate-wise OLS +implementation. + +| Method | From | One-liner | +|---|---|---| +| `ttest` | `@fmri_surface_data` | Grayordinate-wise one-sample t-test across maps (t-map in `.dat`; p/ste/sig in `additional_info.statistic`) | +| `regress` | `@fmri_surface_data` | Grayordinate-wise OLS onto `obj.X` (betas in `.dat`; t/p/se in `additional_info.statistic`) | +| `predict` | `@fmri_surface_data` | Cross-validated multivariate prediction (`obj.Y`); weight map remapped to a surface object | +| `ica` | `@fmri_surface_data` | Spatial ICA (requires the GIFT/`icatb` toolbox) | + +## Rendering and QC + +| Method | From | One-liner | +|---|---|---| +| `surface` | `@fmri_surface_data` | Native mode returns a **managed `fmridisplay`** (4-panel, matching meshes painted directly); also `'existingsurface'` handles or an `'mni_surface'` addbrain surface (both return a struct) | +| `render_on_surface` | `@fmri_surface_data` | Color existing patch handles: directly if the mesh matches, else via a volume projection | +| `plot` | `@fmri_surface_data` | QC panel: value histogram, per-map mean±sd, coverage, mean-map render | + +`surface` / `render_on_surface` accept the **same color vocabulary as the volume +visualization pipeline** (`addblobs` / `set_colormap`): `clim` / `cmaprange`, +`colormap` / `colormapname` (a single sequential map), `pos_colormap` / +`neg_colormap`, `splitcolor`, `maxcolor` / `mincolor`, and `color` (solid). So the +same options color surface data and volume blobs. + +### Managed display (`fmridisplay`) + +A surface object lives in a stateful `fmridisplay` as a **surface-native layer**, so +the same managed-display experience works for surface and volume data. The simplest +entry point is `surface(obj)` itself — it now **returns a managed `fmridisplay`** +with the matching native surfaces added and the data painted: + +```matlab +o2 = surface(surf_stat); % managed display, native meshes +o2 = set_colormap(o2, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]); % recolors in place +o2 = removeblobs(o2); % restores the anatomy + +% Or add to a display you already have: +o2 = fmridisplay; +o2 = surface(o2, 'hcp inflated left'); o2 = surface(o2, 'hcp inflated right'); +o2 = addblobs(o2, surf_stat, 'colormap', 'hot'); % paints the fs_LR meshes directly +% equivalently, add + paint the object's own matching surfaces in one call: +o2 = surface(o2, surf_stat); +``` + +`addblobs` (and `surface(o2, obj)`) detect the `fmri_surface_data` and paint matching +cortical meshes **directly from the per-vertex data** (no volume resampling), using +the same central `canlab_colormap` value→color map as montages, so colors match. Each +hemisphere shows its own data (resolved by tag, falling back to vertex x-position when +`addbrain` relabels the `foursurfaces` patches). The layer participates in +`set_colormap` / `set_opacity` / `rethreshold` / `removeblobs` / the controller like a +volume layer (`rethreshold` on a surface layer is a magnitude cutoff applied per +vertex — no volume/`volInfo` needed). + +**Rendering onto any surface just works.** If the surface(s) you render onto are a +*different* recognized standard mesh than the data (e.g. fs_LR data on an fsaverage +`foursurfaces_freesurfer` surface, or vice versa), the data is **automatically +resampled** to that mesh's space (`resample_surface`, nearest for render speed) and +painted. If the surface is an *arbitrary* MNI isosurface with no spherical +registration (e.g. `addbrain('hires left')`, `'cutaway'`, or a pial mesh), the data +is instead **projected to a volume** (`to_display_volume`: cortex resampled to +fsaverage + `surf2vol`, merged with any subcortex) and the mesh is coloured by +sampling that volume at its vertices — cortex *and* subcortex show up. Both the +resampled data and the projected volume are **cached on the layer**, so +`set_colormap` / `rethreshold` / `set_opacity` re-renders stay fast. A surface is +only left unpainted (with a `spacemismatch` warning) if the projection genuinely +fails. + +**Mixed grayordinate objects (cortex + subcortex).** For an object that also has +subcortical voxels (a `91k`-style dscalar), `surface(obj)` adds a *second* managed +layer: the subcortical grayordinates go in as a standard **volume layer**, which paints +the subcortical anatomical meshes the `foursurfaces` set draws (thalamus, brainstem, +cerebellum, …) and is confined to them (it does not bleed onto the cortical meshes / +medial wall). Both layers respond to `set_colormap` / `rethreshold` / `set_opacity` / +the controller together. `montage(o2, obj)` builds the slice montage and renders those +same subcortical grayordinates on the slices (cortical surface data has no volume and +is not shown on slices — use `surface(obj)` for the cortex). + +## Parcellation and regions + +| Method | From | One-liner | +|---|---|---| +| `apply_parcellation` | `@fmri_surface_data` | Parcel means `[nMaps × nParcels]` from a `.dlabel` object or key vector (optional `'area'` weighting) | +| `surface_region` | `@fmri_surface_data` | Summarize contiguous clusters as region-like structs (`.struct`, `.XYZmm`, `.numVox`, `.val`, …) | + +## Volume-only methods (redirected or masked) + +`fmri_surface_data` is a full subclass of `image_vector`, but many `image_vector` +methods assume a single 3-D volume and are not meaningful for surface/grayordinate +data. These are handled so you get useful behavior or a clear message instead of a +cryptic error, and so `methods(obj)` stays clean: + +- **Redirected to the subcortical volume:** `orthviews`, `montage`, `slices` route + the subcortical grayordinates through `to_fmri_data` and call the `fmri_data` + method (so you see the subcortex on slices). For a cortex-only object they give a + clear error pointing to `surface(obj)`. +- **Masked (hidden + informative error):** volume-only methods with no surface + meaning — e.g. `flip`, `isosurface`, `interpolate`, `resample_space`, + `extract_gray_white_csf`, `searchlight`, `slice_movie`, `trim_mask`, + `read_from_file`, `extract_roi_averages`, … — are overridden in a `methods + (Hidden)` block. They no longer appear in `methods(obj)` / tab-completion; calling + one raises `fmri_surface_data:unsupportedMethod` with a pointer to the surface + equivalent (e.g. `apply_parcellation` instead of `extract_roi_averages`). The + object remains an `image_vector` subclass — only these specific methods are masked. + +## Surface data in Neuroimaging_Pattern_Masks + +CanlabCore ships one small continuous sample map +(`Sample_datasets/CIFTI_surface_examples/S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii`). +**Surface atlases and additional CIFTI maps live in the companion +`Neuroimaging_Pattern_Masks` (NPM) repository**, a standard CANlab dependency +(add it with `canlab_toolbox_setup`). Once on the path, load any of them by +filename, e.g. `fmri_surface_data(which('transcriptomic_gradients.dscalar.nii'))`. +Summary of what's available (representative files per family): + +| Atlas / map family | Type | Space | Notes | +|---|---|---|---| +| **Gordon-333 + Tian S1–S4** (`Gordon333.32k_fs_LR_Tian_Subcortex_S*.dlabel.nii`) | `.dlabel` | fs_LR-32k, 91k | Gordon cortical parcels + Tian subcortex (4 subcortical scales) | +| **Schaefer-400 + Tian S1–S4** (`Schaefer2018_400Parcels_{7,17}Networks_..._S*.dlabel.nii`) | `.dlabel` | fs_LR-32k, 91k | Schaefer 400 (7 or 17 networks) + Tian subcortex | +| **HCP-MMP (Glasser) + Tian S1–S4** (`Q1-Q6_...CorticalAreas..._Tian_Subcortex_S*.dlabel.nii`) | `.dlabel` | fs_LR-32k, 91k | 360 HCP-MMP areas + Tian subcortex | +| **Glasser-2016 (HCP-MMP 360)** (`Glasser_2016.32k.{L,R}.label.gii`) | `.label.gii` | fs_LR-32k | Cortex only, per hemisphere | +| **JulichBrain 3.0.3** (`{lh,rh}.JulichBrainAtlas_3.0.3[.fslr_32k].label.gii`) | `.label.gii` | fsaverage & fs_LR-32k | Cytoarchitectonic cortex, per hemisphere | +| **CANLab2024 (coarse)** (`openCANLab2024_..._coarse_2mm.dlabel.nii`, `CANLab2024_fsaverage-164k_hemi-{L,R}_coarse.label.gii`) | `.dlabel` / `.label.gii` | MNI152NLin6Asym 91k / fsaverage-164k | CANLab whole-brain atlas | +| **CIT168 amygdala nuclei** (`CIT168_AmyNuc.dlabel.nii`) | `.dlabel` | subcortical | Amygdala subnuclei | +| **Transcriptomic gradients** (`transcriptomic_gradients.dscalar.nii`) | `.dscalar` | fs_LR-32k, 91k | Gene-expression gradient maps (continuous) | +| **HCP group ICAs** (`hcp_d{15,25,50}_ICs.dscalar.nii`) | `.dscalar` | fs_LR-32k, 91k | Resting-state group ICA component maps | +| **HCP spectral bases** (`spectral_bases_200.dscalar.nii`) | `.dscalar` | fs_LR-32k, 91k | Spatial-basis functions | + +(Grayordinate parcellations = cortex surface + subcortical volume; `.label.gii` +files are cortex-only, per hemisphere. Paths are under +`Neuroimaging_Pattern_Masks/Atlases_and_parcellations/` and +`.../spatial_basis_functions/`.) + +## Native I/O functions (`CanlabCore/Surface_tools/`) + +Standalone functions backing the object; usable directly, no external toolbox. + +| Function | Purpose | +|---|---| +| `canlab_read_cifti` / `canlab_write_cifti` | Native CIFTI-2 reader / writer | +| `canlab_read_gifti` / `canlab_write_gifti` | Native GIFTI reader / writer | +| `canlab_surface_vertexcolors` | Map values → truecolor (split hot/cool; NaN/zero = gray) | +| `canlab_cbig_warp_path` | Resolve the vendored CBIG registration-fusion warp paths | diff --git a/docs/workflows/fmri_surface_atlas.png b/docs/workflows/fmri_surface_atlas.png new file mode 100644 index 00000000..ba54c097 Binary files /dev/null and b/docs/workflows/fmri_surface_atlas.png differ diff --git a/docs/workflows/fmri_surface_data_howto.md b/docs/workflows/fmri_surface_data_howto.md new file mode 100644 index 00000000..ca40a3f7 --- /dev/null +++ b/docs/workflows/fmri_surface_data_howto.md @@ -0,0 +1,235 @@ +# Surface & grayordinate data with `fmri_surface_data` — how-to + +This walkthrough introduces **`fmri_surface_data`**, the CANlab object for +cortical-surface and grayordinate (HCP CIFTI) data. It is the surface analogue +of `fmri_data`: data live in a flat `[grayordinates × maps]` `.dat` matrix, and +the familiar method names (`surface`, `threshold`, `mean`, `apply_parcellation`, +`write`, …) work on surface data. + +By the end you will be able to: + +- load CIFTI/GIFTI surface data and **visualize it on the cortical surface**, +- **map a volumetric `fmri_data` result onto the surface** with object methods, +- **bring surface data back to a volumetric `fmri_data`** (and write NIfTI), +- and use the core `fmri_surface_data` methods. + +Everything runs natively in MATLAB — no gifti toolbox, FieldTrip, Connectome +Workbench, or FreeSurfer is required. + +**See also:** the full method reference, +[`fmri_surface_data_methods.md`](../fmri_surface_data_methods.md); the runnable +script [`CanlabCore/docs/fmri_surface_data_walkthrough.m`](../../CanlabCore/docs/fmri_surface_data_walkthrough.m). + +## Quick reference + +```matlab +s = fmri_surface_data(which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii')); % load CIFTI +o2 = surface(s); % render on native fs_LR surface -> managed fmridisplay +o2 = set_colormap(o2, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]); % recolor live +r = reconstruct_image(s); % dense vertex arrays + subcortex volume +vol = to_fmri_data(s); % subcortex -> fmri_data (writeable .nii) +ssurf = vol2surf(ttest(load_image_set('emotionreg'))); % volume result -> surface +back = surf2vol(ssurf); % surface -> MNI volume (fmri_data) +write(s, 'out.dscalar.nii'); % write CIFTI natively +``` + +## Setup + +You need CanlabCore on your path (`canlab_toolbox_setup`), which puts +`@fmri_surface_data/`, `Surface_tools/`, and the sample data under +`CanlabCore/Sample_datasets/CIFTI_surface_examples/` on the path. The +volume→surface example also uses the built-in `emotionreg` dataset. + +CanlabCore ships one small continuous CIFTI example (see that folder's `README.md`): + +- `S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii` — HCP group cortical myelin map + (continuous; 59,412 fs_LR-32k cortical grayordinates). + +**Surface atlases and other CIFTI maps** used below (e.g. the Gordon+Tian +parcellation, the transcriptomic gradients) come from the companion +**`Neuroimaging_Pattern_Masks`** repository — a standard CANlab dependency that +`canlab_toolbox_setup` adds to the path. See the inventory table in +[`fmri_surface_data_methods.md`](../fmri_surface_data_methods.md#surface-data-in-neuroimaging_pattern_masks). +The atlas examples below are wrapped in `if ~isempty(which(...))` so the +walkthrough still runs if NPM is not installed. + +## Section A — Load surface data and visualize it + +The constructor auto-detects CIFTI (`.dscalar/.dtseries/.dlabel.nii`) and GIFTI +(`.surf/.func/.shape/.label.gii`) by extension and reads them natively. + +```matlab +s = fmri_surface_data(which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii')); + +s.imagetype % 'dscalar' +s.surface_space % 'fsLR_32k' +size(s.dat) % [59412 1] (grayordinates x maps) +``` + +`surface` loads the bundled mesh that matches the object's `surface_space` and +colors the vertices directly — no resampling. The default is a 4-panel figure +(left/right × lateral/medial); the medial wall renders gray. + +```matlab +surface(s, 'pos_colormap', hot(256)); +``` + +![HCP myelin map on the fs_LR surface](fmri_surface_myelin.png) + +You can pick a different map (`'which_image'`), color range (`'clim'`), colormap, +or surface (`'surftype'`, e.g. `'midthickness'`). The object also renders on any +existing surface — see Section D. + +## Section B — The grayordinate model + +`brain_model` mirrors the CIFTI BrainModels: one entry per cortical hemisphere +(surface vertices) and per subcortical structure (voxels). It plays the role that +`volInfo` plays for `fmri_data`. + +```matlab +% Gordon+Tian atlas ships with Neuroimaging_Pattern_Masks (a CANlab dependency): +atl = fmri_surface_data(which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii')); + +atl.brain_model.grayordinate_type % '91k' +numel(atl.brain_model.models) % 21 (2 cortex + 19 subcortical) +cellfun(@(m) m.struct, atl.brain_model.models, 'UniformOutput', false)' +``` + +`reconstruct_image` unpacks the flat `.dat` into its natural spatial forms — dense +per-hemisphere vertex arrays (medial wall = `NaN`) and a subcortical volume: + +```matlab +r = reconstruct_image(atl); +size(r.cortex_left) % [32492 x 1] dense vertices +size(r.volume) % [X Y Z x 1] subcortical volume +``` + +## Section C — Volume → surface (and back) + +Project any volumetric `fmri_data` / `statistic_image` (in MNI152) onto the +cortical surface with `vol2surf`. **`vol2surf` *is* the CBIG registration-fusion +mapper, natively** — it uses the vendored CBIG RF-ANTs MNI152↔fsaverage warp +(Wu et al. 2018, *Human Brain Mapping*) and is a line-for-line reimplementation of +CBIG's `CBIG_RF_projectMNI2fsaverage.m` (same warp data, same `interpn` sampling), +driven by SPM's affine instead of FreeSurfer's `MRIread`, so **no FreeSurfer or +Connectome Workbench is needed**. `surf2vol` is the native inverse using the same +warp. Here we project a group t-map: + +```matlab +img = load_image_set('emotionreg'); % 30 contrast images (fmri_data) +t = ttest(img); % volumetric statistic_image +ssurf = vol2surf(t); % -> fmri_surface_data (fsaverage_164k) +surface(ssurf, 'clim', [-6 6]); +``` + +![emotionreg group t-map projected to the surface](fmri_surface_vol2surf.png) + +`surf2vol` is the self-consistent inverse — surface data back to an MNI `fmri_data` +volume, which you can montage, threshold, or write to NIfTI: + +```matlab +backvol = surf2vol(ssurf); % -> fmri_data in MNI 2 mm +% backvol.fullpath = fullfile(pwd,'surf_back.nii'); write(backvol); +``` + +For the **subcortical (volumetric) grayordinates** of a native CIFTI object, use +`to_fmri_data`, which returns them directly as an `fmri_data` in MNI space: + +```matlab +subctx = to_fmri_data(atl); % 31,870 subcortical voxels as fmri_data +% subctx.fullpath = fullfile(pwd,'subctx.nii'); write(subctx); +``` + +## Section D — Render on any existing surface + +Because `addbrain('hcp inflated')` is the fs_LR-32k template and +`addbrain('inflated')` is fsaverage, an object's data can be painted directly onto +those meshes. For a *different* surface (e.g. an MNI pial surface), the data is +projected through a volume automatically: + +```matlab +% Native mesh you already created (direct, no resampling): +h = addbrain('hcp inflated left'); +surface(s, 'existingsurface', h); + +% An addbrain MNI surface (auto volume projection): +surface(ssurf, 'mni_surface', 'left'); +``` + +## Section E — Parcellation, thresholding, and clusters + +A surface atlas is simply a `.dlabel` `fmri_surface_data`. `apply_parcellation` +averages each map within its parcels (background / medial wall excluded). The data +and the atlas must be on the **same grayordinate space** (`compare_space == 0`). +Here we load the Gordon+Tian atlas (from `Neuroimaging_Pattern_Masks`) and +parcellate a continuous map built on its own grayordinate set: + +```matlab +atl = fmri_surface_data(which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii')); + +mydata = atl; % same grayordinate space as the atlas +mydata.dat = single(sqrt(double(atl.dat))); % any continuous map on those grayordinates +mydata.imagetype = 'dscalar'; + +[parcel_means, labels] = apply_parcellation(mydata, atl); % [nMaps x nParcels] +``` + +Threshold (raw value, with optional cluster extent), then summarize contiguous +clusters as region-like structs. `ssurf` is the surface t-map from Section C: + +```matlab +tthr = threshold(ssurf, 3, 'positive', 'k', 20); % t > 3, clusters >= 20 grayordinates +reg = surface_region(tthr); +[reg.numVox] % cluster sizes +``` + +![Gordon+Tian atlas on the surface](fmri_surface_atlas.png) + +## Section F — Group analysis and writing + +Concatenate per-map/per-subject objects, run analyses, and write results. Here we +build a small "group" by projecting individual `emotionreg` contrast images to the +surface (`img` is the `fmri_data` loaded in Section C): + +```matlab +subj = cell(1, 5); +for i = 1:5 + subj{i} = vol2surf(get_wh_image(img, i)); % each subject's contrast on the surface +end +group = cat(subj{:}); % one object, 5 maps (or [subj{:}]) + +tmap = ttest(group); % grayordinate-wise one-sample t-test + +group.X = [ones(5,1), (1:5)']; % set the design BEFORE regress +b = regress(group); % OLS; betas in b.dat, t/p in additional_info + +group.Y = (1:5)'; % set the outcome BEFORE predict +[cverr, stats] = predict(group, 'algorithm_name', 'cv_lassopcr', 'nfolds', 5); + +write(tmap, 'group_t.dscalar.nii'); % native CIFTI +write(ssurf, 'emo_surface.func.gii'); % native GIFTI +``` + +## Notes + +- **Spaces.** Native CIFTI is `fsLR_32k`; `vol2surf` produces `fsaverage_164k`. + These have different mesh topologies and cannot be combined without resampling + (an fsaverage↔fs_LR deformation is a planned enhancement). +- **Group-template mapping (this is the CBIG RF mapper).** `vol2surf`/`surf2vol` + are native reimplementations of the CBIG Registration-Fusion (RF-ANTs) MNI152↔ + fsaverage mappers (Wu et al. 2018) — `vol2surf` ≡ `CBIG_RF_projectMNI2fsaverage`, + using the identical vendored warp — so you are already using CBIG registration + fusion, no FreeSurfer/CBIG toolbox required. It is a fixed group MNI152↔fsaverage + correspondence (correct for group MNI maps; not a per-subject ribbon mapper). + CBIG's heavier `fsaverage2Vol` ribbon-fill script needs FreeSurfer + the CBIG + MARS toolbox + external mask geometry (not bundled), so it is not wired in; the + native `surf2vol` scatter is used instead. +- **No external toolbox** is required at runtime (sole exception: `ica`, which + needs the GIFT/`icatb` toolbox like the base `image_vector` method). + +## See also + +- [`fmri_surface_data_methods.md`](../fmri_surface_data_methods.md) — full method / option reference +- [`Object_methods.md`](../Object_methods.md) — all CanlabCore object classes +- [`CanlabCore/docs/fmri_surface_data_walkthrough.m`](../../CanlabCore/docs/fmri_surface_data_walkthrough.m) — the runnable script version +- [`CanlabCore/docs/fmri_surface_data_design_plan.md`](../../CanlabCore/docs/fmri_surface_data_design_plan.md) — design rationale and roadmap diff --git a/docs/workflows/fmri_surface_myelin.png b/docs/workflows/fmri_surface_myelin.png new file mode 100644 index 00000000..e7533e71 Binary files /dev/null and b/docs/workflows/fmri_surface_myelin.png differ diff --git a/docs/workflows/fmri_surface_vol2surf.png b/docs/workflows/fmri_surface_vol2surf.png new file mode 100644 index 00000000..b2b523d6 Binary files /dev/null and b/docs/workflows/fmri_surface_vol2surf.png differ diff --git a/docs/workflows/load_image_set_howto.md b/docs/workflows/load_image_set_howto.md new file mode 100644 index 00000000..83658628 --- /dev/null +++ b/docs/workflows/load_image_set_howto.md @@ -0,0 +1,168 @@ +# Loading and using image sets with `load_image_set` — how-to + +`load_image_set` is the central registry that turns a short **keyword** into a +loaded brain-image object. It resolves the files on your MATLAB path (mostly from +the CANlab `Neuroimaging_Pattern_Masks` repo), loads them, and hands you back an +object ready to analyze: + +- most keywords return an **`fmri_data`** object; +- surface / grayordinate (CIFTI) map sets return an **`fmri_surface_data`** object. + +This page shows how to discover what is available, load it, and use it — +applying signatures, computing similarity to networks and topics, and dual +regression. + +Prerequisites: CanlabCore **and** `Neuroimaging_Pattern_Masks` on your path +(`canlab_toolbox_setup`). SPM is needed for volume I/O. + +--- + +## 1. Discover what you can load: `load_image_set('list')` + +Start here. With no data, just run: + +```matlab +load_image_set('list') +``` + +This prints a series of **categorized tables** to the screen, each under a +descriptive title: + +| Table | What's in it | Returns | +|---|---|---| +| **Multivariate signatures** | Predictive patterns (NPS, SIIPS, PINES, VPS, …) with domain flags (pain, negemo, reward, …) | `fmri_data` | +| **Person-level datasets** | Subject-level sample data (`emotionreg`, `bmrk3`, `kragel270`, …) | `fmri_data` | +| **Network, ICA & topic maps** | `bucknerlab`, `neurosynth_topics_fi/ri`, `bgloops`, HCP group-ICA, … | `fmri_data` / `fmri_surface_data` | +| **Surface / grayordinate (CIFTI) map sets** | `hcp_ica15/25/50`, `spectral_bases` | `fmri_surface_data` | +| **Gradients & spatial-basis maps** | `transcriptomic_gradients`, `marg`, `mito_maps` | `fmri_data` | +| **Meta-analysis, receptor & curated-domain** | `emometa`, `pet` (Hansen), `kragelemotion`, `kragelschemas` | `fmri_data` | + +`'list'` also **returns a struct** whose fields are those tables, so you can +query them in code: + +```matlab +tmp = load_image_set('list'); +tmp.surface % table of CIFTI map sets +tmp.signatures.keyword % all signature keywords +tmp.signatures(tmp.signatures.pain==1, :) % just the pain-related signatures +``` + +Load anything from the list by its keyword: `obj = load_image_set('')`. + +--- + +## 2. Load a person-level dataset + +```matlab +data = load_image_set('emotionreg'); % ~30 subjects, reappraise-vs-look contrasts +descriptives(data); % quick QC +t = ttest(data); % one-sample t across subjects -> statistic_image +``` + +`data` is a standard `fmri_data` object — use it with `ttest`, `predict`, +`regress`, `apply_atlas`, `montage`, etc. + +--- + +## 3. Apply a signature to your data + +A **signature** is a weight map; applying it computes a per-image score (dot +product = "pattern expression", or a correlation). Load the signature with +`load_image_set` and apply it with `apply_mask`: + +```matlab +data = load_image_set('emotionreg'); +pines = load_image_set('pines'); % negative-emotion signature + +% Dot-product pattern expression (one value per image/subject): +pe = apply_mask(data, pines, 'pattern_expression', 'ignore_missing'); + +% Or the spatial correlation with the pattern instead of the dot product: +r = apply_mask(data, pines, 'pattern_expression', 'ignore_missing', 'correlation'); +``` + +To apply **several** signatures and compare, load them as a set and loop, or use +`image_similarity_plot` (next section), which is built for map sets. + +--- + +## 4. Compare your data to networks and topics (similarity) + +`image_similarity_plot` computes the similarity (correlation or cosine) between +your image(s) and every map in a **map set**, and plots a wedge/polar summary. The +map set can be a `load_image_set` keyword or an `fmri_data` you pass in: + +```matlab +t = ttest(load_image_set('emotionreg')); + +% Similarity to the 7 Yeo/Buckner resting-state networks: +stats = image_similarity_plot(t, 'bucknerlab', 'cosine_similarity'); + +% Similarity to Neurosynth topic maps (reverse inference): +topics = load_image_set('neurosynth_topics_ri'); +stats = image_similarity_plot(t, 'mapset', topics, 'cosine_similarity', 'plotstyle', 'polar'); +``` + +`stats.r` (or `.cosine_sim`) holds the per-map similarity values you can pull into +a table or figure of your own. + +--- + +## 5. Dual regression with networks / ICA components + +**Dual regression** takes a set of group spatial maps (e.g. ICA components or +networks) and, for each subject's **4-D time series**, (1) spatially regresses the +maps into the data to get component time courses, then (2) temporally regresses +those time courses back into the data to get subject-specific spatial maps. + +```matlab +% Group maps: HCP resting-state ICA (surface) or a volumetric network set. +gmaps = load_image_set('bucknerlab'); % fmri_data (7 networks), or: +% gmaps = load_image_set('hcp_ica25'); % fmri_surface_data (25 components) + +subj = fmri_data('sub-01_task-rest_bold.nii.gz'); % your 4-D time series + +[spatial_maps, timecourses, tmaps] = dual_regression(gmaps, subj); +% spatial_maps : subject-specific spatial map per component (fmri_data) +% timecourses : component x timepoint matrix +% tmaps : z-scored spatial maps +``` + +`dual_regression` resamples the data into the group-map space for you. For the +volumetric FSL-style engine directly, see `dual_regression_fsl`. + +--- + +## 6. Surface / grayordinate (CIFTI) map sets + +Surface map sets load natively as `fmri_surface_data` (no external toolbox): + +```matlab +ica = load_image_set('hcp_ica25'); % 25 HCP group-ICA components, 91k grayordinates +ica % fmri_surface_data, size(ica.dat) = [91282 x 25] + +o2 = surface(get_wh_image(ica, 1)); % render component 1 on the cortical surface +o2 = set_colormap(o2, 'colormap', hot(256)); + +bases = load_image_set('spectral_bases'); % 200 spectral basis functions +``` + +Everything on the [`fmri_surface_data` how-to](fmri_surface_data_howto.md) applies: +`surface`, `montage` (subcortex on slices), `apply_parcellation`, `threshold`, +`vol2surf` / `surf2vol`, group analyses, etc. + +--- + +## New keywords added + +| Keyword(s) | Collection | Returns | +|---|---|---| +| `hcp_ica15` / `hcp_ica25` / `hcp_ica50` (aliases `hcp15`…) | HCP resting-state group-ICA components (15/25/50), 91k grayordinates | `fmri_surface_data` | +| `spectral_bases` (alias `hcp_bases`) | 200 spectral (Laplacian eigenmap) basis functions, 91k | `fmri_surface_data` | +| `mito_maps` (alias `mito`) | Mitochondrial energetic-capacity maps (CI, CII, CIV, MitoD, MRC, TRC) | `fmri_data` | + +## See also + +- [`fmri_data` methods](../fmri_data_methods.md), [`fmri_surface_data` methods](../fmri_surface_data_methods.md) +- [`load_atlas`](../atlases_regions_and_patterns.md) — the parallel registry for **parcellations** (run `load_atlas('list')` to see keywords) +- `image_similarity_plot`, `apply_mask`, `dual_regression`