Skip to content

Visualization overhaul: stateful fmridisplay, controller, unified colour pipeline#82

Merged
torwager merged 29 commits into
masterfrom
display_overhaul
Jun 26, 2026
Merged

Visualization overhaul: stateful fmridisplay, controller, unified colour pipeline#82
torwager merged 29 commits into
masterfrom
display_overhaul

Conversation

@torwager

Copy link
Copy Markdown
Contributor

Overview

Reworks the CANlab display layer (@fmridisplay + the slice/surface renderers) into a stateful, source-retaining system with a live controller and one central colour pipeline. Montages, surfaces, and the legend now derive colours from a single source, layers can be re-thresholded/recoloured/toggled in place, and any addbrain surface works in the managed display. Design + per-step log: VISUALIZATION_OVERHAUL_NOTES.md (§11).

29 commits; master's recent niivue/PR-81 work is preserved (merge is conflict-free).

Highlights

Stateful handle class + controller

  • fmridisplay is now a handle class; the value-style call contract (o2 = addblobs(o2, …)) is preserved.
  • Blob layers retain their source object + render options, so they can be re-rendered in place.
  • A uifigure controller lists each layer with a colour-stripe legend (numeric end labels; 0 centre for split), opacity, type-aware threshold (p-values on a log slider), colormap dropdown + preview swatch, and a per-layer visibility/remove. Command-line rethreshold/set_colormap/set_opacity update it live; it echoes the equivalent code.

Surfaces unified as managed views

  • @fmridisplay/surface is a thin pass-through to addbrain: a bare token works (surface(o2,'thalamus')) and any eligible addbrain surface/region keyword is usable; unknown keywords get an informative error. addbrain owns the camera view; brainstem/caudate composites were centralized there.
  • True-colour RGB rendering with multi-layer compositing, per-layer visibility, and opacity blending (transparent layers let lower layers show through, like montages).

One central colour pipeline (canlab_colormap)

  • Single value→RGB module (single / split / solid / indexed / continuous) consumed by surfaces, montage slices (render_blobs routed through it — verified pixel-identical, retired the duplicated split/single math), and the controller legend stripe.
  • Single-range legend fix: single-ramp maps on signed data span the full range through zero and draw one colorbar (split maps keep two).
  • Perceptual colormaps: new canlab_perceptual_colormap (viridis/inferno/magma/plasma + built-in turbo/parula), selectable in the controller, flowing through the whole pipeline.

Default legends off on new montages/surfaces (the controller carries the legend); 'legend' opts the figure colorbar back in for export; Toggle legend fixed.

Bug fixes

  • Mixed isosurface+isocaps surfaces (coronal_slabs_4) no longer error (numeric-handle-safe, per-object face colouring).
  • Graceful errors for addblobs/surface misuse; robust to closed figure windows.

Tests

  • Unit_tests/image_vector/canlab_test_fmridisplay_handle.m (43), Unit_tests/visualization/canlab_test_canlab_colormap.m (19), canlab_test_display.m.
  • QA (fresh MATLAB): handle 43/43, colormap 19/19, display passing. (Two tests flake only under headless -batch — a closed-figure ishandle timing issue and offscreen-GL contention — both pass interactively / when run alone.)

Notes for review

  • fmridisplay is now a handle class — the main behavioural change to be aware of. The value-style return contract is kept, and a back-compat audit found no aliasing call sites.
  • One deliberately-deferred item: the opt-in figure legend.m colorbar keeps its own split-positioning helper (its colours already match the central map) — noted in §11.

🤖 Generated with Claude Code

torwager and others added 29 commits June 24, 2026 08:44
Converts fmridisplay from a value class storing baked graphics into a
handle class whose blob layers retain their source data and render
options, so they can be re-rendered in place. Implements Phases 1-2, 5,
and 6 of VISUALIZATION_OVERHAUL_NOTES.md (the deep render-pipeline
unification and engine consolidation remain deferred, per doc section 11).

- classdef fmridisplay < handle. A full call-site audit found no
  value->handle aliasing hazard; every site uses the safe
  `o2 = method(o2, ...)` self-reassign idiom, so back-compat is preserved.
- addblobs: each activation_maps{} layer now retains source_region,
  source_object (e.g. a statistic_image, enabling re-threshold up and
  down), render_args, wh_montage/wh_surface, applied_threshold.
- New @fmridisplay methods: refresh, rethreshold, set_colormap,
  set_opacity (live in-place re-render on montage views), and
  controller(obj) (uifigure control panel bound to the instance).
- Fix: a bare 'colormap' flag (montage(r, o2, 'colormap')) was a
  pre-existing crash in render_blobs (which requires an n x 3 matrix);
  addblobs now strips it before forwarding. Un-breaks
  canlab_help_5_regression_walkthrough.
- New tests: Unit_tests/image_vector/canlab_test_fmridisplay_handle.m.

Verified: full unit tier 157 pass / 0 fail / 2 skip; walkthroughs 10/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces cryptic MATLAB errors when addblobs is called incorrectly:

- addblobs(o2) with no region -> instead of "Not enough input arguments"
  (MATLAB:minrhs), explain that a region/clusters second argument is
  required and show the montage(t) + addblobs(o2, region(t)) usage.
- addblobs(o2, badtype) -> name the class received and how to convert,
  with a stable error id (fmridisplay:addblobs:badRegion).
- addblobs(img) on image data (forgot to build a display) -> a new
  @image_vector/addblobs stub replaces "Undefined function 'addblobs'
  for input arguments of type 'statistic_image'" with a redirect to
  canlab_results_fmridisplay/montage then addblobs(o2, region(img)).

(Note: surface(o2) on an fmridisplay is valid — it adds a surface view —
so it is intentionally left working.)

Verified: viz unit + help_examples 42/0, walkthroughs 10/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces are now first-class managed views (design notes 4.2 / 7.4), so
blobs act across montages and surfaces together:

- Factor surface drawing out of addblobs into a shared render_layer_surfaces
  method that derives colors from each layer's stored render_args. addblobs,
  surface (pull-in), and refresh all route through it.
- surface(o2) registers on the SAME handle and pulls in any blob layers that
  already exist, so a surface added AFTER addblobs still shows the blobs.
- refresh (hence rethreshold / set_colormap / set_opacity) re-renders surfaces
  too, not just montages.
- Multi-surface keywords 'foursurfaces' / 'foursurfaces_hcp' /
  'foursurfaces_freesurfer' add the four canonical views (L/R lateral + medial)
  as separate registered views on the same object, instead of being ignored.
- Silence the per-slice "N voxels displayed, M not displayed on these slices"
  readout in render_blobs (commented out, with a re-enable note).
- Tests: +4 surface-integration cases in canlab_test_fmridisplay_handle
  (13/13 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes two issues with surfaces as managed views:

- Re-painting an already-colored surface (addblobs twice, addblobs after
  registering a new surface, or refresh) used to poison the saved restore
  data: render_on_surface stashes the *current* FaceVertexCData as the
  "gray" to restore, so a second paint saved the blob colors. removeblobs
  then could not clear the surface. render_layer_surfaces now restores the
  saved gray before re-painting a previously-painted surface, so removeblobs
  reliably returns every registered surface to anatomy.
- set_opacity (and the controller opacity slider) now reaches surfaces:
  render_layer_surfaces applies a constant 'transvalue' as the surface
  patch FaceAlpha. set_colormap already propagated via render_args.

Both are consequences of surfaces being unified views: add/remove/recolor/
opacity now act on montages and every registered surface together.

Tests: +2 regression cases (repeat-paint clears, opacity dims surface);
canlab_test_fmridisplay_handle 15/15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…controller

Addresses several interactive-use issues:

- Closed figure windows no longer crash addblobs/removeblobs/refresh. New
  prune_dead_views drops montage/surface views whose figures were closed
  (with a short note); surface erase/render also filter to live handles.
- rethreshold is now layer-aware and the montage path keeps the real source:
  image_vector.montage passes the original object via a new addblobs
  'source_object' override, so a montage(t) layer retains its statistic_image
  and re-thresholds by p-value. rethreshold dispatches by source class
  (statistic_image -> p-value, fmri_data/mask -> raw value, region ->
  magnitude cutoff) and honors 'layers'.
- Controller is now live: rebuilt in place on repeat calls, auto-refreshed by
  addblobs/removeblobs (update_controller via a Transient controller_handle
  property), each control initialized from the layer's current state, and the
  threshold field is type-aware (p-value vs raw). HandleVisibility='on' so
  `close all` closes it.

Tests: +4 cases (closed-figure prune, montage source class, raw rethreshold,
controller auto-update + state); canlab_test_fmridisplay_handle 19/19.
checkcode clean; canlab_test_display 4/4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ce/orthviews)

Record in VISUALIZATION_OVERHAUL_NOTES.md section 11 that image_vector.surface
and image_vector.orthviews still return raw handles (unlike montage), and the
plan to make them return a managed fmridisplay: opt-in flag first (~24 call
sites use the handle return), mirroring image_vector.montage with
'source_object' retention. Captures T. Wager's keyword-parity idea: pass the
direction keyword + remaining varargin through to addbrain (as the hcp surfaces
already do) to expose addbrain's full surface set with minimal new code, with a
caveat that the parametric surface_cutaway types (coronal_slabs, ycut_mm) live
in region.surface and would need a second step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…utaways)

Correct an earlier caveat: addbrain.m:844 already dispatches the cutaway/slab
keywords (coronal_slabs_4, *_cutaway, slabs) to
canlab_canonical_brain_surface_cutaways, so the planned addbrain pass-through
covers cutaways in one mechanism (no separate step). Record that supporting all
addbrain keywords in @fmridisplay/surface needs bare-keyword parsing + control-
opt stripping + post-render view/lighting handling (moderate, not piecemeal),
and that making the default surface 'left_cutaway' (in @fmridisplay/surface and
image_vector.surface) is a back-compat-affecting decision for the §7.4 phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Legend / colorbar:
- render_on_surface now parents the colorbar legend to the SURFACE's figure
  instead of gcf, so it no longer lands on the montage window.
- New remove_legend(obj) method deletes surface colorbar legends (keeps blobs).

Controller:
- Command-line rethreshold / set_colormap / set_opacity now sync an open
  controller: controls update IN PLACE when the layer count is unchanged
  (rebuild only on addblobs/removeblobs), so the threshold/colormap/opacity
  fields reflect command-line changes without destroying controls mid-callback.
- 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 (inputname, preserved across rebuilds).
- More colormaps (split / warm / cool / winter) plus a 'solid colour…' entry
  that opens the built-in uisetcolor palette.
- Cosmetic: light-green figure, bound-variable name in the title bar, opacity
  slider tick marks removed.

Deferred (documented in section 11): per-layer surface visibility (Visible
toggle hides montage blobs only) — needs a visible flag respected across all
surface-draw paths; do it with the multi-layer compositing work. True
perceptual colormaps (inferno/viridis) also deferred.

