Status: Phase 1–2 + mutators + widget IMPLEMENTED (handle class shipped); render-pipeline unification + engine consolidation still deferred (see §11)
Author: drafted with Claude Code, 2026-06-11; implemented 2026-06-24
Scope: @fmridisplay class and the broader CANlab visualization surface (montage / surface / orthviews / isosurface paths).
This is a resumable design document, in the same spirit as predictive_model_DEVELOPMENT_NOTES.md. It records what the visualization code looks like today, the core architectural decision, the target design, and a phased, non-disruptive migration path.
The load-bearing architectural change is now shipped. Done in @fmridisplay/:
fmridisplayis now a handle class (classdef fmridisplay < handle). A full call-site audit across CanlabCore and CANlab_help_examples found no value→handle aliasing hazard (o3 = o2; ... mutate(o3); ... use(o2)); every site uses the self-reassigningo2 = method(o2, ...)idiom, which is safe. The two var-to-var copies that exist (render_cifti_on_brain.m:246,@image_vector/montage.m:270) are benign (store-at-end / copy-then-clear).- Blob layers retain their source + render options (
addblobs.m): eachactivation_maps{}entry now also storessource_region,source_object(the original, e.g. astatistic_image, enabling re-threshold up and down),render_args(the exact option set passed torender_blobs),wh_montage,wh_surface, andapplied_threshold. - New in-place mutator methods (re-render from retained source):
refresh,rethreshold,set_colormap,set_opacity. - Surfaces are now unified views (§4.2 / §7.4). Surface drawing is factored into a
shared
render_layer_surfacesmethod that derives colors from the layer's storedrender_args, andaddblobs,surface, andrefreshall route through it. Concretely: (a)surface(o2)registers on the same handle and pulls in any blob layers that already exist, so a surface added afteraddblobsstill shows the blobs; (b)addblobs/removeblobsact on montages and surfaces together (remove restores the gray anatomy viaaddbrain('eraseblobs')); (c)refresh— and thereforerethreshold/set_colormap/set_opacity— re-render surfaces too; (d) multi-surface keywords'foursurfaces'/'foursurfaces_hcp'/'foursurfaces_freesurfer'add the four canonical views (L/R lateral + L/R medial) as separate registered views on the same object instead of being silently ignored. - Controller widget:
controller(obj)opens auifigurebound to the instance (back-pointer in figure appdata under'fmridisplay_obj'), with per-layer opacity slider, colormap dropdown, p-threshold field, and visibility toggle — the MATLAB-side analog of the NiiVue control panel (§6). The panel is rebuilt in place (not duplicated) on a repeat call, auto-refreshes whenaddblobs/removeblobschange the layer set (update_controller, via the Transientcontroller_handleproperty), initializes each control from the layer's current state, and is type-aware: the threshold field re-thresholds astatistic_imagelayer by p-value and anfmri_data/mask layer by raw value.HandleVisibility='on', soclose allcloses it.- Later additions: controls now update in place (not a full rebuild) when a layer's
state changes from the command line, so
rethreshold/set_colormap/set_opacitykeep an open panel in sync (they callupdate_controller); each GUI action echoes the equivalent code line to the command window (e.g.han = set_colormap(han, 'color', [1 0 0]);), using the bound variable name frominputname(preserved across rebuilds via figure appdata; falls back toobj). The colormap dropdown gained more options (split, warm, cool, winter) plus asolid colour…entry that opens the built-inuisetcolorpalette. Cosmetic: the figure is light green, the title shows the bound variable name (to tell multiple controllers apart), and the opacity slider's tick marks are removed. (True perceptual maps — inferno/viridis/etc. — are deferred; they need colormap matrices threaded throughrender_blobs/render_on_surface, riskier than the max/min ramps used now.)
- Later additions: controls now update in place (not a full rebuild) when a layer's
state changes from the command line, so
- Controller redesign + more colormaps. Single-column layout per layer with a colormap
title stripe, an opacity slider, a type-aware threshold slider, a Colors dropdown with
a live preview swatch, and a visibility toggle; a footer groups Re-render / Remove
legend / Close. The threshold slider is a log-scale p-value slider (ticks at
.001/.005/.01/.05/.1, so .05–.1 sit close and .001–.005 spread out) for
statistic_imagelayers, or a linear raw|x|slider anchored at 0 and the 99.9th percentile of|data|forfmri_data/region layers. Colormap menu addssplit (mango)andseafiresplit presets alongside warm/cool/winter and thesolid colour…picker. Window colour is set by theFIG_COLORconstant at the top ofcontroller.m(currently[1 .5 0]).- Later tweaks: mango is now the default blob colormap (the
addblobsdefault split colours changed to mango). Footer is Re-render + Toggle legend (legend on/off; the Close button was dropped). Each layer panel has a Remove layer button (newremove_layer(obj, k)method — removes one layer, vsremoveblobswhich removes all). Clicking the colour swatch opens the colour picker (a quick way to set/re-pick a solid colour; note re-selecting the already-selectedsolid colour…dropdown item can't re-fire in MATLAB, so the swatch is the re-pick path). The p-value slider now extends below .001 down to ~0 (1e-6 floor, labelled~0). rethresholdalready accepts a cluster-extent'k'argument (passed through tothreshold), verified working (e.g.rethreshold(o2, .01, 'unc', 'k', 50)).
- Later tweaks: mango is now the default blob colormap (the
squeeze_figure(obj)removes the top/bottom white space from montage figures (a montage often fills only ~40% of its figure's height): it shrinks the figure and remaps the slice axes to fill it, scaled so each slice keeps its pixel size, aspect, and horizontal placement — only the empty margins go. Opt-in (doesn't change montage defaults); skips figures that also contain surfaces.- Surface colorbar legends are parented to the surface figure (
render_on_surface), not togcf— previously a surface legend could land on the montage window. Newremove_legend(obj)method deletes the surface colorbar legends (keeps the blobs). - Surface colormaps now match the montage spec.
render_on_surfaceunderstandscolor/colormap/splitcolor/pos_colormap/neg_colormapbut NOTmaxcolor/mincolor(render_on_surface.m:222), so warm/cool/winter (max/min ramps) and solid colours were silently dropped on surfaces and fell back to the default split hot/cool (e.g. blue negatives under "warm", and cool/winter not updating).render_layer_surfacesnow translates the layer's colour spec intopos_colormap/neg_colormapramps (surface_colormaps_from_args) and strips the tokens render_on_surface can't read. Solid colours map to the same colour on both signs (no stray default-cool negatives). Residual: the split hot/cool map still looks slightly different between montage and surface (different colormap construction inrender_blobsvsrender_on_surface); fully matching is the deferred render-pipeline unification. - Multi-surface keyword uses a dedicated figure.
surface(o2, 'foursurfaces*')opened the 2×2 layout in the current figure, so without a precedingfigure;it drew over the montage and appeared to render nothing. It now reuses an empty current figure or opens a new one. - Public vs internal method surface: the helper methods
activate_figures,prune_dead_views,refresh,render_layer_surfaces,update_controllerare declaredHidden(signature-only block infmridisplay.m), so they no longer cluttermethods(obj)/tab-completion.Hidden(notAccess=private) keeps them callable, which matters:activate_figuresis used by help-example scripts andrefreshby tests. All user-facing methods stay public. - Robust to closed windows:
prune_dead_viewsdrops montage/surface views whose figures the user closed (with a short note); called at the top ofaddblobs,removeblobs, andrefresh, so closing a window no longer causes "Invalid or deleted object" errors. Surface erase/render also filter to live handles. - Layer-aware
rethreshold(andmontagesource retention):image_vector.montagenow passes the original object via a newaddblobs'source_object'override, so amontage(t)layer keeps itsstatistic_imageand can be re-thresholded by p-value.rethresholddispatches by source class:statistic_image→p-value,image_vector→raw value,region→magnitude cutoff; honors'layers'for per-layer thresholding. - Bug fix surfaced by the green-tests bar: a bare
'colormap'flag (e.g.montage(r, o2, 'colormap')) was a pre-existing crash (render_blobsdemands an n×3 matrix).addblobsnow strips a bare'colormap'before forwarding. This un-brokecanlab_help_5_regression_walkthrough. - Tests: new
Unit_tests/image_vector/canlab_test_fmridisplay_handle.m(9 tests: handle identity, source retention, refresh, monotone re-threshold, set_colormap, set_opacity, controller binding, canonical-call-site back-compat). Verified green: full unit tier 157 pass / 0 fail / 2 skip; walkthroughs 10/10.
Still deferred (the doc's own "separate cleanups", §7.3; high-risk refactors of
1000+ line files that would jeopardize the green-walkthroughs bar — left for a focused
follow-up now that the layer architecture exists to support them): unifying
render_blobs + render_on_surface into one color/threshold pipeline; routing orthview
refresh through the same layer mechanism (montages and surfaces are now unified; orthviews
are not yet); composited multi-layer surfaces (surfaces currently show the last-rendered
layer, since render_on_surface overwrites vertex colors); symmetric pull-in for montages
added after blobs; collapsing the duplicate isosurface/orthviews engines; deleting deprecated
surface_cutaway.
- Per-layer surface visibility (deferred; evaluated). The controller's Visible toggle
hides a layer's montage blobs (sets
Visibleonblobhandles) but not its surface coloring, because surfaces don't store per-layer graphics —render_on_surfacebakes one layer's colors into the patch vertices. A clean fix needs a per-layervisibleflag that is respected by every surface-draw path (addblobs,refresh,surfacepull-in): on a visibility change, reset the surfaces to gray and re-render only the visible layers in order (top visible wins). It's moderate, not a one-liner, and shares machinery with the deferred multi-layer surface compositing, so do it together with that.
Step 1 done (2026-06-25): the canlab_colormap component.
CanlabCore/fmridisplay_helper_functions/canlab_colormap.m is the central value→RGB map.
A value class with static factories single / split / solid / indexed and
from_render_args(args, cmaprange) (bridges the existing layer render_args), plus:
map(values) → N×3 RGB (matches render_blobs: split interpolates minpos→maxpos for
positives and maxneg→minneg for negatives; single ramps mincolor→maxcolor; solid is
constant; indexed rounds-and-indexes; uncoloured values are NaN), legend_samples(n) →
one colorbar's worth (split spans zero in a single bar — the fix for the single-range
legend), and lut(n) → n×3 table. Tested in
Unit_tests/visualization/canlab_test_canlab_colormap.m (14 cases).
Step 2 done (2026-06-25): surfaces render in true-colour RGB via the central map.
render_on_surface gained an opt-in 'truecolor', <canlab_colormap> option: after it samples
per-vertex values, it colours each vertex with map() (N×3 RGB), compositing over the anatomy
gray (anatomy_to_rgb, which preserves curvature shading) for uncoloured vertices, and sets
FaceVertexCData to RGB with CDataMapping direct. Default behaviour is unchanged when the
option is absent (the @image_vector/surface bypass path still uses indexed colour).
render_layer_surfaces now builds canlab_colormap.from_render_args(args, cmaprange) and passes
it, so the managed display's surfaces match the montage colours (e.g. warm = red→yellow with no
blue negatives, verified) and removeblobs/erase still restore the gray. The colorbar legend is
still built by render_on_surface from pos/neg colormaps for now.
Step 3 done (2026-06-25): multi-layer surface compositing. Now that surfaces are true-colour
RGB, layers composite: render_layer_surfaces paints a layer onto the current surface colours
(no erase), so a new layer's coloured vertices win and its uncoloured vertices keep what's
underneath (lower layers / gray). render_on_surface saves the anatomy gray once (save-once
UserData) so erase/removeblobs still restore gray. A full recompute (reset to gray, repaint
every layer bottom→top) is composite_surfaces, used by refresh (hence rethreshold /
set_colormap / set_opacity) and remove_layer. Verified: a broad green mask under narrower split
stats shows green where the stats don't cover and split on top; remove_layer recomposites the
remainder; removeblobs restores gray.
Step 4 done (2026-06-25): per-layer surface visibility + opacity blending + sign-aware
defaults.
- Surfaces now blend layers by opacity rather than top-wins:
render_on_surfacetakes a'truecolor_alpha'and blends a layer's coloured vertices with what's underneath (a*colour + (1-a)*below), so a semi-transparent layer lets the layer below show through — matching montages.render_layer_surfacespasses the layer'stransvalueas the alpha (no more whole-patchFaceAlpha). - Per-layer visibility: a layer with
activation_maps{k}.visible == falseis skipped inrender_layer_surfaces/composite_surfaces; the controller's Visible toggle sets the flag, hides the montage blobs, and recomposites the surfaces. - Sign-aware default colormap (
addblobs, only when no explicit colour is given): a binary mask → solid colour, positive-only → warm, negative-only → cool, mixed +/- → mango split. Propagates to both montage and surface via the central map.
Step 5 done (2026-06-25): addbrain keyword pass-through for @fmridisplay/surface (§7.4) +
help/region listing.
@fmridisplay/surfaceno longer hard-codes a per-directionif/elseifswitch. It now parses three reserved keyword pairs ('direction','orientation','axes'), accepts a bare direction token (surface(o2, 'thalamus')==surface(o2, 'direction', 'thalamus')), and passes any remaining args straight through toaddbrain(dir, extra{:}). So any eligible addbrain surface/region keyword works in the managed display automatically — new addbrain surfaces need no change here.- addbrain owns the view. addbrain already sets the default (lateral) camera view + lighting
per keyword, so surface() no longer duplicates that. It only (1) special-cases
'bigbrain left/right'(they map to a different addbrain surface thanaddbrain('bigbrain left')would) and (2) mirrors the azimuth 180° for'medial'(medial = lateral az + 180, read back from the axes — works for every surface regardless of its lateral convention). Verified the managed multi-surface layouts (hires/hcp/freesurfer L+R × lateral/medial) keep identical view angles; only a couple of standalone lateral azimuths (inflated right,surface right) move to addbrain's surface-specific convention, which was the more correct one. - Composites centralized into addbrain (T. Wager's suggestion). The
brainstem left/rightandcaudate left/rightcomposite handle-sets that surface() used to assemble inline now live inaddbrainas cases (next tolimbic/bg/*_group), with their oblique 3D views. surface() just passes the keyword through. addbrain's help region list documents them. - Help/region listing:
help fmridisplay/surfacenow documents the common directions, the bare-token form, the'orientation'/'axes'options, and points tohelp addbrainfor the full, current keyword list. - Tests: added
test_surface_addbrain_passthrough(bare token + composite multi-patch) andtest_surface_medial_flips_azimuthto the handle suite (now 37/37). Display 4/4 unchanged.
Step 6 done (2026-06-25): the legend lives in the controller; figure legends default OFF.
- In-controller legend. Each layer panel's colour stripe is now the legend: it is taller and
carries numeric end labels underneath (
leglo_*/legmid_*/leghi_*), read from the layer'scmaprangeand rounded to 1 significant figure. Split (+/-) maps label both extremes with0in the centre; single ramps label just the two ends.update_controls_in_placerefreshes the labels whencmaprangechanges (rethreshold/recolor). The opacity/threshold/colors/visible rows shifted down a row to make room (panel 195->220 px, fig increment 205->230). - Figure legends OFF by default.
@image_vector/montageno longer draws the montage-figure colorbar by default (pass'legend'to opt back in, e.g. for export); the previously no-op'nolegend'is now effectively the default.render_layer_surfaces/composite_surfacestake ashow_legendflag (default false -> pass'nolegend'torender_on_surface), so surfaces get no colorbar by default either. (fmridisplayconstructor +@image_vector/montagenow accept'legend'/'nolegend'without warning.) - Toggle legend fixed + repurposed. The footer button toggles colorbars on the montage/surface
FIGURES (for export). ON targets the montage figure explicitly (the old bug:
legend()drew into the controller uifigure viagcf, so it never reappeared) and re-renders surfaces withshow_legend=true; OFF removes them. State tracked in the controller's appdata; montage legend axes taggedfmridisp_fig_legendfor clean removal. Verified on->off->on round-trip. - Tests: +
test_controller_shows_legend_labels, +test_montage_figure_legend_off_by_default; updated the surface-legend tests to force legends on (build_montage_surface_blobs(tc,true)/composite_surfaces(o,[],true)) andtest_surface_pulls_in_existing_blobsto check vertex colouring instead of a legend. NOTE:render_layer_surfacesis declared in the classdefmethods (Hidden)block, so its new 4-arg signature requires a fresh class load (clear classes) — verified in a clean MATLAB process; the live MCP session couldn'tclear classes(lingering onCleanup objects), so the surface-legend tests there error with a stale 3-arg dispatch only.
Bugfix (2026-06-25): mixed isosurface+isocaps surfaces (coronal_slabs_4).
@fmridisplay/surface did if strcmp(h(1).FaceColor, 'interp'), which errored ("Dot indexing is
not supported for variables of this type") when addbrain returns OLD-STYLE numeric handles
(doubles) rather than graphics objects — as coronal_slabs_4 does (8 patch handles). It also would
have blanket-grayed the whole set, destroying the isocaps that use 'interp' to show the anatomy
cross-section. Fixed with a per-object loop using get/set (works on numeric handles too): keep any
'interp' colouring, set the rest to flat gray, make all opaque. Test:
test_surface_mixed_patch_handles.
QA pass (2026-06-25): all unit suites green. canlab_test_fmridisplay_handle 41/41,
canlab_test_display 4/4, canlab_test_canlab_colormap 14/14 (interactive). Verified in a fresh
MATLAB process for the surface-legend / 4-arg-method tests (the live MCP session can't clear classes — lingering onCleanup objects — so classdef changes need a fresh class load there). Three
apparent failures in headless -batch are environment artifacts, not regressions: closed-figure
ishandle timing (test_removeblobs_survives_closed_surface_figure), flaky offscreen GL
(test_surface_runs_on_thresholded_t, passes when run alone), and the colormap suite folder not
being on the batch path — all pass in an interactive session.
Step 7a done (2026-06-25): montage slices routed through the central canlab_colormap.
render_blobs no longer re-derives split/single colours inline. It builds one canlab_colormap
(split/single) from the parsed colours + cmaprange before the slice loop, and colours each slice
via a shared central_map_slice helper — the SAME mapping surfaces already use, so montage and
surface colours now come from one implementation. Verified pixel-identical to the old renderer on
split/single/solid sample montages (displayed-colour maxdiff: single 0, split 4e-12 float-eps,
solid 0; the only raw-CData differences were out-of-range garbage the old code stored at invisible
alpha=0 voxels, which the central map correctly clamps). Retired the now-dead duplicated colour
logic: splitcolor_Z_to_slicecdat and the cdat2/cdatminneg/cdatmaxneg matrices.
Step 7b done (2026-06-25): the legend bar routed through the central map.
Added canlab_colormap.colorbar_ramp(n) — the continuous legend-bar ramp (NO threshold gap, unlike
map/legend_samples): for split it is the negative ramp (minneg->maxneg) followed by the positive
ramp (minpos->maxpos), reading extreme-neg ... 0 ... extreme-pos. The controller's per-layer colour
stripe + preview swatch (the in-app legend the user actually reads) now come from
from_render_args(args).colorbar_ramp(64) instead of bespoke colormap_tor calls, so the stripe,
the rendered blobs, and surfaces all share one colour source. Colormap suite 14 -> 16.
Remaining (minor, optional): @fmridisplay/legend (the FIGURE colorbar, now opt-in/default-off)
still has its own split-positioning helper (get_split_colormap_values_and_colors). Its colours
already match the central map (same maths, different field storage), so this is a code-dedup
nicety, not a correctness issue — deferred to avoid risk in its intricate value-positioned layout.
Status: montage slices, surfaces, and the controller legend now all derive colours from the one
canlab_colormap. The colour pipeline is unified end-to-end for the paths users see.
Step 8 done (2026-06-25): single-range legend fix + perceptual colormaps.
- Single-range colorbar legend. A single-ramp (warm/cool/winter) map on SIGNED data now gets a
cmaprange that spans the full robust range THROUGH ZERO (
get_default_cmaprange:[prctile(2) prctile(98)]when data has both signs), instead of a positive-only[p10 p90]that hid the negatives. And the surface draws ONE colorbar for single-ramp/solid/continuous maps (newsingle_colorbarflag inrender_on_surface, set byrender_layer_surfacesfrom the central-map type) rather than a misleading pos+neg pair. Split maps still get two bars. Verified: warm map range[2.82 4.86]->[-2.98 6.07]; surface colorbars 2 -> 1; split/solid montage colours unchanged. - Perceptual colormaps. Added a
continuoustype tocanlab_colormap(maps a value range continuously through an n x 3 LUT — unlikeindexed, which uses value-as-row for atlases) and acanlab_perceptual_colormap(name, n)helper providing viridis / inferno / magma / plasma (embedded matplotlib anchors, interpolated) plus turbo / parula (MATLAB built-ins). The controller dropdown offers all six; selecting one callsset_colormap(o2, 'colormap', canlab_perceptual_colormap(name)), and they flow through the whole pipeline (montage via the existing customcolormap path, surfaces + controller stripe via the central continuous map, one colorbar). Colormap suite 16 -> 19.
The agreed long-term architecture for the colour pipeline. Today the value→colour logic is
duplicated and divergent: render_blobs (slices) and render_on_surface (surfaces) each
re-implement how a layer's values map to colours for split / single-ramp / solid / index
colormaps, which is exactly why a "warm" or split map looks different (or wrong) on surfaces
vs montages, and why colour fields drift out of sync (e.g. the legend mincolor bug).
Target design:
- One central colour-mapping module. Given a layer's colormap type (single ramp,
split +/- , solid, indexed) and its parameters (colours, cmaprange/threshold), produce a
single canonical value→colour map (e.g. a function or a resolved lookup table +
the value range), plus the matching legend spec. This is the single source of truth;
set_colormapupdates it and everything else reads it (montage, surface, legend, controller), so nothing goes stale. - Pass that map to the renderers, which differ in mechanism. Slices keep their per-slice
surfobjects; surfaces should render in true-colour RGB (N×3FaceVertexCData,CDataMappingdirect) computed from the central map — this also unlocks the deferred multi-layer surface compositing (composite RGB across layers, top wins per vertex) and per-layer surface visibility, neither of which is cleanly possible with the current indexed-colour, single-axes-colormap surface path. - Outcome: identical colours across montage/surface/legend, no field-sync bugs, and a natural home for new colormaps (mango, niivue inferno/viridis, …) defined once.
Concrete bug this fixes — single-range colorbar legend. For single-ramp colormaps
(warm/cool/winter = max/min colour), the legend should be one colorbar spanning the
full data range (through zero), not a split +/- pair. Today it can't be done cleanly:
render_blobs computes a positive-percentile cmaprange (e.g. [2.82 4.86]) that doesn't
span zero, and render_on_surface draws two colorbars (pos+neg) whenever the data has both
signs — so the montage legend range is wrong and the surface legend looks split. Both follow
from the duplicated range/colorbar logic; the central map (one canonical value→colour range
per colormap type, with single-ramp → one full-range bar) is the right place to fix it.
This subsumes and is the right way to do the earlier "unify render_blobs + render_on_surface",
"composited multi-layer surfaces", and "true-colour RGB surfaces" items.
image_vector.surface(t, …) and image_vector.orthviews(t, …) still return raw graphics
handles (unlike montage(t), which returns a managed fmridisplay). Plan:
- Opt-in flag first, then default (the §7.4 recipe; ~24 call sites + the walkthroughs
use
surface_handles = surface(t, …)as graphics handles, so the default must not change yet). In managed mode, mirrorimage_vector.montage:o2 = fmridisplay; o2 = surface(o2, kw…); o2 = addblobs(o2, region(t), 'source_object', t, …). The'source_object'retention makesrethreshold/controller work on the result. - Keyword parity via addbrain pass-through (T. Wager's idea). Rather than
re-implementing surface types inside
@fmridisplay/surface, pass the direction keyword plus the remainingvararginstraight through toaddbrain— which@fmridisplay/surfacealready does for the hcp surfaces (addbrain('hcp inflated right', varargin{:})). That exposes addbrain's full surface catalog to the managed path with almost no new fmridisplay code: collapse the large hard-codedif/elseif dirswitch into a defaulth = addbrain(dir, varargin{:})(keeping only the cases that need a specificview()/ layout), and let addbrain validate / error on unknown keywords (it has 69 cases + anotherwise: error('Unknown method.')).- Cutaways/slabs are already covered by addbrain (corrects an earlier caveat):
addbrain.m:844dispatches'cutaway' / 'left_cutaway' / 'right_cutaway' / 'right_cutaway_x8' / 'coronal_slabs' / 'coronal_slabs_4' / 'coronal_slabs_5' / '*_insula_slab' / 'accumbens_slab'tocanlab_canonical_brain_surface_cutaways(meth, varargin{:}). So the addbrain pass-through covers cutaways and coronal slabs in ONE mechanism — no separate cutaway step needed. (Only nuance: the fully parametricsurface(r, 'cutaway', 'ycut_mm', -30)form in@region/surfaceuses the oldersurface_cutawayengine, a slightly different beast than addbrain'scanlab_canonical_brain_surface_cutaways; the named keywords likecoronal_slabs_4are the supported managed path.)
- Cutaways/slabs are already covered by addbrain (corrects an earlier caveat):
- The real
@fmridisplay/surfacework (why this isn't a quick piecemeal change): the method currently setsdironly from the explicit'direction'keyword and never treats a bare token as a direction (except thefoursurfaces*special-case). To accept bare addbrain keywords likesurface(o2, 'coronal_slabs_4'), the option parsing must learn to pull a direction token out ofvarargin, strip the fmridisplay control opts (direction/orientation/axes), forward the rest toaddbrain, and apply sensible post-renderview()/lighting (some surfaces set their own). Wants per-surface-type testing, so do it here, not by hand-adding cases. - Default surface =
left_cutaway(requested): change the defaultdirin@fmridisplay/surface(currently'hires right') and the no-keyword default ofimage_vector.surface(currently@region/surface's default). This is back-compat- affecting (changes whatsurface(o2)/surface(t)shows with no keyword, incl. in walkthroughs/scripts), so make it a deliberate decision in this phase rather than a standalone tweak.
The visualization layer is a "Frankenstein": several independent engines that each re-implement montage/surface/orthviews and color/threshold logic, most of which produce static figures and return raw handles. fmridisplay was created to manage multi-view displays (add/remove blobs from a persisting figure), but it captures too little state to support the interaction we actually want — live re-thresholding, colormap changes, and opacity changes after a display already exists.
canlab_niivue (the NiiVue web viewer) is the contrasting model: each overlay/underlay is a persistent layer object with mutable cal_min, colormap, opacity, and a property change triggers a re-render. We want that property-centric, stateful behavior on the MATLAB side, across montages and surfaces, controllable from a widget — without collapsing to a single global display instance.
@fmridisplay/fmridisplay.m is classdef fmridisplay — a value class. Fields:
overlay— the underlay filename (string);SPACE— sampling grid fromspm_vol.activation_maps{}— one struct per blob layer:mapdata(the already-thresholded, resampled mask),cmaprange,mincolor/maxcolor/color,blobhandles.montage{},surface{},orthviews{}— registered views (axis handles + orientation/direction + patch handles).history.
Decisive limitation: in addblobs.m → render_blobs.m, each slice's blob is drawn as a surf object whose color and alpha are baked into CData/AlphaData at render time (render_blobs.m:749-781). The continuous source values and the threshold that produced the region are discarded — activation_maps keeps only the post-threshold mask. So "re-threshold" or "change colormap" is structurally impossible without deleting and re-adding blobs from a source the caller must still be holding. The color fields that are stored (cmaprange, mincolor, ...) are write-only bookkeeping for the legend; nothing reads them back to re-render.
A user obtains an fmridisplay either from the constructor or from canlab_results_fmridisplay. Of ~18 class visualization methods, only three return one: @fmri_data/montage, @region/montage, @atlas/montage. Everything else returns raw handles and never touches fmridisplay:
@image_vector/surface.m→[all_surf_handles, pcl, ncl]@image_vector/orthviews.m/@statistic_image/orthviews.m→region@image_vector/isosurface.m,render_on_surface.m,display_slices.m→ handles / nothing
So surfaces and orthviews are entirely outside the managed-display world.
- Montage:
fmridisplaypath vs legacymontage_clusters(732 lines,spm_orthviews) vscluster_orthviews_montage. - Orthviews:
cluster_orthviews(SPM backend) vscanlab_orthviews(1890-line SPM-free reimplementation, same API). - Surface:
render_on_surface(1138 lines),@region/surface,surface_cutaway(DEPRECATED),addbrain(1391 lines),canlab_canonical_brain_surface_cutaways,mask2surface. - Isosurface: 4 separate implementations (
@image_vector,@region,@atlas,mask2surface). - Color/threshold logic is re-implemented in each path.
Inverted data model: each layer is a live object with mutable cal_min/colormap/opacity; a property change calls updateGLVolume(). State lives in persistent layer objects, not in the pixels. That is the pattern to bring to MATLAB.
Make the managed display a handle class that stores source data and display parameters as live layers, and re-renders from them — instead of a value class that stores baked graphics. Everything else follows from this.
Why handle (not value):
- A handle instance can live in the workspace, be referenced by its figure(s), and be referenced by a controller widget — all pointing at one object, no copies. A value class cannot, which is the root reason in-place re-thresholding does not exist today.
- In-place mutation (
obj.threshold = 3→ auto-refresh) becomes possible.
One per overlay or underlay. Holds:
- source: the
statistic_image/fmri_data/atlas/regionat full resolution, unthresholded. - display params:
threshold(value + type: unc / fdr / k-extent),colormap/colormap_negative,cmaprange,opacity,visible,outline. - handle registry: graphics handles drawn into each view.
set.threshold, set.colormap, etc. trigger refresh(), which re-derives displayed values from source and updates (or deletes+redraws) only that layer's handles in every attached view. Underlays are layers too, so underlay opacity/colormap become adjustable.
Each montage, surface, or orthview is a registered view (axes + geometry + which layers are drawn there). Layers render into all attached views; a view added later pulls in all existing layers. Generalizes today's obj.montage{} / obj.surface{}, but routes montage/surface/orthviews through one color+threshold pipeline instead of three.
Owns layers{} and views{}, plus methods preserving today's surface:
addblobs, removeblobs, montage, surface, orthviews, rethreshold, set_colormap, set_opacity, refresh.
This realizes "register more properties of both overlay and underlay for multiple views": the layer holds the properties, views are many, one refresh propagates a change everywhere.
Recommendation: the object instance is the single source of truth; each figure stores a back-pointer to it in appdata. This is both candidate options used correctly, not a choice between them.
- Because it is a handle class, the instance can be referenced by the workspace variable, its figure(s), and its widget — one object, no copies.
setappdata(fig, 'canlab_display', obj)lets a figure close, a click, or a widget callback route back to the owning instance. The figure holds a reference, not a duplicate — no sync problem.- Multiplicity is free: every
fmri_data/regiondisplayed constructs its own handle instance with its own figures and widget. No singleton anywhere — multiple independent displays of different datasets coexist in the workspace by construction.
Do not store the actual data in the figure; that is what forces the brittle "figure is the source of truth for some things" split today.
A uifigure-based controller (canlab_display_controller) bound to one display instance. Lists its layers with per-layer: threshold slider, colormap dropdown, opacity slider, visibility/outline toggles, and layer add/remove/reorder. Every control sets a property on the layer and calls refresh(). Because it is bound to an instance, opening two displays gives two widgets. This is the MATLAB-side analog of the NiiVue control panel. (Use uifigure/uigridlayout; the matlab-build-app skill covers the patterns.)
Hard constraint: do not break existing o2 = canlab_results_fmridisplay(...); addblobs(o2, region(t)) call sites.
- Introduce the handle class alongside the value class. New class (e.g.
fmridisplay2internally, orfmridisplayreimplemented behind a compatibility façade).addblobs/montage/removeblobskeep their exact current signatures and still work value-style (o2 = addblobs(o2, ...)returns the same handle). This value-vs-handle return semantics is the riskiest compatibility point — needs a deliberate shim plus a test script underUnit_tests/. - Make layers store source data.
addblobsretains the sourceregion/statistic_imageon the layer (it already receives it), so re-threshold has something to work from, without changing what callers pass. - Centralize one render pipeline for montage + surface + orthviews; route
render_blobsandrender_on_surfacecolor/threshold logic through it. Delete deprecatedsurface_cutaway; collapse duplicate isosurface/orthviews engines as separate cleanups. - Funnel bypass methods through the object.
@fmri_data/surface,@image_vector/orthviews, etc. gain an option (then a default) to return a display instance with a registered view, while still returning handles where back-compat needs it. - Add
rethreshold/set_colormap/set_opacity/refresh, then the widget.
Each phase is independently shippable and leaves existing scripts working.
- Value→handle semantics is the real hazard: handle objects mutate in place, so old code relying on copy-on-assignment (
o3 = o2; addblobs(o3, ...)expectingo2untouched) changes behavior. Needs an explicit audit + the back-compat shim; most likely thing to bite. - Memory: storing full unthresholded source per layer costs RAM (many layers × whole-brain
fmri_data). Mitigate by storing a downsampled-to-display-space copy plus a handle to the original, or loading lazily. - Re-threshold from source only works toward lower stringency if the unthresholded data is retained — be explicit that the layer stores pre-threshold values.
Prototype Phase 1: a handle-class skeleton with one layer (storing source + display params) and one montage view, plus a refresh() that re-renders that layer. This de-risks the value→handle decision before committing the full migration. Pair it with a Unit_tests/ script that exercises the back-compat o2 = addblobs(o2, ...) call pattern against both the old and new classes.
| Concern | File |
|---|---|
| Display class (value) | CanlabCore/@fmridisplay/fmridisplay.m |
| Add/remove blobs | CanlabCore/@fmridisplay/addblobs.m, removeblobs.m |
| Montage view | CanlabCore/@fmridisplay/montage.m |
| Surface view | CanlabCore/@fmridisplay/surface.m |
| Blob rasterizer (color/alpha baked here) | CanlabCore/fmridisplay_helper_functions/render_blobs.m |
| Surface colorizer | CanlabCore/@image_vector/render_on_surface.m |
| Wrapper / 2nd entry point | CanlabCore/Visualization_functions/canlab_results_fmridisplay.m |
| Surface loader | CanlabCore/Visualization_functions/addbrain.m |
| Orthviews engines | cluster_orthviews.m, canlab_orthviews.m |
| NiiVue model to emulate | CanlabCore/Visualization_functions/canlab_niivue/ |