diff --git a/CanlabCore/@fmridisplay/addblobs.m b/CanlabCore/@fmridisplay/addblobs.m index d32e6327..8cc276a9 100644 --- a/CanlabCore/@fmridisplay/addblobs.m +++ b/CanlabCore/@fmridisplay/addblobs.m @@ -176,6 +176,15 @@ ' o2 = addblobs(o2, region(t)); %% add the blobs from t']); end +% Multi-image objects: a single blob layer shows one image, so if an +% image_vector with more than one image (column) is passed, use only the FIRST +% image (with a note). This keeps addblobs(o2, multi_image_obj) from erroring or +% silently combining images; use image_vector.montage for a per-image display. +if isa(cl, 'image_vector') && size(cl.dat, 2) > 1 + fprintf('addblobs: object has %d images; showing the first only. (For a per-image display use montage(obj).)\n', size(cl.dat, 2)); + cl = get_wh_image(cl, 1); +end + % Retain the ORIGINAL input as the layer source, before any conversion. % A statistic_image/fmri_data source enables re-thresholding downward % (rethreshold); a region source supports re-render at the same or higher @@ -506,6 +515,18 @@ end +% Ensure a colour range exists even when there is NO montage (e.g. a +% surface-only object), so render_layer_surfaces and the controller's numeric +% legend both have one. With a montage the loop above already stored the +% render_blobs-computed range; here we fill the surface-only gap using the same +% shared, uniform default (split-aware) on the layer's mapped values. +if isempty(obj.activation_maps{wh_to_display}.cmaprange) + split_flag = {}; + if any(strcmp(varargin, 'splitcolor')), split_flag = {'splitcolor'}; end + obj.activation_maps{wh_to_display}.cmaprange = ... + canlab_default_cmaprange(currentmap.mapdata(:), split_flag{:}); +end + % Surfaces % ------------------------------------------------------------------------- % Render this layer onto every (targeted) registered surface view. The actual diff --git a/CanlabCore/@fmridisplay/controller.m b/CanlabCore/@fmridisplay/controller.m index 5818ebcb..6f8ce28c 100644 --- a/CanlabCore/@fmridisplay/controller.m +++ b/CanlabCore/@fmridisplay/controller.m @@ -44,6 +44,7 @@ if ~isempty(obj.controller_handle) && isgraphics(obj.controller_handle) && isvalid(obj.controller_handle) fig = obj.controller_handle; vname = getappdata(fig, 'objname'); + is_new_fig = false; existing = findobj(fig, 'Type', 'uipanel'); if nlayers > 0 && numel(existing) == nlayers update_controls_in_place(fig, obj, cmap_options); @@ -54,13 +55,27 @@ fig = uifigure('Name', 'CANlab display controller', 'HandleVisibility', 'on', ... 'Color', FIG_COLOR); obj.controller_handle = fig; + is_new_fig = true; if isempty(vname), vname = 'obj'; end end +% Prefer the caller's real workspace variable name over 'obj' (for the title and +% the echoed code lines). +vn = obj_varname(obj); if ~isempty(vn), vname = vn; end + setappdata(fig, 'objname', vname); setappdata(fig, 'fmridisplay_obj', obj); fig.Name = sprintf('CANlab display controller [ %s ]', vname); -fig.Position(3:4) = [460, 60 + 230 * max(nlayers, 1)]; +fig.Position(3:4) = [460, 60 + 262 * max(nlayers, 1)]; + +% Position a NEW controller in the top-right of the screen so it does not overlap +% the montage / surface figures (which open toward the centre/left). Only on +% creation, so a controller the user has moved stays put on later refreshes. +if is_new_fig + ss = get(0, 'ScreenSize'); + fig.Position(1:2) = [max(10, ss(3) - fig.Position(3) - 30), ... + max(60, ss(4) - fig.Position(4) - 90)]; +end if nlayers == 0 uilabel(fig, 'Position', [20 fig.Position(4)-70 420 50], 'FontSize', 17, ... @@ -69,7 +84,7 @@ end outer = uigridlayout(fig, [nlayers + 1, 1]); -outer.RowHeight = [repmat({220}, 1, nlayers), {40}]; +outer.RowHeight = [repmat({250}, 1, nlayers), {40}]; outer.Scrollable = 'on'; for k = 1:nlayers @@ -87,7 +102,7 @@ opts = {'split (hot/cool)', 'split (mango)', 'seafire', 'warm (red-yellow)', ... 'cool (blue-cyan)', 'winter (blue-green)', ... 'viridis', 'inferno', 'magma', 'plasma', 'turbo', 'parula', ... - 'solid colour…'}; + 'indexed (atlas)', 'solid colour…'}; end function names = perceptual_names() @@ -123,20 +138,24 @@ function build_layer_panel(parent, obj, k, cmap_options, vname) panel.Layout.Row = k; g = uigridlayout(panel, [6, 2]); -% stripe(taller), legend value labels, opacity, threshold(+ticks), colors, visible -g.RowHeight = {24, 16, 34, 44, 34, 30}; +% colour bar (~text height), legend value labels, opacity, threshold(+ticks), colors, visible +g.RowHeight = {26, 22, 34, 44, 34, 30}; g.ColumnWidth = {96, '1x'}; g.RowSpacing = 4; g.ColumnSpacing = 6; g.Padding = [8 4 8 2]; -% Row 1: colormap "title stripe" doubling as the legend colour bar (full width) -stripe = make_colormap_strip(g, swatch_colormap(args)); +% Row 1: colormap "title stripe" doubling as the legend colour bar (full width). +% For a split map this has a grey GAP in the middle (the un-thresholded near-zero +% band that is NOT coloured on the brain), so it reads: extreme-neg .. near-0-neg +% [gap] near-0-pos .. extreme-pos. +stripe = make_colormap_strip(g, stripe_colormap(args, layer)); stripe.Layout.Row = 1; stripe.Layout.Column = [1 2]; stripe.Tag = sprintf('stripe_%d', k); -% Row 2: numeric legend labels under the stripe (extreme ends; 0 in the centre -% for split maps). This is the in-controller legend (replaces the figure colorbar). +% Row 2: numeric legend labels under the stripe. Split maps show FOUR values +% aligned to the bar (neg extreme / neg near-0 [gap] pos near-0 / pos extreme); +% single ramps show the two ends. Reflects the layer's current cmaprange. lg = make_legend_labels(g, layer, k); lg.Layout.Row = 2; lg.Layout.Column = [1 2]; @@ -147,11 +166,14 @@ function build_layer_panel(parent, obj, k, cmap_options, vname) 'ValueChangedFcn', @(s, ~) on_opacity(obj, k, s.Value, vname)); sld.Layout.Row = 3; sld.Layout.Column = 2; -% Row 4: threshold slider (type-aware; p-values on a log scale) +% Row 4: threshold control (type-aware; p-values on a log scale). A numeric +% entry field sits beside the slider so a value can be typed directly — the +% slider only responds to dragging its thumb, not to clicking the track. [thr_label, thr_value, ~, is_pval] = threshold_spec(src, layer); uilabel(g, 'Text', thr_label, 'FontSize', fs); -ts = build_threshold_slider(g, obj, k, src, is_pval, thr_value, vname, fs); -ts.Layout.Row = 4; ts.Layout.Column = 2; +tgrid = uigridlayout(g, [1 2]); tgrid.Layout.Row = 4; tgrid.Layout.Column = 2; +tgrid.ColumnWidth = {'1x', 74}; tgrid.Padding = [0 0 0 0]; tgrid.ColumnSpacing = 6; +build_threshold_controls(tgrid, obj, k, src, is_pval, thr_value, vname, fs); % Row 5: Colors dropdown + live preview swatch uilabel(g, 'Text', 'Colors', 'FontSize', fs); @@ -161,10 +183,10 @@ function build_layer_panel(parent, obj, k, cmap_options, vname) 'FontSize', fs, 'Tag', sprintf('colormap_%d', k), ... 'ValueChangedFcn', @(d, ~) on_colormap(obj, k, d.Value, vname)); dd.Layout.Column = 1; -sw = make_colormap_strip(cgrid, swatch_colormap(args)); +sw = make_colormap_strip(cgrid, swatch_colormap(args, layer)); sw.Layout.Column = 2; sw.Tag = sprintf('swatch_%d', k); % Clicking the colour swatch opens the colour picker (also re-picks a solid colour) -set(findobj(sw, 'Type', 'image'), 'ButtonDownFcn', @(~, ~) pick_solid_colour(obj, k, vname)); +sw.ImageClickedFcn = @(~, ~) pick_solid_colour(obj, k, vname); % Row 6: Visible + per-layer Remove uilabel(g, 'Text', 'Visible', 'FontSize', fs); @@ -249,6 +271,7 @@ function pick_solid_colour(obj, k, vname) % 'solid colour…' dropdown item and by clicking the colour swatch). c = uisetcolor([1 0 0], 'Choose a blob colour'); if numel(c) ~= 3, return, end +vn = obj_varname(obj); if ~isempty(vn), vname = vn; end % echo the real variable name set_colormap(obj, 'color', c, 'layers', k); echo_code(vname, sprintf('set_colormap(%s, ''color'', [%g %g %g], ''layers'', %d)', vname, c(1), c(2), c(3), k)); end @@ -256,11 +279,18 @@ function pick_solid_colour(obj, k, vname) % ---- threshold slider -------------------------------------------------- -function sld = build_threshold_slider(g, obj, k, src, is_pval, v0, vname, fs) +function build_threshold_controls(g, obj, k, src, is_pval, v0, vname, fs) +% Threshold slider (col 1) + numeric entry field (col 2), kept in sync. % statistic_image -> LOG-scale p-value slider (slider value = log10(p)), ticks at % .001/.005/.01/.05/.1 so .05–.1 sit close and .001–.005 spread out. % fmri_data/region -> linear raw |x| slider anchored at 0 and the 99.9th pct of |data|. +% Both controls call on_threshold; update_controller then re-syncs them, so +% dragging the slider updates the field and typing in the field updates the slider. tickfs = max(9, fs - 5); +sl_tag = sprintf('threshold_%d', k); +ef_tag = sprintf('threshedit_%d', k); +v0 = double(v0); % uislider/uieditfield require double; data-derived thresholds may be single + if is_pval pfloor = 1e-6; ptop = 0.1; % extends below .001 down to ~0 ticks = [pfloor .001 .005 .01 .05 .1]; @@ -268,19 +298,40 @@ function pick_solid_colour(obj, k, vname) lims = log10([pfloor ptop]); v = log10(min(max(v0, pfloor), ptop)); sld = uislider(g, 'Limits', lims, 'Value', v, 'MajorTicks', log10(ticks), ... - 'MajorTickLabels', labs, 'FontSize', tickfs, ... - 'Tag', sprintf('threshold_%d', k), ... - 'ValueChangedFcn', @(s, ~) on_threshold(obj, k, 10 .^ s.Value, true, vname)); + 'MajorTickLabels', labs, 'FontSize', tickfs, 'Tag', sl_tag, ... + 'ValueChangedFcn', @(s, ~) on_threshold(obj, k, 10 .^ s.Value, true, vname)); + sld.Layout.Column = 1; + ef = uieditfield(g, 'numeric', 'Value', min(max(v0, pfloor), ptop), ... + 'Limits', [pfloor ptop], 'ValueDisplayFormat', '%.4g', 'FontSize', fs, ... + 'Tag', ef_tag, 'ValueChangedFcn', @(e, ~) on_threshold(obj, k, e.Value, true, vname)); + ef.Layout.Column = 2; + elseif isa(src, 'image_vector') || isa(src, 'region') - b = raw_bound(src); + % Extend the range to cover the current threshold (out-of-range values still + % sit on the slider and show an empty map). + b = max(double(raw_bound(src)), v0); + if ~(b > 0), b = 1; end v = min(max(v0, 0), b); - sld = uislider(g, 'Limits', [0 b], 'Value', v, 'MajorTicks', [0 b], ... - 'MajorTickLabels', {'0', sprintf('%.3g', b)}, 'FontSize', tickfs, ... - 'Tag', sprintf('threshold_%d', k), ... - 'ValueChangedFcn', @(s, ~) on_threshold(obj, k, s.Value, false, vname)); + % NORMALISED [0 1] slider. Raw |x| ranges are tiny (e.g. [0 7e-4]); a uislider + % with such a small Limits span does NOT reliably fire its callback on + % drag-release (unlike the O(1)-range p-value and opacity sliders, which work). + % So the slider runs on [0 1] and maps back to raw |x| via its UserData scale + % (raw = position * b). The numeric field stays in raw units. + sld = uislider(g, 'Limits', [0 1], 'Value', v / b, 'MajorTicks', [0 1], ... + 'MajorTickLabels', {'0', sprintf('%.3g', b)}, 'FontSize', tickfs, 'Tag', sl_tag, ... + 'ValueChangedFcn', @(s, ~) on_threshold(obj, k, s.Value * s.UserData, false, vname)); + sld.UserData = b; % raw |x| value at slider position = 1 + sld.Layout.Column = 1; + ef = uieditfield(g, 'numeric', 'Value', v, 'Limits', [0 Inf], ... + 'ValueDisplayFormat', '%.4g', 'FontSize', fs, 'Tag', ef_tag, ... + 'ValueChangedFcn', @(e, ~) on_threshold(obj, k, e.Value, false, vname)); + ef.Layout.Column = 2; + else - sld = uislider(g, 'Limits', [0 1], 'Value', 0, 'Enable', 'off', 'FontSize', tickfs, ... - 'Tag', sprintf('threshold_%d', k)); + sld = uislider(g, 'Limits', [0 1], 'Value', 0, 'Enable', 'off', 'FontSize', tickfs, 'Tag', sl_tag); + sld.Layout.Column = 1; + ef = uieditfield(g, 'numeric', 'Value', 0, 'Enable', 'off', 'FontSize', fs, 'Tag', ef_tag); + ef.Layout.Column = 2; end end @@ -299,69 +350,118 @@ function pick_solid_colour(obj, k, vname) % ---- colormap preview -------------------------------------------------- -function ax = make_colormap_strip(parent, cm) -ax = uiaxes(parent); -draw_colormap_strip(ax, cm); +function h = make_colormap_strip(parent, cm) +% A uiimage (NOT uiaxes) for the colour bar / swatch: it fills its grid cell with +% no reserved tick/label margins, so the bar shows its full height instead of +% collapsing to a hairline the way an invisible uiaxes does. +h = uiimage(parent); +h.ScaleMethod = 'fill'; % stretch the ramp to fill the cell +draw_colormap_strip(h, cm); end -function draw_colormap_strip(ax, cm) +function draw_colormap_strip(h, cm) if isempty(cm), cm = repmat([.5 .5 .5], 8, 1); end -image(ax, reshape(cm, [1 size(cm, 1) 3])); -ax.XLim = [0.5 size(cm, 1) + 0.5]; ax.YLim = [0.5 1.5]; -ax.XTick = []; ax.YTick = []; ax.Box = 'off'; ax.Visible = 'off'; -try - ax.Toolbar.Visible = 'off'; - disableDefaultInteractivity(ax); -catch +n = size(cm, 1); +img = repmat(reshape(cm, [1 n 3]), [16 1 1]); % a few rows tall; 'fill' stretches it +h.ImageSource = uint8(round(min(max(img, 0), 1) * 255)); +end + +function cm = swatch_colormap(args, layer) +% Continuous colour ramp (no gap) for the small preview swatch, taken from the +% central canlab_colormap so the swatch, the stripe, and the rendered blobs agree. +if nargin < 2, layer = []; end +cm = display_colormap(args, layer).colorbar_ramp(64); +end + +function c = display_colormap(args, layer) +% The canlab_colormap the controller should PREVIEW for a layer. Usually just +% from_render_args, but a single-region indexed/atlas layer (one index value) +% previews as that region's SOLID colour rather than the whole atlas palette. +c = canlab_colormap.from_render_args(args, []); +if strcmp(c.type, 'indexed') && ~isempty(layer) && isfield(layer, 'cmaprange') ... + && ~isempty(layer.cmaprange) + cr = double(layer.cmaprange); + idx = round(mean(cr)); + n = size(c.colors, 1); + if (max(cr) - min(cr)) < 1 && idx >= 1 && idx <= n + c = canlab_colormap.solid(c.colors(idx, :)); + end end end -function cm = swatch_colormap(args) -% Continuous colour ramp for the layer's stripe (= the in-controller legend bar) -% and the preview swatch, taken from the central canlab_colormap so the stripe, -% the figure legend, and the rendered blobs all agree. colorbar_ramp gives the -% no-gap legend ramp (extreme-neg ... 0 ... extreme-pos for split). -cm = canlab_colormap.from_render_args(args, []).colorbar_ramp(64); +function cm = stripe_colormap(args, layer) +% Colour bar for the legend stripe. Split maps get a grey GAP in the middle (the +% un-thresholded near-zero band that shows as gray on the brain), matching the +% 5-column label layout below: neg ramp (2 units) | gap (1) | pos ramp (2). +if nargin < 2, layer = []; end +c = display_colormap(args, layer); +if strcmp(c.type, 'split') + nramp = 32; ngap = 8; % 32 | 8 | 32 == 2 : 0.5 : 2 units + full = c.colorbar_ramp(2 * nramp); % [neg(nramp); pos(nramp)] + graycol = repmat([0.75 0.75 0.75], ngap, 1); + cm = [full(1:nramp, :); graycol; full(nramp+1:end, :)]; +else + cm = c.colorbar_ramp(72); +end end % ---- in-controller legend (numeric labels under the colour stripe) ----- function lg = make_legend_labels(parent, layer, k) -% A 3-cell row of numeric labels aligned to the colour stripe above it: -% left = low end, right = high end, centre = 0 for split (+/-) maps only. -lg = uigridlayout(parent, [1 3]); -lg.ColumnWidth = {'1x', '1x', '1x'}; -lg.Padding = [2 0 2 0]; lg.ColumnSpacing = 2; lg.RowSpacing = 0; -fs = max(9, base_fontsize() - 5); -[lo, mid, hi] = legend_label_strings(layer); -l1 = uilabel(lg, 'Text', lo, 'FontSize', fs, 'HorizontalAlignment', 'left', 'Tag', sprintf('leglo_%d', k)); -l2 = uilabel(lg, 'Text', mid, 'FontSize', fs, 'HorizontalAlignment', 'center', 'Tag', sprintf('legmid_%d', k)); -l3 = uilabel(lg, 'Text', hi, 'FontSize', fs, 'HorizontalAlignment', 'right', 'Tag', sprintf('leghi_%d', k)); -l1.Layout.Column = 1; l2.Layout.Column = 2; l3.Layout.Column = 3; -end - -function [lo, mid, hi] = legend_label_strings(layer) -% Numeric end labels from the layer's cmaprange (the mapped value range). -% cmaprange is [lo hi] for a single ramp, or [negExtreme negNear0 posNear0 -% posExtreme] for a split map. We show the extreme ends; split maps add 0 at -% the centre. Labels are rounded to 1 significant figure. -lo = ''; mid = ''; hi = ''; +% Numeric labels aligned to the colour stripe above. Five columns (2:2:1:2:2) +% match the split stripe (neg ramp | gap | pos ramp): col1 neg-extreme, col2 +% neg-near-0, col3 the gap (blank), col4 pos-near-0, col5 pos-extreme. A single +% ramp uses only col1 (low) and col5 (high). +lg = uigridlayout(parent, [1 5]); +lg.ColumnWidth = {'2x', '2x', '1x', '2x', '2x'}; +lg.Padding = [2 0 2 0]; lg.ColumnSpacing = 1; lg.RowSpacing = 0; +fs = max(12, base_fontsize() - 2); % numeric legend labels (a bit larger) +aligns = {'left', 'right', 'center', 'left', 'right'}; +strs = legend_label_set(layer); +for c = 1:5 + u = uilabel(lg, 'Text', strs{c}, 'FontSize', fs, 'HorizontalAlignment', aligns{c}, ... + 'Tag', sprintf('leg%d_%d', c, k)); + u.Layout.Column = c; +end +end + +function strs = legend_label_set(layer) +% Five label strings (col3 is the gap, always blank) from the layer's current +% cmaprange (the mapped value range). Split cmaprange = [negExtreme negNear0 +% posNear0 posExtreme] -> those four values in cols 1,2,4,5. A 2-element single +% ramp -> ends in cols 1 and 5. Rounded to 2 significant figures. +strs = {'', '', '', '', ''}; +% Indexed / atlas layers colour by category, not by a continuous value range, +% so numeric end-labels (e.g. a single index +-0.1) are meaningless -> leave blank. +if isfield(layer, 'render_args') && any(strcmp(layer.render_args, 'indexmap')) + return +end cr = []; if isfield(layer, 'cmaprange'), cr = layer.cmaprange; end -cr = cr(~isnan(cr) & ~isinf(cr)); +cr = double(cr(~isnan(cr) & ~isinf(cr))); if numel(cr) < 2, return, end -f = @(x) num2str(round(x, 1, 'significant')); +f = @(x) num2str(round(x, 2, 'significant')); if numel(cr) >= 4 - lo = f(min(cr)); hi = f(max(cr)); mid = '0'; % split: both extremes + 0 + cr = sort(cr); % [negExtreme negNear0 posNear0 posExtreme] + strs{1} = f(cr(1)); strs{2} = f(cr(2)); strs{4} = f(cr(3)); strs{5} = f(cr(4)); else - lo = f(cr(1)); hi = f(cr(end)); % single ramp: just the ends + strs{1} = f(cr(1)); strs{5} = f(cr(end)); end end function update_controls_in_place(fig, obj, cmap_options) % Re-sync each control + preview to its layer's current state (no rebuild). + +% Keep the title / echoed name in sync with the real workspace variable (it may +% only become resolvable after the constructor returns and assigns it). +vn = obj_varname(obj); +if ~isempty(vn) + setappdata(fig, 'objname', vn); + fig.Name = sprintf('CANlab display controller [ %s ]', vn); +end + for k = 1:numel(obj.activation_maps) layer = obj.activation_maps{k}; args = render_args_of(layer); @@ -376,21 +476,36 @@ function update_controls_in_place(fig, obj, cmap_options) [~, val, ~, is_pval] = threshold_spec(src, layer); thr = findobj(fig, 'Tag', sprintf('threshold_%d', k)); if ~isempty(thr) && isprop(thr, 'Limits') - if is_pval, sv = log10(max(val, 1e-6)); else, sv = val; end - thr.Value = min(max(sv, thr.Limits(1)), thr.Limits(2)); + if is_pval + sv = log10(max(val, 1e-6)); + thr.Value = min(max(sv, thr.Limits(1)), thr.Limits(2)); + else + % Normalised [0 1] |x| slider: convert the raw threshold to a slider + % position via the stored scale, growing the scale if the threshold + % now exceeds it (so the thumb sits at the true value). + b = thr.UserData; if isempty(b) || ~(b > 0), b = 1; end + if val > b + b = val; thr.UserData = b; + thr.MajorTickLabels = {'0', sprintf('%.3g', b)}; + end + thr.Value = min(max(val / b, 0), 1); + end end - - cm = swatch_colormap(args); - for tagn = {sprintf('stripe_%d', k), sprintf('swatch_%d', k)} - a = findobj(fig, 'Tag', tagn{1}); - if ~isempty(a), draw_colormap_strip(a, cm); end + ef = findobj(fig, 'Tag', sprintf('threshedit_%d', k)); + if ~isempty(ef) && isprop(ef, 'Value') + lim = [0 Inf]; if isprop(ef, 'Limits'), lim = ef.Limits; end + ef.Value = min(max(val, lim(1)), lim(2)); end - % Refresh the numeric legend labels (cmaprange may have changed). - [lo, mid, hi] = legend_label_strings(layer); - set_label_text(fig, sprintf('leglo_%d', k), lo); - set_label_text(fig, sprintf('legmid_%d', k), mid); - set_label_text(fig, sprintf('leghi_%d', k), hi); + a = findobj(fig, 'Tag', sprintf('stripe_%d', k)); + if ~isempty(a), draw_colormap_strip(a, stripe_colormap(args, layer)); end % gapped bar for split + a = findobj(fig, 'Tag', sprintf('swatch_%d', k)); + if ~isempty(a), draw_colormap_strip(a, swatch_colormap(args, layer)); end + + % Refresh the numeric legend labels from the current cmaprange (which + % rethreshold/refresh may have changed), so labels always match the colours. + strs = legend_label_set(layer); + for c = 1:5, set_label_text(fig, sprintf('leg%d_%d', c, k), strs{c}); end end end @@ -403,11 +518,13 @@ function set_label_text(fig, tag, txt) % ---- callbacks (apply + echo the equivalent code line) ----------------- function on_opacity(obj, k, value, vname) +vn = obj_varname(obj); if ~isempty(vn), vname = vn; end % echo the real variable name set_opacity(obj, value, 'layers', k); echo_code(vname, sprintf('set_opacity(%s, %g, ''layers'', %d)', vname, value, k)); end function on_colormap(obj, k, choice, vname) +vn = obj_varname(obj); if ~isempty(vn), vname = vn; end % echo the real variable name switch choice case 'split (hot/cool)' set_colormap(obj, 'splitcolor', {[0 0 1] [0 1 1] [1 .5 0] [1 1 0]}, 'layers', k); @@ -437,19 +554,55 @@ case perceptual_names() end function on_threshold(obj, k, value, is_pval, vname) -if is_pval - rethreshold(obj, value, 'unc', 'layers', k); - echo_code(vname, sprintf('rethreshold(%s, %.4g, ''unc'', ''layers'', %d)', vname, value, k)); -else - rethreshold(obj, value, 'layers', k); - echo_code(vname, sprintf('rethreshold(%s, %.4g, ''layers'', %d)', vname, value, k)); +% Apply the threshold on slider RELEASE / field entry (the expensive step: +% rethreshold + refresh). Wrapped so a bad value (e.g. one that leaves no voxels) +% cannot break the control. The numeric field re-syncs afterwards via +% update_controls_in_place. (Slider uses ValueChangedFcn only, matching the +% opacity slider, which fires reliably on first use.) +vn = obj_varname(obj); if ~isempty(vn), vname = vn; end % echo the real variable name +try + if is_pval + rethreshold(obj, value, 'unc', 'layers', k); + echo_code(vname, sprintf('rethreshold(%s, %.4g, ''unc'', ''layers'', %d)', vname, value, k)); + else + rethreshold(obj, value, 'layers', k); + echo_code(vname, sprintf('rethreshold(%s, %.4g, ''layers'', %d)', vname, value, k)); + end +catch ME + warning('fmridisplay:controller:threshold', 'Re-threshold failed: %s', ME.message); end end + function echo_code(vname, callstr) fprintf('%s = %s;\n', vname, callstr); end +function nm = obj_varname(obj) +% Best-effort lookup of the base-workspace variable name that currently holds +% this fmridisplay handle, so echoed code (and the title) use the user's real +% variable name instead of 'obj'. Returns '' if not found (e.g. the controller +% was auto-opened from the constructor before the variable was assigned, or the +% object lives in a function workspace) — callers keep their fallback then. +nm = ''; +try + vars = evalin('base', 'who'); +catch + return +end +for i = 1:numel(vars) + try + val = evalin('base', vars{i}); + if isa(val, 'fmridisplay') && isscalar(val) && val == obj + nm = vars{i}; + return + end + catch + % skip variables that error on evaluation / comparison + end +end +end + % ---- small state-readers ----------------------------------------------- @@ -478,7 +631,9 @@ function echo_code(vname, callstr) end function lbl = current_colormap_label(args, opts) %#ok -if any(strcmp(args, 'splitcolor')) +if any(strcmp(args, 'indexmap')) + lbl = 'indexed (atlas)'; +elseif any(strcmp(args, 'splitcolor')) sc = args{find(strcmp(args, 'splitcolor'), 1) + 1}; if iscell(sc) && numel(sc) == 4 && isequal(sc, {[.5 0 1] [0 .8 .3] [1 .2 1] [1 1 .3]}) lbl = 'split (mango)'; @@ -515,6 +670,7 @@ function echo_code(vname, callstr) else lbl = 'thresh'; is_pval = false; enable = false; val = 0; end +val = double(val); % uislider/uieditfield require double; data-derived thresholds may be single end function set_layer_visible(obj, k, tf) diff --git a/CanlabCore/@fmridisplay/fmridisplay.m b/CanlabCore/@fmridisplay/fmridisplay.m index 27bc289f..3d05fdc7 100644 --- a/CanlabCore/@fmridisplay/fmridisplay.m +++ b/CanlabCore/@fmridisplay/fmridisplay.m @@ -213,13 +213,16 @@ obj.history_descrip = []; obj.additional_info = ''; - if nargin == 0 - return - end - + % Control-only flag: 'nocontroller' suppresses the auto-launched + % controller (below). Stripped here so it is not forwarded to the + % input parser. The controller auto-opens by default but never in + % -batch (so scripts / unit tests are unaffected). + do_controller = ~batchStartupOptionUsed; + wh_noctrl = strcmp(varargin, 'nocontroller'); + if any(wh_noctrl), do_controller = false; varargin(wh_noctrl) = []; end % Now parse inputs and run methods depending on what is entered - + if any(strcmp(varargin, 'montage')) wh = strcmp(varargin, 'montage'); varargin(wh) = []; @@ -250,7 +253,32 @@ end end - + % Bring up the interactive controller by default (fmridisplay is a + % handle class, so it stays bound to this object and updates as layers + % are added). Suppressed in -batch and with 'nocontroller'; wrapped so + % it can never break construction. Save/restore the current figure so + % opening the controller uifigure does NOT hijack gcf — otherwise the + % very next montage/surface call (e.g. `figure; montage(dat)`) would + % render its slices into the controller window. + if do_controller + prevfig = get(groot, 'CurrentFigure'); + try + controller(obj); + catch ME + warning('fmridisplay:controllerAutolaunch', ... + 'Could not open display controller: %s', ME.message); + end + % Do not leave the controller uifigure as the current figure: + % downstream rendering (montage's bare axes(), addbrain's subplot) + % targets gcf and would draw into / error on the controller. + if ~isempty(prevfig) && isgraphics(prevfig) && isvalid(prevfig) + set(groot, 'CurrentFigure', prevfig); % back to the caller's figure + elseif isgraphics(obj.controller_handle) && ... + isequal(get(groot, 'CurrentFigure'), obj.controller_handle) + figure; % no prior figure: hand off a fresh one + end + end + end % constructor function end % methods diff --git a/CanlabCore/@fmridisplay/montage.m b/CanlabCore/@fmridisplay/montage.m index adb188de..d0e74833 100644 --- a/CanlabCore/@fmridisplay/montage.m +++ b/CanlabCore/@fmridisplay/montage.m @@ -140,7 +140,56 @@ if isempty(obj.SPACE) || ~isstruct(obj.SPACE.V) error('fmridisplay is not initialized correctly. run obj = fmridisplay; first and then pass in to this method.') - + +end + +% Multi-panel routing +% ------------------------------------------------------------------------- +% The default montage(obj) (and montage(obj, ) for any +% canlab_results_fmridisplay layout keyword) composes a MULTI-PANEL display via +% multiview() — the sagittal+axial 'compact' combo by default, matching +% fmri_data.montage. Explicit single-orientation calls ('axial'/'sagittal'/ +% 'coronal', slice options, 'existing_axes' — including the per-orientation +% calls multiview/canlab_results_fmridisplay make internally) fall through to the +% single-orientation montage below. The orientation words are deliberately kept +% OUT of the routed set, so montage(obj,'sagittal') stays a single sagittal row. +mv_types = {'compact', 'compact2', 'compact3', 'full', 'full2', 'multirow', ... + 'allslices', 'blobcenters', 'regioncenters', 'full hcp', 'full hcp inflated', ... + 'full no surfaces', 'hcp grayordinates', 'hcp grayordinates subcortex', ... + 'hcp grayordinates compact', 'subcortex compact', 'subcortex full', ... + 'subcortex slices', 'subcortex 3d', 'leftright inout', 'leftright inout subcortex', ... + 'hcp inflated', 'hcp', 'hcp sphere', 'freesurfer inflated', 'freesurfer white', ... + 'freesurfer sphere', 'MNI152NLin2009cAsym white', 'MNI152NLin2009cAsym midthickness', ... + 'MNI152NLin2009cAsym pial', 'MNI152NLin6Asym white', 'MNI152NLin6Asym midthickness', ... + 'MNI152NLin6Asym pial', 'MNI152NLin6Asym sphere'}; + +% Keywords that request a SINGLE-orientation montage (existing behaviour) and so +% are NOT routed to multiview: the orientation words, the per-slice options, and +% the existing-axes/figure paths (which the multi-panel layouts use internally, +% preventing recursion). Anything else -> multi-panel multiview (default compact). +single_kw = {'axial', 'coronal', 'sag', 'sagg', 'saggital', 'sagittal', ... + 'wh_slice', 'slice_range', 'spacing', 'onerow', 'existing_axes', ... + 'volume_data', 'nofigure', 'nofig', 'existing_figure'}; +is_single = false; +for i = 1:numel(varargin) + if ischar(varargin{i}) && any(strcmpi(varargin{i}, single_kw)), is_single = true; break; end +end + +if ~is_single + mv_type = 'compact'; mv_extra = varargin; % default multi-panel layout + for i = 1:numel(varargin) % use an explicit montagetype if given + if ischar(varargin{i}) && any(strcmpi(varargin{i}, mv_types)) + mv_type = varargin{i}; mv_extra(i) = []; break; + end + end + if ~any(strcmpi(varargin, 'noverbose')) + vname = inputname(1); if isempty(vname), vname = 'o2'; end + fprintf('Composing multi-panel display. Equivalent call: %s = multiview(%s, ''%s'');\n', ... + vname, vname, mv_type); + end + obj = multiview(obj, mv_type, mv_extra{:}); + dat = []; %#ok % keep the 2nd output defined if requested + return end donewfigure = true; % default - new figure and axes. even if donewaxes is on, this can be turned off to use existing figure @@ -274,15 +323,20 @@ for i = 1:length(slice_vox_coords) axes(newax(i)); - + wh_slice = slice_vox_coords(i); Z = display_slice(dat, wh_slice, obj.SPACE, myview, lightenstr); - + % ✅ Store the actual mm coordinate for this axis setappdata(newax(i), 'mm_coord', slice_mm_coords(i)); - + hold on axis off + + % Hide the per-axes interaction toolbar (the "..." overflow button that + % appears in the corner of every slice in recent MATLAB releases) and disable + % the default pan/zoom/rotate interactions, which are meaningless for a slice. + hide_axes_toolbar(newax(i)); end @@ -291,6 +345,25 @@ obj.montage{end + 1} = struct('axis_handles', newax, 'orientation', myview, 'slice_mm_coords', slice_mm_coords); +% Pull existing blob layers onto this newly added montage, so blobs that were +% added BEFORE this montage (e.g. drawn onto surfaces first) also appear here. +% Mirrors the surface() pull-in. For the usual montage-then-addblobs order there +% are no layers yet, so this is a no-op. See VISUALIZATION_OVERHAUL_NOTES.md. +new_montage_idx = numel(obj.montage); +for k = 1:numel(obj.activation_maps) + layer = obj.activation_maps{k}; + if ~isfield(layer, 'mapdata') || isempty(layer.mapdata), continue, end + render_args = {}; + if isfield(layer, 'render_args') && ~isempty(layer.render_args), render_args = layer.render_args; end + [blobhan, this_cmaprange] = render_blobs(layer, obj.montage{new_montage_idx}, obj.SPACE, render_args{:}); + if ~isempty(blobhan) + obj.activation_maps{k}.blobhandles = [obj.activation_maps{k}.blobhandles; blobhan]; + if ~isfield(obj.activation_maps{k}, 'cmaprange') || isempty(obj.activation_maps{k}.cmaprange) + obj.activation_maps{k}.cmaprange = this_cmaprange; + end + end +end + if brighten_factor % if we are brightening - this is a bit slower... % set color map to enhance contrast datvec = dat(:); @@ -372,37 +445,49 @@ end if ~donewaxes - % Break and return here if we are using existing axes. - % was apparently not implemented. now checking for axis - % handles. SG 2016/10/26 + % Using existing axes. Ensure we end up with exactly num_axes valid + % axes: use the provided ones if enough were passed, otherwise EXPAND + % from the first provided axis into a row of num_axes axes. The expand + % path is what canlab_results_fmridisplay 'multirow' relies on — it + % passes a single placeholder axis and lets montage lay out the slices + % (previously this was gated on there being >1 axis in the figure, so + % a lone axis with many slices errored: "Index exceeds ... must not + % exceed 1"). SG 2016/10/26; fixed 2026. + valid = false(1, numel(newax)); for k = 1:numel(newax) - axcheck(k) = isscalar(newax(k)) && ishandle(newax(k)) && strcmp(get(newax(k), 'type'), 'axes'); + valid(k) = isscalar(newax(k)) && ishandle(newax(k)) && strcmp(get(newax(k), 'type'), 'axes'); end - if numel(newax)>1 && sum(axcheck)==numel(newax) - axis(newax,'off'); + newax = newax(valid); + if isempty(newax) + error('fmridisplay:montage:badExistingAxes', ... + '''existing_axes'' did not contain any valid axes handles.'); + end + + if numel(newax) >= num_axes + newax = newax(1:num_axes); + axis(newax, 'off'); else - axis(newax,'off'); - allaxh = findobj(gcf, 'Type', 'axes'); - if length(allaxh) > 1 - init_pos = get(newax, 'Position'); - if num_axes > 1 - for i = 1:num_axes - x_pos = init_pos(1) + (i * (0.85 / num_axes)); - newax(i) = axes('Position', [x_pos,init_pos(2),init_pos(3),init_pos(4)]); - axis(newax(i), 'image'); - end - end + % Fewer axes than slices: expand from the first provided axis. + init_pos = get(newax(1), 'Position'); + axis(newax(1), 'off'); + for i = 1:num_axes + x_pos = init_pos(1) + (i * (0.85 / num_axes)); + newax(i) = axes('Position', [x_pos, init_pos(2), init_pos(3), init_pos(4)]); + axis(newax(i), 'image'); end end - slices_fig_h = get(newax(1), 'Parent'); - while ~isa(slices_fig_h,'matlab.ui.Figure') - slices_fig_h = get(slices_fig_h, 'Parent'); - end + + slices_fig_h = ancestor(newax(1), 'figure'); return end if donewfigure slices_fig_h = figure; %create_figure(myview); + elseif canlab_is_uifigure(gcf) + % 'nofigure' asked to reuse the current figure, but it is a uifigure + % (the controller): subplot/axes cannot draw into it, so open a fresh + % traditional figure instead (consistent with the other methods). + slices_fig_h = figure; else slices_fig_h = gcf; end @@ -429,3 +514,10 @@ end % setup axes end % main function + + +function hide_axes_toolbar(ax) +% Hide the per-axes "..." interaction toolbar on the slice axes. Delegates to the +% shared canlab_hide_axes_toolbar so montages and surfaces behave identically. +canlab_hide_axes_toolbar(ax); +end diff --git a/CanlabCore/@fmridisplay/multiview.m b/CanlabCore/@fmridisplay/multiview.m new file mode 100644 index 00000000..15a55cc6 --- /dev/null +++ b/CanlabCore/@fmridisplay/multiview.m @@ -0,0 +1,68 @@ +function obj = multiview(obj, montagetype, varargin) +% multiview Compose a canonical multi-panel display onto this fmridisplay object. +% +% Lays out one of the montage/surface figure compositions defined in +% canlab_results_fmridisplay (e.g. 'compact', 'compact2', 'full', 'multirow', +% the HCP / freesurfer surface sets, the subcortical layouts, ...) onto THIS +% object, then renders any existing blob layers onto the new panels (via the +% fmridisplay.montage / surface pull-in). This is the fmridisplay method behind +% the default fmri_data.montage appearance; fmridisplay.montage(obj) routes here +% with 'compact'. +% +% :Usage: +% :: +% +% obj = multiview(obj) % default 'compact' (sag + axial combo) +% obj = multiview(obj, 'full') % montages + surfaces +% obj = multiview(obj, 'compact2') +% obj = multiview(obj, montagetype, ...) % any canlab_results_fmridisplay type +% +% :Inputs: +% +% **obj:** +% An fmridisplay object (handle). Composition is added to it in place. +% +% **montagetype:** +% A canlab_results_fmridisplay montage/surface layout keyword. Default +% 'compact'. See `help canlab_results_fmridisplay` for the full list +% (compact, compact2, compact3, full, full2, multirow, allslices, +% blobcenters/regioncenters, full hcp, hcp inflated, freesurfer inflated, +% subcortex *, ...). +% +% :Optional Inputs: +% +% Any further arguments are passed through to canlab_results_fmridisplay. +% +% :Outputs: +% +% **obj:** +% The same handle, with the composed montages/surfaces registered and +% existing blob layers rendered onto them. +% +% :Examples: +% :: +% +% o2 = fmridisplay; +% o2 = addblobs(o2, region(my_stat_image)); +% o2 = multiview(o2, 'full'); % montages + surfaces, blobs pulled in +% +% :See also: +% - canlab_results_fmridisplay, montage, surface, addblobs, fmridisplay +% +% .. +% 2026 visualization overhaul +% .. + +if nargin < 2 || isempty(montagetype), montagetype = 'compact'; end + +% Delegate the figure/axes composition to canlab_results_fmridisplay, passing +% THIS (handle) object so it composes onto it rather than building a new one +% (see its do_setup_display logic). 'noblobs' skips adding new blobs and +% 'noremove' keeps any EXISTING blob layers on the object (canlab_results_ +% fmridisplay otherwise clears them before adding). The existing layers are +% rendered onto the new montages/surfaces by the fmridisplay.montage / surface +% pull-in as the panels are created. fmridisplay is a handle class, so obj is +% updated in place; we also capture the return for clarity. +obj = canlab_results_fmridisplay([], obj, montagetype, 'noblobs', 'noremove', varargin{:}); + +end diff --git a/CanlabCore/@fmridisplay/removeblobs.m b/CanlabCore/@fmridisplay/removeblobs.m index acda2ee1..7b533adf 100644 --- a/CanlabCore/@fmridisplay/removeblobs.m +++ b/CanlabCore/@fmridisplay/removeblobs.m @@ -34,36 +34,33 @@ % from deleted graphics handles. obj = prune_dead_views(obj); -to_remove = []; - for i = 1:length(obj.activation_maps) - + if isfield(obj.activation_maps{i}, 'blobhandles') - + wh = ishandle(obj.activation_maps{i}.blobhandles); - if any(wh) delete(obj.activation_maps{i}.blobhandles(wh)) - - to_remove(end+1) = i; end - + if isfield(obj.activation_maps{i}, 'legendhandle') - + wh = ishandle(obj.activation_maps{i}.legendhandle); - + if any(wh) delete(obj.activation_maps{i}.legendhandle(wh)) end - + end end - -end -% Surfaces +end -obj.activation_maps(to_remove) = []; +% Remove ALL layers. (Previously only layers with live montage blobhandles were +% dropped, which left surface-only layers — whose blobs are vertex colours, not +% graphics handles — stuck in the object and in the controller.) Surface colours +% are erased separately below. +obj.activation_maps = {}; if ~isempty(obj.surface) diff --git a/CanlabCore/@fmridisplay/render_layer_surfaces.m b/CanlabCore/@fmridisplay/render_layer_surfaces.m index 48dbba48..60021351 100644 --- a/CanlabCore/@fmridisplay/render_layer_surfaces.m +++ b/CanlabCore/@fmridisplay/render_layer_surfaces.m @@ -69,13 +69,30 @@ img = region2imagevec(layer.source_region); +% Default colour limits via the shared, uniform policy (canlab_default_cmaprange: +% robust per-arm percentile range, NOT [min max]), so a SURFACE-ONLY layer scales +% exactly like the montage and render_on_surface defaults. Without this, such a +% layer (no montage to supply a cmaprange) falls back to canlab_colormap's fixed +% [-1 1], washing out maps whose values are far from unit scale (e.g. SVM weights +% ~1e-4). When a montage IS present its render_blobs-computed cmaprange is already +% stored on the layer, so this only fills the surface-only gap and never overrides +% an explicit or montage-derived range. 'splitcolor' -> 4-element per-arm range. +if isempty(cmaprange) + v = double(img.dat(:)); + v = v(v ~= 0 & isfinite(v)); + if ~isempty(v) + split_flag = {}; + if any(strcmp(args, 'splitcolor')), split_flag = {'splitcolor'}; end + cmaprange = canlab_default_cmaprange(v, split_flag{:}); + end +end + % Translate the layer's colour spec (in render_args) into colormaps that % render_on_surface understands. It knows pos_colormap / neg_colormap / colormap / % color / splitcolor but NOT maxcolor / mincolor, so we build pos/neg ramps here. % Without this, warm/cool/winter (maxcolor/mincolor) and solid colours were % silently ignored on surfaces and render_on_surface fell back to its default % split hot/cool (e.g. blue negatives under a "warm" map). See render_on_surface.m:222. -wh_index = find(strcmp(args, 'indexmap'), 1); [surf_pos_cm, surf_neg_cm] = surface_colormaps_from_args(args); clean_args = strip_color_tokens(args); color_args = {}; @@ -114,18 +131,22 @@ % erase still restore gray. A full recomposite (reset to gray, repaint all % layers in order) is done by composite_surfaces, used by refresh / remove_layer. - if ~isempty(wh_index) - % Index/atlas colormap: pass straight through (render_on_surface handles it). - idxmap = args{wh_index + 1}; - [~, bar1axis, bar2axis] = render_on_surface(img, surfh, args{:}, 'colormap', idxmap, 'indexmap', leg_args{:}); - else - call_args = [clean_args, color_args, {'truecolor', tc_map, 'truecolor_alpha', layer_alpha}, leg_args]; - if ~isempty(cmaprange), call_args = [call_args, {'cmaprange', cmaprange}]; end - % Single-ramp / solid maps get ONE colorbar (not a pos+neg pair, which - % would wrongly imply a split colour scale). See render_on_surface. - if ismember(tc_map.type, {'single', 'solid', 'continuous'}), call_args = [call_args, {'single_colorbar'}]; end - [~, bar1axis, bar2axis] = render_on_surface(img, surfh, call_args{:}); + % ALL colour modes — including indexed/atlas — colour the vertices via the + % central canlab_colormap (tc_map) through render_on_surface's true-colour + % path, so the surface uses the exact same value->colour mapping as the + % montage. Indexed maps must sample nearest-neighbour so integer region + % indices survive projection (linear interp would blend indices at borders). + call_args = [clean_args, color_args, {'truecolor', tc_map, 'truecolor_alpha', layer_alpha}, leg_args]; + if ~isempty(cmaprange), call_args = [call_args, {'cmaprange', cmaprange}]; end + if strcmp(tc_map.type, 'indexed') && ~any(strcmp(call_args, 'interp')) + call_args = [call_args, {'interp', 'nearest'}]; + end + % Single-ramp / solid / continuous / indexed maps get ONE colorbar (not a + % pos+neg pair, which would wrongly imply a split colour scale). + if ismember(tc_map.type, {'single', 'solid', 'continuous', 'indexed'}) + call_args = [call_args, {'single_colorbar'}]; end + [~, bar1axis, bar2axis] = render_on_surface(img, surfh, call_args{:}); % Track the colorbar axes as the layer's legend; drop a prior one so % legends from multiple surfaces don't stack up. @@ -176,7 +197,7 @@ % Remove the colour-spec tokens we translate ourselves (plus cmaprange, re-added % from the layer), so they are not double-handled by render_on_surface. Keeps % all other options (sourcespace, targetsurface, nooutline, smooth, colormap, ...). -keys = {'splitcolor', 'color', 'maxcolor', 'mincolor', 'pos_colormap', 'neg_colormap', 'cmaprange'}; +keys = {'splitcolor', 'color', 'maxcolor', 'mincolor', 'pos_colormap', 'neg_colormap', 'cmaprange', 'indexmap', 'labels'}; out = {}; i = 1; while i <= numel(args) diff --git a/CanlabCore/@fmridisplay/surface.m b/CanlabCore/@fmridisplay/surface.m index a03a6d04..afa684ae 100644 --- a/CanlabCore/@fmridisplay/surface.m +++ b/CanlabCore/@fmridisplay/surface.m @@ -80,6 +80,18 @@ end +% Surfaces cannot be drawn into a uifigure (e.g. the auto-opened controller +% window): addbrain's subplot/gca then errors ("Adding subplots to a container +% with 'AutoResizeChildren' set to 'on' is not supported"). If the active figure +% is a uifigure and the caller did not target an explicit axes, open a dedicated +% traditional figure so gca/subplot land there. (The multi-surface path below +% manages its own figure; this covers the single-surface path and pre-empties the +% current figure so the multi path opens a clean one.) +if ~any(strcmp(varargin, 'axes')) + cf = get(groot, 'CurrentFigure'); + if ~isempty(cf) && is_uifigure(cf), figure; end +end + % Multi-surface keywords (e.g. 'foursurfaces', 'foursurfaces_hcp') add a SET % of surface views to THIS SAME object, laid out in the current figure. Each % becomes its own registered view, so blobs/refresh act on all of them @@ -195,12 +207,17 @@ set(hh, 'FaceAlpha', 1); end %[ -lightRestoreSingle; +lightRestoreSingle; axis image; -axis off; +axis off; lighting gouraud; material dull; +% Hide the "..." interaction toolbar. Sweep the whole figure, not just axh, since +% some keywords (cutaways, coronal_slabs_*) draw their patches into extra axes +% they create themselves rather than into axh. +canlab_hide_axes_toolbar(ancestor(axh, 'figure')); + % register info in fmridisplay object % 'axis_handles', axh, @@ -219,16 +236,23 @@ function obj = add_multiple_surfaces(obj, multi, varargin) % Add a canonical set of four surface views (L/R lateral + L/R medial) to the -% same fmridisplay object, arranged 2x2 in a dedicated figure. Each is added -% via a normal single-surface surface() call (handle semantics keep obj the -% same), which also triggers blob pull-in per surface. +% same fmridisplay object, laid out 2x2 in a dedicated figure. +% +% For the keywords addbrain builds directly ('foursurfaces' and +% 'foursurfaces_hcp'), the geometry is routed THROUGH addbrain so the surfaces +% are identical to what fmri_data.surface / region.surface produce (same +% meshes, subplot layout, camera views, lighting, and the added thalamus). +% Each addbrain subplot is then registered as its own fmridisplay view, so +% blobs added with addblobs (and refresh / removeblobs) act on all four panels. +% 'foursurfaces_freesurfer' is NOT an addbrain keyword, so it is still built +% here from individual inflated surfaces via single-surface surface() calls. % Ensure a dedicated figure for the 2x2 layout. If the current figure already -% has content (e.g. a montage), the four surfaces would otherwise be drawn over -% it (and appear to render nothing). Reuse an empty current figure (e.g. the -% user typed `figure;` first); otherwise open a new one. +% has content (a montage's axes, OR a uifigure like the controller with uipanel +% children), the four surfaces would draw over it or error, so open a fresh one. +% Reuse only a genuinely empty traditional figure (e.g. the user typed `figure;`). curfig = get(groot, 'CurrentFigure'); -if isempty(curfig) || ~isempty(findobj(curfig, 'Type', 'axes')) +if isempty(curfig) || is_uifigure(curfig) || ~isempty(allchild(curfig)) figure; end layout_fig = gcf; @@ -241,34 +265,72 @@ for j = sort(wh, 'descend'), rest(j:j+1) = []; end end -switch multi - case 'foursurfaces_hcp' - Lsurf = 'hcp inflated left'; Rsurf = 'hcp inflated right'; - case 'foursurfaces_freesurfer' - Lsurf = 'freesurfer inflated left'; Rsurf = 'freesurfer inflated right'; - otherwise % 'foursurfaces' - Lsurf = 'hires left'; Rsurf = 'hires right'; -end +if any(strcmp(multi, {'foursurfaces', 'foursurfaces_hcp'})) + + % Build the canonical layout through addbrain (drawn into layout_fig via + % subplots), so the surfaces match fmri_data.surface exactly. + h = addbrain(multi, rest{:}); + h = h(ishandle(h)); + + % Register one fmridisplay view per axes addbrain populated. Group the + % returned patch handles by their parent axes, preserving creation order, + % so each panel (including its thalamus) becomes a view that addblobs and + % refresh render onto. + ax_list = gobjects(0); + grp = zeros(numel(h), 1); + for ii = 1:numel(h) + a = ancestor(h(ii), 'axes'); + idx = find(ax_list == a, 1); + if isempty(idx), ax_list(end + 1) = a; idx = numel(ax_list); end %#ok + grp(ii) = idx; + end + + for a = 1:numel(ax_list) + these = h(grp == a); + obj.surface{end + 1} = struct('axis_handles', ax_list(a), ... + 'direction', multi, 'orientation', '', 'object_handle', these(:)'); + + % Pull any existing blob layers onto the newly registered surface, so + % a surface added after addblobs still shows the blobs (design notes 4.2). + new_surf_idx = numel(obj.surface); + for k = 1:numel(obj.activation_maps) + obj = render_layer_surfaces(obj, k, new_surf_idx); + end + end + +else + + % 'foursurfaces_freesurfer' is not an addbrain keyword, so build the 2x2 + % from individual inflated surfaces. {direction, orientation, axes-position}: + % top row = lateral views, bottom row = medial views; left col = left hemi. + Lsurf = 'freesurfer inflated left'; Rsurf = 'freesurfer inflated right'; + configs = { ... + {Lsurf, 'lateral', [0.03 0.50 0.45 0.45]}, ... + {Rsurf, 'lateral', [0.52 0.50 0.45 0.45]}, ... + {Lsurf, 'medial', [0.03 0.03 0.45 0.45]}, ... + {Rsurf, 'medial', [0.52 0.03 0.45 0.45]} }; + + for c = 1:numel(configs) + cfg = configs{c}; + obj = surface(obj, 'direction', cfg{1}, 'orientation', cfg{2}, 'axes', cfg{3}, rest{:}); + end -% {direction, orientation, axes-position} for a 2x2 layout: -% top row = lateral views, bottom row = medial views; left col = left hemi. -configs = { ... - {Lsurf, 'lateral', [0.03 0.50 0.45 0.45]}, ... - {Rsurf, 'lateral', [0.52 0.50 0.45 0.45]}, ... - {Lsurf, 'medial', [0.03 0.03 0.45 0.45]}, ... - {Rsurf, 'medial', [0.52 0.03 0.45 0.45]} }; - -for c = 1:numel(configs) - cfg = configs{c}; - obj = surface(obj, 'direction', cfg{1}, 'orientation', cfg{2}, 'axes', cfg{3}, rest{:}); end -% Turn off the axis box/background for EVERY axes in the layout figure — the four +% Turn off the axis box/background for EVERY axes in the layout figure — the % surface axes, the hidden colorbar axes, and any stray default axes created % during rendering (which otherwise shows a frame behind the surfaces). The % surface patches are children of the axes and stay visible; colorbars are % separate objects and are unaffected. set(findobj(layout_fig, 'Type', 'axes'), 'Visible', 'off'); +canlab_hide_axes_toolbar(layout_fig); % hide the "..." toolbar on all surface panels + +end + +function tf = is_uifigure(f) +% Thin wrapper on the shared canlab_is_uifigure (keeps figure-creation behaviour +% consistent with image_vector.montage / canlab_results_fmridisplay). +tf = canlab_is_uifigure(f); end diff --git a/CanlabCore/@image_vector/render_on_surface.m b/CanlabCore/@image_vector/render_on_surface.m index e582a6ef..95c61fad 100644 --- a/CanlabCore/@image_vector/render_on_surface.m +++ b/CanlabCore/@image_vector/render_on_surface.m @@ -341,7 +341,23 @@ if ~isempty(clim) [datvec, clim] = get_data_range(obj, clim); else - [datvec, clim] = get_data_range(obj, [min(obj.dat), max(obj.dat)]); + % Uniform default colour range via the shared policy (robust per-arm + % percentiles, NOT [min max], so a few extreme voxels do not compress the + % scale; matches the montage / fmridisplay-surface defaults). This engine + % takes a 2-element clim and maps it through zero, so a signed map asks for + % the per-arm split range and collapses it to its outer [negsat possat] + % saturation bounds. + dd = double(obj.dat(:)); + dd = dd(dd ~= 0 & isfinite(dd)); + if isempty(dd) + clim_default = [min(obj.dat(:)), max(obj.dat(:))]; + elseif any(dd < 0) && any(dd > 0) + clim_default = canlab_default_cmaprange(dd, 'splitcolor'); % 4-element per-arm + clim_default = clim_default([1, end]); % -> [negsat possat] + else + clim_default = canlab_default_cmaprange(dd); % 2-element + end + [datvec, clim] = get_data_range(obj, clim_default); end % ------------------------------------------------------------------------- diff --git a/CanlabCore/@region/surface.m b/CanlabCore/@region/surface.m index f2ba34b3..328b166d 100644 --- a/CanlabCore/@region/surface.m +++ b/CanlabCore/@region/surface.m @@ -222,4 +222,14 @@ render_on_surface(obj, surface_handles, varargin{:}); +% Hide the "..." interaction toolbar on the surface axes (recent MATLAB shows one +% in the corner of every axes). Sweep the figure(s) holding the surface patches. +try + sh = surface_handles(ishandle(surface_handles)); + if ~isempty(sh) + canlab_hide_axes_toolbar(ancestor(sh(1), 'figure')); + end +catch +end + end % function \ No newline at end of file diff --git a/CanlabCore/Unit_tests/image_vector/canlab_test_fmridisplay_handle.m b/CanlabCore/Unit_tests/image_vector/canlab_test_fmridisplay_handle.m index 62a96eaf..83a984d1 100644 --- a/CanlabCore/Unit_tests/image_vector/canlab_test_fmridisplay_handle.m +++ b/CanlabCore/Unit_tests/image_vector/canlab_test_fmridisplay_handle.m @@ -624,24 +624,30 @@ function test_squeeze_figure_preserves_slice_size(tc) function test_controller_shows_legend_labels(tc) -% The controller HOSTS the legend: numeric end labels under each layer's colour -% stripe (extremes; 0 in the centre for split maps), read from cmaprange. +% The controller HOSTS the legend: numeric labels under each layer's colour +% stripe, read from cmaprange. A split map shows FOUR values aligned to the bar +% (neg extreme / neg near-0 [gap] pos near-0 / pos extreme) in columns 1,2,4,5 +% with column 3 the blank gap; a single ramp shows the two ends in cols 1 and 5. tc.assumeTrue(usejava('desktop') || (usejava('jvm') && feature('ShowFigureWindows')), ... 'controller widget requires an interactive figure window'); t = canlab_get_sample_thresholded_t(0.01); o2 = montage(t); fig = controller(o2); tc.addTeardown(@() delete(fig(isvalid(fig)))); -cr = o2.activation_maps{1}.cmaprange; -lo = findobj(fig, 'Tag', 'leglo_1'); -mid = findobj(fig, 'Tag', 'legmid_1'); -hi = findobj(fig, 'Tag', 'leghi_1'); -tc.verifyNotEmpty(lo, 'low-end legend label exists'); -tc.verifyNotEmpty(hi, 'high-end legend label exists'); -tc.verifyEqual(lo.Text, num2str(round(min(cr), 1, 'significant')), 'low end labelled from cmaprange'); -tc.verifyEqual(hi.Text, num2str(round(max(cr), 1, 'significant')), 'high end labelled from cmaprange'); +cr = sort(double(o2.activation_maps{1}.cmaprange)); +f = @(x) num2str(round(x, 2, 'significant')); +L = @(c) findobj(fig, 'Tag', sprintf('leg%d_1', c)); +tc.verifyNotEmpty(L(1), 'first legend label exists'); +tc.verifyNotEmpty(L(5), 'last legend label exists'); if numel(cr) >= 4 - tc.verifyEqual(mid.Text, '0', 'split map labels 0 in the centre'); + tc.verifyEqual(L(1).Text, f(cr(1)), 'neg extreme labelled from cmaprange'); + tc.verifyEqual(L(2).Text, f(cr(2)), 'neg near-0 labelled from cmaprange'); + tc.verifyEqual(L(4).Text, f(cr(3)), 'pos near-0 labelled from cmaprange'); + tc.verifyEqual(L(5).Text, f(cr(4)), 'pos extreme labelled from cmaprange'); + tc.verifyEmpty(L(3).Text, 'centre column is the blank gap'); +else + tc.verifyEqual(L(1).Text, f(cr(1)), 'low end labelled from cmaprange'); + tc.verifyEqual(L(5).Text, f(cr(end)), 'high end labelled from cmaprange'); end end @@ -742,3 +748,62 @@ function test_removeblobs_clears_layers_and_surface_legend(tc) tc.verifyFalse(any(ishandle(leg)), 'surface colorbar(s) removed by removeblobs'); end end + + +function test_montage_existing_axes_expands_to_num_slices(tc) +% Regression: montage() into a SINGLE existing axis with many slices must expand +% into one axis per slice, not error ("Index exceeds ... must not exceed 1"). +% This is the root cause of the multi-image (multirow) montage failure. +tc.assumeTrue(usejava('jvm'), 'montage rendering requires Java'); +o2 = fmridisplay('nocontroller'); +f = figure; tc.addTeardown(@() close(f(isvalid(f)))); +axh = axes('Position', [.1 .5 .3 .2]); +o2 = montage(o2, 'axial', 'slice_range', [-32 50], 'onerow', 'spacing', 8, ... + 'noverbose', 'existing_axes', axh); +tc.verifyGreaterThan(numel(o2.montage{end}.axis_handles), 1, ... + 'existing_axes expanded from one placeholder axis into one axis per slice'); +end + + +function test_montage_multiimage_no_error(tc) +% Regression: montage() of a multi-image image_vector composes a per-image +% (multirow) display without erroring. +tc.assumeTrue(usejava('jvm'), 'montage rendering requires Java'); +obj = canlab_get_sample_fmri_data(); +tc.assumeTrue(size(obj.dat, 2) > 1, 'need a multi-image sample object'); +imgs = get_wh_image(obj, 1:3); +f = figure; tc.addTeardown(@() close(f(isvalid(f)))); +o = montage(imgs, 'noverbose'); +tc.verifyClass(o, 'fmridisplay'); +tc.verifyGreaterThan(numel(o.montage), 0, 'per-image montages were composed'); +end + + +function test_addblobs_multiimage_uses_first_image(tc) +% Regression: addblobs() of a multi-image object uses only the FIRST image as the +% layer source (one layer, single-image source), instead of erroring or combining. +tc.assumeTrue(usejava('jvm'), 'rendering requires Java'); +obj = canlab_get_sample_fmri_data(); +tc.assumeTrue(size(obj.dat, 2) > 1, 'need a multi-image sample object'); +o2 = fmridisplay('nocontroller'); o2 = montage(o2, 'axial', 'noverbose'); +o2 = addblobs(o2, obj, 'noverbose'); +tc.verifyEqual(numel(o2.activation_maps), 1, 'exactly one blob layer added'); +tc.verifyEqual(size(o2.activation_maps{1}.source_object.dat, 2), 1, ... + 'layer source is a single image (first image only)'); +end + + +function test_multiview_composes_and_pulls_existing_blobs(tc) +% multiview() composes a montage layout onto an EXISTING object (that already has +% surfaces + blobs) and pulls the existing blob layer onto the new montages. +tc.assumeTrue(usejava('jvm'), 'rendering requires Java'); +t = canlab_get_sample_thresholded_t(0.01); +o2 = fmridisplay('nocontroller'); +try, o2 = surface(o2); catch ME, tc.assumeFail(ME.message); end +o2 = addblobs(o2, region(t), 'source_object', t, 'noverbose'); +nb0 = numel(o2.activation_maps{1}.blobhandles); +o2 = multiview(o2, 'compact'); +tc.verifyGreaterThan(numel(o2.montage), 0, 'multiview composed montages onto the object'); +tc.verifyGreaterThan(numel(o2.activation_maps{1}.blobhandles), nb0, ... + 'existing blobs were pulled onto the new montages'); +end diff --git a/CanlabCore/Visualization_functions/canlab_hide_axes_toolbar.m b/CanlabCore/Visualization_functions/canlab_hide_axes_toolbar.m new file mode 100644 index 00000000..d4aaf004 --- /dev/null +++ b/CanlabCore/Visualization_functions/canlab_hide_axes_toolbar.m @@ -0,0 +1,39 @@ +function canlab_hide_axes_toolbar(h) +% Hide the per-axes interaction toolbar and disable default interactions. +% +% Removes the "..." overflow / hover toolbar (pan/zoom/rotate/datatips) that +% recent MATLAB releases (e.g. R2026a) show in the corner of every axes. Static +% brain slices and rendered surfaces don't need those controls, and the button +% clutters every panel of a montage / surface figure. +% +% :Usage: +% :: +% +% canlab_hide_axes_toolbar(ax) % one or more axes handles +% canlab_hide_axes_toolbar(fig) % every axes in a figure +% canlab_hide_axes_toolbar(gcf) +% +% Accepts axes handles, figure handles, or a mix; figures are expanded to all of +% their axes. Wrapped in try/catch so it is a safe no-op on older MATLAB. +% +% :See also: fmridisplay, montage, surface, canlab_results_fmridisplay + +if nargin < 1 || isempty(h), return, end + +axs = gobjects(0); +for g = reshape(h, 1, []) + if ~isgraphics(g), continue, end + switch get(g, 'type') + case 'figure' + axs = [axs; findall(g, 'Type', 'axes')]; %#ok + case 'axes' + axs = [axs; g]; %#ok + end +end + +for a = reshape(axs, 1, []) + try, a.Toolbar.Visible = 'off'; catch, end + try, disableDefaultInteractivity(a); catch, end + try, a.Interactions = []; catch, end +end +end diff --git a/CanlabCore/Visualization_functions/canlab_is_uifigure.m b/CanlabCore/Visualization_functions/canlab_is_uifigure.m new file mode 100644 index 00000000..6064aa9a --- /dev/null +++ b/CanlabCore/Visualization_functions/canlab_is_uifigure.m @@ -0,0 +1,32 @@ +function tf = canlab_is_uifigure(f) +% Return true if F is a uifigure (App Designer / uifigure) rather than a +% traditional figure. +% +% :Usage: +% :: +% +% tf = canlab_is_uifigure(f) % f defaults to gcf if omitted +% +% Detected via AutoResizeChildren='on', which is the uifigure default (a +% traditional figure is 'off') AND is exactly the property that makes +% subplot()/axes() fail with "Adding subplots to a container with the +% 'AutoResizeChildren' property set to 'on' is not supported". This is used by +% the CANlab display methods (fmridisplay.montage/surface, image_vector.montage/ +% surface, canlab_results_fmridisplay) to avoid rendering slices/surfaces into +% the interactive controller window: they open a fresh traditional figure +% instead, so figure-creation behaviour stays consistent across all of them. +% +% NOTE: matlab.ui.internal.isUIFigure returns true for BOTH traditional and +% uifigures, so it cannot be used to tell them apart. +% +% :See also: fmridisplay, canlab_results_fmridisplay + +if nargin < 1, f = get(groot, 'CurrentFigure'); end + +tf = false; +if isempty(f) || ~isgraphics(f), return, end +try + tf = isprop(f, 'AutoResizeChildren') && strcmp(f.AutoResizeChildren, 'on'); +catch +end +end diff --git a/CanlabCore/Visualization_functions/canlab_results_fmridisplay.m b/CanlabCore/Visualization_functions/canlab_results_fmridisplay.m index 69382bb7..5f1f2005 100644 --- a/CanlabCore/Visualization_functions/canlab_results_fmridisplay.m +++ b/CanlabCore/Visualization_functions/canlab_results_fmridisplay.m @@ -493,9 +493,35 @@ % complete path specified o2 = fmridisplay('overlay',overlay); end - - % You can customize these and run them from the command line - + + do_setup_display = true; % freshly constructed object: compose the layout + +else + % An existing fmridisplay object was passed in (e.g. + % o2 = canlab_results_fmridisplay(cl, o2, ...)): REUSE it instead of building + % a new one. Compose the requested montage/surface layout onto it when it has + % NO montages yet (so fmridisplay.multiview can compose even onto an object + % that already has surfaces); if montages already exist, skip composition and + % just add blobs to it (the long-standing reuse behaviour). This lets + % fmridisplay.multiview delegate the figure/axes compositions here. + do_setup_display = isempty(o2.montage); + +end + +% You can customize these and run them from the command line + +if do_setup_display + + % Never draw into the interactive controller (a uifigure): several montage + % layouts (e.g. the default 'compact') draw into the current figure via + % axes(), which errors on a uifigure ('AutoResizeChildren' is 'on'). If the + % current figure is a uifigure, open a fresh traditional figure so behaviour + % is consistent with fmridisplay.montage / .surface. (Layouts that create + % their own figure below are unaffected.) + if dofigure && canlab_is_uifigure(get(groot, 'CurrentFigure')) + figure; + end + switch montagetype case {'blobcenters', 'regioncenters'} @@ -2206,6 +2232,24 @@ otherwise error('illegal montage type. choose full, compact, or compact2 when ad o2 = addblobs(o2, cl, 'color', outlinecolor, 'outline', 'wh_montages', wh_montages, 'no_surface'); end +% Hide the "..." per-axes interaction toolbar on every panel of the composed +% figures (montage slices AND rendered surfaces, incl. layouts like 'full' / +% 'compact2' that build extra axes). Sweep the figures that hold this object's +% montage / surface axes. +try + composed_figs = gobjects(0); + for mi = 1:numel(o2.montage) + ah = o2.montage{mi}.axis_handles; ah = ah(ishandle(ah)); + if ~isempty(ah), composed_figs(end+1) = ancestor(ah(1), 'figure'); end %#ok + end + for si = 1:numel(o2.surface) + oh = o2.surface{si}.object_handle; oh = oh(ishandle(oh)); + if ~isempty(oh), composed_figs(end+1) = ancestor(oh(1), 'figure'); end %#ok + end + canlab_hide_axes_toolbar(composed_figs); +catch +end + % ------------------------------------------------------ % INLINE FUNCTIONS diff --git a/CanlabCore/fmridisplay_helper_functions/canlab_colormap.m b/CanlabCore/fmridisplay_helper_functions/canlab_colormap.m index a883234a..3adee0bb 100644 --- a/CanlabCore/fmridisplay_helper_functions/canlab_colormap.m +++ b/CanlabCore/fmridisplay_helper_functions/canlab_colormap.m @@ -89,18 +89,24 @@ case 'indexed' cmap = obj.colors; - idx = round(v); idx = min(max(idx, 1), size(cmap, 1)); + bad = ~isfinite(v); % NaN/Inf voxels (e.g. outside-blob) -> uncoloured + idx = round(v); idx(bad) = 1; + idx = min(max(idx, 1), size(cmap, 1)); rgb = cmap(idx, :); + rgb(bad, :) = NaN; case 'continuous' % Perceptual / arbitrary LUT mapped CONTINUOUSLY over the value % range (unlike 'indexed', which uses the value AS the row index). lut = obj.colors; lo = obj.range(1); hi = obj.range(2); + bad = ~isfinite(v); % NaN/Inf voxels -> uncoloured w = clamp01((v - lo) ./ nonzero(hi - lo)); + w(bad) = 0; idx = 1 + round(w .* (size(lut, 1) - 1)); idx = min(max(idx, 1), size(lut, 1)); rgb = lut(idx, :); + rgb(bad, :) = NaN; end % Clamp to [0 1] but preserve NaN (uncoloured) entries — note MATLAB's @@ -204,7 +210,10 @@ hask = @(k) any(strcmp(args, k)); valk = @(k) args{find(strcmp(args, k), 1) + 1}; - if hask('splitcolor') + if hask('indexmap') && isnumeric(valk('indexmap')) + % Indexed / atlas map: value is a ROW INDEX into this colormap. + obj = canlab_colormap.indexed(valk('indexmap')); + elseif hask('splitcolor') sc = valk('splitcolor'); % {minneg maxneg minpos maxpos} obj = canlab_colormap.split(sc{1}, sc{2}, sc{3}, sc{4}, default_clim(clim, [-1 1])); elseif hask('color') diff --git a/CanlabCore/fmridisplay_helper_functions/canlab_default_cmaprange.m b/CanlabCore/fmridisplay_helper_functions/canlab_default_cmaprange.m new file mode 100644 index 00000000..3d1db630 --- /dev/null +++ b/CanlabCore/fmridisplay_helper_functions/canlab_default_cmaprange.m @@ -0,0 +1,120 @@ +function cmaprange = canlab_default_cmaprange(mapd, varargin) +% Uniform default colour-limit (cmaprange) policy for CanlabCore blob displays. +% +% Single source of truth for the DEFAULT value->colour range used when the user +% does not pass an explicit 'cmaprange'. Montages (render_blobs), volumetric +% surface rendering (render_on_surface, used by fmri_data.surface), and the +% stateful fmridisplay surface path (render_layer_surfaces) all call this, so a +% montage and a surface of the same data scale identically by default. +% +% The policy is robust (percentile-based, NOT [min max]) so that one or a few +% extreme voxels do not compress the colour scale: +% +% - signed, non-split : [prctile 2, prctile 98] +% - single-sign : [prctile 10, prctile 90] +% - 'splitcolor' : per-arm 4-element range +% [p20(neg) p80(neg) p20(pos) p80(pos)], so the +% positive and negative colour arms are scaled +% independently. The split percentile relaxes +% (20 -> 15 -> ...) if the data lack the variability +% to give four distinct edges. +% +% (This is the long-standing montage default, factored out of +% render_blobs.get_default_cmaprange so surfaces share it.) +% +% :Usage: +% :: +% +% cmaprange = canlab_default_cmaprange(datavec) +% cmaprange = canlab_default_cmaprange(datavec, 'splitcolor') +% +% :Inputs: +% +% **mapd:** +% Numeric vector (or array) of map values. Zeros, NaNs and Infs are +% handled internally (zeros/NaNs dropped; Infs clamped to the max finite +% magnitude). +% +% :Optional Inputs: +% +% **'splitcolor':** +% Return the per-arm 4-element split range instead of a 2-element range. +% +% :Outputs: +% +% **cmaprange:** +% 2-element [lo hi], or 4-element [negmin negmax posmin posmax] for +% 'splitcolor'. (canlab_colormap and render_on_surface both accept either +% form; render_on_surface collapses a 4-element range to its outer +% [lo hi] saturation bounds.) +% +% :See also: +% - render_blobs, render_on_surface, render_layer_surfaces, canlab_colormap + +mapd = double(mapd(:)); +mapd = mapd(mapd ~= 0 & ~isnan(mapd)); % drop zeros and NaNs + +% Edge case: no usable data +if isempty(mapd) + cmaprange = [0, 1]; + return +end + +% Edge case: Inf values -> clamp to max finite magnitude +if any(isinf(mapd)) + whinf = isinf(mapd); + mapd(whinf) = sign(mapd(whinf)) .* max(abs(mapd(~whinf))); +end + +% Edge case: constant data +if numel(unique(mapd)) == 1 + constant_value = unique(mapd); + cmaprange = [constant_value - 0.1, constant_value + 0.1]; + return +end + +% Default for single-ramp / solid maps +cmaprange = double([prctile(mapd, 10), prctile(mapd, 90)]); + +% Signed single-ramp: widen (robustly) through zero so negatives are not all +% clamped to the min colour. Positive-only / negative-only keep [10 90] above. +if ~any(strcmp(varargin, 'splitcolor')) && any(mapd < 0) && any(mapd > 0) + cmaprange = double([prctile(mapd, 2), prctile(mapd, 98)]); +end + +% Split maps: scale the positive and negative arms independently. +prct_splitcolor = 20; +if any(strcmp(varargin, 'splitcolor')) && ~any(strcmp(varargin, 'cmaprange')) + + cmaprange = double([ ... + safe_prctile(mapd(mapd < 0), prct_splitcolor), ... + safe_prctile(mapd(mapd < 0), 100 - prct_splitcolor), ... + safe_prctile(mapd(mapd > 0), prct_splitcolor), ... + safe_prctile(mapd(mapd > 0), 100 - prct_splitcolor)]); + + % Relax the percentile if four distinct edges are not available + while numel(unique(cmaprange)) < 4 + prct_splitcolor = prct_splitcolor - 5; + if prct_splitcolor <= 0 + cmaprange([2, 3]) = cmaprange([1, 4]) * 0.9; % compress middle + break + end + cmaprange = double([ ... + safe_prctile(mapd(mapd < 0), prct_splitcolor), ... + safe_prctile(mapd(mapd < 0), 100 - prct_splitcolor), ... + safe_prctile(mapd(mapd > 0), prct_splitcolor), ... + safe_prctile(mapd(mapd > 0), 100 - prct_splitcolor)]); + end +end + +end % main function + + +function p = safe_prctile(data, percentile) +% Percentile that tolerates empty input (returns 0). +if isempty(data) + p = 0; +else + p = prctile(data, percentile); +end +end diff --git a/CanlabCore/fmridisplay_helper_functions/render_blobs.m b/CanlabCore/fmridisplay_helper_functions/render_blobs.m index a6bcef25..e6b98528 100644 --- a/CanlabCore/fmridisplay_helper_functions/render_blobs.m +++ b/CanlabCore/fmridisplay_helper_functions/render_blobs.m @@ -516,10 +516,18 @@ % See canlab_colormap. if dosplitcolor central_cm = canlab_colormap.split(minnegcolor, maxnegcolor, mincolor, color, cmaprange); -elseif docolormap && ~customcolormap && isempty(indexmap) - central_cm = canlab_colormap.single(mincolor, color, cmaprange); +elseif ~isempty(indexmap) + % Indexed / atlas map: each value is a ROW INDEX into the colormap matrix. + central_cm = canlab_colormap.indexed(indexmap); +elseif customcolormap + % Continuous custom / perceptual LUT mapped across the value range. + central_cm = canlab_colormap.continuous(cm, cmaprange); +elseif ~docolormap + % Solid single-colour blobs ('color' / 'onecolor'). + central_cm = canlab_colormap.solid(color); else - central_cm = []; + % Single ramp mincolor -> maxcolor (docolormap, no split/index/custom). + central_cm = canlab_colormap.single(mincolor, color, cmaprange); end % ----------------------------------------------------------- @@ -674,51 +682,22 @@ % surface map method % ----------------------------------------------------------- - if ~docolormap - % single-color map - slicecdat = cdat .* repmat(double(abs(Z) > 0), [1 1 3]); - - elseif ~dosplitcolor - % color-mapped - if isempty(indexmap) & ~customcolormap - % Single ramp via the central map (shared with surfaces): - % mincolor at min(cmaprange) -> maxcolor at max(cmaprange). - % Equivalent to the previous inline interpolation. - slicecdat = central_map_slice(central_cm, Z); - - elseif customcolormap - %w = repmat(Z, [1 1 3]); - - w = map_function(Z,cmaprange(1),cmaprange(2),1,size(cm,1)); - w(isnan(w)) = 1; % replace NaN indices (from NaN Z values) with 1 to avoid indexing errors - slicecdat = reshape(cm(round(w),:),[size(Z),3]); - - else - w = repmat(Z, [1 1 3]); - [Zi, Zj] = find(w > 0); - slicecdat = nan(size(Z,1), size(Z,2) ,3); - slicecdat(sub2ind(size(w),Zi,Zj)) = indexmap(Z(Z> 0),:); -% I'm pretty sure this is a bug since -% indexmap is not selecting rows.... 2/11/2023 MS -% slicecdat(sub2ind(size(w),Zi,Zj)) = indexmap(find(Z(Z > 0)),:); - end - - elseif dosplitcolor - % split colormap around zero - - % if max(cmaprange) < 0, [dummy, wh] = max(cmaprange); cmaprange(wh) = abs(min(cmaprange)); end - % if min(cmaprange) > 0, [dummy, wh] = min(cmaprange); cmaprange(wh) = -(max(cmaprange)); end - - % make into 4-element: min neg, max neg, min pos, max pos - if length(cmaprange) == 2 - cmaprange = [min(cmaprange) 0 0 max(cmaprange)]; % just like before = all the way to 0 - end - - % Split +/- ramp via the central map (shared with surfaces): - % positive interpolates minpos (near 0) -> maxpos (extreme), - % negative interpolates maxneg (near 0) -> minneg (extreme). + % Single source of truth: EVERY value-mapped mode (split, single + % ramp, solid, continuous LUT, indexed/atlas) is coloured by the + % central canlab_colormap, the SAME object surfaces use, so montage + % and surface agree exactly. central_cm is built once above from the + % parsed colours + cmaprange. Non-blob voxels come back NaN (or are + % alpha-masked below), so no explicit in-blob masking is needed here. + if ~isempty(central_cm) slicecdat = central_map_slice(central_cm, Z); + elseif ~docolormap + % Fallback solid fill (central_cm somehow unavailable). + slicecdat = cdat .* repmat(double(abs(Z) > 0), [1 1 3]); + + else + error('render_blobs:noCentralColormap', ... + 'No central colormap was built for this colour mode.'); end if ~isa(slicecdat, 'double') @@ -842,75 +821,11 @@ function cmaprange = get_default_cmaprange(currentmap, varargin) - - % Extract and preprocess map data - mapd = currentmap.mapdata(:); - mapd = mapd(mapd ~= 0 & ~isnan(mapd)); % Remove zeros and NaNs - - % Handle edge case: empty mapd - if isempty(mapd) - warning('No valid data values in currentmap. Returning default colormap range.'); - cmaprange = [0, 1]; % Default range for empty data - return; - end - - % Handle edge case: Inf values - if any(isinf(mapd)) - warning('Some image values are Inf. Replacing Inf with max finite value.'); - whinf = isinf(mapd); - mapd(whinf) = sign(mapd(whinf)) .* max(abs(mapd(~whinf))); % Replace Inf with max finite value - end - - % Handle edge case: constant or nearly constant mapd - if numel(unique(mapd)) == 1 - % All values are constant - constant_value = unique(mapd); - % warning('All non-zero, non-NaN values in mapd are constant. Using default colormap range.'); - cmaprange = [constant_value - 0.1, constant_value + 0.1]; % Default range for constant values - return; - end - - % Default colormap range for non-splitcolor (single ramp / solid). - cmaprange = double([prctile(mapd, 10), prctile(mapd, 90)]); - - % Single-ramp map on SIGNED data: span the full (robust) range THROUGH ZERO, - % so the one colorbar reflects negatives too instead of clamping them all to - % the min colour (and so the legend isn't a misleading positive-only range). - % Positive-only / negative-only data keep the percentile range above. - % (Split maps are handled separately below.) - if ~any(strcmp(varargin, 'splitcolor')) && any(mapd < 0) && any(mapd > 0) - cmaprange = double([prctile(mapd, 2), prctile(mapd, 98)]); - end - - % Handle splitcolor logic - prct_splitcolor = 20; % Starting percentile range for splitcolor - if any(strcmp(varargin, 'splitcolor')) && ~any(strcmp(varargin, 'cmaprange')) - - % Compute splitcolor colormap range - cmaprange = double([ - safe_prctile(mapd(mapd < 0), prct_splitcolor), ... - safe_prctile(mapd(mapd < 0), 100 - prct_splitcolor), ... - safe_prctile(mapd(mapd > 0), prct_splitcolor), ... - safe_prctile(mapd(mapd > 0), 100 - prct_splitcolor) - ]); - - % Handle edge case: insufficient variability in splitcolor - while numel(unique(cmaprange)) < 4 - prct_splitcolor = prct_splitcolor - 5; - if prct_splitcolor <= 0 - warning('Splitcolor logic failed due to insufficient data variability. Adjusting colormap range.'); - cmaprange([2, 3]) = cmaprange([1, 4]) * 0.9; % Compress middle range - break; - end - % Recalculate cmaprange with reduced prct_splitcolor - cmaprange = double([ - safe_prctile(mapd(mapd < 0), prct_splitcolor), ... - safe_prctile(mapd(mapd < 0), 100 - prct_splitcolor), ... - safe_prctile(mapd(mapd > 0), prct_splitcolor), ... - safe_prctile(mapd(mapd > 0), 100 - prct_splitcolor) - ]); - end - end + % Default value->colour range for a montage blob layer. Delegated to the + % shared canlab_default_cmaprange so montages, render_on_surface, and the + % fmridisplay surface path all use ONE uniform percentile-based default. + % (Behaviour is unchanged from the previous inline implementation.) + cmaprange = canlab_default_cmaprange(currentmap.mapdata(:), varargin{:}); end % Safe percentile function to handle empty data diff --git a/README.md b/README.md index 391461c9..1e11217b 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,19 @@ Getting help and additional information: ------------------------------------------------------------ Sources of documentation for this toolbox: -1. **[canlab.github.io](https://canlab.github.io/)** — top-level entry point with Setup, Repositories, Philosophy, and Batch system for 2nd-level analysis. -2. **[Walkthroughs](https://canlab.github.io/walkthroughs/)** — step-by-step analysis tutorials with code. "How to do stuff with Canlab tools". -3. **[Tutorials](https://canlab.github.io/tutorials/)** — longer-form tutorials with more equations and theoretical explanation. -4. **[Object methods reference](docs/Object_methods.md)** — the entry point for object help: a class-by-class index (`fmri_data`, `image_vector`, `statistic_image`, `atlas`, `region`, `predictive_model`, ...) of object methods with per-function code maps, runnable examples, and sample figures. The fastest way to learn the API. -5. Function-by-function help documents and examples are provided in each function. In Matlab type >>help +### In this repository (`docs/`) + +- **[Object methods reference](docs/Object_methods.md)** — the entry point for object help: a class-by-class index (`fmri_data`, `image_vector`, `statistic_image`, `atlas`, `region`, `predictive_model`, ...) of object methods with per-function code maps, runnable examples, and sample figures. The fastest way to learn the API. +- **[Visualization walkthrough](docs/visualization_walkthrough/index.md)** — a hands-on, multi-page guide to visualizing brain data and results: montages, 3-D surfaces, the interactive display controller, colormaps, and atlases. Each section pairs runnable code with the figure it produces. +- **[Workflows](docs/Workflows.md)** — end-to-end recipes that chain several methods to accomplish a common analysis goal (ROI data extraction, first-/second-level GLM maps, ...). Each comes as a conceptual *roadmap* plus a runnable *walkthrough*. +- **[Markdown tutorials](docs/markdown_tutorials/)** — longer-form, multi-part didactic tutorials with copy-pasteable code (e.g. [multivariate classification with SVM](docs/markdown_tutorials/multivariate_classification_with_SVM/multivariate_decoding_part1_classification_with_SVM.md)). +- **Function-level help** — every function has help text and examples. In MATLAB, type `>> help `. + +### Online + +- **[canlab.github.io](https://canlab.github.io/)** — top-level entry point with Setup, Repositories, Philosophy, and Batch system for 2nd-level analysis. +- **[Walkthroughs](https://canlab.github.io/walkthroughs/)** — step-by-step analysis tutorials with code. "How to do stuff with Canlab tools". +- **[Tutorials](https://canlab.github.io/tutorials/)** — longer-form tutorials with more equations and theoretical explanation. For more information on fMRI analysis generally, see Martin Lindquist and Tor Wager's online book and our free Coursera videos and classes Principles of fMRI Part 1 and Part 2 . New in 2026: Martin and Tor's expanded fMRI methods book Elements of fMRI. diff --git a/docs/Object_methods.md b/docs/Object_methods.md index 2b91923b..8424918c 100644 --- a/docs/Object_methods.md +++ b/docs/Object_methods.md @@ -99,6 +99,8 @@ A small set of stand-alone helpers that are not class methods but are used by `p ## Visualizing images and results +> **New to CANlab visualization?** Start with the **[Visualization Walkthrough](visualization_walkthrough/index.md)** — a hands-on, multi-page guide (montages, 3-D surfaces, the interactive display controller, colormaps, atlases) that pairs runnable code with the figure it produces. The entry points below are the reference; the walkthrough shows them in context. + Most image objects (`fmri_data`, `statistic_image`, `atlas`) share a common set of visualization entry points — pick by output medium: diff --git a/docs/visualization_walkthrough/01_getting_started.md b/docs/visualization_walkthrough/01_getting_started.md new file mode 100644 index 00000000..e006395e --- /dev/null +++ b/docs/visualization_walkthrough/01_getting_started.md @@ -0,0 +1,94 @@ +# 1. Getting started + +[Back to index](index.md) · Next: [2. Montages and slices →](02_montages.md) + +## Objects you'll visualize + +CANlab visualization is organized around a few object classes. Knowing which one you hold tells you which display methods are available: + +| Object | What it holds | Typical display | +|--------|---------------|-----------------| +| **`fmri_data`** | A set of brain images (data). | `plot()` QC overview, `mean()`, then montage/surface. | +| **`statistic_image`** | A statistic map with p‑values; supports `threshold()`. | `orthviews`, `montage`, `surface`. | +| **`region`** | Contiguous suprathreshold blobs, as analysis units. | `montage(r, 'regioncenters')`, `table()`. | +| **`atlas`** | A labeled parcellation (many named regions). | `montage`, `isosurface`, unique-color rendering. | +| **`fmridisplay`** | A display container: montages + surfaces + their handles. | `addblobs`, `removeblobs`, `controller`. | + +Almost every method below exists on several of these classes, so `montage`, `surface`, and `orthviews` "just work" whatever you're holding. + +## How the display methods fit together + +CANlab visualization is object-oriented. The two workhorse methods — `montage` (2‑D slice arrays, [page 2](02_montages.md)) and `surface` (3‑D cortical and subcortical renderings, [page 3](03_surfaces.md)) — don't just draw a picture and return: they **return an `fmridisplay` object** that holds the axes, the rendered layers, and each layer's source data and options. You keep that object and keep working with it: + +```matlab +o = montage(t); % returns an fmridisplay object +o = surface(o, 'foursurfaces'); % add a surface to the SAME object +o = addblobs(o, region(t2)); % overlay a second map as a new layer +o = removeblobs(o); % take blobs off without redrawing the underlay +controller(o); % open an interactive panel bound to o +``` + +Because `fmridisplay` is a **handle class** whose layers remember where they came from, one object can carry montages *and* surfaces together and re-render them all in sync — from the command line or from the interactive [controller](05_controller.md). That is the thread running through the whole walkthrough. + +> **Reproduce everything at once.** Every figure in this guide is produced by a single master script, [`_gen/visualization_walkthrough.m`](_gen/visualization_walkthrough.m), organized one section per walkthrough page (1.1, 1.2, … 6.5). Run it with CanlabCore on the path to regenerate all figures, or copy any section as a starting point. + +## Load a dataset + +The examples throughout this walkthrough use the bundled `emotionreg` sample dataset, so they run without downloading anything: + +```matlab +obj = load_image_set('emotionreg', 'noverbose'); % 30 contrast images (fmri_data) +``` + +`load_image_set` also fetches larger published datasets by keyword (e.g. `'kragel18_alldata'`); see the [datasets tutorial](https://canlab.github.io/_pages/canlab_help_2c_loading_datasets/canlab_help_2c_loading_datasets.html). + +## A quick quality-control look: `plot` + +`plot(obj)` on an `fmri_data` object gives a six-panel QC overview (data matrix, covariance/correlation, histogram, global signal, and a Mahalanobis outlier plot) plus orthviews of the mean. Run it as a first check on any dataset: + +```matlab +plot(obj); % interactive QC figure + outlier report in the console +``` + +## Make a result to display + +Most of the walkthrough displays a statistic map. Compute one with a voxelwise t‑test and threshold it: + +```matlab +t = ttest(obj); % statistic_image +t = threshold(t, .05, 'unc'); % keep p < .05 uncorrected +``` + +## First look: `orthviews` + +The fastest way to inspect a map interactively is `orthviews` — three planes with a movable crosshair. Click to move the crosshair through the volume: + +```matlab +orthviews(t); +``` + +![orthviews of the thresholded map](figures/01_orthviews.png) + +`orthviews` is convenient but depends on **SPM** for its display windows. CANlab ships two SPM‑free alternatives that show the same three‑plane view: + +- **`canlab_orthviews(t)`** — a self-contained MATLAB figure (no SPM), with a split warm/cool colorbar. Good for scripts and machines without SPM installed. + + ```matlab + canlab_orthviews(t); + ``` + + ![canlab_orthviews of the thresholded map](figures/01_canlab_orthviews.png) + +- **`canlab_niivue(t)`** — an interactive **web** viewer (built on NiiVue): point-and-click slices in the browser, a live MNI‑coordinate + value readout, an attached atlas that names the region under the crosshair, and colormap/threshold/opacity controls — no plugin, no server, works offline. See its dedicated guide: [`canlab_niivue` — interactive web viewer](../canlab_niivue_guide.md). + +That's enough to start. From here the walkthrough builds up the full toolkit: + +- **[2. Montages and slices](02_montages.md)** — publication slice arrays, per-region close-ups, customization. +- **[3. Surfaces and 3‑D rendering](03_surfaces.md)** — cortical surfaces, cutaways, subcortical structures, isosurfaces. +- **[4. Colors and colormaps](04_colormaps.md)** — the shared value→color pipeline; making maps comparable. +- **[5. The display controller](05_controller.md)** — the interactive panel, and its command-line twin. +- **[6. Atlases and regions](06_atlases_and_regions.md)** — parcellations in unique colors, on slices, surfaces, and in 3‑D. + +--- + +[Back to index](index.md) · Next: [2. Montages and slices →](02_montages.md) diff --git a/docs/visualization_walkthrough/02_montages.md b/docs/visualization_walkthrough/02_montages.md new file mode 100644 index 00000000..65978169 --- /dev/null +++ b/docs/visualization_walkthrough/02_montages.md @@ -0,0 +1,152 @@ +# 2. Montages and slices + +[← Back to index](index.md) · Next: [3. Surfaces and 3‑D rendering →](03_surfaces.md) + +Montages are the workhorse of CANlab visualization: a canonical array of slices that shows the **whole** result, not a hand-picked slice. This page covers the quick interactive look (`orthviews`), the prepackaged montage layouts, per-region "close-up" montages, and the main customization options. + +Every example below starts from a thresholded statistic image. We use the bundled `emotionreg` sample dataset so the code runs anywhere: + +```matlab +obj = load_image_set('emotionreg', 'noverbose'); % 30 first-level contrast images +t = threshold(ttest(obj), .05, 'unc'); % voxelwise one-sample t-test +r = region(t); % contiguous blobs, for region methods +``` + +--- + +## 2.1 A quick interactive look: `orthviews` + +`orthviews` opens the familiar three-plane (coronal / sagittal / axial) view with movable crosshairs. It is the fastest way to inspect a map interactively — click anywhere to move the crosshair. + +```matlab +orthviews(t); +``` + +![orthviews of the thresholded t-map](figures/01_orthviews.png) + +Positive values render in warm colors, negative in cool, with a colorbar in t units. `orthviews` works on `fmri_data`, `statistic_image`, `region`, and `atlas` objects. Useful options include `'unique'` / `'solid'` (each contiguous blob a distinct solid color — handy for masks and atlases) and `'largest_region'`. + +--- + +## 2.2 Prepackaged montage layouts + +The function `canlab_results_fmridisplay` builds a complete, publication-ready montage in one call. Pass a statistic image and a **montage type**: + +```matlab +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +``` + +**`compact2`** — a single row: midline sagittals plus a row of axial slices. The most compact "whole-picture" view. + +![compact2 montage](figures/02_compact2.png) + +**`full`** — the comprehensive layout: four cortical surfaces, a sagittal row, a coronal row, and two axial rows. This is what you publish when you want to show everything. + +```matlab +o = canlab_results_fmridisplay(t, 'full', 'noverbose'); +``` + +![full montage](figures/02_full.png) + +Other prepackaged types you can pass instead (see `help canlab_results_fmridisplay`): + +| Type | What it shows | +|------|----------------| +| `'compact'` | Default. Midline sagittal + two axial rows. | +| `'compact2'` | Single row: sagittal + axials (shown above). | +| `'full'` | Slices in all 3 planes + 4 cortical surfaces (shown above). | +| `'full hcp'` | `full`, with HCP surfaces and subcortical volumes. | +| `'multirow'` | Several `compact2` rows stacked, to compare maps side by side. Call as `canlab_results_fmridisplay([], 'multirow', 2)` then add each map to a row. | +| `'regioncenters'` | One axis per region, each centered on a blob (see §2.3). | +| `'subcortex compact'` / `'subcortex 3d'` | Subcortical-focused layouts. | + +> **Tip.** `montage(t)` (the object method) is a shortcut that routes to `canlab_results_fmridisplay` with the default `compact` layout. `montage(r)` does the same for a region object. + +--- + +## 2.3 A close-up of every blob: `regioncenters` + +To inspect each activation cluster individually, convert the map to a `region` object and use the `'regioncenters'` montage. Each contiguous blob gets **its own axis**, centered and zoomed on that region — ideal for reports where you want to see every cluster clearly. + +```matlab +r = region(t); +montage(r, 'regioncenters', 'colormap', 'noverbose'); +``` + +![regioncenters: each blob in its own axis](figures/02_regioncenters.png) + +The `'colormap'` option colors each blob by its value (warm = positive, cool = negative). Add `r = autolabel_regions_using_atlas(r)` before plotting to label each region with its anatomical name (see [6. Atlases and regions](06_atlases_and_regions.md)). + +--- + +## 2.4 Customizing blobs: color, outline, transparency + +`canlab_results_fmridisplay` (and `addblobs`) accept color and style options. Here we render the map as solid-colored blobs with a black **outline** and partial **transparency** so the underlay shows through: + +```matlab +o = canlab_results_fmridisplay(t, 'compact2', ... + 'color', [1 0 0], 'outline', 'linewidth', 2, 'trans', 'noverbose'); +``` + +![outlined, transparent blobs](figures/02_outline_trans.png) + +Common blob options: + +| Option | Effect | +|--------|--------| +| `'color', [r g b]` | One solid color for all blobs. | +| `'maxcolor', [..]`, `'mincolor', [..]` | Endpoints of a single value→color ramp. | +| `'splitcolor', {…}` | Four colors: `{minneg maxneg minpos maxpos}` for a signed split map. | +| `'outline'`, `'linewidth', n` | Draw a blob outline instead of / on top of the fill. | +| `'trans'` / `'scaledtransparency'` | Transparent blobs; scaled by value with `'scaledtransparency'`. | +| `'cmaprange', [lo hi]` | Fix the value→color limits (see [4. Colors and colormaps](04_colormaps.md)). | + +Color behavior is unified across montages and surfaces — a map scaled one way on a montage looks identical on a surface. That pipeline is the subject of [page 4](04_colormaps.md). + +--- + +## 2.5 Overlaying multiple maps + +Because an `fmridisplay` object is a persistent container, you can add several maps and remove them without redrawing the underlay. Here the full map is drawn in one color and a stricter-threshold subset in another: + +```matlab +o = canlab_results_fmridisplay(t, 'compact2', 'color', [1 .3 0], 'noverbose'); +tstrict = threshold(ttest(obj), .001, 'unc'); +o = addblobs(o, region(tstrict), 'color', [0 .4 1], 'no_surface'); +% ... later: o = removeblobs(o); % clear blobs, keep the montage +``` + +![two maps overlaid](figures/02_two_maps.png) + +`addblobs` / `removeblobs` let you build one canonical set of slices and cycle different maps through it. The [controller page](05_controller.md) shows how to do this interactively, and how montage and surface views stay in sync. + +--- + +## 2.6 Rolling your own slices: `fmridisplay.montage` + +The prepackaged layouts pick their slices for you. When you need a specific set of slices — a particular orientation, a slice range, custom spacing — build an empty `fmridisplay` and call its `montage` method directly, then add your blobs. Here we lay out a **single row of axial slices** from z = −30 to z = 60 every 10 mm: + +```matlab +o = fmridisplay; % empty display container +o = montage(o, 'axial', 'slice_range', [-30 60], ... + 'spacing', 10, 'onerow'); % choose the slices +o = addblobs(o, region(t), 'nooutline'); % paint the map onto them +``` + +![custom axial montage, one row, -30 to 60 mm](figures/02_custom_montage.png) + +The montage method's slice controls: + +| Option | Effect | +|--------|--------| +| `'axial'` / `'sagittal'` / `'coronal'` | Orientation of the slices. | +| `'slice_range', [lo hi]` | First/last slice coordinate (mm). | +| `'spacing', n` | Millimeters between slices. | +| `'wh_slice', [..]` or `'slices', [..]` | Give explicit slice coordinates instead of a range. | +| `'onerow'` | Force all slices into a single row. | + +You can call `montage` more than once on the same object to stack rows of different orientations (a sagittal row above an axial row, say) — exactly how the prepackaged `full`/`compact` layouts are assembled internally. See `help fmridisplay.montage`. + +--- + +[← Back to index](index.md) · Next: [3. Surfaces and 3‑D rendering →](03_surfaces.md) diff --git a/docs/visualization_walkthrough/03_surfaces.md b/docs/visualization_walkthrough/03_surfaces.md new file mode 100644 index 00000000..7f41ca64 --- /dev/null +++ b/docs/visualization_walkthrough/03_surfaces.md @@ -0,0 +1,129 @@ +# 3. Surfaces and 3‑D rendering + +[← 2. Montages](02_montages.md) · [Back to index](index.md) · Next: [4. Colors and colormaps →](04_colormaps.md) + +Surface renderings localize activity on the cortical sheet and in 3‑D subcortical structures, and make patterns comparable across studies. Most data objects (`fmri_data`, `statistic_image`, `region`) have a `surface()` method, and the underlying engine — `render_on_surface` — paints values onto any set of surface handles you build with `addbrain` or `isosurface`. + +```matlab +obj = load_image_set('emotionreg', 'noverbose'); +t = threshold(ttest(obj), .05, 'unc'); +``` + +--- + +## 3.1 The `surface()` method + +Called with a layout keyword, `surface()` builds the surfaces and renders the map in one step. **`foursurfaces`** gives the standard lateral + medial view of both hemispheres: + +```matlab +sh = surface(t, 'foursurfaces', 'noverbose'); % returns surface handles +``` + +![foursurfaces](figures/03_foursurfaces.png) + +Positive values are warm, negative cool, with a split colorbar in t units. The returned handles `sh` can be recolored, made transparent, or rotated afterward. + +Called with no layout, `surface(t)` produces a **cutaway** that reveals medial cortex and subcortical structures together: + +```matlab +surface(t); +``` + +![surface cutaway](figures/03_surface_default.png) + +--- + +## 3.2 Recoloring with `render_on_surface` + +`render_on_surface` repaints existing surface handles — change the colormap, the color limits, or emphasize one sign. Here the same `foursurfaces` handles are recolored with a perceptual `summer` colormap: + +```matlab +render_on_surface(t, sh, 'colormap', 'summer'); +``` + +![foursurfaces recolored with summer](figures/03_foursurfaces_summer.png) + +Key `render_on_surface` options: + +| Option | Effect | +|--------|--------| +| `'colormap', name` | Any MATLAB colormap (`'hot'`, `'summer'`, `'winter'`, …) or an *n×3* matrix. | +| `'clim', [lo hi]` | Value→color limits. Use `[-3 3]` for a symmetric signed map, or `[2 6]` to emphasize positives. | +| `'splitcolor', {…}` | Custom warm/cool split colors. | +| `'srcdepth'`, `'sourcespace'`, `'targetsurface'` | Accurate volume→surface projection (see note below). | + +> **Accurate projection.** MNI *volume* voxels don't map one‑to‑one onto standard *surface* vertices. For publication-grade cortical projection, pass `'sourcespace'` (e.g. `MNI152NLin2009cAsym`) and `'targetsurface'` (`fsLR_32k`, `fsaverage_164k`) so CANlab uses precomputed vertex mappings and samples across the cortical ribbon. See `help render_on_surface`. + +--- + +## 3.3 Cutaways and slabs + +`surface()` accepts many prepackaged 3‑D cutaways. **`coronal_slabs_4`** cuts the brain into four coronal slabs so you can see activation on interior cross-sections: + +```matlab +surface(t, 'coronal_slabs_4'); +``` + +![coronal slabs](figures/03_coronal_slabs_4.png) + +Other cutaway keywords: `'left_cutaway'`, `'right_cutaway'`, `'left_insula_slab'`, `'right_insula_slab'`, `'accumbens_slab'`, `'coronal_slabs'`, `'coronal_slabs_5'`. Each can be passed to `surface()` (to render a map) or to `addbrain()` (to get bare surfaces). + +--- + +## 3.4 Subcortical close-ups with `addbrain` + +`addbrain` is the master surface builder: dozens of keywords return handles to cortical or subcortical anatomy that you compose and then color with `render_on_surface`. This builds a subcortical scene — thalamic nuclei, brainstem, amygdala — and renders the t‑map onto it: + +```matlab +sh = addbrain('thalamus_group'); +sh = [sh addbrain('brainstem')]; +sh = [sh addbrain('amygdala')]; +render_on_surface(t, sh, 'clim', [-3 3]); +set(sh, 'FaceAlpha', .9); +view(135, 12); lightRestoreSingle; camzoom(1.3); +``` + +![subcortical close-up](figures/03_subcortical_closeup.png) + +`addbrain` knows individual structures, composites, and cortical surfaces (catalogued in §3.6 below). Handles are standard MATLAB graphics: set `FaceAlpha`, rotate with `view()`, zoom with `camzoom`, and re-fix lighting with `lightRestoreSingle`. `addbrain('eraseblobs', sh)` resets a surface to gray so you can render a different contrast on the same scene. Run `addbrain` with no arguments (`addbrain`) to print its built-in keyword list, and see `help addbrain` for the full reference. Many keywords resolve regions through `canlab_load_ROI` and the atlas objects in `Neuroimaging_Pattern_Masks` (see [6. Atlases and regions](06_atlases_and_regions.md)), so the exact anatomy depends on those installed atlases. + +--- + +## 3.5 Isosurfaces from atlases and images + +`isosurface()` turns any image or atlas into 3‑D mesh blobs. Here every nucleus of a thalamic atlas becomes a surface; then we render the t‑map onto them: + +```matlab +atl = load_atlas('thalamus'); +sh = isosurface(atl); +axis image vis3d off; material dull; view(210, 20); lightRestoreSingle +render_on_surface(t, sh, 'colormap', 'hot'); +``` + +Atlas nuclei as bare isosurfaces | t‑map rendered onto them +:---:|:---: +![thalamus isosurface](figures/03_isosurface_thalamus.png) | ![thalamus isosurface rendered](figures/03_isosurface_thalamus_rendered.png) + +`isosurface` also builds custom cutaways from any anatomical image via `'thresh'`, `'nosmooth'`, and `'xlim'/'ylim'/'zlim'` bounds — useful for a bespoke publication surface. See `help image_vector.isosurface`. + +--- + +## 3.6 The surface catalog + +Any of these keywords can be passed to `addbrain(keyword)` to get bare surface handles, or (for the whole-brain layouts) to `surface(obj, keyword)` to render a map in one step. The list below is the built-in set as of this writing; run `addbrain` with no arguments for the authoritative list in your install. + +**Cortical surfaces (single hemisphere or pair).** `'left'` / `'right'`, `'hires left'` / `'hires right'` (higher-resolution pial), `'surface left'` / `'surface right'`, `'inflated left'` / `'inflated right'` (FreeSurfer inflated), plus template-specific meshes `'MNI152NLin2009cAsym white|midthickness|pial left|right'`, `'MNI152NLin6Asym …'`, `'hcp inflated left|right'`, `'hcp sphere …'`, `'freesurfer inflated|white|sphere left|right'`, `'bigbrain left|right'`, `'flat left'` / `'flat right'`. + +**Whole-brain surface layouts** (pass to `surface()` to render a map): `'foursurfaces'` (lateral + medial, both hemispheres — shown in §3.1), `'foursurfaces_hcp'`, `'inflated surfaces'`, `'flat surfaces'`, `'insula surfaces'`, `'multi_surface'`. + +**Cutaways and slabs** (reveal interior anatomy): `'cutaway'`, `'left_cutaway'`, `'right_cutaway'`, `'left_insula_slab'`, `'right_insula_slab'`, `'accumbens_slab'`, `'coronal_slabs'`, `'coronal_slabs_4'`, `'coronal_slabs_5'`, `'brainbottom'`. + +**Individual subcortical / brainstem structures:** `'thalamus'`, `'amygdala'` (+ `'amygdala hires'`), `'hippocampus'` (+ `'hippocampus hires'`), `'brainstem'`, `'pag'`, `'nucleus accumbens'` (`'nacc'`), `'caudate'`, `'putamen'`, `'globus pallidus'` (`'GP'`/`'GPe'`/`'GPi'`), `'cerebellum'` (`'cblm'`), `'hypothalamus'`, `'habenula'`, `'STN'`, `'SN'` (`'SNc'`/`'SNr'`), `'VTA'`, `'red nucleus'` (`'rn'`), `'LC'`, `'PBP'`, `'BST'`/`'BNST'`, `'vmpfc'`, and many more brainstem nuclei (`'rvm'`, `'nts'`, `'drn'`, `'mrn'`, `'PBN'`, `'medullary_raphe'`, …). + +**Composites** (several structures at once): `'limbic'` (+ `'limbic hires'`), `'basal ganglia'` (`'BG'`), `'brainstem_group'`, `'thalamus_group'`, `'midbrain_group'`, `'subcortex'`, `'CIT168'`, `'pauli_subcortical'`. + +Because these are ordinary graphics handles, you compose them freely — concatenate handle arrays (`sh = [addbrain('thalamus') addbrain('brainstem')]`), then `render_on_surface` a map onto the whole scene (§3.4). + +--- + +[← 2. Montages](02_montages.md) · [Back to index](index.md) · Next: [4. Colors and colormaps →](04_colormaps.md) diff --git a/docs/visualization_walkthrough/04_colormaps.md b/docs/visualization_walkthrough/04_colormaps.md new file mode 100644 index 00000000..fa1a92d4 --- /dev/null +++ b/docs/visualization_walkthrough/04_colormaps.md @@ -0,0 +1,105 @@ +# 4. Colors and colormaps + +[← 3. Surfaces](03_surfaces.md) · [Back to index](index.md) · Next: [5. The display controller →](05_controller.md) + +CANlab display methods share **one** value→color pipeline (`canlab_colormap`). A montage, a surface, and the controller's legend of the *same* data all use the same mapping, so a result looks the same everywhere. This page shows the color modes, how to set color limits, and how to change colors on an existing display with `set_colormap`. + +```matlab +obj = load_image_set('emotionreg', 'noverbose'); +t = threshold(ttest(obj), .05, 'unc'); +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +``` + +--- + +## 4.1 One colormap, montage and surface + +The point of the shared pipeline: render the same map on a montage and a surface and the value→color mapping is **identical** — same limits, same colors — with no extra arguments. + +```matlab +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +o = surface(o, 'foursurfaces'); % montage + surface on one object +``` + +Montage | Surface (same colors, same scale) +:---:|:---: +![uniform montage](figures/04_uniform_montage.png) | ![uniform surface](figures/04_uniform_surface.png) + +--- + +## 4.2 Color modes + +The colormap type is chosen by which color option you pass (to `addblobs`, `canlab_results_fmridisplay`, or `set_colormap`). All five modes flow through the same central map: + +**Split (signed).** The default for signed maps: separate warm/cool arms for positive and negative values, scaled independently. Two presets: + +```matlab +set_colormap(o, 'splitcolor', {[0 0 1] [0 1 1] [1 .5 0] [1 1 0]}); % hot/cool (default) +set_colormap(o, 'splitcolor', {[.5 0 1] [0 .8 .3] [1 .2 1] [1 1 .3]}); % "mango" +``` + +hot/cool | mango +:---:|:---: +![hot/cool split](figures/04_hotcool.png) | ![mango split](figures/04_mango.png) + +**Single ramp.** One color at the low limit interpolating to another at the high limit — set with `maxcolor`/`mincolor`: + +```matlab +set_colormap(o, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]); % red -> yellow +``` + +![single warm ramp](figures/04_warm.png) + +**Continuous LUT.** Any perceptual/MATLAB colormap mapped continuously across the value range: + +```matlab +set_colormap(o, 'colormap', hot(256)); % or viridis, inferno, turbo, ... +``` + +![continuous perceptual LUT](figures/04_perceptual.png) + +**Solid.** One flat color for every suprathreshold voxel — good for masks or overlaying several maps: + +```matlab +set_colormap(o, 'color', [1 .4 .9]); +``` + +![solid color](figures/04_solid.png) + +**Indexed.** Each value is a row index into a color table — the mode atlases use to give every region a distinct color (see [6. Atlases and regions](06_atlases_and_regions.md)). + +--- + +## 4.3 Color limits: `cmaprange` + +By default the color limits are set robustly from the data (percentiles, so a few extreme voxels don't wash out the scale). Override them with `cmaprange` to make maps comparable or to change contrast: + +```matlab +set_colormap(o, 'cmaprange', [-6 6]); % wider limits -> lower-contrast, comparable across maps +``` + +Default (data-driven limits) | Fixed `cmaprange` `[-6 6]` +:---:|:---: +![default range](figures/04_cmaprange_default.png) | ![wide range](figures/04_cmaprange_wide.png) + +Fixing `cmaprange` to the same value across several maps makes their colors quantitatively comparable — essential when you show more than one result. + +--- + +## 4.4 `set_colormap` and `set_opacity` + +Because an `fmridisplay` object retains each layer's source and options, you recolor **in place** — no re-thresholding, no re-supplying data — and every view (montage + surface) updates together: + +```matlab +set_colormap(o, 'splitcolor', {...}); % change colors +set_colormap(o, 'cmaprange', [-4 4]); % change limits +set_colormap(o, 'color', [1 0 0]); % switch to solid +set_opacity(o, 0.5); % half-transparent blobs +set_colormap(o, ..., 'layers', 2); % target one layer only +``` + +All of these are also exposed as clickable controls in the [display controller](05_controller.md). + +--- + +[← 3. Surfaces](03_surfaces.md) · [Back to index](index.md) · Next: [5. The display controller →](05_controller.md) diff --git a/docs/visualization_walkthrough/05_controller.md b/docs/visualization_walkthrough/05_controller.md new file mode 100644 index 00000000..37f5a8e6 --- /dev/null +++ b/docs/visualization_walkthrough/05_controller.md @@ -0,0 +1,111 @@ +# 5. The display controller + +[← 4. Colors](04_colormaps.md) · [Back to index](index.md) · Next: [6. Atlases and regions →](06_atlases_and_regions.md) + +The **controller** is an interactive panel bound to an `fmridisplay` object. Because `fmridisplay` is a handle class whose layers remember their source and options, the controller can re-threshold, recolor, and re-render every view — montages *and* surfaces — live. Every control has a one-line command-line equivalent, and the controller echoes that line to the command window when you use it. So you can work by clicking or by scripting, interchangeably. + +--- + +## 5.1 Build a composite and open the controller + +Put a montage and a surface on one object, add a second blob layer, then open the controller: + +```matlab +obj = load_image_set('emotionreg', 'noverbose'); +t = threshold(ttest(obj), .05, 'unc'); +tstrict = threshold(ttest(obj), .001, 'unc'); + +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); % montage +o = surface(o, 'foursurfaces'); % + surface, same object +o = addblobs(o, region(tstrict), 'color', [0 .4 1]); % 2nd layer (solid blue) + +controller(o); +``` + +The controller shows **one panel per blob layer**, plus global buttons at the bottom: + +![the display controller, two layers](figures/05_controller.png) + +The montage and surface it drives: + +Montage | Surface +:---:|:---: +![montage driven by controller](figures/05_montage.png) | ![surface driven by controller](figures/05_surface.png) + +--- + +## 5.2 What each control does — and its command-line twin + +Per-layer panel: + +| Control | What it does | Command-line equivalent | +|---------|--------------|--------------------------| +| **Color stripe + numeric labels** | Shows the layer's colormap and value limits (the legend). | — (reflects the current colormap) | +| **Opacity slider** | Blends the layer with what's beneath it. | `set_opacity(o, value, 'layers', k)` | +| **Threshold slider + box** | Re-thresholds from retained source; p-value (log scale) for stat images, `\|x\|` for masks. | `rethreshold(o, p, 'unc', 'layers', k)` | +| **Colors dropdown** | Pick a colormap: split (hot/cool, mango), warm/cool ramps, perceptual (viridis, inferno, turbo…), indexed (atlas), or solid. | `set_colormap(o, …, 'layers', k)` | +| **Color swatch** (click) | Opens a color picker for a solid color. | `set_colormap(o, 'color', [r g b], 'layers', k)` | +| **Visible checkbox** | Hides/shows the layer (lower layers show through on surfaces). | (layer `visible` flag) | +| **Remove layer** | Deletes the layer from all views. | `remove_layer(o, k)` / `removeblobs(o)` | + +Global footer: + +| Button | Action | Command line | +|--------|--------|--------------| +| **Re-render** | Redraw all layers on all views. | `refresh(o)` | +| **Toggle legend** | Put/remove colorbar legends on the montage & surface figures. | `remove_legend(o)` | + +--- + +## 5.3 Command line and GUI are the same thing + +Anything you do in the panel you can script, and the object updates every view in place. These two command-line actions — recolor layer 1 to *mango*, and make layer 2 half-transparent — are exactly what dragging the dropdown and the opacity slider would do: + +```matlab +set_colormap(o, 'splitcolor', {[.5 0 1] [0 .8 .3] [1 .2 1] [1 1 .3]}, 'layers', 1); +set_opacity(o, 0.4, 'layers', 2); +``` + +Controller after the change | Montage after the change +:---:|:---: +![controller after](figures/05_controller_after.png) | ![montage after](figures/05_montage_after.png) + +Layer 1 is now mango; layer 2's blue blobs are translucent, letting layer 1 show through. The montage updated without redrawing the underlay, and the surface (not shown) updated identically — one object, all views in sync. + +> **Tip.** Change a layer from the command line while its controller is open and the panel refreshes to match — the controller always reflects the object's true state. Conversely, the controller echoes the equivalent code to the command window, which is a good way to learn the scripting API. + +--- + +## 5.4 Composing views: `multiview` + +`multiview` lays a canonical multi-panel composition (any montage type) onto an existing object and renders its current layers there — handy for building a full figure from a display you were exploring interactively: + +```matlab +o = multiview(o, 'full'); % re-compose this object's blobs onto the 'full' layout +``` + +### The full catalog of layouts and surfaces + +It's worth having one place that lists everything you can compose. There are three families: + +**Montage sets** — pass to `canlab_results_fmridisplay(t, set)`, `montage(t, set)`, or `multiview(o, set)`: + +| Set | Layout | +|-----|--------| +| `'compact'` | Default: midline sagittal + two axial rows. | +| `'compact2'` | Single row: sagittal + axials. | +| `'full'` | All 3 planes + 4 cortical surfaces. | +| `'full hcp'` | `full` with HCP surfaces and subcortical volumes. | +| `'multirow'` | Several `compact2` rows stacked, to compare maps. | +| `'regioncenters'` | One axis per region, centered on each blob. | +| `'subcortex compact'` / `'subcortex 3d'` | Subcortical-focused layouts. | + +You can also build a montage set by hand with `fmridisplay.montage` — choosing orientation, slice range, and spacing yourself (see [§2.6](02_montages.md#26-rolling-your-own-slices-fmridisplaymontage)). + +**Whole-brain surface layouts** — pass to `surface(t, layout)`: `'foursurfaces'`, `'foursurfaces_hcp'`, `'inflated surfaces'`, `'flat surfaces'`, `'insula surfaces'`, `'multi_surface'`, plus the cutaways (`'coronal_slabs_4'`, `'left_cutaway'`, …). + +**Surfaces and composites** for building custom 3‑D scenes with `addbrain` — the full list of hemispheres, subcortical structures, and composites (`'limbic'`, `'basal ganglia'`, `'thalamus_group'`, …) is catalogued in [§3.6 The surface catalog](03_surfaces.md#36-the-surface-catalog). + +--- + +[← 4. Colors](04_colormaps.md) · [Back to index](index.md) · Next: [6. Atlases and regions →](06_atlases_and_regions.md) diff --git a/docs/visualization_walkthrough/06_atlases_and_regions.md b/docs/visualization_walkthrough/06_atlases_and_regions.md new file mode 100644 index 00000000..a208c1f1 --- /dev/null +++ b/docs/visualization_walkthrough/06_atlases_and_regions.md @@ -0,0 +1,112 @@ +# 6. Atlases and regions + +[← 5. The controller](05_controller.md) · [Back to index](index.md) + +Atlases and region objects are collections of discrete parcels, so they use **indexed** coloring — every region gets its own color from a color table — rather than a value ramp. CANlab renders that indexed coloring identically on slices, surfaces, and 3‑D isosurfaces. + +```matlab +obj = load_image_set('emotionreg', 'noverbose'); +t = threshold(ttest(obj), .05, 'unc'); +``` + +An `atlas` object is a first-class CANlab class (a labeled `image_vector`): it carries region labels, indices, and often probability maps, and supports `select_atlas_subset`, `num_regions`, `atlas2region`, `split_atlas_into_contiguous_regions`, and more. See `help atlas` and the [atlas object methods](../Object_methods.md). + +> **Atlases live in a separate repository.** `load_atlas` reads its parcellations from the **[`Neuroimaging_Pattern_Masks`](https://github.com/canlab/Neuroimaging_Pattern_Masks)** repository, which must be on your MATLAB path (it is a separate download from CanlabCore). If `load_atlas('canlab2018_2mm')` errors, that repo is missing or not on the path. + +**Many atlases are available** by keyword — run `load_atlas` with no arguments to list them all. A few in common use: + +| Keyword | Atlas | +|---------|-------| +| `'canlab2018_2mm'` / `'canlab2024'` | CANlab combined whole-brain atlas (cortex + subcortex + brainstem). | +| `'glasser'` | Glasser (HCP-MMP1) 360-region cortical parcellation. | +| `'schaefer400'` | Schaefer 400-region cortical parcellation. | +| `'thalamus'` / `'morel'` / `'iglesias_thal'` | Thalamic nuclei. | +| `'cit168'` | CIT168 subcortical/basal-ganglia atlas. | +| `'brainstem'` / `'kragel2019pag'` | Brainstem structures / PAG. | +| `'painpathways'` | CANlab pain-pathways atlas. | + +Many keywords also accept a normalization-space suffix (`_fmriprep20`, `_fsl6`) to match your data's template. All of these display with the same indexed-color pipeline shown below. + +--- + +## 6.1 Regions in unique colors + +Convert a thresholded map to a `region` object and montage it — each contiguous blob gets a distinct color: + +```matlab +r = region(t); +montage(r, 'compact2', 'noverbose'); % one color per region +``` + +![regions in unique colors](figures/06_region_unique.png) + +Region coloring options: `'color', [r g b]` (all one color), `'colors', {…}` (a cell array, one color per region), or `'indexmap', cmap` (values index a color table — best for parcellations, since it avoids color bleed between adjacent regions). Add `'symmetric'` to mirror left/right homologues in matching colors. + +--- + +## 6.2 Atlases on slices + +An `atlas` object displays its parcels in unique colors with a single call. Here a thalamic atlas shows each nucleus in a distinct color: + +```matlab +atl = load_atlas('thalamus'); +montage(atl, 'compact2', 'noverbose'); +``` + +![atlas on slices](figures/06_atlas_montage.png) + +`atlas/montage` converts the atlas to regions and assigns `scn_standard_colors` by default, matching left/right homologues. Subset first with `select_atlas_subset(atl, {'PAG'})` or a laterality filter to focus on specific structures. + +--- + +## 6.3 Atlases on cortical surfaces + +The same indexed coloring renders on surfaces. Build cortical surfaces and paint the atlas onto them with `render_on_surface`, using `'indexmap'` (each value is a color-table row) and nearest-neighbor sampling so region boundaries stay crisp: + +```matlab +ctx = select_atlas_subset(load_atlas('canlab2018_2mm'), {'Ctx'}); % 360 cortical parcels +cols = scn_standard_colors(num_regions(ctx)); +cmap = cat(1, cols{:}); % n×3 color table +sh = addbrain('foursurfaces'); +render_on_surface(ctx, sh, 'colormap', cmap, 'indexmap', 'interp', 'nearest'); +``` + +![cortical atlas on surfaces in unique colors](figures/06_atlas_surface.png) + +The value→color mapping here is the same one montages use, so a parcel is the same color on a slice and on the surface. + +--- + +## 6.4 Atlases as 3‑D isosurfaces (subcortical close-ups) + +`isosurface` turns each atlas parcel into a 3‑D mesh, giving a close-up of subcortical anatomy in unique colors: + +```matlab +atl = load_atlas('thalamus'); +create_figure('iso'); axis off +isosurface(atl); +axis image vis3d off; material dull; view(210, 20); lightRestoreSingle; camzoom(1.4) +``` + +![thalamic nuclei as 3-D isosurfaces](figures/06_atlas_isosurface.png) + +This is the natural way to show deep-brain parcellations (thalamic nuclei, brainstem, basal ganglia) that don't sit on the cortical sheet. You can also render a statistic map onto these nuclei with `render_on_surface` (see [3. Surfaces](03_surfaces.md#34-subcortical-close-ups-with-addbrain)). + +--- + +## 6.5 Labeling regions from an atlas + +To annotate your own results with anatomical names, label a region object against an atlas — the names then appear in `regioncenters` montages and in `region.table()`: + +```matlab +r = region(t); +r = autolabel_regions_using_atlas(r); % default: canlab2018_2mm +montage(r, 'regioncenters', 'colormap', 'noverbose'); +r.table(); % printed table with labels +``` + +--- + +[← 5. The controller](05_controller.md) · [Back to index](index.md) + +*This walkthrough is part of the CanlabCore documentation. See also [`fmridisplay_methods.md`](../fmridisplay_methods.md) and the frozen tutorial snapshots under `CanlabCore/Unit_tests/walkthroughs/private/`.* diff --git a/docs/visualization_walkthrough/_gen/HANDOFF.md b/docs/visualization_walkthrough/_gen/HANDOFF.md new file mode 100644 index 00000000..d883852f --- /dev/null +++ b/docs/visualization_walkthrough/_gen/HANDOFF.md @@ -0,0 +1,66 @@ +# Visualization Walkthrough — Status: COMPLETE + +**Workspace:** `/Users/f003vz1/Documents/GitHub/CanlabCore` +**Branch:** `fmridisplay-controller-overhaul` (remote `origin` = github.com/canlab/CanlabCore) +**MATLAB:** R2026a via MATLAB MCP server (desktop visible to user; figures appear in MATLAB UI). + +The walkthrough and its supporting color-pipeline work are finished. Nothing is +pending. History below is kept for context. + +--- + +## What is DONE + +### 1. Central colormap unification (committed & pushed) +- Commit `700f5a54` — `render_blobs.m` + `canlab_colormap.m`: all montage modes + (split / single / solid / continuous / indexed) build a `central_cm` and color + through `central_map_slice`. +- Commit `4d3ad933` — `canlab_colormap.m` (NaN/Inf-robust `.map()`, `indexmap` + case), `@fmridisplay/controller.m` (indexed-atlas legend), `render_layer_surfaces.m` + (all modes incl. indexed through the truecolor path, `'interp','nearest'`). +- Verified: `max|dRGB| = 0` across montage modes; surface indexmap vertex colors + equal `cmap(region_index)` exactly. **Do not redo.** + +### 2. Walkthrough documentation (committed) +Six pages under `docs/visualization_walkthrough/` (`index.md`, `01`…`06`), figures in +`figures/`, generators in `_gen/`. First committed in `91e9d110`. + +### 3. The 8 approved revisions — ALL DONE +1. Surface figures rendered without colorbar legends (`'nobars'` in `wt_save`). +2. `canlab_orthviews` shown in §1 (new `figures/01_canlab_orthviews.png`), plus a + pointer to `canlab_niivue` linking `docs/canlab_niivue_guide.md`. +3. §1 "How the display methods fit together" — montages/surfaces/OO API; methods + return an `fmridisplay` object you keep manipulating. +4. §3.4 `addbrain` now points to `help addbrain` / no-arg keyword list and notes + the `Neuroimaging_Pattern_Masks` / `canlab_load_ROI` dependency. +5. Full catalog of surfaces & composites in new §3.6; montage-sets + surface + layouts catalog in §5.4. +6. §2.6 custom montage via `fmridisplay.montage()` (orientation / slice_range / + spacing), new `figures/02_custom_montage.png`. +7. Master script `_gen/visualization_walkthrough.m` — one section per page + (1.1…6.5), regenerates every figure; referenced near the top of §1 and in + `index.md`. Ran end-to-end in R2026a with no errors. +8. §6 atlas intro — `help atlas` / Object_methods link, atlas-type table, and the + `Neuroimaging_Pattern_Masks` repository dependency spelled out. + +`_gen/gen_revise.m` (the temporary regen helper) was run, verified, and deleted. +The per-page `gen_0*.m` generators are kept for single-page regeneration. + +--- + +## Gotchas learned +- Reload edited classdefs: `close all force; clear all; clear classes` (plain + `clear classes` fails while instances exist). +- Capture the RIGHT figure for multi-figure methods: + `ancestor(o.montage{1}.axis_handles(1),'figure')`, not `gcf`. +- R2026a rejects `func(...){:}` — assign the cell to a temp first + (`cols = scn_standard_colors(n); cmap = cat(1, cols{:});`). +- `'foursurfaces'` is NOT a montage type. For atlas-on-surface: + `sh = addbrain('foursurfaces')` then + `render_on_surface(ctx, sh, 'colormap', cmap, 'indexmap', 'interp', 'nearest', 'nolegend')` + where `cmap = cat(1, cols{:})`. `'indexmap'` is a flag; the color matrix goes + through `'colormap'`. +- Dropped from figures (render badly / error), described in prose instead: + `compact` montage (tiny), `multirow` (region() errors on arrays), `plot()` QC + (empty axes). +- `docs/tmp_test_fmridisplay.m` is untracked scratch — intentionally NOT committed. diff --git a/docs/visualization_walkthrough/_gen/gen_01_getting_started.m b/docs/visualization_walkthrough/_gen/gen_01_getting_started.m new file mode 100644 index 00000000..cd5bd1f9 --- /dev/null +++ b/docs/visualization_walkthrough/_gen/gen_01_getting_started.m @@ -0,0 +1,18 @@ +function gen_01_getting_started +% Figures for "1. Getting started". +here = fileparts(mfilename('fullpath')); +addpath(here); +figdir = fullfile(fileparts(here), 'figures'); +close all force + +obj = load_image_set('emotionreg', 'noverbose'); +t = threshold(ttest(obj), .05, 'unc'); + +% orthviews (SPM) of the thresholded map +orthviews(t); +gwin = spm_figure('FindWin', 'Graphics'); +if ~isempty(gwin), wt_save(gwin, fullfile(figdir, '01_orthviews.png')); end +close all force + +disp('gen_01 DONE'); +end diff --git a/docs/visualization_walkthrough/_gen/gen_02_montages.m b/docs/visualization_walkthrough/_gen/gen_02_montages.m new file mode 100644 index 00000000..2c585c92 --- /dev/null +++ b/docs/visualization_walkthrough/_gen/gen_02_montages.m @@ -0,0 +1,41 @@ +function gen_02_montages +% Figures for "2. Montages and slices". +here = fileparts(mfilename('fullpath')); +addpath(here); +figdir = fullfile(fileparts(here), 'figures'); +close all force +montfig = @(o) ancestor(o.montage{1}.axis_handles(1), 'figure'); + +obj = load_image_set('emotionreg', 'noverbose'); +t = threshold(ttest(obj), .05, 'unc'); +r = region(t); + +% compact2 and full layouts +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '02_compact2.png')); +close all force + +o = canlab_results_fmridisplay(t, 'full', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '02_full.png')); +close all force + +% regioncenters: each blob in its own axis, coloured by value +o = montage(r, 'regioncenters', 'colormap', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '02_regioncenters.png')); +close all force + +% customization: red outline + transparency +o = canlab_results_fmridisplay(t, 'compact2', 'color', [1 0 0], 'outline', ... + 'linewidth', 2, 'trans', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '02_outline_trans.png')); +close all force + +% overlay two maps in two colours +o = canlab_results_fmridisplay(t, 'compact2', 'color', [1 .3 0], 'noverbose'); +tstrict = threshold(ttest(obj), .001, 'unc'); +o = addblobs(o, region(tstrict), 'color', [0 .4 1], 'no_surface'); +wt_save(montfig(o), fullfile(figdir, '02_two_maps.png')); +close all force + +disp('gen_02 DONE'); +end diff --git a/docs/visualization_walkthrough/_gen/gen_03_surfaces.m b/docs/visualization_walkthrough/_gen/gen_03_surfaces.m new file mode 100644 index 00000000..50182726 --- /dev/null +++ b/docs/visualization_walkthrough/_gen/gen_03_surfaces.m @@ -0,0 +1,58 @@ +function gen_03_surfaces +here = fileparts(mfilename('fullpath')); +addpath(here); +figdir = fullfile(fileparts(here), 'figures'); +close all force + +obj = load_image_set('emotionreg', 'noverbose'); +t = threshold(ttest(obj), .05, 'unc'); + +% --- default surface() : a preset series incl. subcortical cutaway ---------- +create_figure('surf'); axis off +surface(t, 'noverbose'); +wt_save(gcf, fullfile(figdir, '03_surface_default.png')); +close all force + +% --- foursurfaces : lateral + medial cortical set --------------------------- +create_figure('four'); +sh = surface(t, 'foursurfaces', 'noverbose'); +wt_save(gcf, fullfile(figdir, '03_foursurfaces.png')); +% recolor the SAME handles with a perceptual colormap +render_on_surface(t, sh, 'colormap', 'summer'); +wt_save(gcf, fullfile(figdir, '03_foursurfaces_summer.png')); +close all force + +% --- cutaways --------------------------------------------------------------- +create_figure('lc'); axis off +surface(t, 'left_cutaway', 'noverbose'); +wt_save(gcf, fullfile(figdir, '03_left_cutaway.png')); +close all force + +create_figure('cs'); axis off +surface(t, 'coronal_slabs_4', 'noverbose'); +wt_save(gcf, fullfile(figdir, '03_coronal_slabs_4.png')); +close all force + +% --- subcortical close-up via addbrain + render_on_surface ------------------ +create_figure('sub'); axis off +sh = addbrain('thalamus_group'); +sh = [sh addbrain('brainstem')]; +sh = [sh addbrain('amygdala')]; +render_on_surface(t, sh, 'clim', [-3 3]); +set(sh, 'FaceAlpha', .9); +view(135, 12); lightRestoreSingle; camzoom(1.3); +wt_save(gcf, fullfile(figdir, '03_subcortical_closeup.png')); +close all force + +% --- isosurface of an atlas (thalamic nuclei), coloured by the t-map -------- +create_figure('iso'); axis off +atl = load_atlas('thalamus'); +sh = isosurface(atl); +axis image vis3d off; material dull; view(210, 20); lightRestoreSingle +wt_save(gcf, fullfile(figdir, '03_isosurface_thalamus.png')); +render_on_surface(t, sh, 'colormap', 'hot'); +wt_save(gcf, fullfile(figdir, '03_isosurface_thalamus_rendered.png')); +close all force + +disp('gen_03 DONE'); +end diff --git a/docs/visualization_walkthrough/_gen/gen_04_colormaps.m b/docs/visualization_walkthrough/_gen/gen_04_colormaps.m new file mode 100644 index 00000000..09cde518 --- /dev/null +++ b/docs/visualization_walkthrough/_gen/gen_04_colormaps.m @@ -0,0 +1,49 @@ +function gen_04_colormaps +here = fileparts(mfilename('fullpath')); +addpath(here); +figdir = fullfile(fileparts(here), 'figures'); +close all force + +obj = load_image_set('emotionreg', 'noverbose'); +t = threshold(ttest(obj), .05, 'unc'); + +montfig = @(o) ancestor(o.montage{1}.axis_handles(1), 'figure'); + +% Default split hot/cool +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '04_hotcool.png')); + +% Mango split +set_colormap(o, 'splitcolor', {[.5 0 1] [0 .8 .3] [1 .2 1] [1 1 .3]}); +wt_save(montfig(o), fullfile(figdir, '04_mango.png')); + +% Single warm ramp (mincolor -> maxcolor) +set_colormap(o, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]); +wt_save(montfig(o), fullfile(figdir, '04_warm.png')); + +% Continuous perceptual LUT +set_colormap(o, 'colormap', hot(256)); +wt_save(montfig(o), fullfile(figdir, '04_perceptual.png')); + +% Solid colour +set_colormap(o, 'color', [1 .4 .9]); +wt_save(montfig(o), fullfile(figdir, '04_solid.png')); +close all force + +% cmaprange: same data, two colour limits (default vs tight) +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '04_cmaprange_default.png')); +set_colormap(o, 'cmaprange', [-6 6]); +wt_save(montfig(o), fullfile(figdir, '04_cmaprange_wide.png')); +close all force + +% Uniformity: SAME map, montage AND surface, one colour source +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +o = surface(o, 'foursurfaces'); +wt_save(montfig(o), fullfile(figdir, '04_uniform_montage.png')); +sh = o.surface{1}.object_handle; sh = sh(ishandle(sh)); +wt_save(ancestor(sh(1), 'figure'), fullfile(figdir, '04_uniform_surface.png')); +close all force + +disp('gen_04 DONE'); +end diff --git a/docs/visualization_walkthrough/_gen/gen_05_controller.m b/docs/visualization_walkthrough/_gen/gen_05_controller.m new file mode 100644 index 00000000..2d1a0938 --- /dev/null +++ b/docs/visualization_walkthrough/_gen/gen_05_controller.m @@ -0,0 +1,38 @@ +function gen_05_controller +here = fileparts(mfilename('fullpath')); +addpath(here); +figdir = fullfile(fileparts(here), 'figures'); +close all force + +obj = load_image_set('emotionreg', 'noverbose'); +t = threshold(ttest(obj), .05, 'unc'); +tstrict = threshold(ttest(obj), .001, 'unc'); + +montfig = @(o) ancestor(o.montage{1}.axis_handles(1), 'figure'); +surffig = @(o) ancestor(local_surfh(o), 'figure'); + +% Build a composite: montage + surface, two blob layers +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +o = surface(o, 'foursurfaces'); +o = addblobs(o, region(tstrict), 'color', [0 .4 1]); % 2nd layer, solid blue +cf = controller(o); + +wt_save(cf, fullfile(figdir, '05_controller.png'), 'app'); +wt_save(montfig(o), fullfile(figdir, '05_montage.png')); +wt_save(surffig(o), fullfile(figdir, '05_surface.png')); + +% Command-line action that mirrors a GUI control: recolor layer 1, drop +% opacity of layer 2. Montage AND surface update in place. +set_colormap(o, 'splitcolor', {[.5 0 1] [0 .8 .3] [1 .2 1] [1 1 .3]}, 'layers', 1); +set_opacity(o, 0.4, 'layers', 2); +cf = controller(o); +wt_save(cf, fullfile(figdir, '05_controller_after.png'), 'app'); +wt_save(montfig(o), fullfile(figdir, '05_montage_after.png')); +close all force + +disp('gen_05 DONE'); +end + +function h = local_surfh(o) +sh = o.surface{1}.object_handle; sh = sh(ishandle(sh)); h = sh(1); +end diff --git a/docs/visualization_walkthrough/_gen/gen_06_atlases.m b/docs/visualization_walkthrough/_gen/gen_06_atlases.m new file mode 100644 index 00000000..c5d0a8ea --- /dev/null +++ b/docs/visualization_walkthrough/_gen/gen_06_atlases.m @@ -0,0 +1,47 @@ +function gen_06_atlases +here = fileparts(mfilename('fullpath')); +addpath(here); +figdir = fullfile(fileparts(here), 'figures'); +close all force +montfig = @(o) ancestor(o.montage{1}.axis_handles(1), 'figure'); + +% region(t) in unique colors ------------------------------------------------- +try + obj = load_image_set('emotionreg', 'noverbose'); + t = threshold(ttest(obj), .05, 'unc'); + o = montage(region(t), 'compact2', 'noverbose'); % region -> unique per-blob colors + wt_save(montfig(o), fullfile(figdir, '06_region_unique.png')); + close all force +catch e, fprintf('region_unique FAILED: %s\n', e.message); end + +% Atlas montage in unique colors (subcortical) ------------------------------- +try + atl = load_atlas('thalamus'); + o = montage(atl, 'compact2', 'noverbose'); + wt_save(montfig(o), fullfile(figdir, '06_atlas_montage.png')); + close all force +catch e, fprintf('atlas_montage FAILED: %s\n', e.message); end + +% Atlas isosurface in unique colors (subcortical 3-D close-up) ---------------- +try + atl = load_atlas('thalamus'); + create_figure('iso'); axis off + isosurface(atl); + axis image vis3d off; material dull; view(210, 20); lightRestoreSingle; camzoom(1.4); + wt_save(gcf, fullfile(figdir, '06_atlas_isosurface.png')); + close all force +catch e, fprintf('atlas_isosurface FAILED: %s\n', e.message); end + +% Cortical atlas on surfaces in unique colors -------------------------------- +try + ctx = select_atlas_subset(load_atlas('canlab2018_2mm'), {'Ctx'}); + cmap = cat(1, scn_standard_colors(num_regions(ctx)){:}); + create_figure('atlsurf'); + sh = addbrain('foursurfaces'); + render_on_surface(ctx, sh, 'colormap', cmap, 'indexmap', 'interp', 'nearest'); + wt_save(gcf, fullfile(figdir, '06_atlas_surface.png')); + close all force +catch e, fprintf('atlas_surface FAILED: %s\n', e.message); end + +disp('gen_06 DONE'); +end diff --git a/docs/visualization_walkthrough/_gen/visualization_walkthrough.m b/docs/visualization_walkthrough/_gen/visualization_walkthrough.m new file mode 100644 index 00000000..18bbe373 --- /dev/null +++ b/docs/visualization_walkthrough/_gen/visualization_walkthrough.m @@ -0,0 +1,232 @@ +function visualization_walkthrough +% VISUALIZATION_WALKTHROUGH Recreate every figure in the CANlab visualization walkthrough. +% +% One section per walkthrough page (1.1, 1.2, ... 6.5). Running this script with +% CanlabCore (and Neuroimaging_Pattern_Masks, for atlases) on the path regenerates +% all PNGs under ../figures/. It is also meant to be read: each section is the +% copy-pasteable code shown on the corresponding walkthrough page, plus the one +% wt_save() line that captures the figure. +% +% Pages: +% 1. Getting started docs/visualization_walkthrough/01_getting_started.md +% 2. Montages and slices .../02_montages.md +% 3. Surfaces and 3-D .../03_surfaces.md +% 4. Colors and colormaps .../04_colormaps.md +% 5. The display controller .../05_controller.md +% 6. Atlases and regions .../06_atlases_and_regions.md +% +% This supersedes the per-section gen_*.m scripts; those remain as small, +% independently runnable per-page generators. + +here = fileparts(mfilename('fullpath')); +addpath(here); +figdir = fullfile(fileparts(here), 'figures'); +if ~exist(figdir, 'dir'), mkdir(figdir); end + +% Reload edited classdefs cleanly if instances linger from a prior run. +close all force + +% Shared handles: capture the RIGHT figure for multi-figure display methods. +montfig = @(o) ancestor(o.montage{1}.axis_handles(1), 'figure'); +surffig = @(o) ancestor(local_surfh(o), 'figure'); + +% Running dataset used throughout (bundled; no download needed). +obj = load_image_set('emotionreg', 'noverbose'); % 30 contrast images (fmri_data) +t = threshold(ttest(obj), .05, 'unc'); % statistic_image, p < .05 unc +tstrict = threshold(ttest(obj), .001, 'unc'); % stricter map for overlays +r = region(t); % contiguous blobs + + +%% ===== 1. Getting started ================================================= + +%% 1.1 orthviews (SPM-based three-plane viewer) +orthviews(t); +gwin = spm_figure('FindWin', 'Graphics'); +if ~isempty(gwin), wt_save(gwin, fullfile(figdir, '01_orthviews.png')); end +close all force + +%% 1.2 canlab_orthviews (SPM-free three-plane viewer) +canlab_orthviews(t); +wt_save(gcf, fullfile(figdir, '01_canlab_orthviews.png')); +close all force + +% (canlab_niivue(t) opens an interactive browser viewer; nothing to capture.) + + +%% ===== 2. Montages and slices ============================================= + +%% 2.2 Prepackaged layouts: compact2 and full +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '02_compact2.png')); +close all force + +o = canlab_results_fmridisplay(t, 'full', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '02_full.png')); +close all force + +%% 2.3 regioncenters: each blob in its own axis, colored by value +o = montage(r, 'regioncenters', 'colormap', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '02_regioncenters.png')); +close all force + +%% 2.4 Customizing blobs: red outline + transparency +o = canlab_results_fmridisplay(t, 'compact2', 'color', [1 0 0], 'outline', ... + 'linewidth', 2, 'trans', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '02_outline_trans.png')); +close all force + +%% 2.5 Overlaying two maps in two colors +o = canlab_results_fmridisplay(t, 'compact2', 'color', [1 .3 0], 'noverbose'); +o = addblobs(o, region(tstrict), 'color', [0 .4 1], 'no_surface'); +wt_save(montfig(o), fullfile(figdir, '02_two_maps.png')); +close all force + +%% 2.6 Custom montage: fmridisplay.montage with orientation / range / spacing +o = fmridisplay; +o = montage(o, 'axial', 'slice_range', [-30 60], 'spacing', 10, 'onerow'); +o = addblobs(o, region(t), 'nooutline'); +wt_save(montfig(o), fullfile(figdir, '02_custom_montage.png')); +close all force + + +%% ===== 3. Surfaces and 3-D rendering ====================================== + +%% 3.1 The surface() method: foursurfaces, and the default cutaway +create_figure('four'); +sh = surface(t, 'foursurfaces', 'noverbose'); % returns surface handles +wt_save(gcf, fullfile(figdir, '03_foursurfaces.png'), 'nobars'); + +%% 3.2 Recoloring existing handles with render_on_surface (summer LUT) +render_on_surface(t, sh, 'colormap', 'summer', 'nolegend'); +wt_save(gcf, fullfile(figdir, '03_foursurfaces_summer.png'), 'nobars'); +close all force + +create_figure('def'); axis off +surface(t, 'noverbose'); % no layout -> cutaway +wt_save(gcf, fullfile(figdir, '03_surface_default.png'), 'nobars'); +close all force + +%% 3.3 Cutaways and slabs: coronal_slabs_4 +create_figure('cs'); axis off +surface(t, 'coronal_slabs_4', 'noverbose'); +wt_save(gcf, fullfile(figdir, '03_coronal_slabs_4.png'), 'nobars'); +close all force + +%% 3.4 Subcortical close-up composed with addbrain +create_figure('sub'); axis off +sh = addbrain('thalamus_group'); +sh = [sh addbrain('brainstem')]; +sh = [sh addbrain('amygdala')]; +render_on_surface(t, sh, 'clim', [-3 3], 'nolegend'); +set(sh, 'FaceAlpha', .9); view(135, 12); lightRestoreSingle; camzoom(1.3); +wt_save(gcf, fullfile(figdir, '03_subcortical_closeup.png'), 'nobars'); +close all force + +%% 3.5 Isosurfaces from an atlas: bare nuclei, then t-map rendered onto them +create_figure('iso'); axis off +atl = load_atlas('thalamus'); +sh = isosurface(atl); +axis image vis3d off; material dull; view(210, 20); lightRestoreSingle +wt_save(gcf, fullfile(figdir, '03_isosurface_thalamus.png'), 'nobars'); +render_on_surface(t, sh, 'colormap', 'hot', 'nolegend'); +wt_save(gcf, fullfile(figdir, '03_isosurface_thalamus_rendered.png'), 'nobars'); +close all force + + +%% ===== 4. Colors and colormaps ============================================ +% One fmridisplay object, restyled in place: split -> mango -> warm ramp -> +% continuous LUT -> solid. The value->color source drives montage and legend. + +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '04_hotcool.png')); % default split + +set_colormap(o, 'splitcolor', {[.5 0 1] [0 .8 .3] [1 .2 1] [1 1 .3]}); +wt_save(montfig(o), fullfile(figdir, '04_mango.png')); % mango split + +set_colormap(o, 'maxcolor', [1 1 0], 'mincolor', [1 0 0]); +wt_save(montfig(o), fullfile(figdir, '04_warm.png')); % single warm ramp + +set_colormap(o, 'colormap', hot(256)); +wt_save(montfig(o), fullfile(figdir, '04_perceptual.png')); % continuous LUT + +set_colormap(o, 'color', [1 .4 .9]); +wt_save(montfig(o), fullfile(figdir, '04_solid.png')); % solid color +close all force + +%% 4.x Color limits: same data, two cmaprange settings +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '04_cmaprange_default.png')); +set_colormap(o, 'cmaprange', [-6 6]); +wt_save(montfig(o), fullfile(figdir, '04_cmaprange_wide.png')); +close all force + +%% 4.x Uniformity: one color source drives montage AND surface identically +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +o = surface(o, 'foursurfaces'); +wt_save(montfig(o), fullfile(figdir, '04_uniform_montage.png')); +sh = o.surface{1}.object_handle; sh = sh(ishandle(sh)); +wt_save(ancestor(sh(1), 'figure'), fullfile(figdir, '04_uniform_surface.png')); +close all force + + +%% ===== 5. The display controller ========================================== + +%% 5.1 Build a composite (montage + surface + 2nd layer) and open the controller +o = canlab_results_fmridisplay(t, 'compact2', 'noverbose'); +o = surface(o, 'foursurfaces'); +o = addblobs(o, region(tstrict), 'color', [0 .4 1]); % 2nd layer, solid blue +cf = controller(o); +wt_save(cf, fullfile(figdir, '05_controller.png'), 'app'); +wt_save(montfig(o), fullfile(figdir, '05_montage.png')); +wt_save(surffig(o), fullfile(figdir, '05_surface.png')); + +%% 5.3 Command line == GUI: recolor layer 1 (mango), fade layer 2; views sync +set_colormap(o, 'splitcolor', {[.5 0 1] [0 .8 .3] [1 .2 1] [1 1 .3]}, 'layers', 1); +set_opacity(o, 0.4, 'layers', 2); +cf = controller(o); +wt_save(cf, fullfile(figdir, '05_controller_after.png'), 'app'); +wt_save(montfig(o), fullfile(figdir, '05_montage_after.png')); +close all force + +%% 5.4 multiview re-composes the object's layers onto a canonical layout +% o = multiview(o, 'full'); % (interactive; not captured here) + + +%% ===== 6. Atlases and regions ============================================= + +%% 6.1 region(t) in unique per-blob colors +o = montage(region(t), 'compact2', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '06_region_unique.png')); +close all force + +%% 6.2 Atlas on slices (thalamic nuclei, unique colors) +atl = load_atlas('thalamus'); +o = montage(atl, 'compact2', 'noverbose'); +wt_save(montfig(o), fullfile(figdir, '06_atlas_montage.png')); +close all force + +%% 6.4 Atlas as 3-D isosurfaces (subcortical close-up) +create_figure('iso'); axis off +isosurface(load_atlas('thalamus')); +axis image vis3d off; material dull; view(210, 20); lightRestoreSingle; camzoom(1.4); +wt_save(gcf, fullfile(figdir, '06_atlas_isosurface.png')); +close all force + +%% 6.3 Cortical atlas on surfaces in unique (indexed) colors +ctx = select_atlas_subset(load_atlas('canlab2018_2mm'), {'Ctx'}); +cols = scn_standard_colors(num_regions(ctx)); +cmap = cat(1, cols{:}); % n-by-3 color table +create_figure('atlsurf'); +sh = addbrain('foursurfaces'); +render_on_surface(ctx, sh, 'colormap', cmap, 'indexmap', 'interp', 'nearest', 'nolegend'); +wt_save(gcf, fullfile(figdir, '06_atlas_surface.png'), 'nobars'); +close all force + +disp('visualization_walkthrough: ALL figures regenerated.'); +end + +% ------------------------------------------------------------------------- +function h = local_surfh(o) +% First valid surface graphics handle on an fmridisplay object. +sh = o.surface{1}.object_handle; sh = sh(ishandle(sh)); h = sh(1); +end diff --git a/docs/visualization_walkthrough/_gen/wt_save.m b/docs/visualization_walkthrough/_gen/wt_save.m new file mode 100644 index 00000000..52b98749 --- /dev/null +++ b/docs/visualization_walkthrough/_gen/wt_save.m @@ -0,0 +1,52 @@ +function wt_save(figh, fname, varargin) +% Capture a figure (or uifigure controller) to a tightly-cropped PNG for the +% visualization walkthrough docs. +% +% wt_save(figh, fname) % regular figure via exportgraphics +% wt_save(figh, fname, 'app') % uifigure (controller) via exportapp +% wt_save(figh, fname, 'pad', 12) % white padding (px) around content +% +% Trims surrounding white so montages/surfaces/controllers come out clean +% regardless of the empty layout space CANlab figures leave. + +isapp = any(strcmpi(varargin, 'app')); +nobars = any(strcmpi(varargin, 'nobars')); % strip colorbar legends (cleaner surfaces) +pad = 10; wh = find(strcmpi(varargin, 'pad'), 1); if ~isempty(wh), pad = varargin{wh+1}; end +res = 130; wh = find(strcmpi(varargin, 'resolution'), 1); if ~isempty(wh), res = varargin{wh+1}; end + +figdir = fileparts(fname); +if ~isempty(figdir) && ~exist(figdir, 'dir'), mkdir(figdir); end + +if nobars + % Delete colorbar legends and the (now-empty) axes that hosted them, so the + % exported surface is clean. render_on_surface builds these with colorbar(). + delete(findall(figh, 'Type', 'ColorBar')); +end + +tmp = [tempname '.png']; +drawnow +if isapp + exportapp(figh, tmp); +else + exportgraphics(figh, tmp, 'Resolution', res, 'BackgroundColor', 'white'); +end + +% Autocrop white borders +im = imread(tmp); +delete(tmp); +mask = min(im, [], 3) < 248; % non-white content (white = 255 all channels) +if ~any(mask(:)), imwrite(im, fname); return, end +% Require a minimum number of content pixels per kept row/col, so stray faint +% anti-aliasing dots don't defeat the crop (montage figures leave big margins). +minpix = 4; +rows = find(sum(mask, 2) >= minpix); cols = find(sum(mask, 1) >= minpix); +if isempty(rows) || isempty(cols) % fall back to any-content if too aggressive + rows = find(any(mask, 2)); cols = find(any(mask, 1)); +end +r1 = max(1, rows(1) - pad); r2 = min(size(im, 1), rows(end) + pad); +c1 = max(1, cols(1) - pad); c2 = min(size(im, 2), cols(end) + pad); +imwrite(im(r1:r2, c1:c2, :), fname); + +info = imfinfo(fname); +fprintf(' saved %s (%dx%d)\n', fname, info.Width, info.Height); +end diff --git a/docs/visualization_walkthrough/figures/01_canlab_orthviews.png b/docs/visualization_walkthrough/figures/01_canlab_orthviews.png new file mode 100644 index 00000000..0f1b2711 Binary files /dev/null and b/docs/visualization_walkthrough/figures/01_canlab_orthviews.png differ diff --git a/docs/visualization_walkthrough/figures/01_orthviews.png b/docs/visualization_walkthrough/figures/01_orthviews.png new file mode 100644 index 00000000..d43591a8 Binary files /dev/null and b/docs/visualization_walkthrough/figures/01_orthviews.png differ diff --git a/docs/visualization_walkthrough/figures/02_compact2.png b/docs/visualization_walkthrough/figures/02_compact2.png new file mode 100644 index 00000000..6486cfe0 Binary files /dev/null and b/docs/visualization_walkthrough/figures/02_compact2.png differ diff --git a/docs/visualization_walkthrough/figures/02_custom_montage.png b/docs/visualization_walkthrough/figures/02_custom_montage.png new file mode 100644 index 00000000..f5e38786 Binary files /dev/null and b/docs/visualization_walkthrough/figures/02_custom_montage.png differ diff --git a/docs/visualization_walkthrough/figures/02_full.png b/docs/visualization_walkthrough/figures/02_full.png new file mode 100644 index 00000000..18803518 Binary files /dev/null and b/docs/visualization_walkthrough/figures/02_full.png differ diff --git a/docs/visualization_walkthrough/figures/02_outline_trans.png b/docs/visualization_walkthrough/figures/02_outline_trans.png new file mode 100644 index 00000000..1ba5d6b9 Binary files /dev/null and b/docs/visualization_walkthrough/figures/02_outline_trans.png differ diff --git a/docs/visualization_walkthrough/figures/02_regioncenters.png b/docs/visualization_walkthrough/figures/02_regioncenters.png new file mode 100644 index 00000000..2fa5a18c Binary files /dev/null and b/docs/visualization_walkthrough/figures/02_regioncenters.png differ diff --git a/docs/visualization_walkthrough/figures/02_two_maps.png b/docs/visualization_walkthrough/figures/02_two_maps.png new file mode 100644 index 00000000..53716306 Binary files /dev/null and b/docs/visualization_walkthrough/figures/02_two_maps.png differ diff --git a/docs/visualization_walkthrough/figures/03_coronal_slabs_4.png b/docs/visualization_walkthrough/figures/03_coronal_slabs_4.png new file mode 100644 index 00000000..c657234f Binary files /dev/null and b/docs/visualization_walkthrough/figures/03_coronal_slabs_4.png differ diff --git a/docs/visualization_walkthrough/figures/03_foursurfaces.png b/docs/visualization_walkthrough/figures/03_foursurfaces.png new file mode 100644 index 00000000..7f82ceaf Binary files /dev/null and b/docs/visualization_walkthrough/figures/03_foursurfaces.png differ diff --git a/docs/visualization_walkthrough/figures/03_foursurfaces_summer.png b/docs/visualization_walkthrough/figures/03_foursurfaces_summer.png new file mode 100644 index 00000000..6703300f Binary files /dev/null and b/docs/visualization_walkthrough/figures/03_foursurfaces_summer.png differ diff --git a/docs/visualization_walkthrough/figures/03_isosurface_thalamus.png b/docs/visualization_walkthrough/figures/03_isosurface_thalamus.png new file mode 100644 index 00000000..6283b43c Binary files /dev/null and b/docs/visualization_walkthrough/figures/03_isosurface_thalamus.png differ diff --git a/docs/visualization_walkthrough/figures/03_isosurface_thalamus_rendered.png b/docs/visualization_walkthrough/figures/03_isosurface_thalamus_rendered.png new file mode 100644 index 00000000..3d431d1f Binary files /dev/null and b/docs/visualization_walkthrough/figures/03_isosurface_thalamus_rendered.png differ diff --git a/docs/visualization_walkthrough/figures/03_subcortical_closeup.png b/docs/visualization_walkthrough/figures/03_subcortical_closeup.png new file mode 100644 index 00000000..0f9039ed Binary files /dev/null and b/docs/visualization_walkthrough/figures/03_subcortical_closeup.png differ diff --git a/docs/visualization_walkthrough/figures/03_surface_default.png b/docs/visualization_walkthrough/figures/03_surface_default.png new file mode 100644 index 00000000..665a25e9 Binary files /dev/null and b/docs/visualization_walkthrough/figures/03_surface_default.png differ diff --git a/docs/visualization_walkthrough/figures/04_cmaprange_default.png b/docs/visualization_walkthrough/figures/04_cmaprange_default.png new file mode 100644 index 00000000..e196d3a4 Binary files /dev/null and b/docs/visualization_walkthrough/figures/04_cmaprange_default.png differ diff --git a/docs/visualization_walkthrough/figures/04_cmaprange_wide.png b/docs/visualization_walkthrough/figures/04_cmaprange_wide.png new file mode 100644 index 00000000..549eda2c Binary files /dev/null and b/docs/visualization_walkthrough/figures/04_cmaprange_wide.png differ diff --git a/docs/visualization_walkthrough/figures/04_hotcool.png b/docs/visualization_walkthrough/figures/04_hotcool.png new file mode 100644 index 00000000..aa50999d Binary files /dev/null and b/docs/visualization_walkthrough/figures/04_hotcool.png differ diff --git a/docs/visualization_walkthrough/figures/04_mango.png b/docs/visualization_walkthrough/figures/04_mango.png new file mode 100644 index 00000000..98c2cc9e Binary files /dev/null and b/docs/visualization_walkthrough/figures/04_mango.png differ diff --git a/docs/visualization_walkthrough/figures/04_perceptual.png b/docs/visualization_walkthrough/figures/04_perceptual.png new file mode 100644 index 00000000..b269a63f Binary files /dev/null and b/docs/visualization_walkthrough/figures/04_perceptual.png differ diff --git a/docs/visualization_walkthrough/figures/04_solid.png b/docs/visualization_walkthrough/figures/04_solid.png new file mode 100644 index 00000000..0f6a8719 Binary files /dev/null and b/docs/visualization_walkthrough/figures/04_solid.png differ diff --git a/docs/visualization_walkthrough/figures/04_uniform_montage.png b/docs/visualization_walkthrough/figures/04_uniform_montage.png new file mode 100644 index 00000000..1a6035b0 Binary files /dev/null and b/docs/visualization_walkthrough/figures/04_uniform_montage.png differ diff --git a/docs/visualization_walkthrough/figures/04_uniform_surface.png b/docs/visualization_walkthrough/figures/04_uniform_surface.png new file mode 100644 index 00000000..090fdb53 Binary files /dev/null and b/docs/visualization_walkthrough/figures/04_uniform_surface.png differ diff --git a/docs/visualization_walkthrough/figures/04_warm.png b/docs/visualization_walkthrough/figures/04_warm.png new file mode 100644 index 00000000..c1758ef2 Binary files /dev/null and b/docs/visualization_walkthrough/figures/04_warm.png differ diff --git a/docs/visualization_walkthrough/figures/05_controller.png b/docs/visualization_walkthrough/figures/05_controller.png new file mode 100644 index 00000000..8b733443 Binary files /dev/null and b/docs/visualization_walkthrough/figures/05_controller.png differ diff --git a/docs/visualization_walkthrough/figures/05_controller_after.png b/docs/visualization_walkthrough/figures/05_controller_after.png new file mode 100644 index 00000000..3c2b2ae9 Binary files /dev/null and b/docs/visualization_walkthrough/figures/05_controller_after.png differ diff --git a/docs/visualization_walkthrough/figures/05_montage.png b/docs/visualization_walkthrough/figures/05_montage.png new file mode 100644 index 00000000..93cece23 Binary files /dev/null and b/docs/visualization_walkthrough/figures/05_montage.png differ diff --git a/docs/visualization_walkthrough/figures/05_montage_after.png b/docs/visualization_walkthrough/figures/05_montage_after.png new file mode 100644 index 00000000..854b84fe Binary files /dev/null and b/docs/visualization_walkthrough/figures/05_montage_after.png differ diff --git a/docs/visualization_walkthrough/figures/05_surface.png b/docs/visualization_walkthrough/figures/05_surface.png new file mode 100644 index 00000000..cbccaea4 Binary files /dev/null and b/docs/visualization_walkthrough/figures/05_surface.png differ diff --git a/docs/visualization_walkthrough/figures/06_atlas_isosurface.png b/docs/visualization_walkthrough/figures/06_atlas_isosurface.png new file mode 100644 index 00000000..44343ae0 Binary files /dev/null and b/docs/visualization_walkthrough/figures/06_atlas_isosurface.png differ diff --git a/docs/visualization_walkthrough/figures/06_atlas_montage.png b/docs/visualization_walkthrough/figures/06_atlas_montage.png new file mode 100644 index 00000000..9a4351a6 Binary files /dev/null and b/docs/visualization_walkthrough/figures/06_atlas_montage.png differ diff --git a/docs/visualization_walkthrough/figures/06_atlas_surface.png b/docs/visualization_walkthrough/figures/06_atlas_surface.png new file mode 100644 index 00000000..8a58f052 Binary files /dev/null and b/docs/visualization_walkthrough/figures/06_atlas_surface.png differ diff --git a/docs/visualization_walkthrough/figures/06_region_unique.png b/docs/visualization_walkthrough/figures/06_region_unique.png new file mode 100644 index 00000000..c30fb609 Binary files /dev/null and b/docs/visualization_walkthrough/figures/06_region_unique.png differ diff --git a/docs/visualization_walkthrough/index.md b/docs/visualization_walkthrough/index.md new file mode 100644 index 00000000..af2f35c7 --- /dev/null +++ b/docs/visualization_walkthrough/index.md @@ -0,0 +1,65 @@ +# CANlab Visualization Walkthrough + +A hands-on guide to visualizing brain data and results with the CanlabCore MATLAB tools — montages, 3‑D surfaces, the interactive display controller, colormaps, and atlases. Every section pairs runnable code with the figure it produces, and shows both the **command-line** commands and the equivalent **controller** (GUI) actions. + +All examples use the bundled `emotionreg` sample dataset, so they run anywhere CanlabCore is installed: + +```matlab +obj = load_image_set('emotionreg', 'noverbose'); +t = threshold(ttest(obj), .05, 'unc'); +``` + +--- + +## Contents + +### [1. Getting started](01_getting_started.md) +The object classes you'll display (`fmri_data`, `statistic_image`, `region`, `atlas`, `fmridisplay`), loading data, quick QC with `plot`, and a first interactive look with `orthviews`. + +orthviews + +### [2. Montages and slices](02_montages.md) +Publication slice arrays with `canlab_results_fmridisplay` (`compact2`, `full`, …), a close-up of every blob in its own axis (`regioncenters`), and blob customization — color, outline, transparency, overlaying maps. + +full montage + +### [3. Surfaces and 3‑D rendering](03_surfaces.md) +Cortical surfaces (`foursurfaces`), recoloring with `render_on_surface`, cutaways and coronal slabs, subcortical close-ups built with `addbrain`, and 3‑D isosurfaces from atlases and images. + +surfaces subcortical + +### [4. Colors and colormaps](04_colormaps.md) +The shared value→color pipeline: one colormap drives montages, surfaces, and the legend identically. Split / single / solid / continuous / indexed modes, and color limits (`cmaprange`) for comparable maps. + +colormaps + +### [5. The display controller](05_controller.md) +The interactive panel bound to an `fmridisplay` object — opacity, threshold, colormap, visibility, per layer — acting on montages and surfaces together, with a one-line command-line equivalent for every control. + +controller + +### [6. Atlases and regions](06_atlases_and_regions.md) +Parcellations in unique colors — regions and atlases on slices, on cortical surfaces, and as 3‑D subcortical isosurfaces — plus labeling your own results from an atlas. + +atlas isosurface atlas surface + +--- + +## Two ways to work + +Everything here can be done two ways, interchangeably: + +- **From the command line** — scriptable, reproducible, good for papers and pipelines. +- **From the display controller** — an interactive panel for exploring thresholds, colors, and opacity, that echoes the equivalent code to the command window as you click. + +Because `fmridisplay` is a handle class whose layers remember their source and options, both routes update every montage and surface of an object in place. Section 5 shows the correspondence in detail. + +## Reproducing the figures + +Every figure in this walkthrough is produced by a single master script, [`_gen/visualization_walkthrough.m`](_gen/visualization_walkthrough.m) — one section per page (1.1, 1.2, … 6.5). Run it in MATLAB with CanlabCore (and, for the atlas sections, `Neuroimaging_Pattern_Masks`) on the path to regenerate every PNG in [`figures/`](figures/), or copy any section as a starting point. The per-page generators (`gen_02_montages.m`, `gen_03_surfaces.m`, …) remain in [`_gen/`](_gen/) for regenerating one page at a time. All of them write via the helper `wt_save.m`. + +## See also + +- Method reference: [`../fmridisplay_methods.md`](../fmridisplay_methods.md), [`../individual_functions/`](../individual_functions/) +- Frozen tutorial snapshots: `CanlabCore/Unit_tests/walkthroughs/private/canlab_help_2b_basic_image_visualization.m`, `…_4b_3D_visualization.m` +- Online docs and tutorials: