diff --git a/CanlabCore/@fmri_surface_data/apply_mask.m b/CanlabCore/@fmri_surface_data/apply_mask.m new file mode 100644 index 00000000..801d880d --- /dev/null +++ b/CanlabCore/@fmri_surface_data/apply_mask.m @@ -0,0 +1,55 @@ +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, 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:** one of +% - logical/numeric vector [nGrayordinates x 1] (nonzero = keep) +% - another fmri_surface_data on the same space (nonzero/non-NaN = keep) +% +% :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')); + +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..79a47aa2 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/apply_parcellation.m @@ -0,0 +1,138 @@ +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))); + if ~isempty(parcels.label_table) + for i = 1:numel(parcels.label_table) + names_by_key(parcels.label_table(i).key) = parcels.label_table(i).name; + end + 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..c0e69817 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/fmri_surface_data.m @@ -0,0 +1,371 @@ +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: cortical mesh(es) for rendering/area (lazy). +% intent 'dscalar'|'dtseries'|'dlabel'|'func'|'shape'|'label'. +% series_info struct for .dtseries (start/step/unit/exponent). +% label_table struct array (.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 = []; + + % Cortical mesh cache (faces/vertices per hemisphere) for rendering and + % surface-area computations. Loaded lazily from bundled assets. + geom = []; + + % CIFTI/GIFTI intent: dscalar | dtseries | dlabel | func | shape | label + intent = ''; + + % For .dtseries: struct with .start/.step/.unit/.exponent + series_info = []; + + % For .dlabel/.label: struct array with fields .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 + +end % classdef + + +% ========================================================================= +% 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.intent = 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 = 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.intent = 'func'; + if isfield(g, 'labels') && ~isempty(g.labels) + obj.label_table = g.labels; + obj.intent = '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.intent, 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/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/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/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..f806265b --- /dev/null +++ b/CanlabCore/@fmri_surface_data/private/obj_to_volume.m @@ -0,0 +1,27 @@ +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. +% +% - fsaverage_164k cortex -> surf2vol (CBIG RF) to MNI 2 mm. +% - volume-only grayordinate -> to_fmri_data (subcortex). +% - other spaces (e.g. fsLR_32k cortex) are not yet supported here. +% +% :See also: surf2vol, to_fmri_data, surface, render_on_surface + +if strcmp(obj.surface_space, 'fsaverage_164k') + vol = surf2vol(obj); +elseif ~isempty(obj.brain_model) && any(cellfun(@(m) strcmp(m.type,'vox'), obj.brain_model.models)) ... + && ~any(cellfun(@(m) strcmp(m.type,'surf'), obj.brain_model.models)) + vol = to_fmri_data(obj); +else + error('fmri_surface_data:obj_to_volume:space', ... + ['Cannot project surface_space ''%s'' to a volume for rendering on an arbitrary ' ... + 'MNI surface. Supported: fsaverage_164k (via surf2vol) or volume-only objects ' ... + '(via to_fmri_data). Render fsLR_32k data on its native mesh instead, or pass ' ... + 'an fsaverage object.'], obj.surface_space); +end +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..4984bf19 --- /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.intent = '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..a2984f66 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/render_on_surface.m @@ -0,0 +1,102 @@ +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, 'clim', [-3 3], 'which_image', 2) +% +% 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 at the patch +% vertices. +% +% :Inputs: +% **obj:** an fmri_surface_data object. +% **surface_handles:** vector of patch handles (e.g. from addbrain or surface). +% +% :Optional Inputs: +% **'which_image':** map (column) to render. Default 1. +% **'clim':** [lo hi] color limits. Default symmetric from the data. +% **'pos_colormap' / 'neg_colormap':** [n x 3] colormaps (default hot / cool). +% Other options are passed through to image_vector.render_on_surface on the +% volume fallback path. +% +% :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 = []; +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', clim = varargin{i+1}; i = i + 2; continue + case 'pos_colormap', poscm = varargin{i+1}; i = i + 2; continue + case 'neg_colormap', negcm = varargin{i+1}; i = i + 2; continue + 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 + +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, clim, poscm, negcm); + elseif nv == nR && (isRight || ~isempty(Rdat)) + local_apply(hp, Rdat, clim, poscm, negcm); + else + % Arbitrary surface: project to volume and reuse image_vector renderer + vol = obj_to_volume(obj); + render_on_surface(vol, surface_handles, passthrough{:}); + out_handles = surface_handles; + return + end +end + +out_handles = surface_handles; +end + + +% ------------------------------------------------------------------------- +function local_apply(hp, vals, clim, poscm, negcm) +rgb = canlab_surface_vertexcolors(vals, clim, poscm, negcm); +set(hp, 'FaceVertexCData', rgb, 'FaceColor', 'interp', 'EdgeColor', 'none', 'FaceAlpha', 1); +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/surf2vol.m b/CanlabCore/@fmri_surface_data/surf2vol.m new file mode 100644 index 00000000..5f1f96d0 --- /dev/null +++ b/CanlabCore/@fmri_surface_data/surf2vol.m @@ -0,0 +1,131 @@ +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.). Uses the same vendored CBIG RF-ANTs +% MNI152<->fsaverage per-vertex coordinates as vol2surf, scattering each vertex's +% value into its MNI voxel and averaging co-located vertices (accumarray). Fully +% native -- no FreeSurfer / Connectome Workbench. +% +% This is the inverse of vol2surf and is self-consistent with it. 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. +% +% :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(fmri_data(which('weights_NSF_grouppred_cvpcr.img'))); +% 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..6c974b6f --- /dev/null +++ b/CanlabCore/@fmri_surface_data/surface.m @@ -0,0 +1,123 @@ +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): loads the bundled mesh that matches the object's +% surface_space (fs_LR-32k or fsaverage-164k) and colors vertices DIRECTLY +% from the data -- no resampling. Produces a 4-panel figure (L/R x +% lateral/medial). The medial wall and zero values render gray. +% +% 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. +% **'clim':** [lo hi] color limits (default symmetric from data). +% **'pos_colormap' / 'neg_colormap':** [n x 3] colormaps (default hot / cool). +% **'existingsurface':** vector of patch handles to color. +% **'mni_surface':** an addbrain keyword (string). +% +% :Outputs: +% **han:** struct with fields .figure, .axes, .surfaces (graphics handles). +% +% :Examples: +% :: +% s = fmri_surface_data(which('transcriptomic_gradients.dscalar.nii')); +% surface(s, 'which_image', 1); +% +% v = fmri_data(which('weights_NSF_grouppred_cvpcr.img')); +% 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; +clim = []; +poscm = []; +negcm = []; +existing = []; +mni_surface = ''; + +i = 1; +while i <= numel(varargin) + switch lower(varargin{i}) + case 'surftype', surftype = varargin{i+1}; i = i + 2; + case 'which_image', which_image = varargin{i+1}; i = i + 2; + case 'clim', clim = varargin{i+1}; i = i + 2; + case 'pos_colormap', poscm = varargin{i+1}; i = i + 2; + case 'neg_colormap', negcm = 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; + otherwise + error('fmri_surface_data:surface:badopt', 'Unknown option: %s', num2str(varargin{i})); + end +end + +ropts = {'which_image', which_image, 'clim', clim, 'pos_colormap', poscm, 'neg_colormap', negcm}; + +% ---- 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 4-panel render ---- +geom = load_surface_geom(obj.surface_space, surftype); + +panels = { 'lh', [270 0], 1; ... % left lateral + 'lh', [90 0], 2; ... % left medial + 'rh', [90 0], 3; ... % right lateral + 'rh', [270 0], 4 }; % right medial + +fig = figure('Color', 'w', 'Name', sprintf('fmri_surface_data: %s (%s)', obj.surface_space, surftype)); +surfaces = gobjects(1, 4); +axes_h = gobjects(1, 4); +for p = 1:4 + ax = subplot(2, 2, panels{p, 3}); + hold(ax, 'on'); + if strcmp(panels{p, 1}, 'lh') + V = geom.vertices_lh; F = geom.faces_lh; tag = 'left'; + else + V = geom.vertices_rh; F = geom.faces_rh; tag = 'right'; + end + hp = patch('Parent', ax, 'Faces', F, 'Vertices', V, 'EdgeColor', 'none', ... + 'FaceColor', [.5 .5 .5], 'Tag', tag, 'SpecularStrength', .2, 'SpecularExponent', 200); + axis(ax, 'off', 'image', 'vis3d'); + view(ax, panels{p, 2}(1), panels{p, 2}(2)); + try, lightRestoreSingle(ax); catch, camlight(ax); end %#ok + material(ax, 'dull'); + surfaces(p) = hp; + axes_h(p) = ax; +end + +render_on_surface(obj, surfaces, ropts{:}); + +han = struct('figure', fig, 'axes', axes_h, 'surfaces', surfaces); +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_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..2947f77b --- /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.intent = '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..09ef1bff --- /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.intent, 'label') || strcmp(obj.intent, 'dlabel'); + if islabel + g.intents = repmat({'NIFTI_INTENT_LABEL'}, 1, size(g.cdata, 2)); + g.labels = 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.intent, 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 = 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/@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..b667b5f2 --- /dev/null +++ b/CanlabCore/@image_vector/vol2surf.m @@ -0,0 +1,90 @@ +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. Uses +% the vendored CBIG Registration-Fusion (RF-ANTs) MNI152->fsaverage mapping +% (per-vertex MNI RAS coordinates) and samples the volume at those coordinates +% with interpn. Fully native -- no FreeSurfer or Connectome Workbench required. +% +% This is the native v1 mapper. 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). +% +% :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 = fmri_data(which('weights_NSF_grouppred_cvpcr.img')); +% s = vol2surf(dat); % fsaverage surface object +% % surface(s) % (rendering: M5) +% +% :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', 'intent', '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/Cifti_plotting/plot_surface_map.m b/CanlabCore/Cifti_plotting/plot_surface_map.m index 3bb0b17b..0b1688a6 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/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..997663d4 --- /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('transcriptomic_gradients.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/canlab_run_all_tests.m b/CanlabCore/Unit_tests/canlab_run_all_tests.m index 2257e139..5e013a72 100644 --- a/CanlabCore/Unit_tests/canlab_run_all_tests.m +++ b/CanlabCore/Unit_tests/canlab_run_all_tests.m @@ -63,7 +63,9 @@ case 'include' % run everything end - suite = [suite, TestSuite.fromFile(fpath)]; %#ok + % canlab_safe_suite_from_file warn-skips files that are not valid test + % files instead of letting a NonTestFile error abort the whole run. + suite = [suite, canlab_safe_suite_from_file(fpath)]; %#ok end tag = char(p.Results.Tag); diff --git a/CanlabCore/Unit_tests/canlab_test_runner_robustness.m b/CanlabCore/Unit_tests/canlab_test_runner_robustness.m new file mode 100644 index 00000000..149254dd --- /dev/null +++ b/CanlabCore/Unit_tests/canlab_test_runner_robustness.m @@ -0,0 +1,76 @@ +function tests = canlab_test_runner_robustness +%CANLAB_TEST_RUNNER_ROBUSTNESS Discovery must not abort on a stray non-test file. +% +% canlab_run_all_tests globs every canlab_test_*.m under Unit_tests and +% concatenates the suites. A file that matches the name pattern but is not a +% valid matlab.unittest test (an old plain-assert script, or a function whose +% internal name does not match the filename) makes TestSuite.fromFile throw +% MATLAB:unittest:TestSuite:NonTestFile. Without guarding, that single error +% aborts discovery of the entire suite. canlab_safe_suite_from_file warn-skips +% such files instead; these tests pin that behavior. + +tests = functiontests(localfunctions); +end + + +function test_nontest_file_is_warn_skipped(tc) %#ok<*DEFNU> +% A canlab_test_*.m whose function name != filename and that never calls +% functiontests is not a valid test file: skip it, warn, return empty. +folder = tc.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +fpath = local_write(folder, 'canlab_test_bogus_nontest.m', { ... + 'function some_other_name()' + 'assert(true);' + 'end'}); + +% Use lastwarn rather than verifyWarning so the check does not depend on the +% ambient warning display state (a caller may have warnings off; lastwarn +% still records the id even when the warning event is not displayed). +lastwarn('', ''); +[suite, ok] = canlab_safe_suite_from_file(fpath); +[~, warn_id] = lastwarn; + +tc.verifyFalse(ok, 'expected ok=false for a non-test file'); +tc.verifyEmpty(suite, 'expected an empty suite for a non-test file'); +tc.verifyEqual(warn_id, 'canlab_run_all_tests:skippedNonTestFile', ... + 'expected a skippedNonTestFile warning'); +end + + +function test_valid_test_file_is_loaded(tc) +% A well-formed functiontests file loads normally (ok=true, non-empty suite). +folder = tc.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +fpath = local_write(folder, 'canlab_test_valid_dummy.m', { ... + 'function tests = canlab_test_valid_dummy' + 'tests = functiontests(localfunctions);' + 'end' + '' + 'function test_trivial(tc)' + 'tc.verifyTrue(true);' + 'end'}); + +[suite, ok] = canlab_safe_suite_from_file(fpath); +tc.verifyTrue(ok, 'expected ok=true for a valid test file'); +tc.verifyNotEmpty(suite); +tc.verifyEqual(numel(suite), 1); +end + + +function fpath = local_write(folder, name, lines) +% Write a cellstr of lines to folder/name and return the full path. Uses +% fopen/fprintf (not writelines) so the test runs on older MATLAB too. +fpath = fullfile(folder, name); +fid = fopen(fpath, 'w'); +tc_assert_open(fid, fpath); +cleanup = onCleanup(@() fclose(fid)); +fprintf(fid, '%s\n', lines{:}); +end + + +function tc_assert_open(fid, fpath) +if fid < 0 + error('canlab_test_runner_robustness:cannotWrite', ... + 'Could not open %s for writing.', fpath); +end +end diff --git a/CanlabCore/Unit_tests/helpers/canlab_get_dpsp_hot_warm.m b/CanlabCore/Unit_tests/helpers/canlab_get_dpsp_hot_warm.m new file mode 100644 index 00000000..e94fdd88 --- /dev/null +++ b/CanlabCore/Unit_tests/helpers/canlab_get_dpsp_hot_warm.m @@ -0,0 +1,27 @@ +function [hot, warm, ok] = canlab_get_dpsp_hot_warm() +%CANLAB_GET_DPSP_HOT_WARM Load the DPSP single-subject Hot/Warm sample maps. +% +% [hot, warm, ok] = canlab_get_dpsp_hot_warm() +% +% Returns the single-subject Hot and Warm condition maps from +% Sample_datasets/DPSP_pain_rejection_participant_maps as fmri_data objects. +% Used by the predictive_model / xval_* tests so the load semantics live in +% one place. ok is false (and hot/warm are []) if the sample files are not +% on the path, so callers can assume/skip gracefully. + +sample_dir = fullfile(fileparts(fileparts(which('fmri_data'))), ... + 'Sample_datasets', 'DPSP_pain_rejection_participant_maps'); +hot_file = fullfile(sample_dir, 'DPSP_single_subject_images_hot.mat'); +warm_file = fullfile(sample_dir, 'DPSP_single_subject_images_warm.mat'); + +ok = exist(hot_file, 'file') == 2 && exist(warm_file, 'file') == 2; +hot = []; +warm = []; + +if ok + H = load(hot_file); + W = load(warm_file); + hot = H.single_subject_images_hot; + warm = W.single_subject_images_warm; +end +end diff --git a/CanlabCore/Unit_tests/helpers/canlab_safe_suite_from_file.m b/CanlabCore/Unit_tests/helpers/canlab_safe_suite_from_file.m new file mode 100644 index 00000000..b50b122f --- /dev/null +++ b/CanlabCore/Unit_tests/helpers/canlab_safe_suite_from_file.m @@ -0,0 +1,42 @@ +function [suite, ok] = canlab_safe_suite_from_file(fpath) +%CANLAB_SAFE_SUITE_FROM_FILE Build a test suite from one file without aborting on bad files. +% +% [suite, ok] = canlab_safe_suite_from_file(fpath) +% +% Wraps matlab.unittest.TestSuite.fromFile so that a file which is not a +% valid test (e.g. a stray canlab_test_*.m that is an old plain-assert +% script or whose internal function name does not match the filename) +% does not throw and abort discovery of the whole suite. Such a file is +% warn-skipped and an empty Test array is returned instead. +% +% This matters because canlab_run_all_tests globs every canlab_test_*.m +% under Unit_tests and concatenates the results; a single NonTestFile +% error from fromFile would otherwise take down the entire run. +% +% :Inputs: +% **fpath:** absolute path to a candidate .m test file. +% +% :Outputs: +% **suite:** a matlab.unittest.Test array (empty if the file is not a +% valid test file). +% **ok:** logical, true if fromFile succeeded, false if the file was +% skipped. +% +% :See also: canlab_run_all_tests, matlab.unittest.TestSuite + +ok = true; +suite = matlab.unittest.Test.empty; + +try + suite = matlab.unittest.TestSuite.fromFile(fpath); +catch ME + ok = false; + if strcmp(ME.identifier, 'MATLAB:unittest:TestSuite:NonTestFile') + warning('canlab_run_all_tests:skippedNonTestFile', ... + 'Skipping %s: not a valid matlab.unittest test file.', fpath); + else + warning('canlab_run_all_tests:fromFileError', ... + 'Skipping %s: %s (%s)', fpath, ME.message, ME.identifier); + end +end +end diff --git a/CanlabCore/Unit_tests/image_vector/canlab_test_check_roi_extraction.m b/CanlabCore/Unit_tests/image_vector/canlab_test_check_roi_extraction.m new file mode 100644 index 00000000..3d609903 --- /dev/null +++ b/CanlabCore/Unit_tests/image_vector/canlab_test_check_roi_extraction.m @@ -0,0 +1,58 @@ +function tests = canlab_test_check_roi_extraction +%CANLAB_TEST_CHECK_ROI_EXTRACTION Multi-region ROI extraction + write/reload round-trip. +% +% Complements canlab_test_extract_roi (single-mask extraction) by exercising +% the two things that test does not: +% 1. Multi-region extraction with 'unique_mask_values' (one average per +% integer label in a parcellation), using atlas_labels_combined.img. +% 2. Invariance of the extracted region averages across a write-to-disk and +% reload cycle - a regression guard on the NIfTI I/O path. +% +% Converted from the old standalone script Unit_tests/old_to_integrate/ +% check_roi_extraction.m, which printed PASS/FAIL but never asserted. + +tests = functiontests(localfunctions); +end + + +function test_unique_mask_values_roundtrip(tc) %#ok<*DEFNU> +mask_file = which('atlas_labels_combined.img'); +tc.assumeNotEmpty(mask_file, 'atlas_labels_combined.img not on path'); + +mask_image = fmri_data(mask_file, 'noverbose'); + +% Synthetic per-region timeseries: region i carries the signal ts*sqrt(i), +% so every region has a distinct, known mean trajectory across images. +wh_region = mask_image.dat; +regions = unique(wh_region); +nimgs = 20; +ts = linspace(-10, 10, nimgs); + +dat = mask_image; +dat.dat = zeros(size(mask_image.dat, 1), nimgs); +for i = 1:numel(regions) + idx = wh_region == regions(i); + dat.dat(idx, :) = repmat(ts .* sqrt(i), sum(idx), 1); +end + +cl1 = extract_roi_averages(dat, mask_image, 'unique_mask_values'); +all_reg1 = cat(2, cl1(:).dat); + +% One row per image, one column per non-zero region label. +tc.verifyEqual(size(all_reg1, 1), nimgs); +tc.verifyGreaterThan(size(all_reg1, 2), 1); + +% Round-trip through disk in a scratch folder, then re-extract. +tc.applyFixture(matlab.unittest.fixtures.WorkingFolderFixture); +dat.fullpath = fullfile(pwd, 'test_roi_image.nii'); +write(dat, 'overwrite'); +reloaded = fmri_data(dat.fullpath, 'noverbose'); + +cl2 = extract_roi_averages(reloaded, mask_image, 'unique_mask_values'); +all_reg2 = cat(2, cl2(:).dat); + +tc.verifyEqual(size(all_reg2), size(all_reg1)); +% Small absolute slack absorbs single-precision NIfTI write rounding (the +% original script used a 1e-3 relative threshold for the same reason). +tc.verifyEqual(all_reg2, all_reg1, 'AbsTol', 1e-2, 'RelTol', 1e-3); +end diff --git a/CanlabCore/Unit_tests/image_vector/canlab_test_resampling_pattern_expression.m b/CanlabCore/Unit_tests/image_vector/canlab_test_resampling_pattern_expression.m new file mode 100644 index 00000000..79ebfebb --- /dev/null +++ b/CanlabCore/Unit_tests/image_vector/canlab_test_resampling_pattern_expression.m @@ -0,0 +1,48 @@ +function tests = canlab_test_resampling_pattern_expression +%CANLAB_TEST_RESAMPLING_PATTERN_EXPRESSION Pattern expression is stable across resampling. +% +% apply_mask(..., 'pattern_expression') resamples the weight map into the +% data space when the two differ. This test checks that the resulting +% pattern-expression scores barely change regardless of which interpolation +% method does that resampling (default vs nearest vs spline) - i.e. the +% dot-product readout is not an artifact of the interpolation choice. +% +% Uses the SIIPS signature against the emotionreg sample. Skipped if the +% SIIPS image set is not available on the path (it ships with +% Neuroimaging_Pattern_Masks). +% +% Converted from the old standalone visual script +% Unit_tests/old_to_integrate/resampling_pattern_expression_unit_test1.m, +% which only plotted the variants and asserted nothing. + +tests = functiontests(localfunctions); +end + + +function test_pattern_expression_stable_across_interp(tc) %#ok<*DEFNU> +obj = canlab_get_sample_fmri_data(); + +try + siips = load_image_set('siips', 'noverbose'); +catch ME + tc.assumeFail(['SIIPS signature not available on this runner: ' ME.message]); +end +tc.assumeNotEmpty(siips.dat, 'SIIPS loaded but empty'); + +n = size(obj.dat, 2); + +% Default path lets apply_mask resample internally; the other two pre-resample +% the weight map with an explicit interpolation method. +pe_default = apply_mask(obj, siips, 'pattern_expression'); +pe_nearest = apply_mask(obj, resample_space(siips, obj, 'nearest'), 'pattern_expression'); +pe_spline = apply_mask(obj, resample_space(siips, obj, 'spline'), 'pattern_expression'); + +for v = {pe_default, pe_nearest, pe_spline} + tc.verifyEqual(numel(v{1}), n); + tc.verifyTrue(all(isfinite(v{1})), 'pattern expression returned non-finite values'); +end + +% Empirically these correlate at >= 0.9999; 0.99 is a safe regression floor. +tc.verifyGreaterThan(corr(pe_default, pe_nearest), 0.99); +tc.verifyGreaterThan(corr(pe_default, pe_spline), 0.99); +end diff --git a/CanlabCore/Unit_tests/old_to_integrate/check_roi_extraction.m b/CanlabCore/Unit_tests/old_to_integrate/check_roi_extraction.m deleted file mode 100644 index b25734b2..00000000 --- a/CanlabCore/Unit_tests/old_to_integrate/check_roi_extraction.m +++ /dev/null @@ -1,64 +0,0 @@ -function check_roi_extraction() - -% test ROI extraction -mask_image = fmri_data(which('atlas_labels_combined.img')); -dat = mask_image; -wh_region = dat.dat; - -% create timeseries data -- add data "after" 1st image -nimgs = 50; - -n = unique(wh_region); -ts = linspace(-10, 10, nimgs); -v = size(dat.dat, 1); -dat.dat = zeros(v, nimgs); - -for i = 1:length(n) - my_indx = wh_region == n(i); - - dat.dat(my_indx, :) = repmat(ts .* sqrt(i), sum(my_indx), 1); -end - -fprintf('\nDATA GENERATED\n\n'); - -cl1 = extract_roi_averages(dat, mask_image, 'unique_mask_values'); - -% extracted average values for all the regions in mask_image. -all_reg1 = cat(2, cl1(:).dat); - -dat.fullpath = fullfile(pwd, 'test_image.img'); -write(dat); -fprintf('\nSAVED DATA TO %s\n\n', dat.fullpath); - -% reload -fprintf('\nLOADING DATA FROM %s\n\n', dat.fullpath); -dat = fmri_data(dat.fullpath, which('gray_matter_mask.img')); - -% region obj -> extract_data -% test extract_roi_averages -cl2 = extract_roi_averages(dat, mask_image, 'unique_mask_values'); - -% extracted average values for all the regions in mask_image. -all_reg2 = cat(2, cl2(:).dat); - -% uniq_reg = sort(unique(mask_image.dat)); - -% compare roi averages of initially data generated and data loaded from file -if is_equal(all_reg1, all_reg2, 0.001) - fprintf('\nExtracted ROI averages are equal with an error threshold of 0.001\nPASS\n'); -else - fprintf('\nExtracted ROI averages are not equal with an error threshold of 0.001\nFAIL\n'); -end - - -% ------------------------------------------------------ -% INLINE FUNCTION -% ------------------------------------------------------ - - function res = is_equal(A, B, error_threshold) - err = abs((A-B)./B) <= error_threshold; - - res = all(err(:)); - end % is_equal - -end % main function \ No newline at end of file diff --git a/CanlabCore/Unit_tests/old_to_integrate/resampling_pattern_expression_unit_test1.m b/CanlabCore/Unit_tests/old_to_integrate/resampling_pattern_expression_unit_test1.m deleted file mode 100644 index b7f654e0..00000000 --- a/CanlabCore/Unit_tests/old_to_integrate/resampling_pattern_expression_unit_test1.m +++ /dev/null @@ -1,40 +0,0 @@ -function resampling_pattern_expression_unit_test1 - -obj = load_image_set('emotionreg'); % test dataset -siips = load_image_set('siips'); % sampled to higher-res mask on loading -siips_resamp = resample_space(siips, obj, 'spline'); -siips_orig = fmri_data(siips.image_names); % original pattern - -val = []; -val(:, 1) = apply_mask(obj, siips_orig, 'pattern_expression'); -val(:, 2) = apply_mask(obj, resample_space(siips_orig, obj), 'pattern_expression'); -val(:, 3) = apply_mask(obj, resample_space(siips_orig, obj, 'nearest'), 'pattern_expression'); -val(:, 4) = apply_mask(obj, resample_space(siips_orig, obj, 'spline'), 'pattern_expression'); - -val(:, 5) = apply_mask(obj, siips, 'pattern_expression'); -val(:, 6) = apply_mask(obj, resample_space(siips, obj), 'pattern_expression'); -val(:, 7) = apply_mask(obj, resample_space(siips, obj, 'nearest'), 'pattern_expression'); -val(:, 8) = apply_mask(obj, resample_space(siips, obj, 'spline'), 'pattern_expression'); - -val(:, 9) = apply_mask(obj, siips, 'pattern_expression'); -val(:, 10) = apply_mask(obj, resample_space(siips, obj), 'pattern_expression'); -val(:, 11) = apply_mask(obj, resample_space(siips, obj, 'nearest'), 'pattern_expression'); -val(:, 12) = apply_mask(obj, resample_space(siips, obj, 'spline'), 'pattern_expression'); - -val(:, 13) = apply_mask(obj, siips_resamp, 'pattern_expression'); -sdat = apply_siips(obj); -val(:, 14) = sdat{1}; - -m = mean(val); -s = std(val); -c = corr(val); - -create_figure('siips comparison', 1, 3); -errorbar(m, s); title('means and standard errors') -subplot(1, 3, 2); plot(m); title('means (zooming in on differences') -subplot(1, 3, 3); imagesc(c); colorbar -title('Correlations among variants') - -set(gca, 'YDir', 'reverse'); axis tight - -end diff --git a/CanlabCore/Unit_tests/predictive_model/canlab_test_predictive_model_api.m b/CanlabCore/Unit_tests/predictive_model/canlab_test_predictive_model_api.m new file mode 100644 index 00000000..515bf5ac --- /dev/null +++ b/CanlabCore/Unit_tests/predictive_model/canlab_test_predictive_model_api.m @@ -0,0 +1,311 @@ +function tests = canlab_test_predictive_model_api +%CANLAB_TEST_PREDICTIVE_MODEL_API End-to-end test of the @predictive_model public API. +% +% Converted from Unit_tests/predictive_model_unit_test.m. Exercises the +% sklearn-style @predictive_model object surface on the DPSP Hot vs Warm +% dataset: construction, fit/predict/score, crossval (cv_splitter), bootstrap, +% weight_map_object, permutation_test, calibrate/predict_proba, +% select_features, grid_search, stability_selection, @pipeline, +% fmri_data.predict 'newapi' routing, and the regression/report/summary path. +% Visualisation methods (plot/confusionchart/montage) are smoke-tested and +% skipped on a headless runner. Complements the xval_* wrapper tests, which +% cover the legacy entry points rather than the object's own methods. +% +% The shared fit -> crossval -> bootstrap chain is computed once in setupOnce +% and cached; method-specific tests build their own small models. Small +% nboot/nperm/grid values keep this to a few minutes; it is still much slower +% than the rest of the suite. Skipped if DPSP_hotwarm is not on the path. + +tests = functiontests(localfunctions); +end + + +% ===================================================================== +% Fixtures +% ===================================================================== + +function setupOnce(tc) %#ok<*DEFNU> +tc.assumeNotEmpty(which('load_image_set'), 'load_image_set not on path'); +try + hw_obj = load_image_set('DPSP_hotwarm', 'noverbose'); +catch ME + tc.assumeFail(['DPSP_hotwarm sample not available: ' ME.message]); + return +end + +% Restrict to a sparse gray-matter ROI so the many cross-validations below +% are fast: mask to gray matter, then deterministically thin to every 8th +% voxel (~20k features instead of ~195k). hw_obj and X stay in lockstep, so +% weight_map_object / montage still back-project consistently. Falls back to +% the full volume if the gray-matter mask is not on the path. +gm_file = which('gray_matter_mask.img'); +if ~isempty(gm_file) + hw_obj = remove_empty(apply_mask(hw_obj, gm_file)); + thin = get_wh_image(hw_obj, 1); + thin.dat = zeros(size(hw_obj.dat, 1), 1); + thin.dat(1:8:end) = 1; + hw_obj = remove_empty(apply_mask(hw_obj, thin)); +end + +X = double(hw_obj.dat'); +Y = hw_obj.Y; +id = grp2idx(hw_obj.metadata_table.subj_id); + +tc.TestData.hw_obj = hw_obj; +tc.TestData.X = X; +tc.TestData.Y = Y; +tc.TestData.id = id; + +% Shared expensive chain, computed once: in-sample fit, cross-validation, +% and bootstrap on the cross-validated model. +tc.TestData.pm_insample = fit( ... + predictive_model('algorithm', 'svm', 'task', 'classification', 'random_state', 0), ... + X, Y, 'id', id); + +pm_cv = predictive_model('algorithm', 'svm', 'task', 'classification', ... + 'modeloptions', {'KernelFunction', 'linear'}, 'random_state', 0); +pm_cv = crossval(pm_cv, X, Y, ... + 'cv', cv_splitter.stratified_group_kfold(5), 'groups', id, ... + 'scoring', 'balanced_accuracy'); +tc.TestData.pm_cv = pm_cv; + +% Minimum-viable nboot: just enough to compute boot std / z / p and exercise +% the empirical p-floor. This is a smoke test, not real inference. +tc.TestData.pm_boot = bootstrap(pm_cv, X, Y, 'nboot', 5, 'groups', id, 'verbose', false); +end + + +function setup(tc) +tc.TestData.PrevFigVis = get(0, 'DefaultFigureVisible'); +set(0, 'DefaultFigureVisible', 'off'); +end + + +function teardown(tc) +close all force +if isfield(tc.TestData, 'PrevFigVis') + set(0, 'DefaultFigureVisible', tc.TestData.PrevFigVis); +end +end + + +% ===================================================================== +% Construction / in-sample fit / predict / score +% ===================================================================== + +function test_construction_hyperparameters(tc) +pm = predictive_model('algorithm', 'svm', 'task', 'classification', 'random_state', 0); +tc.verifyEqual(pm.algorithm, 'svm'); +tc.verifyEqual(pm.task, 'classification'); +tc.verifyFalse(pm.is_fitted, 'should start unfitted'); +tc.verifyTrue(pm.is_classifier); +end + + +function test_fit_insample(tc) +pm = tc.TestData.pm_insample; +tc.verifyTrue(pm.is_fitted); +tc.verifyEqual(pm.fit_type, 'insample'); +tc.verifyLessThanOrEqual(numel(pm.weights.w), size(tc.TestData.X, 2)); +end + + +function test_predict(tc) +[yhat, scores] = predict(tc.TestData.pm_insample, tc.TestData.X); +tc.verifyNumElements(yhat, numel(tc.TestData.Y)); +tc.verifyEqual(size(scores, 1), numel(tc.TestData.Y)); +end + + +function test_score_balanced_accuracy(tc) +pm = tc.TestData.pm_insample; +pm.scorer = cv_scorer.balanced_accuracy(); +v = score(pm, tc.TestData.X, tc.TestData.Y); +tc.verifyGreaterThanOrEqual(v, 0); +tc.verifyLessThanOrEqual(v, 1); +end + + +% ===================================================================== +% crossval / bootstrap / weight map +% ===================================================================== + +function test_crossval_stratified_group_kfold(tc) +pm = tc.TestData.pm_cv; +tc.verifyEqual(pm.fit_type, 'crossval'); +tc.verifyEqual(pm.cv_partition.nfolds, 5); +tc.verifyNumElements(pm.fitted_values.yfit, numel(tc.TestData.Y)); +tc.verifyFalse(isnan(pm.error_metrics.balanced_accuracy.value), 'metric populated'); +end + + +function test_bootstrap_weight_stats(tc) +nboot = 5; % must match setupOnce +pm = tc.TestData.pm_boot; +tc.verifyEqual(size(pm.weights.boot_w, 2), nboot, 'boot_w should have nboot columns'); +tc.verifyNumElements(pm.weights.z, numel(pm.weights.w)); +tc.verifyNumElements(pm.weights.p, numel(pm.weights.w)); +tc.verifyTrue(all(pm.weights.p >= 0 & pm.weights.p <= 1), 'p in [0,1]'); +tc.verifyTrue(all(abs(pm.weights.z) < 1e6), 'z floored, not 1e15'); +p_floor = 2 / (nboot + 1); +tc.verifyGreaterThanOrEqual(min(pm.weights.p), p_floor - eps, 'empirical p floor'); +end + + +function test_weight_map_object(tc) +pm = tc.TestData.pm_boot; +hw = tc.TestData.hw_obj; +[pm_w, si_full] = weight_map_object(pm, hw); +[~, si_thr] = weight_map_object(pm, hw, 'use', 'thresh_fdr'); +tc.verifyClass(si_full, 'statistic_image'); +tc.verifyEqual(size(si_full.dat, 1), size(hw.dat, 1)); +tc.verifyEqual(size(si_thr.dat, 1), size(hw.dat, 1)); +tc.verifyNotEmpty(pm_w.weights.weight_obj, 'weight_obj cached on pm'); +tc.verifyClass(pm_w.weights.weight_obj, 'statistic_image'); +end + + +% ===================================================================== +% permutation / calibration / feature selection +% ===================================================================== + +function test_permutation_test_within_subjects(tc) +pm = predictive_model('algorithm', 'svm', 'task', 'classification', ... + 'modeloptions', {'KernelFunction', 'linear'}, 'random_state', 0); +pm.cv = cv_splitter.stratified_group_kfold(3); +nperm = 3; % minimum-viable: enough to form a null and a defined p-value +pm = permutation_test(pm, tc.TestData.X, tc.TestData.Y, ... + 'nperm', nperm, 'groups', tc.TestData.id, 'verbose', false); +tc.verifyNumElements(pm.permutation_results.null_scores, nperm); +tc.verifyGreaterThan(pm.permutation_results.p_value, 0); +tc.verifyLessThanOrEqual(pm.permutation_results.p_value, 1); +% DPSP is paired (Hot+Warm per subject) -> auto should pick within_subjects. +tc.verifyEqual(pm.permutation_results.permutation, 'within_subjects'); +end + + +function test_calibrate_predict_proba(tc) +pm_cal = calibrate(tc.TestData.pm_cv, tc.TestData.X, tc.TestData.Y); +tc.verifyTrue(isfield(pm_cal.fitted_values, 'calibrator'), 'calibrator present'); +P = predict_proba(pm_cal, tc.TestData.X); +tc.verifyTrue(all(P >= 0 & P <= 1), 'predict_proba in [0,1]'); +end + + +function test_select_features(tc) +pm = predictive_model('algorithm', 'svm', 'task', 'classification'); +pm = select_features(pm, tc.TestData.X, tc.TestData.Y, 'k', 500, 'verbose', false); +tc.verifyEqual(pm.diagnostics.feature_selection.n_selected, 500); +end + + +% ===================================================================== +% grid search / stability selection / pipeline / newapi +% ===================================================================== + +function test_grid_search(tc) +pm = predictive_model('algorithm', 'svm', 'task', 'classification', ... + 'cv', cv_splitter.stratified_group_kfold(3)); +pm = grid_search(pm, tc.TestData.X, tc.TestData.Y, ... + struct('BoxConstraint', [0.1 1]), 'groups', tc.TestData.id, 'verbose', false); +tc.verifyTrue(isfield(pm.diagnostics.grid_search, 'best_score')); +end + + +function test_stability_selection(tc) +pm = stability_selection( ... + predictive_model('algorithm', 'linear_svm', 'task', 'classification'), ... + tc.TestData.X, tc.TestData.Y, 'nboot', 5, 'k', 1000, 'threshold', 0.6, ... + 'groups', tc.TestData.id, 'verbose', false); +ss = pm.diagnostics.stability_selection; +tc.verifyNumElements(ss.selection_freq, size(tc.TestData.X, 2)); +tc.verifyTrue(all(ss.selection_freq >= 0 & ss.selection_freq <= 1), 'freq in [0,1]'); +end + + +function test_pipeline_pca_svm(tc) +est = predictive_model('algorithm', 'svm', 'task', 'classification'); +pipe = pipeline({ {'pca', 'k', 20} }, est); +pipe = crossval(pipe, tc.TestData.X, tc.TestData.Y, ... + 'groups', tc.TestData.id, 'cv', cv_splitter.stratified_group_kfold(5)); +tc.verifyEqual(pipe.fit_type, 'crossval'); +tc.verifyNumElements(pipe.weights.w, size(tc.TestData.X, 2), ... + 'pipeline should back-project weights to full feature space'); +si_pipe = weight_map_object(pipe, tc.TestData.hw_obj); +tc.verifyClass(si_pipe, 'statistic_image'); +end + + +function test_fmri_data_predict_newapi(tc) +[cverr, st, ~, pm_api] = predict(tc.TestData.hw_obj, 'algorithm_name', 'cv_svm', ... + 'nfolds', 5, 'newapi', 'verbose', 0); +tc.verifyClass(pm_api, 'predictive_model'); +tc.verifyEqual(pm_api.fit_type, 'crossval'); +tc.verifyTrue(isfield(st, 'dist_from_hyperplane_xval')); +tc.verifyTrue(isfinite(cverr)); +end + + +% ===================================================================== +% regression metrics / report_accuracy / summary +% ===================================================================== + +function test_regression_metrics_and_reports(tc) +rng(7); +Y = tc.TestData.Y; +Yr = double(Y) + 0.5 * randn(size(Y)); +pm_reg = predictive_model('algorithm', 'pcr', 'task', 'regression'); +pm_reg = crossval(pm_reg, tc.TestData.X, Yr, ... + 'cv', cv_splitter.group_kfold(5), 'groups', tc.TestData.id); + +tc.verifyTrue(isfield(pm_reg.error_metrics, 'predicted_r2')); +tc.verifyTrue(isfield(pm_reg.error_metrics, 'out_of_sample_r2')); + +% predicted_r2 must equal 1 - PRESS/SST computed by hand. +yf = pm_reg.fitted_values.yfit(:); +yo = Yr(:); +pr2_manual = 1 - sum((yo - yf).^2) / sum((yo - mean(yo)).^2); +tc.verifyEqual(pm_reg.error_metrics.predicted_r2.value, pr2_manual, 'AbsTol', 1e-9); + +acc_r = report_accuracy(pm_reg, 'noverbose'); +tc.verifyEqual(acc_r.task, 'regression'); +tc.verifyFalse(isnan(acc_r.predicted_r2)); + +s_reg = summary(pm_reg, 'noverbose'); +tc.verifyEqual(s_reg.provenance.fit_type, 'crossval'); + +% Classification report_accuracy: ROC-derived fields populated. +acc_c = report_accuracy(tc.TestData.pm_cv, 'noverbose'); +tc.verifyEqual(acc_c.task, 'classification'); +tc.verifyFalse(isnan(acc_c.auc)); +tc.verifyFalse(isnan(acc_c.npv)); +end + + +% ===================================================================== +% Visualisation (smoke; skipped on a headless runner) +% ===================================================================== + +function test_rocplot_metrics(tc) +% rocplot('noplot') returns metrics without a display. +ROC = rocplot(tc.TestData.pm_boot, 'noplot'); +tc.verifyTrue(isfield(ROC, 'AUC')); +tc.verifyGreaterThanOrEqual(ROC.AUC, 0); +tc.verifyLessThanOrEqual(ROC.AUC, 1); +end + + +function test_display_methods_smoke(tc) +pm = tc.TestData.pm_boot; +hw = tc.TestData.hw_obj; +try + h = plot(pm); close(h); % classification dispatcher (violin + ROC) + confusionchart(pm); close(gcf); + montage(pm, hw); close all force; % delegates to weight-map montage +catch ME + if strcmp(canlab_classify_environment_error(ME), 'genuine') + rethrow(ME); + end + tc.assumeFail(['display method needs a graphics environment: ' ME.message]); +end +end diff --git a/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_SVM.m b/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_SVM.m new file mode 100644 index 00000000..93d2753d --- /dev/null +++ b/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_SVM.m @@ -0,0 +1,105 @@ +function tests = canlab_test_xval_SVM +%CANLAB_TEST_XVAL_SVM Cross-validated SVM classification via xval_SVM. +% +% Converted from Unit_tests/xval_SVM_unit_test.m. Drives xval_SVM on the DPSP +% Hot vs Warm single-subject maps (a paired, within-person design) and checks +% the returned @predictive_model object: class/state, canonical-path fields, +% shapes/ranges, fit_type + omitted markers, validate_object, and clone. +% +% The model is fit once in setupOnce and cached; each test reads the cached +% object. Skipped if the DPSP sample data is not on the path. + +tests = functiontests(localfunctions); +end + + +function setupOnce(tc) %#ok<*DEFNU> +[hot, warm, ok] = canlab_get_dpsp_hot_warm(); +tc.assumeTrue(ok, 'DPSP sample data not on path'); + +n_hot = size(hot.dat, 2); +n_warm = size(warm.dat, 2); + +rng(0); +p = size(hot.dat, 1); +keep_vox = randsample(p, min(5000, p)); +X = double([hot.dat(keep_vox, :) warm.dat(keep_vox, :)])'; +Y = [ones(n_hot, 1); -ones(n_warm, 1)]; +id = [(1:n_hot)'; (1:n_warm)']; % each subject contributes both conditions + +tc.TestData.X = X; +tc.TestData.Y = Y; +tc.TestData.id = id; +tc.TestData.pm = xval_SVM(X, Y, id, 'nooptimize', 'norepeats', ... + 'nobootstrap', 'noverbose', 'noplot'); +end + + +function test_class_and_state(tc) +pm = tc.TestData.pm; +tc.verifyClass(pm, 'predictive_model'); +tc.verifyTrue(pm.is_fitted, 'is_fitted should be true'); +tc.verifyTrue(pm.is_classifier, 'binary Y should give is_classifier true'); +end + + +function test_canonical_path_fields_populated(tc) +pm = tc.TestData.pm; +tc.verifyNotEmpty(pm.Y); +tc.verifyNotEmpty(pm.id); +tc.verifyNotEmpty(pm.fitted_values.yfit); +tc.verifyNotEmpty(pm.fitted_values.dist_from_hyperplane_xval); +tc.verifyNotEmpty(pm.weights.w); +tc.verifyNotEmpty(pm.error_metrics.crossval_accuracy.value); +tc.verifyNotEmpty(pm.error_metrics.d_singleinterval.value); +tc.verifyNotEmpty(pm.ml_model); +tc.verifyNotEmpty(pm.cv_partition.trIdx); +tc.verifyNotEmpty(pm.cv_partition.teIdx); +tc.verifyNotEmpty(pm.cv_partition.nfolds); +end + + +function test_shapes_and_ranges(tc) +pm = tc.TestData.pm; +n = numel(tc.TestData.Y); +nfeat = size(tc.TestData.X, 2); + +tc.verifyNumElements(pm.fitted_values.yfit, n); +tc.verifyNumElements(pm.fitted_values.dist_from_hyperplane_xval, n); +tc.verifyEqual(size(pm.weights.w, 1), nfeat, ... + 'weights.w should have one row per feature'); + +cv_acc = pm.error_metrics.crossval_accuracy.value; +tc.verifyGreaterThanOrEqual(cv_acc, 0); +tc.verifyLessThanOrEqual(cv_acc, 100); + +% DPSP is paired, so within-person scoring should be populated. +tc.verifyFalse(isnan(pm.error_metrics.crossval_accuracy_within.value)); +tc.verifyFalse(isnan(pm.error_metrics.d_within.value)); +tc.verifyTrue(pm.diagnostics.mult_obs_within_person, ... + 'should detect multiple observations per id'); +end + + +function test_fit_type_and_omitted_markers(tc) +pm = tc.TestData.pm; +tc.verifyEqual(pm.fit_type, 'crossval'); +tc.verifyClass(pm.omitted_cases, 'logical'); +tc.verifyClass(pm.omitted_features, 'logical'); +tc.verifyNumElements(pm.omitted_cases, numel(tc.TestData.Y)); +tc.verifyNumElements(pm.omitted_features, size(tc.TestData.X, 2)); +end + + +function test_validate_object_accepts(tc) +% validate_object throws on an invalid object; reaching the end is a pass. +tc.TestData.pm.validate_object('noverbose'); +end + + +function test_clone_clears_fitted_state(tc) +pm2 = clone(tc.TestData.pm); +tc.verifyFalse(pm2.is_fitted, 'clone should clear fitted state'); +tc.verifyEqual(pm2.modeloptions, tc.TestData.pm.modeloptions, ... + 'clone should preserve modeloptions'); +end diff --git a/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_SVR.m b/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_SVR.m new file mode 100644 index 00000000..c5dafebb --- /dev/null +++ b/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_SVR.m @@ -0,0 +1,72 @@ +function tests = canlab_test_xval_SVR +%CANLAB_TEST_XVAL_SVR Cross-validated SVR regression via xval_SVR. +% +% Converted from Unit_tests/xval_SVR_unit_test.m. Predicts a synthetic +% continuous outcome from the DPSP Hot-Warm contrast maps via cross-validated +% linear SVR and checks the returned @predictive_model object. Model fit once +% in setupOnce; skipped if the DPSP sample data is not on the path. + +tests = functiontests(localfunctions); +end + + +function setupOnce(tc) %#ok<*DEFNU> +[hot, warm, ok] = canlab_get_dpsp_hot_warm(); +tc.assumeTrue(ok, 'DPSP sample data not on path'); + +hot_vs_warm = image_math(hot, warm, 'minus'); + +rng(0); +[p, n] = size(hot_vs_warm.dat); +keep_vox = randsample(p, min(3000, p)); +X = double(hot_vs_warm.dat(keep_vox, :))'; + +% Synthetic outcome predictable from a sparse subset of features. +b_true = zeros(size(X, 2), 1); +b_true(1:30) = randn(30, 1); +Y = X * b_true + 0.5 * std(X * b_true) * randn(n, 1); +id = (1:n)'; + +tc.TestData.X = X; +tc.TestData.Y = Y; +tc.TestData.pm = xval_SVR(X, Y, id, 'nooptimize', 'norepeats', ... + 'nobootstrap', 'noverbose', 'noplot'); +end + + +function test_class_and_state(tc) +pm = tc.TestData.pm; +tc.verifyClass(pm, 'predictive_model'); +tc.verifyTrue(pm.is_fitted, 'is_fitted should be true'); +tc.verifyTrue(pm.is_regressor, 'continuous Y should give is_regressor true'); +end + + +function test_canonical_path_fields_populated(tc) +pm = tc.TestData.pm; +tc.verifyNotEmpty(pm.Y); +tc.verifyNotEmpty(pm.fitted_values.yfit); +tc.verifyNotEmpty(pm.weights.w); +tc.verifyNotEmpty(pm.error_metrics.prediction_outcome_r.value); +tc.verifyNotEmpty(pm.ml_model); +end + + +function test_weight_shape(tc) +pm = tc.TestData.pm; +tc.verifyEqual(size(pm.weights.w, 1), size(tc.TestData.X, 2), ... + 'weights.w should have one row per feature'); +end + + +function test_fit_type_and_omitted_markers(tc) +pm = tc.TestData.pm; +tc.verifyEqual(pm.fit_type, 'crossval'); +tc.verifyClass(pm.omitted_cases, 'logical'); +tc.verifyClass(pm.omitted_features, 'logical'); +end + + +function test_validate_object_accepts(tc) +tc.TestData.pm.validate_object('noverbose'); +end diff --git a/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_discriminant_classifier.m b/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_discriminant_classifier.m new file mode 100644 index 00000000..2fec72d4 --- /dev/null +++ b/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_discriminant_classifier.m @@ -0,0 +1,66 @@ +function tests = canlab_test_xval_discriminant_classifier +%CANLAB_TEST_XVAL_DISCRIMINANT_CLASSIFIER Cross-validated LDA classification. +% +% Converted from Unit_tests/xval_discriminant_classifier_unit_test.m. Drives +% cross-validated LDA (fitcdiscr) on the DPSP Hot vs Warm single-subject maps +% (100 random voxels, so n > p) and checks the returned @predictive_model +% object. Model fit once in setupOnce; skipped if DPSP data is not on the path. + +tests = functiontests(localfunctions); +end + + +function setupOnce(tc) %#ok<*DEFNU> +[hot, warm, ok] = canlab_get_dpsp_hot_warm(); +tc.assumeTrue(ok, 'DPSP sample data not on path'); + +rng(0); +p = size(hot.dat, 1); +keep_vox = randsample(p, 100); % LDA needs n > p +X = double([hot.dat(keep_vox, :) warm.dat(keep_vox, :)])'; +labels = int32([ones(size(hot.dat, 2), 1); 2 * ones(size(warm.dat, 2), 1)]); + +tc.TestData.pm = xval_discriminant_classifier(X, labels, 'nFolds', 5, ... + 'verbose', false, 'doplot', false); +end + + +function test_class_and_state(tc) +pm = tc.TestData.pm; +tc.verifyClass(pm, 'predictive_model'); +tc.verifyTrue(pm.is_fitted, 'is_fitted should be true'); +end + + +function test_canonical_path_fields_populated(tc) +pm = tc.TestData.pm; +tc.verifyNotEmpty(pm.Y); +tc.verifyNotEmpty(pm.fitted_values.yfit); +tc.verifyNotEmpty(pm.fitted_values.predictions); +tc.verifyNotEmpty(pm.fitted_values.Y_per_fold); +tc.verifyNotEmpty(pm.error_metrics.accuracy.value); +tc.verifyNotEmpty(pm.error_metrics.overallAccuracy.value); +tc.verifyNotEmpty(pm.cv_partition.trIdx); +tc.verifyNotEmpty(pm.cv_partition.teIdx); +tc.verifyNotEmpty(pm.cv_partition.nfolds); +end + + +function test_fold_models_match_nfolds(tc) +pm = tc.TestData.pm; +tc.verifyNumElements(pm.fold_models, pm.cv_partition.nfolds, ... + 'fold_models length should equal nfolds'); +end + + +function test_fit_type_and_omitted_markers(tc) +pm = tc.TestData.pm; +tc.verifyEqual(pm.fit_type, 'crossval'); +tc.verifyClass(pm.omitted_cases, 'logical'); +tc.verifyClass(pm.omitted_features, 'logical'); +end + + +function test_validate_object_accepts(tc) +tc.TestData.pm.validate_object('noverbose'); +end diff --git a/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_regression_multisubject.m b/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_regression_multisubject.m new file mode 100644 index 00000000..d6bebbe3 --- /dev/null +++ b/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_regression_multisubject.m @@ -0,0 +1,74 @@ +function tests = canlab_test_xval_regression_multisubject +%CANLAB_TEST_XVAL_REGRESSION_MULTISUBJECT OLS+PCA multisubject regression. +% +% Converted from Unit_tests/xval_regression_multisubject_unit_test.m. Runs +% xval_regression_multisubject ('ols' + 'pca') on the DPSP Hot-Warm contrast +% maps against a synthetic continuous outcome and checks the returned +% @predictive_model object. Model fit once in setupOnce; skipped if the DPSP +% sample data is not on the path. + +tests = functiontests(localfunctions); +end + + +function setupOnce(tc) %#ok<*DEFNU> +[hot, warm, ok] = canlab_get_dpsp_hot_warm(); +tc.assumeTrue(ok, 'DPSP sample data not on path'); + +hot_vs_warm = image_math(hot, warm, 'minus'); + +rng(0); +[p, n] = size(hot_vs_warm.dat); +keep_vox = randsample(p, min(5000, p)); +X = double(hot_vs_warm.dat(keep_vox, :))'; % n x v + +b_true = zeros(size(X, 2), 1); +b_true(1:50) = randn(50, 1); +Y = X * b_true + 0.5 * std(X * b_true) * randn(n, 1); + +tc.TestData.X = X; +tc.TestData.Y = Y; +tc.TestData.n = n; +tc.TestData.pm = xval_regression_multisubject('ols', {Y}, {X}, ... + 'pca', 'ndims', 10, 'holdout_method', 'balanced4', 'noverbose'); +end + + +function test_class_and_state(tc) +pm = tc.TestData.pm; +tc.verifyClass(pm, 'predictive_model'); +tc.verifyTrue(pm.is_fitted, 'is_fitted should be true'); +end + + +function test_canonical_path_fields_populated(tc) +pm = tc.TestData.pm; +tc.verifyNotEmpty(pm.fitted_values.subjfit); +tc.verifyNotEmpty(pm.weights.subjbetas); +tc.verifyNotEmpty(pm.weights.mean_vox_weights); +tc.verifyNotEmpty(pm.error_metrics.pred_err.value); +tc.verifyNotEmpty(pm.error_metrics.pred_err_null.value); +tc.verifyNotEmpty(pm.error_metrics.var_reduction.value); +tc.verifyNotEmpty(pm.error_metrics.r_each_subject.value); +tc.verifyNotEmpty(pm.Y); +tc.verifyNotEmpty(pm.inputParameters); +end + + +function test_shapes_and_variance_explained(tc) +pm = tc.TestData.pm; +tc.verifyNumElements(pm.fitted_values.subjfit, 1, ... + 'subjfit cell length should equal number of datasets'); +tc.verifyNumElements(pm.fitted_values.subjfit{1}, tc.TestData.n); +tc.verifyEqual(size(pm.weights.mean_vox_weights, 1), size(tc.TestData.X, 2), ... + 'mean_vox_weights should have one row per voxel'); + +r2 = pm.error_metrics.r_squared.value; +tc.verifyNotEmpty(r2); +tc.verifyGreaterThan(r2(1), 0, 'OLS+PCA should explain non-trivial variance'); +end + + +function test_validate_object_accepts(tc) +tc.TestData.pm.validate_object('noverbose'); +end diff --git a/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_regression_multisubject_featureselect.m b/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_regression_multisubject_featureselect.m new file mode 100644 index 00000000..08311c82 --- /dev/null +++ b/CanlabCore/Unit_tests/predictive_model/canlab_test_xval_regression_multisubject_featureselect.m @@ -0,0 +1,75 @@ +function tests = canlab_test_xval_regression_multisubject_featureselect +%CANLAB_TEST_XVAL_REGRESSION_MULTISUBJECT_FEATURESELECT Feature-select regression. +% +% Converted from Unit_tests/xval_regression_multisubject_featureselect_unit_test.m. +% Runs xval_regression_multisubject_featureselect ('ols') on the DPSP Hot-Warm +% contrast maps against a synthetic continuous outcome. Beyond the usual +% predictive_model checks, it pins the per-fold weight padding: weights.w_perfold +% must span the FULL feature space, not the selected-feature count (the bug this +% test originally guarded). Skipped if the DPSP sample data is not on the path. + +tests = functiontests(localfunctions); +end + + +function setupOnce(tc) %#ok<*DEFNU> +[hot, warm, ok] = canlab_get_dpsp_hot_warm(); +tc.assumeTrue(ok, 'DPSP sample data not on path'); + +hvw = image_math(hot, warm, 'minus'); + +rng(0); +[p, n] = size(hvw.dat); +keep_vox = randsample(p, min(2000, p)); +X = double(hvw.dat(keep_vox, :))'; + +b_true = zeros(size(X, 2), 1); +b_true(1:30) = randn(30, 1); +Y = X * b_true + 0.5 * std(X * b_true) * randn(n, 1); + +tc.TestData.X = X; +tc.TestData.Y = Y; +tc.TestData.pm = xval_regression_multisubject_featureselect('ols', {Y}, {X}, ... + 'holdout_method', 'balanced4', 'noverbose'); +end + + +function test_class_and_state(tc) +pm = tc.TestData.pm; +tc.verifyClass(pm, 'predictive_model'); +tc.verifyTrue(pm.is_fitted, 'is_fitted should be true'); +end + + +function test_canonical_path_fields_populated(tc) +% mean_vox_weights is only populated when pcsquash is enabled; this no-PCA +% test does not exercise that path, so it is not checked here. +pm = tc.TestData.pm; +tc.verifyNotEmpty(pm.fitted_values.subjfit); +tc.verifyNotEmpty(pm.weights.w_perfold); +tc.verifyNotEmpty(pm.error_metrics.pred_err.value); +tc.verifyNotEmpty(pm.error_metrics.pred_err_null.value); +tc.verifyNotEmpty(pm.error_metrics.var_reduction.value); +tc.verifyNotEmpty(pm.Y); +end + + +function test_w_perfold_spans_full_feature_space(tc) +% Regression guard: w_perfold must have one row per ORIGINAL feature, not per +% selected feature. +pm = tc.TestData.pm; +tc.verifyEqual(size(pm.weights.w_perfold, 1), size(tc.TestData.X, 2), ... + 'weights.w_perfold should span the full feature count'); +end + + +function test_variance_explained(tc) +r2 = tc.TestData.pm.error_metrics.r_squared.value; +tc.verifyNotEmpty(r2); +tc.verifyGreaterThan(r2(1), 0, 'expected r_squared > 0'); +end + + +function test_validate_object_accepts(tc) +tc.TestData.pm.validate_object('noverbose'); +end diff --git a/CanlabCore/Unit_tests/predictive_model_unit_test.m b/CanlabCore/Unit_tests/predictive_model_unit_test.m deleted file mode 100644 index c6891160..00000000 --- a/CanlabCore/Unit_tests/predictive_model_unit_test.m +++ /dev/null @@ -1,195 +0,0 @@ -function predictive_model_unit_test() -% predictive_model_unit_test End-to-end test of the new sklearn-style -% @predictive_model API, using DPSP_hotwarm as the test bed. -% -% Small nboot / nperm values are used so the test runs in ~minutes, -% not hours. The point is to exercise every public method, not to -% produce final-quality inference. -% -% Run: cd to this directory, then `predictive_model_unit_test`. - - fprintf('=== predictive_model_unit_test ===\n'); - - % --- Load DPSP Hot vs Warm --- - hw_obj = load_image_set('DPSP_hotwarm', 'noverbose'); - X = double(hw_obj.dat'); - Y = hw_obj.Y; - id = grp2idx(hw_obj.metadata_table.subj_id); - fprintf(' Loaded DPSP_hotwarm: %d obs, %d features, %d subjects\n', ... - numel(Y), size(X, 2), numel(unique(id))); - - % --- 1. Construction + hyperparameters --- - pm = predictive_model('algorithm', 'svm', ... - 'task', 'classification', ... - 'random_state', 0); - assert(strcmp(pm.algorithm, 'svm'), 'algorithm'); - assert(strcmp(pm.task, 'classification'), 'task'); - assert(~pm.is_fitted, 'starts unfitted'); - assert(pm.is_classifier, 'is_classifier'); - fprintf(' 1. Construction + clone OK\n'); - - % --- 2. fit (in-sample) --- - pm = fit(pm, X, Y, 'id', id); - assert(pm.is_fitted, 'is_fitted after fit'); - assert(strcmp(pm.fit_type, 'insample'), 'fit_type=insample'); - assert(numel(pm.weights.w) <= size(X, 2), 'weights.w shape'); - fprintf(' 2. fit OK (fit_type=%s, |w|=%d)\n', pm.fit_type, numel(pm.weights.w)); - - % --- 3. predict --- - [yhat, scores] = predict(pm, X); - assert(numel(yhat) == numel(Y), 'predict yhat length'); - assert(size(scores, 1) == numel(Y), 'predict scores length'); - fprintf(' 3. predict OK (train acc=%.2f)\n', mean(yhat == Y)); - - % --- 4. score --- - pm.scorer = cv_scorer.balanced_accuracy(); - v = score(pm, X, Y); - assert(v >= 0 && v <= 1, 'score in [0,1]'); - fprintf(' 4. score (balanced_accuracy) = %.3f\n', v); - - % --- 5. crossval with stratified_group_kfold --- - pm = predictive_model('algorithm','svm','task','classification', ... - 'modeloptions', {'KernelFunction','linear'}, ... - 'random_state', 0); - pm = crossval(pm, X, Y, ... - 'cv', cv_splitter.stratified_group_kfold(5), ... - 'groups', id, ... - 'scoring', 'balanced_accuracy'); - assert(strcmp(pm.fit_type, 'crossval'), 'fit_type=crossval'); - assert(pm.cv_partition.nfolds == 5, '5 folds'); - assert(numel(pm.fitted_values.yfit) == numel(Y), 'cv yfit length'); - assert(~isnan(pm.error_metrics.balanced_accuracy.value), 'metric populated'); - fprintf(' 5. crossval (stratified_group_kfold(5)) bal_acc = %.3f\n', ... - pm.error_metrics.balanced_accuracy.value); - - % --- 6. bootstrap (small nboot for speed) --- - pm = bootstrap(pm, X, Y, 'nboot', 25, 'groups', id, 'verbose', false); - assert(size(pm.weights.boot_w, 2) == 25, 'nboot=25 samples'); - assert(numel(pm.weights.z) == numel(pm.weights.w), 'z length matches w'); - assert(numel(pm.weights.p) == numel(pm.weights.w), 'p length matches w'); - assert(all(pm.weights.p >= 0 & pm.weights.p <= 1), 'p in [0,1]'); - assert(all(abs(pm.weights.z) < 1e6), 'z floored not 1e15'); - p_floor = 2 / (25 + 1); - assert(min(pm.weights.p) >= p_floor - eps, 'empirical p floor'); - fprintf(' 6. bootstrap (nboot=25) OK\n'); - fprintf(' z range = [%g .. %g]; p floor = %.4f\n', ... - min(pm.weights.z), max(pm.weights.z), min(pm.weights.p)); - - % --- 7. weight_map_object: pre- and post-bootstrap thresholded, + caching --- - [pm_w, si_full] = weight_map_object(pm, hw_obj); - [~, si_thr] = weight_map_object(pm, hw_obj, 'use', 'thresh_fdr'); - assert(isa(si_full, 'statistic_image'), 'si_full class'); - assert(size(si_full.dat, 1) == size(hw_obj.dat, 1), 'si_full voxel count'); - assert(size(si_thr.dat, 1) == size(hw_obj.dat, 1), 'si_thr voxel count'); - assert(~isempty(pm_w.weights.weight_obj), 'weight_obj cached on pm'); - assert(isa(pm_w.weights.weight_obj, 'statistic_image'), 'cached weight_obj class'); - fprintf(' 7. weight_map_object (full + thresh_fdr + cache) OK\n'); - fprintf(' full-w nonzero: %d, FDR-thresh nonzero: %d\n', ... - sum(si_full.dat ~= 0), sum(si_thr.dat ~= 0)); - - % --- 8. permutation_test (small nperm) --- - pm_for_perm = predictive_model('algorithm','svm','task','classification', ... - 'modeloptions', {'KernelFunction','linear'}, ... - 'random_state', 0); - pm_for_perm.cv = cv_splitter.stratified_group_kfold(3); - pm_for_perm = permutation_test(pm_for_perm, X, Y, ... - 'nperm', 10, 'groups', id, ... - 'verbose', false); - assert(numel(pm_for_perm.permutation_results.null_scores) == 10, 'nperm=10 null scores'); - assert(pm_for_perm.permutation_results.p_value > 0, 'p_value > 0'); - assert(pm_for_perm.permutation_results.p_value <= 1, 'p_value <= 1'); - % DPSP is a paired design (Hot+Warm per subject) — auto should pick within_subjects. - assert(strcmp(pm_for_perm.permutation_results.permutation, 'within_subjects'), ... - 'auto should pick within_subjects on paired DPSP'); - fprintf(' 8. permutation_test (nperm=10, %s) observed=%.3f, p=%.3f\n', ... - pm_for_perm.permutation_results.permutation, ... - pm_for_perm.permutation_results.observed, ... - pm_for_perm.permutation_results.p_value); - - % --- 9. calibrate + predict_proba --- - pm_cal = calibrate(pm, X, Y); - assert(isfield(pm_cal.fitted_values, 'calibrator'), 'calibrator present'); - P = predict_proba(pm_cal, X); - assert(all(P >= 0 & P <= 1), 'predict_proba in [0,1]'); - fprintf(' 9. calibrate + predict_proba: P mean=%.3f\n', mean(P)); - - % --- 10. select_features --- - pm_fs = predictive_model('algorithm','svm','task','classification'); - pm_fs = select_features(pm_fs, X, Y, 'k', 500, 'verbose', false); - assert(pm_fs.diagnostics.feature_selection.n_selected == 500, 'k=500 selected'); - fprintf(' 10. select_features OK (kept 500)\n'); - - % --- 11. visualisation methods (smoke test; no display assertions) --- - ROC = rocplot(pm, 'noplot'); - assert(isfield(ROC, 'AUC') && ROC.AUC >= 0 && ROC.AUC <= 1, 'rocplot AUC in [0,1]'); - h = plot(pm); close(h); % classification dispatcher (violin + ROC) - confusionchart(pm); close(gcf); - fprintf(' 11. rocplot/plot/confusionchart OK (AUC=%.3f)\n', ROC.AUC); - - % --- 12. montage / surface delegates (smoke test) --- - montage(pm, hw_obj); close all force; - fprintf(' 12. montage(pm, source) OK\n'); - - % --- 13. grid_search (tiny grid) --- - pm_gs = predictive_model('algorithm','svm','task','classification', ... - 'cv', cv_splitter.stratified_group_kfold(3)); - pm_gs = grid_search(pm_gs, X, Y, struct('BoxConstraint', [0.1 1]), ... - 'groups', id, 'verbose', false); - assert(isfield(pm_gs.diagnostics.grid_search, 'best_score'), 'grid_search best_score'); - fprintf(' 13. grid_search OK (best %s=%.3f)\n', ... - pm_gs.scorer.name, pm_gs.diagnostics.grid_search.best_score); - - % --- 14. stability_selection (small nboot) --- - pm_ss = stability_selection( ... - predictive_model('algorithm','linear_svm','task','classification'), ... - X, Y, 'nboot', 20, 'k', 1000, 'threshold', 0.6, 'groups', id, 'verbose', false); - ss = pm_ss.diagnostics.stability_selection; - assert(numel(ss.selection_freq) == size(X, 2), 'selection_freq length'); - assert(all(ss.selection_freq >= 0 & ss.selection_freq <= 1), 'selection_freq in [0,1]'); - fprintf(' 14. stability_selection OK (%d stable voxels)\n', ss.n_stable); - - % --- 15. @pipeline: PCA -> svm, leakage-free crossval + weight back-projection --- - est = predictive_model('algorithm','svm','task','classification'); - pipe = pipeline({ {'pca','k',20} }, est); - pipe = crossval(pipe, X, Y, 'groups', id, 'cv', cv_splitter.stratified_group_kfold(5)); - assert(strcmp(pipe.fit_type, 'crossval'), 'pipeline crossval fit_type'); - assert(numel(pipe.weights.w) == size(X, 2), 'pipeline back-projected weight length'); - si_pipe = weight_map_object(pipe, hw_obj); - assert(isa(si_pipe, 'statistic_image'), 'pipeline weight_map_object class'); - fprintf(' 15. pipeline (PCA->svm) OK (cv bal_acc=%.3f)\n', ... - pipe.error_metrics.balanced_accuracy.value); - - % --- 16. fmri_data.predict 'newapi' routing --- - [cverr, st, ~, pm_api] = predict(hw_obj, 'algorithm_name', 'cv_svm', ... - 'nfolds', 5, 'newapi', 'verbose', 0); - assert(isa(pm_api, 'predictive_model'), 'newapi returns predictive_model'); - assert(strcmp(pm_api.fit_type, 'crossval'), 'newapi fit_type=crossval'); - assert(isfield(st, 'dist_from_hyperplane_xval'), 'newapi stats has xval distances'); - fprintf(' 16. fmri_data.predict ''newapi'' OK (cverr=%.3f)\n', cverr); - - % --- 17. predicted_r2 / out_of_sample_r2 + report_accuracy + summary --- - % Regression model on a continuous outcome (signed label scaled to vary). - rng(7); - Yr = double(Y) + 0.5 * randn(size(Y)); - pm_reg = predictive_model('algorithm', 'pcr', 'task', 'regression'); - pm_reg = crossval(pm_reg, X, Yr, 'cv', cv_splitter.group_kfold(5), 'groups', id); - assert(isfield(pm_reg.error_metrics, 'predicted_r2'), 'predicted_r2 present'); - assert(isfield(pm_reg.error_metrics, 'out_of_sample_r2'), 'out_of_sample_r2 present'); - % predicted_r2 must equal 1 - PRESS/SST computed by hand. - yf = pm_reg.fitted_values.yfit(:); yo = Yr(:); - pr2_manual = 1 - sum((yo - yf).^2) / sum((yo - mean(yo)).^2); - assert(abs(pm_reg.error_metrics.predicted_r2.value - pr2_manual) < 1e-9, ... - 'predicted_r2 matches 1 - PRESS/SST'); - acc_r = report_accuracy(pm_reg, 'noverbose'); - assert(strcmp(acc_r.task, 'regression') && ~isnan(acc_r.predicted_r2), 'report_accuracy regression'); - s_reg = summary(pm_reg, 'noverbose'); - assert(strcmp(s_reg.provenance.fit_type, 'crossval'), 'summary provenance'); - % Classification report_accuracy: ROC-derived fields populated. - acc_c = report_accuracy(pm, 'noverbose'); - assert(strcmp(acc_c.task, 'classification') && ~isnan(acc_c.auc) && ~isnan(acc_c.npv), ... - 'report_accuracy classification (auc + npv)'); - fprintf(' 17. predicted_r2 / report_accuracy / summary OK (pred R^2=%.3f)\n', ... - pm_reg.error_metrics.predicted_r2.value); - - fprintf('\npredictive_model_unit_test: PASS\n'); -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..28c62f9f --- /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', 'intent', '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', 'intent', 'dscalar'); +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..80a332da --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_object_basic.m @@ -0,0 +1,158 @@ +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.intent, ''); +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.intent, '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 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..a030bb10 --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_parcellation.m @@ -0,0 +1,134 @@ +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.intent = 'dlabel'; +parc.label_table = struct('key', {1 2}, 'name', {'A','B'}, 'rgba', {[1 0 0 1],[0 0 1 1]}); + +[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); +% Use the atlas keys as the data: each parcel mean must equal its key +data = atl; data.dat = single(double(atl.dat)); data.intent = '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', 'intent', '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..1fdb115a --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_render.m @@ -0,0 +1,154 @@ +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.) +h1 = surface(t.TestData.s); % fs_LR-32k +verifyEqual(t, size(get(h1.surfaces(1), 'Vertices'), 1), 32492); +close(h1.figure); + +vol = local_synthetic_volume(); +sf = vol2surf(vol); % fsaverage_164k object +h2 = surface(sf); +verifyEqual(t, size(get(h2.surfaces(1), 'Vertices'), 1), 163842); +close(h2.figure); +end + + +% ------------------------------------------------------------------------- +function test_surface_native_render(t) +h = surface(t.TestData.s, 'which_image', 1); +verifyTrue(t, isgraphics(h.figure)); +verifyEqual(t, numel(h.surfaces), 4, 'Expected 4 panels (L/R x lateral/medial).'); +for k = 1:4 + c = get(h.surfaces(k), 'FaceVertexCData'); + V = get(h.surfaces(k), 'Vertices'); + verifyEqual(t, size(c), [size(V,1) 3], 'Each patch must be truecolor-colored per vertex.'); + verifyEqual(t, size(V,1), 32492, 'Native fs_LR mesh should have 32492 vertices.'); +end +close(h.figure); +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); +h = surface(o); +% Left hemi panel 1: medial-wall vertices (not in vertlist) should be exactly gray +lh_model = o.brain_model.models{1}; +inmask = false(lh_model.numvert, 1); inmask(lh_model.vertlist + 1) = true; +c = get(h.surfaces(1), '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(h.figure); +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_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 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_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..5b2eea8c --- /dev/null +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_vol_map.m @@ -0,0 +1,154 @@ +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).'); + +% 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/xval_SVM_unit_test.m b/CanlabCore/Unit_tests/xval_SVM_unit_test.m deleted file mode 100644 index 535e6be4..00000000 --- a/CanlabCore/Unit_tests/xval_SVM_unit_test.m +++ /dev/null @@ -1,104 +0,0 @@ -function xval_SVM_unit_test() -% xval_SVM_unit_test Smoke test of xval_SVM on the DPSP Hot vs. Warm task. -% -% Reproduces the binary-classification setup from -% docs/markdown_tutorials/multivariate_classification_with_SVM/ -% multivariate_decoding_part1_classification_with_SVM.mlx -% and asserts that the wrapper returns a populated @predictive_model -% object whose categorised sub-structs agree with the legacy flat aliases. -% -% Data: Sample_datasets/DPSP_pain_rejection_participant_maps -% Single-subject Hot and Warm condition maps. -% -% Run: cd to this directory, then `xval_SVM_unit_test`. - - fprintf('=== xval_SVM_unit_test ===\n'); - - % --- Load DPSP Hot and Warm --- - canlabcore_dir = fileparts(fileparts(which('fmri_data'))); - sample_dir = fullfile(canlabcore_dir, 'Sample_datasets', ... - 'DPSP_pain_rejection_participant_maps'); - H = load(fullfile(sample_dir, 'DPSP_single_subject_images_hot.mat')); - W = load(fullfile(sample_dir, 'DPSP_single_subject_images_warm.mat')); - hot = H.single_subject_images_hot; - warm = W.single_subject_images_warm; - - n_hot = size(hot.dat, 2); - n_warm = size(warm.dat, 2); - - % Stack into one (subjects+subjects) x voxels matrix - rng(0); - p = size(hot.dat, 1); - keep_vox = randsample(p, min(5000, p)); - X = double([hot.dat(keep_vox, :) warm.dat(keep_vox, :)])'; - Y = [ones(n_hot, 1); -ones(n_warm, 1)]; - id = [(1:n_hot)'; (1:n_warm)']; % each subject contributes both conditions - - % --- Run xval_SVM (fast: no optimize / no repeats / no bootstrap) --- - pmodel_obj = xval_SVM(X, Y, id, 'nooptimize', 'norepeats', 'nobootstrap', ... - 'noverbose', 'noplot'); - - % --- Class invariants --- - assert(isa(pmodel_obj, 'predictive_model'), ... - 'Expected output of class predictive_model, got %s', class(pmodel_obj)); - assert(pmodel_obj.is_fitted, 'Returned object reports is_fitted = false'); - assert(pmodel_obj.is_classifier, 'Y is binary, expected is_classifier true'); - fprintf(' class = %s, is_fitted = %d, is_classifier = %d\n', ... - class(pmodel_obj), pmodel_obj.is_fitted, pmodel_obj.is_classifier); - - % --- Canonical-path access (legacy flat aliases removed) --- - assert(~isempty(pmodel_obj.Y), 'Y empty'); - assert(~isempty(pmodel_obj.id), 'id empty'); - assert(~isempty(pmodel_obj.fitted_values.yfit), 'yfit empty'); - assert(~isempty(pmodel_obj.fitted_values.dist_from_hyperplane_xval), 'dist_from_hyperplane_xval empty'); - % class_probability_xval is xval_SVM-legacy (per-fold fitPosterior). - % The new pipeline stores raw scores in fitted_values.scores; to get - % calibrated probabilities call calibrate(pm, X, Y) + predict_proba. - assert(~isempty(pmodel_obj.weights.w), 'weights.w empty'); - assert(~isempty(pmodel_obj.error_metrics.crossval_accuracy.value), 'crossval_accuracy.value empty'); - assert(~isempty(pmodel_obj.error_metrics.d_singleinterval.value), 'd_singleinterval.value empty'); - assert(~isempty(pmodel_obj.ml_model), 'ml_model empty'); - assert(~isempty(pmodel_obj.cv_partition.trIdx), 'trIdx empty'); - assert(~isempty(pmodel_obj.cv_partition.teIdx), 'teIdx empty'); - assert(~isempty(pmodel_obj.cv_partition.nfolds), 'nfolds empty'); - fprintf(' Canonical-path access OK\n'); - - % --- Shape / sanity --- - n = numel(Y); - assert(numel(pmodel_obj.fitted_values.yfit) == n, 'yfit length mismatch'); - assert(numel(pmodel_obj.fitted_values.dist_from_hyperplane_xval) == n, 'dist_from_hyperplane_xval length mismatch'); - assert(size(pmodel_obj.weights.w, 1) == size(X, 2), 'weights.w length should match number of features'); - cv_acc = pmodel_obj.error_metrics.crossval_accuracy.value; - assert(cv_acc >= 0 && cv_acc <= 100, 'crossval_accuracy out of [0,100]'); - % Within-person scoring fields populated by crossval (DPSP is paired): - assert(~isnan(pmodel_obj.error_metrics.crossval_accuracy_within.value), 'crossval_accuracy_within populated'); - assert(~isnan(pmodel_obj.error_metrics.d_within.value), 'd_within populated'); - assert(pmodel_obj.diagnostics.mult_obs_within_person == true, 'Should detect multiple obs per id'); - fprintf(' Shape/sanity OK: cv_acc = %.1f%%, d_single = %.2f, cv_acc_within = %.1f%%, d_within = %.2f\n', ... - cv_acc, ... - pmodel_obj.error_metrics.d_singleinterval.value, ... - pmodel_obj.error_metrics.crossval_accuracy_within.value, ... - pmodel_obj.error_metrics.d_within.value); - - % --- fit_type + omitted markers (Phase B) --- - assert(strcmp(pmodel_obj.fit_type, 'crossval'), ... - 'fit_type should be ''crossval'', got ''%s''', pmodel_obj.fit_type); - assert(islogical(pmodel_obj.omitted_cases), 'omitted_cases must be logical'); - assert(islogical(pmodel_obj.omitted_features), 'omitted_features must be logical'); - assert(numel(pmodel_obj.omitted_cases) == numel(Y), 'omitted_cases length should match original Y'); - assert(numel(pmodel_obj.omitted_features) == size(X, 2), 'omitted_features length should match original feature count'); - fprintf(' fit_type=%s, omitted_cases=%d, omitted_features=%d\n', ... - pmodel_obj.fit_type, sum(pmodel_obj.omitted_cases), sum(pmodel_obj.omitted_features)); - - % --- validate_object accepts the returned object --- - pmodel_obj.validate_object('noverbose'); - fprintf(' validate_object OK\n'); - - % --- Clone preserves hyperparameters, clears fitted state --- - pmodel_obj2 = clone(pmodel_obj); - assert(~pmodel_obj2.is_fitted, 'clone did not clear fitted state'); - assert(isequal(pmodel_obj2.modeloptions, pmodel_obj.modeloptions), 'clone lost modeloptions'); - fprintf(' clone() OK\n'); - - fprintf('xval_SVM_unit_test: PASS\n'); -end diff --git a/CanlabCore/Unit_tests/xval_SVR_unit_test.m b/CanlabCore/Unit_tests/xval_SVR_unit_test.m deleted file mode 100644 index 7e7c6c81..00000000 --- a/CanlabCore/Unit_tests/xval_SVR_unit_test.m +++ /dev/null @@ -1,62 +0,0 @@ -function xval_SVR_unit_test() -% xval_SVR_unit_test Smoke test of xval_SVR on the DPSP Hot-Warm contrast. -% -% Predicts a synthetic continuous outcome from Hot-Warm contrast maps -% (one per subject) via cross-validated linear SVR. Asserts that the -% wrapper returns a @predictive_model object with consistent -% categorised <-> legacy alias values. -% -% Run: cd to this directory, then `xval_SVR_unit_test`. - - fprintf('=== xval_SVR_unit_test ===\n'); - - canlabcore_dir = fileparts(fileparts(which('fmri_data'))); - sample_dir = fullfile(canlabcore_dir, 'Sample_datasets', ... - 'DPSP_pain_rejection_participant_maps'); - H = load(fullfile(sample_dir, 'DPSP_single_subject_images_hot.mat')); - W = load(fullfile(sample_dir, 'DPSP_single_subject_images_warm.mat')); - hot_vs_warm = image_math(H.single_subject_images_hot, ... - W.single_subject_images_warm, 'minus'); - - rng(0); - [p, n] = size(hot_vs_warm.dat); - keep_vox = randsample(p, min(3000, p)); - X = double(hot_vs_warm.dat(keep_vox, :))'; - % Synthetic predictable continuous outcome. - b_true = zeros(size(X, 2), 1); - b_true(1:30) = randn(30, 1); - Y = X * b_true + 0.5 * std(X * b_true) * randn(n, 1); - id = (1:n)'; - - pmodel_obj = xval_SVR(X, Y, id, 'nooptimize', 'norepeats', ... - 'nobootstrap', 'noverbose', 'noplot'); - - assert(isa(pmodel_obj, 'predictive_model'), ... - 'Expected predictive_model, got %s', class(pmodel_obj)); - assert(pmodel_obj.is_fitted, 'is_fitted false'); - assert(pmodel_obj.is_regressor, 'Y is continuous, expected is_regressor true'); - fprintf(' class = %s, is_fitted = %d, is_regressor = %d\n', ... - class(pmodel_obj), pmodel_obj.is_fitted, pmodel_obj.is_regressor); - - % Canonical-path access (legacy flat aliases removed). - assert(~isempty(pmodel_obj.Y), 'Y empty'); - assert(~isempty(pmodel_obj.fitted_values.yfit), 'yfit empty'); - assert(~isempty(pmodel_obj.weights.w), 'weights.w empty'); - assert(~isempty(pmodel_obj.error_metrics.prediction_outcome_r.value), 'prediction_outcome_r empty'); - assert(~isempty(pmodel_obj.ml_model), 'ml_model empty'); - fprintf(' Canonical-path access OK\n'); - - fprintf(' cv: r = %.3f, d = %.2f\n', ... - pmodel_obj.error_metrics.prediction_outcome_r.value, ... - pmodel_obj.error_metrics.d_singleinterval.value); - - % --- fit_type + omitted markers (Phase B) --- - assert(strcmp(pmodel_obj.fit_type, 'crossval'), 'fit_type should be crossval'); - assert(islogical(pmodel_obj.omitted_cases), 'omitted_cases must be logical'); - assert(islogical(pmodel_obj.omitted_features), 'omitted_features must be logical'); - fprintf(' fit_type=%s, omitted_cases=%d, omitted_features=%d\n', ... - pmodel_obj.fit_type, sum(pmodel_obj.omitted_cases), sum(pmodel_obj.omitted_features)); - - pmodel_obj.validate_object('noverbose'); - fprintf('xval_SVR_unit_test: PASS\n'); -end diff --git a/CanlabCore/Unit_tests/xval_discriminant_classifier_unit_test.m b/CanlabCore/Unit_tests/xval_discriminant_classifier_unit_test.m deleted file mode 100644 index 2a8810e2..00000000 --- a/CanlabCore/Unit_tests/xval_discriminant_classifier_unit_test.m +++ /dev/null @@ -1,62 +0,0 @@ -function xval_discriminant_classifier_unit_test() -% xval_discriminant_classifier_unit_test Smoke test on DPSP Hot vs Warm. -% -% Drives cross-validated LDA via fitcdiscr on the DPSP single-subject -% Hot vs Warm maps. Asserts @predictive_model return and -% categorised <-> legacy alias agreement. - - fprintf('=== xval_discriminant_classifier_unit_test ===\n'); - - canlabcore_dir = fileparts(fileparts(which('fmri_data'))); - sample_dir = fullfile(canlabcore_dir, 'Sample_datasets', ... - 'DPSP_pain_rejection_participant_maps'); - H = load(fullfile(sample_dir, 'DPSP_single_subject_images_hot.mat')); - W = load(fullfile(sample_dir, 'DPSP_single_subject_images_warm.mat')); - hot = H.single_subject_images_hot; - warm = W.single_subject_images_warm; - - % Stratify: 100 random voxels (LDA needs n > p) - rng(0); - p = size(hot.dat, 1); - keep_vox = randsample(p, 100); - X = double([hot.dat(keep_vox, :) warm.dat(keep_vox, :)])'; - labels = int32([ones(size(hot.dat, 2), 1); ... - 2*ones(size(warm.dat, 2), 1)]); - - pmodel_obj = xval_discriminant_classifier(X, labels, 'nFolds', 5, ... - 'verbose', false, 'doplot', false); - - assert(isa(pmodel_obj, 'predictive_model'), ... - 'Expected predictive_model, got %s', class(pmodel_obj)); - assert(pmodel_obj.is_fitted, 'is_fitted false'); - fprintf(' class = %s, is_fitted = %d\n', class(pmodel_obj), pmodel_obj.is_fitted); - - % Categorised <-> legacy alias agreement - % Canonical-path access (legacy flat aliases removed). - assert(~isempty(pmodel_obj.Y), 'Y empty'); - assert(~isempty(pmodel_obj.fitted_values.yfit), 'yfit empty'); - assert(~isempty(pmodel_obj.fitted_values.predictions), 'predictions empty'); - assert(~isempty(pmodel_obj.fitted_values.Y_per_fold), 'Y_per_fold empty'); - assert(~isempty(pmodel_obj.error_metrics.accuracy.value), 'accuracy.value empty'); - assert(~isempty(pmodel_obj.error_metrics.overallAccuracy.value), 'overallAccuracy.value empty'); - assert(~isempty(pmodel_obj.cv_partition.trIdx), 'trIdx empty'); - assert(~isempty(pmodel_obj.cv_partition.teIdx), 'teIdx empty'); - assert(~isempty(pmodel_obj.cv_partition.nfolds), 'nfolds empty'); - assert(numel(pmodel_obj.fold_models) == pmodel_obj.cv_partition.nfolds, ... - 'fold_models length should equal nfolds'); - fprintf(' Canonical-path access OK\n'); - - fprintf(' overall_acc = %.1f%%, mean_fold_acc = %.1f%%\n', ... - pmodel_obj.error_metrics.overallAccuracy.value, ... - mean(pmodel_obj.error_metrics.accuracy.value)); - - % --- fit_type + omitted markers (Phase B) --- - assert(strcmp(pmodel_obj.fit_type, 'crossval'), 'fit_type should be crossval'); - assert(islogical(pmodel_obj.omitted_cases), 'omitted_cases must be logical'); - assert(islogical(pmodel_obj.omitted_features), 'omitted_features must be logical'); - fprintf(' fit_type=%s, omitted_cases=%d, omitted_features=%d\n', ... - pmodel_obj.fit_type, sum(pmodel_obj.omitted_cases), sum(pmodel_obj.omitted_features)); - - pmodel_obj.validate_object('noverbose'); - fprintf('xval_discriminant_classifier_unit_test: PASS\n'); -end diff --git a/CanlabCore/Unit_tests/xval_regression_multisubject_featureselect_unit_test.m b/CanlabCore/Unit_tests/xval_regression_multisubject_featureselect_unit_test.m deleted file mode 100644 index b0f45baf..00000000 --- a/CanlabCore/Unit_tests/xval_regression_multisubject_featureselect_unit_test.m +++ /dev/null @@ -1,59 +0,0 @@ -function xval_regression_multisubject_featureselect_unit_test() -% xval_regression_multisubject_featureselect_unit_test DPSP smoke test. -% -% Verifies that xval_regression_multisubject_featureselect returns a -% populated @predictive_model object after the per-fold vox_weights -% padding fix. Predicts a synthetic continuous outcome from -% Hot - Warm DPSP contrast maps. - - fprintf('=== xval_regression_multisubject_featureselect_unit_test ===\n'); - - canlabcore_dir = fileparts(fileparts(which('fmri_data'))); - sd = fullfile(canlabcore_dir, 'Sample_datasets', 'DPSP_pain_rejection_participant_maps'); - H = load(fullfile(sd, 'DPSP_single_subject_images_hot.mat')); - W = load(fullfile(sd, 'DPSP_single_subject_images_warm.mat')); - hvw = image_math(H.single_subject_images_hot, W.single_subject_images_warm, 'minus'); - - rng(0); - [p, n] = size(hvw.dat); - keep_vox = randsample(p, min(2000, p)); - X = double(hvw.dat(keep_vox, :))'; - b_true = zeros(size(X, 2), 1); - b_true(1:30) = randn(30, 1); - Y = X * b_true + 0.5 * std(X * b_true) * randn(n, 1); - - pm = xval_regression_multisubject_featureselect('ols', {Y}, {X}, ... - 'holdout_method', 'balanced4', 'noverbose'); - - assert(isa(pm, 'predictive_model'), 'class mismatch: %s', class(pm)); - assert(pm.is_fitted, 'is_fitted=false'); - fprintf(' class = %s, is_fitted = %d\n', class(pm), pm.is_fitted); - - % Canonical-path access (legacy flat aliases removed). - % mean_vox_weights is only populated when pcsquash is enabled; this no-PCA test doesn't exercise that path. - assert(~isempty(pm.fitted_values.subjfit), 'subjfit empty'); - assert(~isempty(pm.weights.w_perfold), 'weights.w_perfold empty (was vox_weights)'); - assert(~isempty(pm.error_metrics.pred_err.value), 'pred_err empty'); - assert(~isempty(pm.error_metrics.pred_err_null.value), 'pred_err_null empty'); - assert(~isempty(pm.error_metrics.var_reduction.value), 'var_reduction empty'); - assert(~isempty(pm.Y), 'Y empty'); - fprintf(' Canonical-path access OK\n'); - - % Verify weights.w_perfold matches the full feature space (the bug we fixed - % was that this was the *selected*-features count instead). - assert(size(pm.weights.w_perfold, 1) == size(X, 2), ... - 'weights.w_perfold should span full feature count (%d), got %d', ... - size(X, 2), size(pm.weights.w_perfold, 1)); - fprintf(' w_perfold shape OK: %d x %d (features x folds)\n', ... - size(pm.weights.w_perfold, 1), size(pm.weights.w_perfold, 2)); - - r2 = pm.error_metrics.r_squared.value; - assert(~isempty(r2) && r2(1) > 0, 'expected r_squared > 0; got %.3f', r2(1)); - fprintf(' r_squared = %.3f, pred_err = %.3f, pred_err_null = %.3f\n', ... - r2(1), ... - pm.error_metrics.pred_err.value(1), ... - pm.error_metrics.pred_err_null.value(1)); - - pm.validate_object('noverbose'); - fprintf('xval_regression_multisubject_featureselect_unit_test: PASS\n'); -end diff --git a/CanlabCore/Unit_tests/xval_regression_multisubject_unit_test.m b/CanlabCore/Unit_tests/xval_regression_multisubject_unit_test.m deleted file mode 100644 index 0696e886..00000000 --- a/CanlabCore/Unit_tests/xval_regression_multisubject_unit_test.m +++ /dev/null @@ -1,75 +0,0 @@ -function xval_regression_multisubject_unit_test() -% xval_regression_multisubject_unit_test Smoke test using DPSP sample data. -% -% Verifies that xval_regression_multisubject returns a populated -% @predictive_model object whose categorised sub-structs and legacy -% flat aliases agree, on a real (small) brain dataset. -% -% Data: Sample_datasets/DPSP_pain_rejection_participant_maps -% Single-subject Hot - Warm contrast maps, regressed against a -% synthetic continuous outcome to exercise the regression code path. -% -% Run: cd to this directory, then `xval_regression_multisubject_unit_test`. - - fprintf('=== xval_regression_multisubject_unit_test ===\n'); - - % --- Load DPSP Hot - Warm contrast across subjects --- - canlabcore_dir = fileparts(fileparts(which('fmri_data'))); - sample_dir = fullfile(canlabcore_dir, 'Sample_datasets', ... - 'DPSP_pain_rejection_participant_maps'); - H = load(fullfile(sample_dir, 'DPSP_single_subject_images_hot.mat')); - W = load(fullfile(sample_dir, 'DPSP_single_subject_images_warm.mat')); - hot_obj = H.single_subject_images_hot; - warm_obj = W.single_subject_images_warm; - hot_vs_warm = image_math(hot_obj, warm_obj, 'minus'); - - % Keep the test light: ~5000 random voxels, all subjects. - rng(0); - [p, n] = size(hot_vs_warm.dat); - keep_vox = randsample(p, min(5000, p)); - X = double(hot_vs_warm.dat(keep_vox, :))'; % n x v - - % Synthetic continuous outcome predictable from X with sparse weights. - b_true = zeros(size(X, 2), 1); - b_true(1:50) = randn(50, 1); - Y = X * b_true + 0.5 * std(X * b_true) * randn(n, 1); - - % --- Run xval_regression_multisubject with OLS + PCA on a single dataset --- - pmodel_obj = xval_regression_multisubject('ols', {Y}, {X}, ... - 'pca', 'ndims', 10, 'holdout_method', 'balanced4', 'noverbose'); - - % --- Class invariants --- - assert(isa(pmodel_obj, 'predictive_model'), ... - 'Expected output of class predictive_model, got %s', class(pmodel_obj)); - assert(pmodel_obj.is_fitted, 'Returned object reports is_fitted = false'); - fprintf(' class = %s, is_fitted = %d\n', class(pmodel_obj), pmodel_obj.is_fitted); - - % --- Canonical-path access (legacy flat aliases removed) --- - assert(~isempty(pmodel_obj.fitted_values.subjfit), 'subjfit empty'); - assert(~isempty(pmodel_obj.weights.subjbetas), 'subjbetas empty'); - assert(~isempty(pmodel_obj.weights.mean_vox_weights), 'mean_vox_weights empty'); - assert(~isempty(pmodel_obj.error_metrics.pred_err.value), 'pred_err empty'); - assert(~isempty(pmodel_obj.error_metrics.pred_err_null.value), 'pred_err_null empty'); - assert(~isempty(pmodel_obj.error_metrics.var_reduction.value), 'var_reduction empty'); - assert(~isempty(pmodel_obj.error_metrics.r_each_subject.value), 'r_each_subject empty'); - assert(~isempty(pmodel_obj.Y), 'Y empty'); - assert(~isempty(pmodel_obj.inputParameters), 'inputParameters empty'); - fprintf(' Canonical-path access OK\n'); - - % --- Shape / sanity --- - assert(numel(pmodel_obj.fitted_values.subjfit) == 1, 'subjfit cell length should equal num datasets'); - assert(numel(pmodel_obj.fitted_values.subjfit{1}) == n, 'subjfit{1} length should equal n'); - assert(size(pmodel_obj.weights.mean_vox_weights, 1) == size(X, 2), 'mean_vox_weights should have one row per voxel'); - r2 = pmodel_obj.error_metrics.r_squared.value; - assert(~isempty(r2) && r2(1) > 0, 'OLS+PCA should explain non-trivial variance'); - fprintf(' Shape/sanity OK: r_squared = %.3f, pred_err = %.3f, pred_err_null = %.3f\n', ... - r2(1), ... - pmodel_obj.error_metrics.pred_err.value(1), ... - pmodel_obj.error_metrics.pred_err_null.value(1)); - - % --- validate_object accepts the returned object --- - pmodel_obj.validate_object('noverbose'); - fprintf(' validate_object OK\n'); - - fprintf('xval_regression_multisubject_unit_test: PASS\n'); -end diff --git a/CanlabCore/Visualization_functions/addbrain.m b/CanlabCore/Visualization_functions/addbrain.m index 2f0ea5a1..41e86ddf 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/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..57427f67 --- /dev/null +++ b/CanlabCore/docs/fmri_surface_data_methods.md @@ -0,0 +1,375 @@ +# `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. | +| `intent` | `'dscalar'` / `'dtseries'` / `'dlabel'` / `'func'` / `'shape'` / `'label'`. | +| `series_info` | For `.dtseries`: `.start`/`.step`/`.unit`/`.exponent`. | +| `label_table` | For `.dlabel`/`.label`: struct array `.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`, `ica`, `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')); +``` + +### Volume ↔ surface mapping + +#### `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`. Samples the CBIG RF-ANTs MNI→fsaverage +per-vertex coordinates with `interpn`. 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):** loads the bundled mesh matching `surface_space` and colors + vertices directly (no resampling) in a 4-panel figure (L/R × lateral/medial). +- **`'existingsurface', handles`:** color patch handles you already have. +- **`'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. + +| Option | Meaning | +|---|---| +| `'surftype'` | `'inflated'` (default), `'midthickness'`, `'sphere'` (fs_LR). | +| `'which_image'` | map (column) to render (default 1). | +| `'clim'` | `[lo hi]` color limits (default symmetric from data). | +| `'pos_colormap'` / `'neg_colormap'` | `[n × 3]` colormaps (default hot / cool). | + +```matlab +surface(s, 'which_image', 1); % native fs_LR, 4 views +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)` | Map per-vertex values → truecolor (split hot/cool; NaN/zero = 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.** `vol2surf`/`surf2vol` use a fixed group MNI152↔ + fsaverage correspondence (correct for group MNI maps; not a per-subject + ribbon-constrained mapper). Best-in-class per-subject mapping is a future step. +- **fs_LR ↔ fsaverage.** Not yet bridged; keep a workflow in one space, or go + through a volume. +- **Cluster-extent thresholding**, a colorbar/outline overlay on the native render, + and surface-aware overrides of `montage`/`orthviews`/`flip`/`descriptives` are + planned; the volumetric escape hatch (`to_fmri_data`) covers those cases today. +- 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..08658d35 --- /dev/null +++ b/CanlabCore/docs/fmri_surface_data_walkthrough.m @@ -0,0 +1,203 @@ +%% 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: make sure CanlabCore (with Surface_tools/ and @fmri_surface_data/) and +% Neuroimaging_Pattern_Masks are on your path (canlab_toolbox_setup), then step +% through the cells below. + +%% Executive summary +% +% A complete tour in a few commands: +% +% s = fmri_surface_data(which('transcriptomic_gradients.dscalar.nii')); % load CIFTI +% surface(s, 'which_image', 1); % render on the native fs_LR surface +% r = reconstruct_image(s); % dense vertex arrays + subcortex volume +% vol = to_fmri_data(s); % subcortex -> fmri_data (writeable .nii) +% s2 = vol2surf(fmri_data('some.nii')); % project a volume onto the surface +% back= surf2vol(s2); % ...and back to a volume +% write(s, 'out.dscalar.nii'); % write CIFTI natively +% +% Now step through it in detail. + +%% 1. Load a CIFTI grayordinate file +% +% The constructor auto-detects CIFTI (.dscalar/.dtseries/.dlabel.nii) and GIFTI +% (.surf/.func/.shape/.label.gii) by extension and reads them natively. + +ciftifile = which('transcriptomic_gradients.dscalar.nii'); % ships with Neuroimaging_Pattern_Masks +s = fmri_surface_data(ciftifile); + +disp(s.intent) % 'dscalar' +disp(s.surface_space) % 'fsLR_32k' +fprintf('%d grayordinates x %d maps\n', size(s.dat,1), size(s.dat,2)); + +%% 2. Inspect the geometry (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. + +cellfun(@(m) sprintf('%-18s %4s %d rows', m.struct, m.type, m.count), ... + s.brain_model.models, 'UniformOutput', false)' + +% The inherited volInfo describes ONLY the subcortical voxel sub-block: +disp(s.volInfo) + +%% 3. Render on the native cortical surface +% +% surface() loads the bundled mesh matching the object's surface_space (here +% fs_LR-32k) and colors vertices directly -- no resampling. The default is a +% 4-panel figure (left/right x lateral/medial). The medial wall renders gray. + +surface(s, 'which_image', 1); +drawnow, snapnow; + +% Pick a different colormap range or map: +% surface(s, 'which_image', 2, 'clim', [-4 4]); + +%% 4. Reconstruct to dense surfaces and a subcortical volume +% +% reconstruct_image returns per-hemisphere dense vertex arrays (medial wall = +% NaN) and a 3-D/4-D subcortical 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\n', size(r.cortex_left,1), size(r.cortex_left,2)); +fprintf('subcortex vol: %s\n', mat2str(size(r.volume))); + +%% 5. Export the subcortical part to an fmri_data object (and a .nii) +% +% to_fmri_data returns the volumetric grayordinates as a standard fmri_data in +% MNI space, which you can montage, threshold, or write to NIfTI. + +vol = to_fmri_data(s); +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 + +%% 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) +m = mean(img); % a single mean volume +ssurf = vol2surf(m); % -> fmri_surface_data, fsaverage_164k +surface(ssurf); % 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. + +create_figure('mni 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). reparse_contiguous +% labels contiguous clusters using the cortical mesh graph (cortex) and 26-voxel +% connectivity (subcortex), storing labels in brain_model.cluster. + +st = threshold(s, 1.0, 'positive'); +[st, ncl] = reparse_contiguous(st, 'which_image', 1); +fprintf('Found %d contiguous clusters above threshold\n', ncl); + +%% 8b. Parcellate with a surface atlas +% +% apply_parcellation averages each map within the parcels of a surface atlas (a +% .dlabel object on the same space, or an integer key vector). Background / medial +% wall (key 0) is excluded. surface_region summarizes contiguous clusters. + +atlasfile = which('Gordon333.32k_fs_LR_Tian_Subcortex_S2.dlabel.nii'); +if ~isempty(atlasfile) + atl = fmri_surface_data(atlasfile); % 91k surface atlas + if compare_space(s, atl) == 0 + [parcel_means, parcel_labels] = apply_parcellation(s, atl); + fprintf('Parcellated into %d parcels (%d maps)\n', size(parcel_means,2), size(parcel_means,1)); + end +end + +% Clusters of the thresholded map as region-like structs: +reg = surface_region(st, 'which_image', 1); +fprintf('surface_region: %d clusters; sizes: %s\n', numel(reg), mat2str([reg.numVox])); + +%% 9. Other analysis-style operations +% +% mean() averages across maps; apply_mask() keeps a subset of grayordinates +% (zeroing the rest -- no shrinking, since grayordinate data is already compact). + +mn = mean(s); % single-map mean +keep = s.dat(:,1) > 0; % a simple grayordinate mask +smasked = apply_mask(s, keep); % zero out non-positive grayordinates + +%% 9b. Group analysis: cat, ttest, regress, predict +% +% Combine per-subject surface maps with cat (or [a, b, ...]) into a single +% object, then run the same analyses as fmri_data. Here we simulate a few +% "subjects" 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 t-test +surface(tmap, 'clim', [-4 4]); +drawnow, snapnow; + +% Cross-validated prediction of an outcome (here a synthetic one): +group.Y = (1:5)'; +[cverr, stats] = predict(group, 'algorithm_name', 'cv_lassopcr', 'nfolds', 5, 'verbose', 0); +% surface(stats.weight_obj); % render the predictive weight map + +% OLS regression onto a design matrix: +group.X = [ones(5,1), (1:5)']; +b = regress(group); % betas in b.dat; t/p in b.additional_info.statistic + +%% 10. 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, 'gradients_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, 'gradients_copy.dscalar.nii')); +fprintf('Round-trip max abs diff: %g\n', max(abs(double(s.dat(:)) - double(s_reloaded.dat(:))))); + +%% 11. 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/fmri_surface_data_design_plan.md - design rationale and roadmap +% methods(fmri_surface_data) - the full method list diff --git a/docs/Object_methods.md b/docs/Object_methods.md index fa39f079..3ecab82b 100644 --- a/docs/Object_methods.md +++ b/docs/Object_methods.md @@ -34,6 +34,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 @@ -57,6 +58,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 ddc3cef5..066a3383 100644 --- a/docs/Workflows.md +++ b/docs/Workflows.md @@ -16,6 +16,7 @@ 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) | *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..ac05c75f --- /dev/null +++ b/docs/fmri_surface_data_methods.md @@ -0,0 +1,154 @@ +# `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` +- **Design rationale / roadmap:** `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. + +## 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` | Cortical mesh cache (faces/vertices) used for rendering. | +| `intent` | `dscalar` / `dtseries` / `dlabel` / `func` / `shape` / `label`. | +| `series_info` | For `.dtseries`: `.start`/`.step`/`.unit`/`.exponent`. | +| `label_table` | For `.dlabel`/`.label`: struct array `.key`/`.name`/`.rgba`. | +| `surface_space` | e.g. `fsLR_32k`, `fsaverage_164k`. Gatekeeps `compare_space`; drives mesh/warp choice. | +| `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) | + +## Volume ↔ surface mapping + +| Method | From | One-liner | +|---|---|---| +| `vol2surf` | `@image_vector` | Project a volumetric image (MNI152) onto the fsaverage-164k surface via the CBIG RF mapping (`interpn`) | +| `surf2vol` | `@fmri_surface_data` | Project an fsaverage-164k object back to an MNI152 `fmri_data` volume (self-consistent inverse of `vol2surf`) | + +## 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 + +Delegated methods treat each grayordinate as a feature (reusing the `fmri_data` +algorithms) and remap geometry-bearing results back to the surface. + +| 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` | Render on the native mesh (4-panel), on `'existingsurface'` handles, or an `'mni_surface'` addbrain surface | +| `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 | + +## 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`, …) | + +## 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..dfa3afc2 --- /dev/null +++ b/docs/workflows/fmri_surface_data_howto.md @@ -0,0 +1,208 @@ +# 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`. + +## Quick reference + +```matlab +s = fmri_surface_data(which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii')); % load CIFTI +surface(s); % render on the native fs_LR surface +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.intent % '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`. It samples the vendored CBIG registration-fusion +MNI↔fsaverage mapping — fully native. 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`): + +```matlab +% mydata is an fmri_surface_data on the atlas's 91k grayordinate set: +if compare_space(mydata, atl) == 0 + [parcel_means, labels] = apply_parcellation(mydata, atl); % [nMaps x nParcels] +end +``` + +Threshold (raw value, with optional cluster extent), then summarize contiguous +clusters as region-like structs: + +```matlab +tthr = threshold(ssurf, 3, 'positive', 'k', 20); % >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: + +```matlab +group = cat(subj1, subj2, subj3); % or [subj1 subj2 subj3] +tmap = ttest(group); % grayordinate-wise t-test +b = regress(group); % OLS on group.X (betas in .dat) +group.Y = scores(:); +[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.** `vol2surf`/`surf2vol` use a fixed group + MNI152↔fsaverage correspondence (correct for group MNI maps; not a per-subject + ribbon mapper). +- **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` — the runnable script version +- `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