Tests: +3 (legend on surface figure, remove_legend, rethreshold->controller
sync); canlab_test_fmridisplay_handle 22/22; checkcode clean; display 4/4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Declare the internal helpers activate_figures, prune_dead_views, refresh,
render_layer_surfaces, and update_controller as Hidden (signature-only block in
fmridisplay.m; bodies stay in their @fmridisplay/*.m files), so they no longer
appear in methods(obj) / tab-completion. Hidden rather than Access=private so
existing external callers keep working: activate_figures is used by
help-example scripts (c_univariate_contrast_maps*.m) and refresh by the unit
tests. All user-facing methods remain public.

Test: +1 (public vs internal method surface, and internal methods still
callable); canlab_test_fmridisplay_handle 23/23.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wn figure

Colormap fix (render_layer_surfaces):
- render_on_surface does not understand maxcolor/mincolor, so warm/cool/winter
  (max/min colour ramps) and solid colours were silently dropped on surfaces and
  fell back to the default split hot/cool (blue negatives under "warm";
  cool/winter not updating). Now the layer's colour spec is translated into
  pos_colormap/neg_colormap ramps (surface_colormaps_from_args) and the tokens
  render_on_surface can't read are stripped. Solid colours map the same colour on
  both signs (no stray default-cool negatives). Verified: warm surface map B=0.0,
  cool/winter produce distinct surface colormaps.
  (Residual: the split hot/cool map still differs slightly montage-vs-surface due
  to the two engines' colormap construction; full match is the deferred
  render-pipeline unification.)

Figure fix (surface / add_multiple_surfaces):
- surface(o2, 'foursurfaces*') opened the 2x2 layout in the current figure, so
  without a preceding `figure;` it drew over the montage and showed nothing. It
  now reuses an empty current figure or opens a new one.

Tests: +2 (surface colormap follows max/min colour; foursurfaces uses its own
figure); canlab_test_fmridisplay_handle 25/25; checkcode clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In add_multiple_surfaces, set the layout figure Color to white and turn the
four surface axes Visible/Color off, so no axes box or grey background shows
behind the surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2x2 layout figure also contained a stray default-position axes (created
during rendering) that showed a visible frame behind the surfaces. Turn off
Visible on every axes in the layout figure (surfaces, colorbar axes, and the
stray one); surface patches and colorbars are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n update

- legend() no longer errors after set_colormap switches a layer to a single
  colormap. Root cause: refresh re-rendered the montage but did not update the
  layer's legend-defining .mincolor/.maxcolor, so a stale 2-row split mincolor
  made legend take the split path and index past the 2-element cmaprange.
  refresh now keeps .mincolor/.maxcolor in sync (as addblobs does), and legend
  has a defensive guard: a split-shaped mincolor without 4 cmaprange anchors
  falls back to a single map instead of erroring.
- Controller: new 'mango' colormap option ->
  set_colormap(obj, 'splitcolor', {[.5 0 1] [0 .8 .3] [1 .2 1] [1 1 .3]}); the
  dropdown reflects it and the action echoes the code line.
- Plan: record the agreed long-term colour architecture (one central
  value->colour mapping module feeding slice and surface renderers; surfaces in
  true-colour RGB; subsumes the unify-pipeline / compositing / RGB-surface items).

Tests: +1 (legend after set_colormap); canlab_test_fmridisplay_handle 26/26.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… to plan

Controller:
- ~2x wider, two compact rows per layer: Opacity slider + Visible on row 1;
  a type-aware threshold slider + Colors dropdown on row 2. Bigger text,
  shorter re-render button, fixed-width opacity/colors controls.
- Threshold is now a slider: p-value slider with ticks at .001/.005/.01/.05/.1
  for statistic_image layers; raw |x| slider anchored at 0 and the 99.9th
  percentile of |data| for fmri_data/region layers.
- Colormap menu: rename 'mango' -> 'split (mango)' and add 'seafire'
  (set_colormap splitcolor {[0 0 1] [.2 .8 .6] [1 .4 .1] [1 1 0]}); dropdown
  reflects both and echoes the code line.

Plan: recorded that the single-range colorbar legend (warm/cool should be ONE
colorbar spanning zero, not split) is part of the central value->colour mapping
work — render_blobs' positive-percentile cmaprange and render_on_surface's
pos+neg colorbars both stem from the duplicated range/colorbar logic.

Tests: +1 (threshold slider + mango/seafire); canlab_test_fmridisplay_handle 27/27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, swatches

- Revert to a narrower (460px) single-column layout (the wide 2-col version cut
  off the colormap dropdown). Each layer panel: colormap title stripe, opacity
  slider, threshold slider, Colors dropdown + live preview swatch, Visible.
- Threshold p-value slider is now LOG scale (value = log10 p), ticks at
  .001/.005/.01/.05/.1 so .05-.1 sit close together and .001-.005 spread out.
  Raw |x| slider unchanged (linear, 0..99.9th pct).
- Colormap preview swatch next to the dropdown and a full-width title stripe,
  both updating live with the layer's colormap.
- Footer groups Re-render / Remove legend / Close buttons.
- Fonts +4pt. Window colour set by FIG_COLOR at the top of controller.m
  (now [1 .5 0]).
- seafire colours changed to {[.4 0 1] [.2 .9 .5] [1 .3 .2] [1 .8 .4]}.

Tests updated for the log slider; canlab_test_fmridisplay_handle 27/27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A montage figure is typically much taller than the slices it contains (they
occupy a ~40% horizontal band, leaving big top/bottom white margins).
squeeze_figure(obj) shrinks each montage figure's height and remaps the slice
axes to fill it, scaled so each slice keeps its pixel size, aspect ratio, and
horizontal placement — only the empty vertical margins are removed. Opt-in
(doesn't change montage defaults); skips figures that also contain surfaces.
Verified: 670 -> 258 px, slice pixel size preserved to 99.9%.

Test: +1 (squeeze_figure preserves slice size); canlab_test_fmridisplay_handle 28/28.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r, p->0

- mango is now the DEFAULT blob colormap (addblobs default split colours).
- Controller footer: 'Remove legend' -> 'Toggle legend' (on/off); dropped the
  Close button.
- Per-layer 'Remove layer' button + new remove_layer(obj, k) method (removes a
  single layer and re-renders remaining layers on surfaces; removeblobs still
  removes all).
- Clicking the colormap swatch opens the colour picker (set/re-pick a solid
  colour; works around uidropdown not re-firing on the already-selected item).
- p-value threshold slider extends below .001 down to ~0 (1e-6 floor, '~0' tick).
- Confirmed rethreshold already supports cluster-extent 'k' (e.g. 'k', 50),
  passed through to threshold(); verified it shrinks blobs as k grows.

Tests: +2 (default mango, remove_layer); slider-range test updated;
canlab_test_fmridisplay_handle 30/30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ur pipeline)

First step of the central value->colour mapping (VISUALIZATION_OVERHAUL_NOTES.md):
a single source of truth that turns data values into RGB for one colormap type,
independent of the renderer, so montage/surface/legend can agree exactly.

canlab_colormap (fmridisplay_helper_functions/canlab_colormap.m):
- static factories single / split / solid / indexed, plus from_render_args(args,
  cmaprange) bridging the existing layer render_args.
- map(values) -> Nx3 RGB, matching render_blobs (split: minpos->maxpos for
  positives, maxneg->minneg for negatives; single: mincolor->maxcolor; solid:
  constant; indexed: round+index; uncoloured -> NaN).
- legend_samples(n) -> one colorbar's samples (split spans zero in a single bar,
  the basis for fixing the single-range legend); lut(n) -> nx3 table.

Tests: Unit_tests/visualization/canlab_test_canlab_colormap.m (14 cases) all pass;
no regression in the fmridisplay handle suite (44/44 total). Not yet wired into
the renderers — surfaces -> true-colour RGB is the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…step 2)

render_on_surface gains an opt-in 'truecolor', <canlab_colormap> option: after
it samples per-vertex values, it colours each vertex via map() as N x 3 RGB,
composites over the anatomy gray (anatomy_to_rgb preserves curvature shading)
for uncoloured vertices, and sets FaceVertexCData=RGB with CDataMapping direct.
Default (option absent) is unchanged, so the @image_vector/surface bypass path
still uses indexed colour.

render_layer_surfaces builds canlab_colormap.from_render_args(args, cmaprange)
and passes it, so the managed display's surfaces now match the montage colours
exactly (warm = red->yellow, no blue negatives; verified) and removeblobs/erase
still restore gray. Surfaces are now N x 3 true colour, which is the basis for
multi-layer compositing and per-layer surface visibility.

Tests: +1 (surface true-colour via central map); canlab_test_fmridisplay_handle
31/31; canlab_colormap 14/14; display 4/4 — no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A fresh surface is displayed via a solid FaceColor ([.5 .5 .5]) but its
FaceVertexCData can hold a stale dark value (~0.13). The true-colour path used
that stale CData as the anatomy gray, so uncoloured areas looked near-black on
the first render (re-render worked because eraseblobs had rewritten the CData
to a proper gray).

Fix: anatomy_to_rgb now prefers the surface's solid FaceColor (the gray
actually on screen) when FaceColor is a 1x3 RGB triplet, and render_on_surface
saves that proper gray to UserData so eraseblobs restores it too.

Test: +1 (first-render uncoloured areas are gray, not black);
canlab_test_fmridisplay_handle 32/32; display 4/4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that surfaces are true-colour RGB, layers composite instead of overwriting.

- render_layer_surfaces paints a layer onto the CURRENT surface colours (the
  erase-before-repaint is gone), so a new layer's coloured vertices win and its
  uncoloured vertices keep whatever is underneath (lower layers / gray). The
  anatomy gray is saved once (save-once UserData) so erase/removeblobs still
  restore gray, not a composite.
- New composite_surfaces(obj[, wh_surface]) does a full recompute: reset each
  surface to gray, then repaint every layer bottom->top. refresh (hence
  rethreshold/set_colormap/set_opacity) and remove_layer use it; addblobs
  composites a single new layer on top incrementally.

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.

Tests: +1 (multi-layer compositing + remove_layer recomposite);
canlab_test_fmridisplay_handle 33/33; display 4/4 — no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ault colormap

- Surfaces now BLEND layers by opacity (render_on_surface 'truecolor_alpha'):
  a layer's coloured vertices = a*colour + (1-a)*below, so a semi-transparent
  layer lets the one below show through, matching montages. render_layer_surfaces
  passes the layer's transvalue as the alpha (no more whole-patch FaceAlpha).
- Per-layer surface visibility: a layer with visible==false is skipped in
  render_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 given):
  binary mask -> solid; positive-only -> warm; negative-only -> cool; mixed +/-
  -> mango split. Propagates to montage and surface via the central map.

Tests: +2 (surface visibility, sign-aware default), opacity test updated for
blending; canlab_test_fmridisplay_handle 35/35; display 4/4; colormap 14/14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…osites

@fmridisplay/surface no longer hard-codes a per-direction if/elseif switch.
It parses the reserved pairs (direction/orientation/axes), accepts a bare
direction token (surface(o2,'thalamus')), and passes remaining args through to
addbrain(dir, extra{:}) — so any eligible addbrain surface/region keyword now
works in the managed display automatically.

addbrain owns the default (lateral) view + lighting per keyword; surface only
special-cases bigbrain left/right (different addbrain surface) and mirrors the
azimuth 180 deg for a medial view. Managed multi-surface layouts keep identical
view angles; a couple of standalone lateral azimuths move to addbrain's
surface-specific convention.

Per T. Wager's suggestion, the brainstem left/right and caudate left/right
composite handle-sets move from surface.m into addbrain (alongside limbic/bg/
*_group), with their oblique 3D views and help/region entries.

help fmridisplay/surface now lists the common directions, the bare-token form,
and points to help addbrain for the full keyword list.

Tests: +test_surface_addbrain_passthrough, +test_surface_medial_flips_azimuth
(handle suite 37/37; display 4/4 unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a bare direction token is not a recognized addbrain keyword, addbrain
errors with a terse 'Unknown method.'. surface() now catches that and raises
fmridisplay:surface:unknownDirection naming the offending token and pointing to
`help addbrain` (e.g. clarifies cutaways are 'left_cutaway'/'right_cutaway', not
'cutaway_left'). All other addbrain errors pass through unchanged.

Test: +test_surface_unknown_keyword_informative_error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… default

The colour stripe in each controller layer panel is now the legend: taller,
with numeric end labels underneath (from the layer's cmaprange, 1 sig fig).
Split (+/-) maps label both extremes with 0 in the centre; single ramps label
the two ends. Labels refresh on rethreshold/recolor. Opacity/threshold/colors/
visible rows shifted down to make room.

Figure colorbars are now OFF by default: @image_vector/montage no longer draws
the montage-figure legend unless 'legend' is passed; render_layer_surfaces /
composite_surfaces take a show_legend flag (default false -> 'nolegend' to
render_on_surface) so surfaces have no colorbar by default. The fmridisplay
constructor and montage now accept 'legend'/'nolegend' without warning.

Toggle legend now toggles colorbars on the montage/surface FIGURES (for export)
and works both ways: ON targets the montage figure explicitly (fixing the bug
where legend() drew into the controller uifigure via gcf and never reappeared)
and re-renders surfaces with colorbars; OFF removes them. State in controller
appdata; montage legend axes tagged for clean removal.

render_layer_surfaces gains a 4th arg (show_legend); its methods(Hidden)
declaration in fmridisplay.m is updated to match (requires a fresh class load).

Tests: +test_controller_shows_legend_labels, +test_montage_figure_legend_off_by_default;
surface-legend tests force legends on; pull-in test checks vertex colouring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rror

surface() did `if strcmp(h(1).FaceColor,'interp')`, which errors ("Dot indexing
is not supported for variables of this type") when addbrain returns OLD-STYLE
numeric handles (doubles) instead of graphics objects -- e.g. coronal_slabs_4
returns 8 patch handles. It would also have blanket-grayed the whole set,
destroying the isocaps that use 'interp' to show the anatomy cross-section.

Replace 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: handle 41/41, display 4/4,
colormap 14/14 (interactive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
render_blobs no longer re-derives split/single colours inline. It builds one
canlab_colormap (split or single) from the parsed colours + cmaprange before the
slice loop and colours each slice via a shared central_map_slice helper -- the
same mapping render_on_surface already uses -- so montage and surface colours
come from a single 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 values 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 colour matrices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rough it

Adds canlab_colormap.colorbar_ramp(n) -- the continuous legend-bar ramp with no
threshold gap (unlike map/legend_samples): for split it is the negative ramp
(minneg->maxneg) then the positive ramp (minpos->maxpos), reading
extreme-neg ... 0 ... extreme-pos.

The controller's per-layer colour stripe + preview swatch (the in-app legend)
now come from from_render_args(args).colorbar_ramp(64) instead of bespoke
colormap_tor calls, so the stripe, the rendered blobs, and surfaces share one
colour source. +2 colorbar_ramp tests (colormap suite 14->16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Single-range colorbar legend:
- get_default_cmaprange: a single-ramp (warm/cool) map on SIGNED data now spans
  the full robust range THROUGH ZERO ([prctile2 prctile98]) instead of a
  positive-only [p10 p90] that hid negatives. Positive/negative-only data and
  split maps are unchanged.
- render_on_surface gains a 'single_colorbar' flag (set by render_layer_surfaces
  from the central-map type) so single-ramp/solid/continuous maps draw ONE
  colorbar over the full range, not a misleading pos+neg pair. Split maps still
  get two. Verified: warm range [2.82 4.86] -> [-2.98 6.07]; surface colorbars
  2 -> 1; split/solid montage colours unchanged.

Perceptual colormaps:
- canlab_colormap gains a 'continuous' type (value range mapped continuously
  through an n x 3 LUT, distinct from atlas-style 'indexed').
- new canlab_perceptual_colormap(name, n): viridis/inferno/magma/plasma
  (embedded matplotlib anchors, interpolated) + turbo/parula (built-ins).
- controller dropdown offers all six; selecting one routes through set_colormap
  -> render_args 'colormap' -> central continuous map, so montage, surface, and
  the controller stripe all render it (one colorbar).

Tests: +single-range legend (2), +continuous/perceptual (3, colormap suite ->19).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@torwager

Copy link
Copy Markdown
Contributor Author

Deferred work / future development

Everything below is optional follow-up — the overhaul is complete and self-consistent without it. Captured here so it can be picked up later. Pointers reference VISUALIZATION_OVERHAUL_NOTES.md sections.

1. Bypass methods → managed object (§7.4) — biggest remaining item

image_vector.surface(t, …) and image_vector.orthviews(t, …) still return raw graphics handles, unlike montage(t) which returns a managed fmridisplay. Goal: have them (optionally, then by default) return a registered managed display so rethreshold/controller/set_colormap work on the result.

  • Why deferred: ~24 call sites + walkthroughs rely on surface_handles = surface(t, …) being graphics handles, so the default can't flip without a deprecation path.
  • Groundwork already done: the @fmridisplay/surface addbrain pass-through (this PR) means the managed path already exposes addbrain's full surface catalog with almost no new code.
  • Recipe: opt-in flag first (mirror @image_vector/montage: build fmridisplay, addblobs(…, 'source_object', t)), keep returning handles where back-compat needs it, then flip the default in a later release. See §7.4.

2. Figure legend.m colorbar dedup

The opt-in figure colorbar (@fmridisplay/legend, default-off now) still has its own split-positioning helper (get_split_colormap_values_and_colors) instead of consuming canlab_colormap.

  • Status: its colours already match the central map (same maths, different field storage), so this is a code-dedup nicety, not a correctness bug.
  • Why deferred: the value-positioned split layout (neg ramp / gap / pos ramp at data-value x-positions) is intricate and low-reward to refactor. Route its colours through canlab_colormap.colorbar_ramp/legend_samples while preserving the positioning.

3. Colour-pipeline loose ends

  • Montage perceptual path: continuous (perceptual) montage colours still go through render_blobs' existing customcolormap branch, not central_map_slice like split/single now do. Colours are equivalent; routing it through the central continuous map would make it 100% single-implementation.
  • Perceptual colormap fidelity: canlab_perceptual_colormap builds viridis/inferno/magma/plasma from ~11 embedded matplotlib anchor points interpolated to N (visually faithful, but approximate). Could embed the exact 256-row tables for pixel-exact parity with matplotlib/NiiVue, and add reversed (_r) and diverging perceptual variants.

4. Test / CI robustness

Two tests pass interactively but flake under headless matlab -batch:

  • canlab_test_fmridisplay_handle/test_removeblobs_survives_closed_surface_figureclose(f) doesn't invalidate patch handles synchronously headless, so prune_dead_views sees them as live.
  • canlab_test_display/test_surface_runs_on_thresholded_t — offscreen-GL contention when run after many surface tests (passes when run alone).
    Make these robust (force drawnow, or assumeTrue(feature('ShowFigureWindows'))) so a clean batch/CI run is fully green.

5. Nice-to-haves (low priority)

  • Indexed/atlas legend rendered in the controller (today the controller legend covers single/split/solid/continuous; atlas index maps still rely on the figure legend).
  • Save/restore display state (layers + thresholds + colormaps) to reproduce a figure.
  • More colormaps as the central map makes them cheap to add.

Environment note for whoever picks this up

The fmridisplay classdef can't hot-reload in a long-running MATLAB session that holds onCleanup objects (clear classes is blocked) — verify classdef changes (e.g. new method signatures) in a fresh MATLAB process. A fresh matlab -batch needs spm('defaults','fmri'); spm_get_defaults('images.tol_orient', 0.0001); before loading sample data.

@torwager torwager merged commit 4e2314c into master Jun 26, 2026
1 check passed
@torwager torwager deleted the display_overhaul branch June 26, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant