From 12de8fd3af1063fe2d871b8cb944cf40a6752d2c Mon Sep 17 00:00:00 2001 From: Tor Wager Date: Mon, 13 Jul 2026 16:59:34 -0400 Subject: [PATCH 1/4] Fix surface-render perf regression: defer volume projection, use nearest for display volume surface()/addblobs() for cortex-only fs_LR data on a mixed patch layout (e.g. foursurfaces_hcp) became very slow: incidental subcortical anatomy meshes triggered a full barycentric resample + surf2vol projection even though the cortex painted natively on its own 32492 mesh. render_layer_surfaces (paint_surface_native_layer): - Per view, defer arbitrary (non-standard) meshes and project to a volume ONLY when no native/resampled cortical patch was painted in that view. Cortex-only data on foursurfaces_hcp now paints natively with no projection (addblobs ~0.2s vs ~48s before). - Print a one-time message when resampling onto a standard mesh or projecting to a volume, so the workspace shows when work is happening. to_display_volume: resample cortex to fsaverage-164k by nearest neighbour instead of barycentric. This volume is display-only (sampled at mesh vertices to colour a patch); at 164k density the result is visually identical but ~250x faster (0.2s vs ~48s -- the barycentric weight build dominated). Arbitrary-mesh render (hires left / cutaway) now ~0.8s. Surface render + fmridisplay suites: 23/23 pass. Co-Authored-By: Claude Opus 4.8 --- .../@fmri_surface_data/to_display_volume.m | 7 +- .../@fmridisplay/render_layer_surfaces.m | 93 +++++++++++++------ 2 files changed, 68 insertions(+), 32 deletions(-) diff --git a/CanlabCore/@fmri_surface_data/to_display_volume.m b/CanlabCore/@fmri_surface_data/to_display_volume.m index 09e26f5a..e262d7e8 100644 --- a/CanlabCore/@fmri_surface_data/to_display_volume.m +++ b/CanlabCore/@fmri_surface_data/to_display_volume.m @@ -42,8 +42,11 @@ fs = obj; if ~strcmp(obj.surface_space, 'fsaverage_164k') % surf2vol needs fsaverage (the CBIG warp is fsaverage-based); get there - % natively. resample_surface auto-selects nearest for label/binary data. - fs = resample_surface(obj, 'fsaverage_164k'); + % natively. This volume is for DISPLAY ONLY (sampled at mesh vertices to + % colour a patch), so resample by nearest neighbour: at 164k density the + % visual result is indistinguishable from barycentric, but ~250x faster + % (0.2 s vs ~48 s -- the barycentric weight build dominates otherwise). + fs = resample_surface(obj, 'fsaverage_164k', 'interp', 'nearest'); end volc = surf2vol(fs); end diff --git a/CanlabCore/@fmridisplay/render_layer_surfaces.m b/CanlabCore/@fmridisplay/render_layer_surfaces.m index 3f1be542..fea0f16b 100644 --- a/CanlabCore/@fmridisplay/render_layer_surfaces.m +++ b/CanlabCore/@fmridisplay/render_layer_surfaces.m @@ -242,11 +242,20 @@ hemiL(native_nv) = local_apply_thr(L0, thr); hemiR(native_nv) = local_apply_thr(R0, thr); +% NOTE ON SPEED: a patch whose vertex count is the object's OWN space is painted +% directly (the common, fast path -- no resample/projection). Only a DIFFERENT +% standard cortical mesh triggers resample_surface, and only an arbitrary mesh in a +% view with NO native cortical match triggers the (slower) volume projection -- so +% e.g. the small subcortical anatomy meshes bundled into a foursurfaces_hcp layout +% do NOT force a projection when the cortex is already painted natively. n_painted = 0; n_unknown = 0; +resample_msg_shown = false; project_msg_shown = false; for i = wh_surface if i < 1 || i > numel(obj.surface), continue; end surfh = obj.surface{i}.object_handle; surfh = surfh(ishandle(surfh)); + view_cortex_painted = false; % a native/resampled cortical patch got the data + nonstd = gobjects(0); % arbitrary meshes deferred to volume projection for hh = surfh(:)' if ~strcmp(get(hh, 'Type'), 'patch'), continue; end V = get(hh, 'Vertices'); @@ -254,46 +263,27 @@ tag = lower(get(hh, 'Tag')); if iscell(tag), tag = strjoin(tag, ' '); end - % Ensure hemisphere data exists for this mesh's vertex count (resample if - % it is a recognized standard space different from the object's own). + % Ensure hemisphere data exists for this mesh's vertex count. if ~isKey(hemiL, nv) tgt = local_std_space_name(nv); if isempty(tgt) - % Non-standard MNI isosurface (e.g. addbrain 'hires left', - % 'cutaway'): no spherical registration exists, so paint it by - % projecting the data to a volume and sampling that volume at the - % patch's vertices (image_vector.render_on_surface). The volume is - % cached on the layer so re-renders stay fast. - if ~isfield(layer, 'display_volume') || isempty(layer.display_volume) - try - layer.display_volume = to_display_volume(surf); - obj.activation_maps{k}.display_volume = layer.display_volume; - catch - n_unknown = n_unknown + 1; continue; - end - end - vimg = layer.display_volume; - if size(vimg.dat, 2) > 1, vimg = get_wh_image(vimg, which_image); end - if ~isempty(thr) && isscalar(thr) && isfinite(thr) && ~strcmp(tc_map.type, 'indexed') - vimg.dat(abs(vimg.dat) < thr) = 0; % apply the layer threshold - end - cargs = {'truecolor', tc_map, 'truecolor_alpha', alpha, 'nolegend'}; - if ~isempty(cmaprange), cargs = [cargs, {'cmaprange', cmaprange}]; end %#ok - if strcmp(tc_map.type, 'indexed'), cargs = [cargs, {'interp', 'nearest'}]; end %#ok - try - render_on_surface(vimg, hh, cargs{:}); - n_painted = n_painted + 1; - catch - n_unknown = n_unknown + 1; - end + nonstd(end + 1) = hh; %#ok % arbitrary mesh: defer (project only if needed) continue end + % A DIFFERENT standard cortical mesh (e.g. fs_LR <-> fsaverage): resample + % the source onto it once, cached on the layer. key = sprintf('nv%d', nv); if ~isfield(layer.resampled, key) + if ~resample_msg_shown + fprintf('render_layer_surfaces: resampling %s data onto a %s mesh (once; cached)...\n', ... + surf.surface_space, tgt); + resample_msg_shown = true; + end try layer.resampled.(key) = resample_surface(surf, tgt, 'interp', 'nearest'); catch - n_unknown = n_unknown + 1; continue; + nonstd(end + 1) = hh; %#ok % fall back to volume projection + continue end obj.activation_maps{k}.resampled = layer.resampled; % persist for re-renders end @@ -337,6 +327,49 @@ out(col, :) = alpha * rgb(col, :) + (1 - alpha) * base(col, :); set(hh, 'FaceVertexCData', out, 'FaceColor', 'interp', 'EdgeColor', 'none'); n_painted = n_painted + 1; + view_cortex_painted = true; + end + + % Arbitrary (non-standard) meshes in this view are painted via a volume + % projection -- but ONLY if no native/resampled cortical patch already + % received the data in this view. This avoids the expensive resample + + % surf2vol round-trip for cortex-only data (e.g. fs_LR myelin) rendered on + % a mixed patch set like 'foursurfaces_hcp', where incidental subcortical + % anatomy meshes would otherwise trigger a needless projection. + if ~isempty(nonstd) && ~view_cortex_painted + if ~isfield(layer, 'display_volume') || isempty(layer.display_volume) + if ~project_msg_shown + fprintf(['render_layer_surfaces: projecting %s data to a volume to render ' ... + 'on an arbitrary surface (once; cached)...\n'], surf.surface_space); + project_msg_shown = true; + end + try + layer.display_volume = to_display_volume(surf); + obj.activation_maps{k}.display_volume = layer.display_volume; + catch + layer.display_volume = []; + end + end + if ~isempty(layer.display_volume) + vimg = layer.display_volume; + if size(vimg.dat, 2) > 1, vimg = get_wh_image(vimg, which_image); end + if ~isempty(thr) && isscalar(thr) && isfinite(thr) && ~strcmp(tc_map.type, 'indexed') + vimg.dat(abs(vimg.dat) < thr) = 0; + end + cargs = {'truecolor', tc_map, 'truecolor_alpha', alpha, 'nolegend'}; + if ~isempty(cmaprange), cargs = [cargs, {'cmaprange', cmaprange}]; end + if strcmp(tc_map.type, 'indexed'), cargs = [cargs, {'interp', 'nearest'}]; end + for hh = nonstd(:)' + try + render_on_surface(vimg, hh, cargs{:}); + n_painted = n_painted + 1; + catch + n_unknown = n_unknown + 1; + end + end + else + n_unknown = n_unknown + numel(nonstd); + end end end From d74711b495f50eb588f6f4dfab302d4524e3291a Mon Sep 17 00:00:00 2001 From: Tor Wager Date: Mon, 13 Jul 2026 17:18:02 -0400 Subject: [PATCH 2/4] fmri_surface_data.surface: default four-surface view matches the object's space surface(obj) with no explicit surface argument now picks a four-surface view that matches the object's surface-space family, so the data renders at native (or near-native) fidelity by default: - fsLR_32k -> foursurfaces_hcp (native, 32492/hemi) - fsaverage_164k -> foursurfaces_freesurfer (native, 163842/hemi) - fsaverage6/5/4 -> foursurfaces_freesurfer (nested; resampled up to 164k) - onavg_41k/10k -> foursurfaces_hcp (fs_LR-aligned; resampled to fs_LR) No new mesh assets are needed: fsaverage6/5/4 are nested subsets of fsaverage-164k and onavg is fs_LR-aligned via the vendored onavg spheres, so each renders on its parent/aligned display space through the fast, cached nearest-neighbour resample already in render_layer_surfaces (a one-line message prints when a resample happens). Previously every non-fs_LR/-164k space fell back to foursurfaces_hcp, forcing an unnecessary cross-family resample. @fmridisplay/surface: surface_default_keyword now keys off a coarse space family (local_space_family) rather than two exact strings. @fmri_surface_data/surface: - Fix parser bug: a bare surface keyword (e.g. surface(obj,'foursurfaces_hcp') or surface(obj,'hcp inflated left')) fell into the colour-option branch and indexed past the end of varargin. Non-mode tokens are now split into colour options (value pairs) vs surface directives (bare token / direction- orientation-axes pairs); directives are forwarded to the native managed path and override the space-matched default view. - Expanded help: a Surface-spaces table (space, verts/hemi, default view, mesh files) and runnable examples rendering one object per space plus the existing/mni-surface modes. New test test_default_view_matches_space (canlab_test_surface_render): verifies fs_LR/fsaverage6/onavg pick the right view and paint, and that an explicit keyword overrides without erroring. Surface render + fmridisplay suites: 24/24 pass. Co-Authored-By: Claude Opus 4.8 --- CanlabCore/@fmri_surface_data/surface.m | 120 ++++++++++++++---- CanlabCore/@fmridisplay/surface.m | 41 +++++- .../surface_data/canlab_test_surface_render.m | 56 ++++++++ 3 files changed, 187 insertions(+), 30 deletions(-) diff --git a/CanlabCore/@fmri_surface_data/surface.m b/CanlabCore/@fmri_surface_data/surface.m index 66671588..0741d096 100644 --- a/CanlabCore/@fmri_surface_data/surface.m +++ b/CanlabCore/@fmri_surface_data/surface.m @@ -13,13 +13,15 @@ % image_vector.surface. Three modes: % % 1. NATIVE (default): builds a managed, stateful fmridisplay whose surface -% views are the mesh set that matches the object's surface_space (fs_LR-32k -% -> 'foursurfaces_hcp', fsaverage-164k -> 'foursurfaces_freesurfer'), then -% paints the data as a MANAGED surface-native layer (colored DIRECTLY from -% the per-vertex data -- no resampling; medial wall and zeros render gray). -% Because the returned object is a stateful fmridisplay under a controller, -% set_colormap / set_opacity / rethreshold / removeblobs / refresh act on -% the surfaces (this is why the colormap is now changeable after the fact). +% views are the four-surface set that MATCHES the object's surface_space +% (see "Surface spaces" below), then paints the data as a MANAGED +% surface-native layer (colored DIRECTLY from the per-vertex data -- no +% resampling when the mesh is the object's own space; medial wall and zeros +% render gray). Because the returned object is a stateful fmridisplay under +% a controller, set_colormap / set_opacity / rethreshold / removeblobs / +% refresh act on the surfaces (this is why the colormap is changeable after +% the fact). With NO explicit surface argument, the default view is chosen +% to match the object's space (below); passing a surface keyword overrides it. % % 2. EXISTING SURFACE ('existingsurface', han): colors patch handles you % already have (e.g. from addbrain or a prior surface call). Matching-space @@ -30,11 +32,40 @@ % 'left', 'right', 'hcp inflated') and renders onto it, projecting to a % volume when the surface is not the object's native mesh. % +% :Surface spaces and their default display meshes: +% +% The object's .surface_space determines the default four-surface view. Two +% spaces have NATIVE display meshes bundled with CanlabCore and paint directly; +% the rest render on the four-surface view of their parent / aligned space via +% a fast, cached nearest-neighbour resample (a one-line message prints when +% that happens). All mesh files live under +% canlab_canonical_brains/Canonical_brains_surfaces/. +% +% ====================================================================================== +% surface_space verts/hemi default view display mesh files +% ====================================================================================== +% fsLR_32k 32492 foursurfaces_hcp S12000.L/R.inflated_MSMAll.32k_fsl_LR.mat (native) +% fsaverage_164k 163842 foursurfaces_freesurfer surf_freesurf_inflated_Left/Right.mat (native) +% fsaverage6 40962 foursurfaces_freesurfer (resampled up to fsaverage-164k, then above) +% fsaverage5 10242 foursurfaces_freesurfer (resampled up to fsaverage-164k, then above) +% fsaverage4 2562 foursurfaces_freesurfer (resampled up to fsaverage-164k, then above) +% onavg_41k 40962 foursurfaces_hcp (onavg is fs_LR-aligned -> resampled to fs_LR) +% onavg_10k 10242 foursurfaces_hcp (onavg is fs_LR-aligned -> resampled to fs_LR) +% ====================================================================================== +% +% Other fs_LR surftypes (fsLR_32k only): 'midthickness' uses +% S1200.L/R.midthickness_MSMAll.32k_fs_LR.surf.gii and 'sphere' uses +% S1200.L/R.sphere.32k_fs_LR.mat (see 'surftype' below). fsaverage-164k has an +% inflated mesh only. To render in a space with no bundled mesh at true native +% fidelity, resample first with resample_surface (see :See also). +% % :Optional Inputs: -% **'surftype':** 'inflated' (default), 'midthickness', 'sphere' (fs_LR). +% **'surftype':** 'inflated' (default), 'midthickness', 'sphere' (fs_LR only). % **'which_image':** map (column) to render. Default 1. % **'existingsurface':** vector of patch handles to color. % **'mni_surface':** an addbrain keyword (string). +% Any addbrain surface keyword given as a bare token (e.g. 'foursurfaces_hcp', +% 'hcp inflated left') overrides the space-matched default view. % % Color options (harmonized with the volume pipeline; forwarded to % render_on_surface -- see there for details): @@ -54,24 +85,60 @@ % % :Examples: % :: +% % ---- Native four-surface render, one example per surface space ---- +% % Each object's space picks its matching four-surface view automatically. +% +% % fs_LR-32k (HCP CIFTI grayordinates) -> foursurfaces_hcp % s = fmri_surface_data(which('S1200.MyelinMap_MSMAll.32k_fs_LR.dscalar.nii')); -% surface(s, 'which_image', 1); +% surface(s); % -% v = ttest(load_image_set('emotionreg')); % a volumetric statistic_image -% surface(vol2surf(v)); % native fsaverage render -% surface(vol2surf(v), 'mni_surface', 'left'); % on an addbrain MNI surface +% % fsaverage-164k (from a volume via CBIG registration fusion) -> foursurfaces_freesurfer +% v = ttest(load_image_set('emotionreg')); % a volumetric statistic_image +% sf = vol2surf(v); % fsaverage-164k object +% surface(sf, 'clim', [-4 4]); % -% :See also: render_on_surface, load_surface_geom, addbrain, fmri_surface_data +% % Nested fsaverage6 (resamples up onto the fsaverage-164k four-surface view) +% s6 = resample_surface(s, 'fsaverage6'); +% surface(s6); +% +% % onavg (equal-area; fs_LR-aligned, so it renders on the fs_LR view) +% son = resample_surface(s, 'onavg_41k'); +% surface(son); +% +% % Other fs_LR surftypes (fsLR_32k meshes only) +% surface(s, 'surftype', 'midthickness'); +% surface(s, 'surftype', 'sphere'); +% +% % ---- Rendering onto a specific existing / MNI surface ---- +% surface(s, 'foursurfaces_hcp'); % explicit view (overrides default) +% surface(sf, 'mni_surface', 'left'); % an addbrain MNI pial surface (via volume) +% hp = addbrain('hcp inflated left'); % a native fs_LR patch +% surface(s, 'existingsurface', hp); % color it directly (no resampling) +% +% :See also: render_on_surface, resample_surface, load_surface_geom, addbrain, fmri_surface_data surftype = 'inflated'; which_image = 1; existing = []; mni_surface = ''; -coloropts = {}; % forwarded to render_on_surface (harmonized colors) +coloropts = {}; % colour opts -> render_on_surface / the managed layer +surf_args = {}; % surface directives (which view) -> @fmridisplay/surface + +% Colour options that take a VALUE (consume a pair). Everything else that is not +% a recognized mode keyword is a surface directive forwarded to the native +% managed path -- either a bare addbrain/foursurfaces keyword (single token) or a +% 'direction'/'orientation'/'axes' pair -- so surface(obj, 'foursurfaces_hcp'), +% surface(obj, 'hcp inflated left'), etc. override the space-matched default view. +color_value_keys = {'clim', 'cmaprange', 'colormap', 'colormapname', ... + 'pos_colormap', 'neg_colormap', 'splitcolor', 'maxcolor', 'mincolor', ... + 'color', 'transvalue'}; +surf_pair_keys = {'direction', 'orientation', 'axes'}; i = 1; while i <= numel(varargin) - switch lower(char(varargin{i})) + tok = varargin{i}; + key = ''; if ischar(tok) || isstring(tok), key = lower(char(tok)); end + switch key case 'surftype', surftype = varargin{i+1}; i = i + 2; case 'which_image', which_image = varargin{i+1}; i = i + 2; case {'existingsurface','surface_handles'}, existing = varargin{i+1}; i = i + 2; @@ -80,12 +147,16 @@ coloropts = [coloropts, varargin(i)]; %#ok i = i + 1; otherwise - % Any other option (clim, colormap, cmaprange, pos_colormap / - % neg_colormap, splitcolor, maxcolor / mincolor, color, ...) is - % forwarded to render_on_surface, which harmonizes them with the - % volume visualization color pipeline. - coloropts = [coloropts, varargin(i:i+1)]; %#ok - i = i + 2; + if any(strcmp(key, color_value_keys)) + coloropts = [coloropts, varargin(i:i+1)]; %#ok + i = i + 2; + elseif any(strcmp(key, surf_pair_keys)) + surf_args = [surf_args, varargin(i:i+1)]; %#ok + i = i + 2; + else + surf_args = [surf_args, varargin(i)]; %#ok % bare surface directive + i = i + 1; + end end end @@ -113,9 +184,12 @@ % on the returned object). The heavy lifting -- pick the matching surfaces, add % them, paint at full fidelity -- is done by @fmridisplay/surface's % fmri_surface_data path, so there is a single code path for surface(obj) and -% surface(o2, obj). +% surface(o2, obj). A surf_args entry (e.g. 'foursurfaces_hcp', a bare addbrain +% keyword, or a direction/orientation/axes pair) overrides the space-matched +% default view; with none, @fmridisplay/surface picks the matching four-surface +% set (see surface_default_keyword). o2 = fmridisplay; -o2 = surface(o2, obj, ropts{:}); +o2 = surface(o2, obj, ropts{:}, surf_args{:}); % Honor a non-default surftype (midthickness / sphere) by swapping the managed % patches' geometry to the requested mesh. The foursurfaces keyword draws diff --git a/CanlabCore/@fmridisplay/surface.m b/CanlabCore/@fmridisplay/surface.m index 90ed7d05..a3614c16 100644 --- a/CanlabCore/@fmridisplay/surface.m +++ b/CanlabCore/@fmridisplay/surface.m @@ -475,13 +475,40 @@ function kw = surface_default_keyword(surf_data) -% Multi-surface keyword whose meshes MATCH the object's surface space, so the -% data paints directly (no cross-space resampling). Used when surface(o2, -% surf_obj) is called with no explicit surface directive. -switch surf_data.surface_space - case 'fsLR_32k', kw = 'foursurfaces_hcp'; % 32492 verts/hemi - case 'fsaverage_164k', kw = 'foursurfaces_freesurfer'; % 163842 verts/hemi - otherwise, kw = 'foursurfaces_hcp'; % best-effort default +% Default four-surface view (L/R lateral + medial) whose meshes MATCH -- or best +% match -- the object's surface space, so the data paints at full fidelity with +% no (or a cheap, cached) resample. Used when surface(o2, surf_obj) is called +% with no explicit surface directive. +% +% Two spaces have native display meshes bundled with CanlabCore: +% fsLR_32k (32492 verts/hemi) -> foursurfaces_hcp (native paint) +% fsaverage_164k (163842 verts/hemi) -> foursurfaces_freesurfer (native paint) +% +% The other supported spaces have no bundled display mesh, so they render on the +% four-surface view of their PARENT / aligned display space (a fast, cached +% nearest-neighbour resample handled by render_layer_surfaces): +% fsaverage6/5/4 (nested icosahedra) -> foursurfaces_freesurfer (resample up to 164k) +% onavg_41k/10k (equal-area, fs_LR-aligned) -> foursurfaces_hcp (resample to fs_LR-32k) +switch local_space_family(surf_data.surface_space) + case 'fslr', kw = 'foursurfaces_hcp'; % fs_LR-32k (native) + case 'fsaverage', kw = 'foursurfaces_freesurfer'; % fsaverage-164k (native for 164k; 6/5/4 resample up) + case 'onavg', kw = 'foursurfaces_hcp'; % onavg is fs_LR-aligned -> render on fs_LR + otherwise, kw = 'foursurfaces_hcp'; % best-effort default +end +end + + +function fam = local_space_family(space) +% Coarse family of a surface_space string ('fslr' | 'fsaverage' | 'onavg' | ''). +s = lower(char(space)); +if contains(s, 'onavg') + fam = 'onavg'; +elseif contains(s, 'fsaverage') || contains(s, 'fsavg') + fam = 'fsaverage'; +elseif contains(s, 'fslr') || contains(s, 'fs_lr') + fam = 'fslr'; +else + fam = ''; end 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 index d37ea37a..c66f9c77 100644 --- a/CanlabCore/Unit_tests/surface_data/canlab_test_surface_render.m +++ b/CanlabCore/Unit_tests/surface_data/canlab_test_surface_render.m @@ -156,6 +156,46 @@ function test_render_on_mni_surface_via_volume(t) end +% ------------------------------------------------------------------------- +function test_default_view_matches_space(t) +% surface(obj) with NO surface directive picks a four-surface view matching the +% object's space family: fs_LR -> foursurfaces_hcp (native 32492); fsaverage +% (incl. nested 6/5/4) -> foursurfaces_freesurfer (163842); onavg -> fs_LR view +% (onavg is fs_LR-aligned). Data paints on the resulting cortical patches in +% every case. +if isempty(which('resample_surface')), t.assumeFail('resample_surface not on path.'); end +s = t.TestData.s; % fs_LR-32k + +% Native fs_LR -> foursurfaces_hcp (32492-vertex patches painted directly). +o = surface(s); +verifyNotEmpty(t, cortex_patches(o, 32492), 'fs_LR default view should have 32492 patches.'); +verifyGreaterThan(t, painted_frac(o, 32492), 0.5, 'fs_LR default view should paint the cortex.'); +close all force; + +% fsaverage6 (nested) -> renders on the fsaverage-164k four-surface view. +s6 = resample_surface(s, 'fsaverage6', 'interp', 'nearest'); +verifyEqual(t, s6.surface_space, 'fsaverage6'); +o6 = surface(s6); +verifyNotEmpty(t, cortex_patches(o6, 163842), 'fsaverage6 should render on 163842-vertex patches.'); +verifyGreaterThan(t, painted_frac(o6, 163842), 0.5, 'fsaverage6 default view should paint the cortex.'); +close all force; + +% onavg (fs_LR-aligned) -> renders on the fs_LR four-surface view. +son = resample_surface(s, 'onavg_41k', 'interp', 'nearest'); +verifyEqual(t, son.surface_space, 'onavg_41k'); +oon = surface(son); +verifyNotEmpty(t, cortex_patches(oon, 32492), 'onavg should render on the fs_LR (32492) view.'); +verifyGreaterThan(t, painted_frac(oon, 32492), 0.5, 'onavg default view should paint the cortex.'); +close all force; + +% An explicit surface keyword overrides the space-matched default (and does not +% error on a bare token -- the historical parser bug). +oe = surface(s, 'foursurfaces_hcp'); +verifyNotEmpty(t, cortex_patches(oe, 32492), 'Explicit foursurfaces_hcp keyword should build the view.'); +close all force; +end + + % ------------------------------------------------------------------------- function test_atlas_surface_unique(t) % A volumetric atlas rendered on surfaces uses UNIQUE per-region solid colours @@ -218,6 +258,22 @@ function test_plot_runs(t) end +% ========================================================================= +function frac = painted_frac(o2, nv) +% Fraction of vertices coloured non-gray across all nv-vertex cortical patches. +p = cortex_patches(o2, nv); +tot = 0; nz = 0; +for k = 1:numel(p) + c = get(p(k), 'FaceVertexCData'); + if size(c, 2) == 3 && size(c, 1) == nv + tot = tot + nv; + nz = nz + sum(~all(abs(c - 0.5) < 1e-6, 2)); + end +end +frac = nz / max(tot, 1); +end + + % ========================================================================= function vol = local_synthetic_volume() dims = [91 109 91]; From 125c445c203b9aa3bf84d1943d9f9b55a865b957 Mon Sep 17 00:00:00 2001 From: Tor Wager Date: Tue, 14 Jul 2026 15:35:36 -0400 Subject: [PATCH 3/4] Docs: add per-space default four-surface view table to fmri_surface_data surface reference Documents which four-surface view surface(obj) picks for each surface_space (fsLR_32k/fsaverage_164k render natively; nested fsaverage6/5/4 -> fsaverage-164k; onavg -> fs_LR, both via a fast cached nearest resample). Added the table to both the comprehensive reference (CanlabCore/docs) and the readthedocs functional index (docs). Also refreshed the comprehensive doc's Surface-spaces section (all 7 spaces; resample_surface is implemented, not a 'planned enhancement') and corrected the surface() space-mismatch text (standard meshes auto-resample; only truly arbitrary meshes project via a volume). Co-Authored-By: Claude Opus 4.8 --- CanlabCore/docs/fmri_surface_data_methods.md | 71 ++++++++++++++------ docs/fmri_surface_data_methods.md | 19 ++++++ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/CanlabCore/docs/fmri_surface_data_methods.md b/CanlabCore/docs/fmri_surface_data_methods.md index ee32930b..374e26ff 100644 --- a/CanlabCore/docs/fmri_surface_data_methods.md +++ b/CanlabCore/docs/fmri_surface_data_methods.md @@ -318,25 +318,44 @@ reg = surface_region(threshold(t, 3, 'positive', 'k', 20)); Render on cortical surfaces. Three modes: - **Native (default):** builds a **managed, stateful `fmridisplay`** whose surface - views are the mesh set matching `surface_space` (fs_LR-32k → `'foursurfaces_hcp'`, - fsaverage-164k → `'foursurfaces_freesurfer'`), and paints the data as a managed - surface-native layer — colored directly, no resampling. Because the returned - object is under a controller, `set_colormap` / `set_opacity` / `rethreshold` / - `removeblobs` / `refresh` act on the surfaces (this is how you change the colormap - after rendering). Each hemisphere shows its own data. + views are a four-surface set (L/R lateral + medial) **chosen to match the object's + `surface_space`** (table below), and paints the data as a managed surface-native + layer. Because the returned object is under a controller, `set_colormap` / + `set_opacity` / `rethreshold` / `removeblobs` / `refresh` act on the surfaces (this + is how you change the colormap after rendering). Each hemisphere shows its own data. - **`'existingsurface', handles`:** color patch handles you already have (returns a struct with `.figure`/`.axes`/`.surfaces`). - **`'mni_surface', name`:** create an `addbrain` surface (e.g. `'left'`, `'hcp inflated'`) and render onto it, projecting through a volume when the surface is not the object's native mesh (returns a struct). +**Default four-surface view per surface space.** With no explicit surface argument, +`surface(obj)` picks the view that matches the object's space, so the data renders at +native (or near-native) fidelity automatically: + +| `surface_space` | verts/hemi | default four-surface view | how it paints | +|---|---|---|---| +| `fsLR_32k` | 32,492 | `foursurfaces_hcp` | **native** (direct, no resampling) | +| `fsaverage_164k` | 163,842 | `foursurfaces_freesurfer` | **native** (direct, no resampling) | +| `fsaverage6` / `fsaverage5` / `fsaverage4` | 40,962 / 10,242 / 2,562 | `foursurfaces_freesurfer` | resampled **up** to fsaverage-164k (nearest, cached) | +| `onavg_41k` / `onavg_10k` | 40,962 / 10,242 | `foursurfaces_hcp` | resampled to fs_LR-32k (nearest, cached) | + +Only `fsLR_32k` and `fsaverage_164k` ship inflated display meshes (in +`canlab_canonical_brains/Canonical_brains_surfaces/`). The other spaces need no new +mesh assets: the nested fsaverage spaces are subsets of fsaverage-164k, and onavg is +fs_LR-aligned via the bundled onavg spheres, so each renders on its parent/aligned +display space through a fast, cached nearest-neighbour resample (a one-line +`render_layer_surfaces: resampling …` message prints the first time). + You can also add a surface object to an **existing** managed display: -`o2 = surface(o2, obj)` adds the object's matching native surfaces and paints it; -`o2 = surface(o2, obj, 'foursurfaces_hcp')` targets a named surface set. Requesting a -surface of a **different** space than the data (e.g. fsaverage meshes for fs_LR data) -warns (`fmridisplay:render_layer_surfaces:spacemismatch`) rather than mapping wrong — -a surface object has no volume to resample onto a foreign mesh, so use the matching -surfaces (the bare `surface(obj)` picks them automatically). +`o2 = surface(o2, obj)` adds the space-matched native surfaces and paints it; +`o2 = surface(o2, obj, 'foursurfaces_hcp')` (or any `addbrain` surface keyword) +overrides that with a named surface set. Requesting a **different standard** mesh than +the data (e.g. fsaverage meshes for fs_LR data) auto-resamples the object onto that +mesh (the same fast, cached nearest-neighbour resample, with the printed message); an +**arbitrary** (non-standard) MNI mesh is handled by projecting the object to a volume. +The `fmridisplay:render_layer_surfaces:spacemismatch` warning fires only when a mesh is +neither a recognized standard cortical mesh nor projectable. | Option | Meaning | |---|---| @@ -391,16 +410,26 @@ only on core MATLAB (+ the JVM for gzip) — no external toolbox. ## 6. Surface spaces -| Space | Per-hemi vertices | Source | Used by | +| Space | Per-hemi vertices | Source | Bundled display mesh | |---|---|---|---| -| `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). +| `fsLR_32k` | 32,492 | native CIFTI (HCP grayordinates) | inflated (`foursurfaces_hcp`) | +| `fsaverage_164k` | 163,842 | `vol2surf` (CBIG RF mapping) | inflated (`foursurfaces_freesurfer`) | +| `fsaverage6` | 40,962 | `resample_surface` (nested subset of 164k) | — (resamples to 164k) | +| `fsaverage5` | 10,242 | `resample_surface` (nested subset of 164k) | — (resamples to 164k) | +| `fsaverage4` | 2,562 | `resample_surface` (nested subset of 164k) | — (resamples to 164k) | +| `onavg_41k` | 40,962 | `resample_surface` (equal-area, fs_LR-aligned) | — (resamples to fs_LR) | +| `onavg_10k` | 10,242 | `resample_surface` (equal-area, fs_LR-aligned) | — (resamples to fs_LR) | + +Native CIFTI is `fsLR_32k`; `vol2surf` produces `fsaverage_164k`. fs_LR and fsaverage +have different mesh topologies, but `resample_surface` maps natively between **all** of +the spaces above (nested fsaverage downsampling is exact; every other direction uses a +spherical barycentric/nearest interpolation on a shared fs_LR-aligned frame — see +`resample_surface(obj, 'list')`). Only `fsLR_32k` and `fsaverage_164k` ship inflated +display meshes in `canlab_canonical_brains/Canonical_brains_surfaces/`; the other +spaces render by resampling onto their parent/aligned display space (see the default +four-surface view table under [`surface`](#4-method-reference)). Native rendering uses +the matching bundled mesh directly (`addbrain('hcp inflated')` for fs_LR, +`addbrain('inflated')` for fsaverage). --- diff --git a/docs/fmri_surface_data_methods.md b/docs/fmri_surface_data_methods.md index f315c64a..ba8cf811 100644 --- a/docs/fmri_surface_data_methods.md +++ b/docs/fmri_surface_data_methods.md @@ -194,6 +194,25 @@ o2 = addblobs(o2, surf_stat, 'colormap', 'hot'); % paints the fs_LR meshes dir o2 = surface(o2, surf_stat); ``` +**Default four-surface view per surface space.** With no explicit surface argument, +`surface(obj)` picks the four-surface view (L/R lateral + medial) that matches the +object's `surface_space`, so the data renders at native (or near-native) fidelity +automatically. Passing a surface keyword (e.g. `surface(obj, 'foursurfaces_hcp')`) +overrides it. + +| `surface_space` | verts/hemi | default four-surface view | how it paints | +|---|---|---|---| +| `fsLR_32k` | 32,492 | `foursurfaces_hcp` | **native** (direct, no resampling) | +| `fsaverage_164k` | 163,842 | `foursurfaces_freesurfer` | **native** (direct, no resampling) | +| `fsaverage6` / `fsaverage5` / `fsaverage4` | 40,962 / 10,242 / 2,562 | `foursurfaces_freesurfer` | resampled **up** to fsaverage-164k (nearest, cached) | +| `onavg_41k` / `onavg_10k` | 40,962 / 10,242 | `foursurfaces_hcp` | resampled to fs_LR-32k (nearest, cached) | + +Only `fsLR_32k` and `fsaverage_164k` ship inflated display meshes; the nested +fsaverage spaces are subsets of fsaverage-164k and onavg is fs_LR-aligned (via the +bundled onavg spheres), so they render on their parent/aligned space through a fast, +cached nearest-neighbour resample (a one-line message prints the first time). No new +mesh assets are required. + `addblobs` (and `surface(o2, obj)`) detect the `fmri_surface_data` and paint matching cortical meshes **directly from the per-vertex data** (no volume resampling), using the same central `canlab_colormap` value→color map as montages, so colors match. Each From e91b921b5c38516f90d6325fd8b657bf61892be9 Mon Sep 17 00:00:00 2001 From: Tor Wager Date: Tue, 14 Jul 2026 16:06:20 -0400 Subject: [PATCH 4/4] Update fmri_surface_data_walkthrough.m --- .../docs/fmri_surface_data_walkthrough.m | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/CanlabCore/docs/fmri_surface_data_walkthrough.m b/CanlabCore/docs/fmri_surface_data_walkthrough.m index 63c8b22a..acd17a04 100644 --- a/CanlabCore/docs/fmri_surface_data_walkthrough.m +++ b/CanlabCore/docs/fmri_surface_data_walkthrough.m @@ -158,6 +158,11 @@ st = threshold(ssurf, 3, 'positive', 'k', 20); % t > 3, clusters >= 20 grayordinates [st, ncl] = reparse_contiguous(st, 'which_image', 1); fprintf('Found %d contiguous clusters above threshold\n', ncl); +surface(st, 'foursurfaces_hcp', 'clim', [-6 6]); + +% An alternate surface thresholding +ssurft = threshold(ssurf, [-1 1], 'raw-outside'); +surface(ssurft, 'foursurfaces_hcp', 'clim', [-6 6]); % Summarize the clusters as region-like structs (centroid, size, mean value): reg = surface_region(st, 'which_image', 1); @@ -181,6 +186,18 @@ size(parcel_means,2), parcel_labels{1}, parcel_means(1,1)); end +%% 9.2. Parcellate with CANlab2024 regions + +atl = load_atlas('canlab2024'); +figure; surface(atl, 'foursurfaces_hcp'); % show volumetric atlas on surface +figure; montage(atl) % show volumetric atlas on slices +atl_surf = vol2surf(atl); % transform to surface object (surface only) + +figure; surface(atl_surf); % show surface object +figure; surface(atl_surf, 'unique'); % ...in unique colors +figure; surface(atl_surf, 'unique', 'foursurfaces_hcp'); % ...on a different surface + + %% 10. Other data operations: mean, apply_mask % % mean() averages across maps; apply_mask() keeps a subset of grayordinates @@ -203,8 +220,10 @@ group = cat(subj{:}); % one object, 5 maps fprintf('group object: %d grayordinates x %d maps\n', size(group.dat,1), size(group.dat,2)); -tmap = ttest(group); % grayordinate-wise one-sample t-test -% surface(tmap, 'clim', [-4 4]); % (render the group t-map) +tmap = ttest(group); % grayordinate-wise one-sample t-test +tmap = threshold(tmap, [-2 2], 'raw-outside'); % 2-sided threshold at ~p<0.05 +figure; surface(tmap); % (render the group t-map) +figure; surface(tmap, 'foursurfaces_hcp'); % ...on a different surface (interpolated) % OLS regression onto a design matrix (set X BEFORE calling regress): group.X = [ones(5,1), (1:5)']; % intercept + a linear predictor