Skip to content

Commit 4e2314c

Browse files
torwagerclaude
andauthored
Visualization overhaul: stateful fmridisplay, controller, unified colour pipeline (#82)
* fmridisplay: stateful handle class with live layers + controller widget 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> * fmridisplay: graceful, informative errors for addblobs misuse 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> * fmridisplay: unify surfaces as managed views; silence per-slice readout 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> * fmridisplay: fix surface re-paint/erase and route opacity to surfaces 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> * fmridisplay: robust to closed windows, layer-aware rethreshold, live 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> * docs: plan deferred bypass-method -> managed object conversion (surface/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> * docs: refine deferred surface-keyword plan (addbrain already covers cutaways) 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> * fmridisplay: surface legend fix + remove_legend + controller upgrades 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> * fmridisplay: hide internal helper methods from the public surface 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> * fmridisplay: surface colormaps match montage; foursurfaces gets its own 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> * fmridisplay: foursurfaces layout — white figure, axes off 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> * fmridisplay: foursurfaces — turn off all axes in the layout figure 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> * fmridisplay: fix legend after set_colormap; add 'mango' colormap; plan 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> * fmridisplay controller: redesign + mango/seafire; legend single-range 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> * fmridisplay controller: narrower single-column redesign, log p-slider, 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> * fmridisplay: add squeeze_figure to remove montage figure white space 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> * fmridisplay: mango default, toggle-legend, remove-layer, swatch picker, 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> * viz: add central canlab_colormap value->RGB component (step 1 of colour 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> * viz: render surfaces in true-colour RGB via central canlab_colormap (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> * viz: fix black uncoloured surface areas on first true-colour render 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> * viz: multi-layer surface compositing in true-colour RGB (step 3) 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> * viz: per-layer surface visibility + opacity blending + sign-aware default 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> * fmridisplay/surface: addbrain keyword pass-through + centralized composites @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> * fmridisplay/surface: informative error for unknown addbrain keyword 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> * fmridisplay: host the legend in the controller; figure legends off by 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> * fmridisplay/surface: fix mixed isosurface+isocaps (coronal_slabs_4) error 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: route montage colours through central canlab_colormap 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> * canlab_colormap: add colorbar_ramp; route controller legend stripe through 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> * fmridisplay colour: single-range legend fix + perceptual colormaps 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> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 34c8415 commit 4e2314c

28 files changed

Lines changed: 4008 additions & 568 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
All notable changes to CanlabCore are documented in this file.
44

5+
## [Unreleased]
6+
7+
### Visualization: stateful `fmridisplay` (display overhaul, Phase 1–2)
8+
9+
- **`fmridisplay` is now a handle class** (`classdef fmridisplay < handle`). The
10+
value-style call contract is fully preserved — `o2 = addblobs(o2, ...)` and every
11+
existing call site keep working (a full audit found no copy-then-reuse aliasing).
12+
The object instance is now a single source of truth that figures and a controller
13+
widget can reference.
14+
- **Blob layers retain their source data and render options**, so they can be
15+
re-rendered in place. New `@fmridisplay` methods: `rethreshold`, `set_colormap`,
16+
`set_opacity`, and `refresh` (live re-threshold / colormap / opacity on montages).
17+
- **`controller(obj)`** opens a `uifigure` control panel bound to the instance
18+
(per-layer opacity / colormap / threshold / visibility), the MATLAB-side analog of
19+
the `canlab_niivue` web control panel.
20+
- **Fix:** a bare `'colormap'` flag (e.g. `montage(r, o2, 'colormap')`) no longer
21+
errors in `render_blobs`; `addblobs` strips it before forwarding.
22+
- New tests: `Unit_tests/image_vector/canlab_test_fmridisplay_handle.m`.
23+
524
## [v2.1.0] - 2026-05-18
625

726
This release is dominated by infrastructure work: a real automated test harness,

CanlabCore/@fmridisplay/addblobs.m

Lines changed: 117 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,55 @@
157157
% Add the volume to activation maps in fmridisplay object
158158
% ..
159159

160+
% Argument checks: fail with a clear, actionable message instead of MATLAB's
161+
% cryptic "Not enough input arguments" when the blobs/region argument is left out
162+
% (e.g. addblobs(o2) instead of addblobs(o2, region(t))).
163+
% -------------------------------------------------------------------
164+
% Drop any views whose figures were closed, so we never draw onto deleted
165+
% graphics handles.
166+
obj = prune_dead_views(obj);
167+
168+
if nargin < 2
169+
error('fmridisplay:addblobs:missingRegion', ...
170+
['addblobs needs something to add as its second argument; you passed only ' ...
171+
'the fmridisplay object.\n\n' ...
172+
'Usage: obj = addblobs(obj, cl)\n' ...
173+
' where cl is a region object (or a clusters struct / atlas / fmri_data / statistic_image).\n\n' ...
174+
'Example:\n' ...
175+
' o2 = montage(t); %% t is a thresholded statistic_image; o2 is an fmridisplay\n' ...
176+
' o2 = addblobs(o2, region(t)); %% add the blobs from t']);
177+
end
178+
179+
% Retain the ORIGINAL input as the layer source, before any conversion.
180+
% A statistic_image/fmri_data source enables re-thresholding downward
181+
% (rethreshold); a region source supports re-render at the same or higher
182+
% stringency. See VISUALIZATION_OVERHAUL_NOTES.md.
183+
% -------------------------------------------------------------------
184+
source_object = cl;
185+
186+
% Allow a caller (e.g. image_vector.montage, which passes region(obj) as the
187+
% blobs but knows the original statistic_image/fmri_data) to override the
188+
% retained source, so rethreshold has the real object with p-values / raw data
189+
% to work from. Stripped here so it is not forwarded to render_blobs.
190+
wh_so = find(strcmp(varargin, 'source_object'));
191+
if ~isempty(wh_so)
192+
source_object = varargin{wh_so(1) + 1};
193+
varargin(wh_so(1):wh_so(1) + 1) = [];
194+
end
195+
160196
% Check and convert to region
161197
% -------------------------------------------------------------------
162198
if isstruct(cl) && ~isa(cl, 'region'), cl = cluster2region(cl); end
163199

164200
if isa(cl, 'atlas'), cl = atlas2region(cl); elseif isa(cl, 'image_vector'), cl = region(cl); end
165201

166202
if ~isa(cl, 'region')
167-
error('cl input must be a region object. Try region() constructor method.');
203+
error('fmridisplay:addblobs:badRegion', ...
204+
['The second argument to addblobs must be a region (got a %s).\n' ...
205+
'Convert your image to a region first, e.g.\n' ...
206+
' o2 = addblobs(o2, region(img));\n' ...
207+
'addblobs also accepts a clusters struct, an atlas, an fmri_data, or a statistic_image.'], ...
208+
class(cl));
168209
end
169210

170211
if isempty(cl), return, end
@@ -185,7 +226,16 @@
185226
SPACE = map_to_world_space(V);
186227

187228
obj.activation_maps{end + 1} = struct('mapdata', mask, 'V', V, 'SPACE', SPACE, 'blobhandles', [], 'cmaprange', [], ...
188-
'mincolor', [0 0 1], 'maxcolor', [1 0 0], 'color', []);
229+
'mincolor', [0 0 1], 'maxcolor', [1 0 0], 'color', [], ...
230+
'source_region', [], 'source_object', [], 'render_args', [], ...
231+
'wh_montage', [], 'wh_surface', [], 'applied_threshold', []);
232+
233+
% Retain the source region + original source object on the layer so the
234+
% layer can be re-rendered in place (refresh / rethreshold / set_colormap /
235+
% set_opacity). render_args (the final option set actually passed to
236+
% render_blobs) is stored further below, once augmented with defaults.
237+
obj.activation_maps{end}.source_region = cl;
238+
obj.activation_maps{end}.source_object = source_object;
189239

190240
% Montage selection and options
191241
% -------------------------------------------------------------------
@@ -218,10 +268,12 @@
218268
domaxcolor = 0;
219269
domincolor = 0;
220270

221-
maxposcolor = [1 1 0]; % max pos, most extreme values
222-
minposcolor = [1 .4 .5]; % [.8 .3 0]; % min pos
223-
maxnegcolor = [0 .8 .8]; % [.3 .6 .9]; % max neg
224-
minnegcolor = [0 0 1]; % min neg, most extreme values
271+
% Default split colormap = "mango" {minneg maxneg minpos maxpos}
272+
% = {[.5 0 1] [0 .8 .3] [1 .2 1] [1 1 .3]}. (Was hot/cool; mango is now the default.)
273+
maxposcolor = [1 1 .3]; % max pos, most extreme values
274+
minposcolor = [1 .2 1]; % min pos
275+
maxnegcolor = [0 .8 .3]; % max neg
276+
minnegcolor = [.5 0 1]; % min neg, most extreme values
225277
indexmap = [];
226278

227279
% More purply orange and deeper blue: {[0 0 1] [.3 0 .8] [.8 .3 0] [1 1 0]}
@@ -300,12 +352,53 @@
300352
end
301353
end
302354

355+
% Drop a BARE 'colormap' flag before forwarding to render_blobs.
356+
% addblobs accepts 'colormap' as a flag meaning "value-mapped (not split)
357+
% colors" (handled above by setting dosplitcolor = 0). render_blobs, however,
358+
% treats 'colormap' as requiring a following n x 3 matrix and errors otherwise.
359+
% A bare flag (no matrix value, e.g. montage(r, o2, 'colormap')) must not be
360+
% passed on, or render_blobs raises "'colormap' argument must be followed by
361+
% an n x 3 matrix". Keep 'colormap' only when it is genuinely followed by an
362+
% n x 3 numeric matrix.
363+
wh_cmap = find(strcmp(varargin, 'colormap'));
364+
to_strip = false(size(varargin));
365+
for j = wh_cmap
366+
has_matrix = j < numel(varargin) && isnumeric(varargin{j + 1}) && size(varargin{j + 1}, 2) == 3;
367+
if ~has_matrix
368+
to_strip(j) = true;
369+
end
370+
end
371+
varargin(to_strip) = [];
372+
373+
% Sign-aware default colormap: when the caller gave NO explicit colour spec
374+
% (still the default split), pick the default by the data's sign — a binary mask
375+
% gets a solid colour, a positive-only (or zero) map gets "warm" (red->yellow), a
376+
% negative-only map gets "cool" (blue->cyan), and a mixed +/- map keeps the
377+
% "mango" split default. Explicit 'splitcolor'/'color'/'maxcolor'/etc. are untouched.
378+
if dosplitcolor && ~any(strcmp(varargin, 'splitcolor'))
379+
dvals = mask(mask ~= 0 & ~isnan(mask));
380+
if ~isempty(dvals)
381+
if numel(unique(dvals)) == 1
382+
dosplitcolor = 0; doonecolor = 1; % binary mask -> solid
383+
varargin{end + 1} = 'color';
384+
varargin{end + 1} = [1 .5 0];
385+
elseif all(dvals >= 0)
386+
dosplitcolor = 0; domaxcolor = 1; domincolor = 1; % positive-only -> warm
387+
maxcolor = [1 1 0]; mincolor = [1 0 0];
388+
elseif all(dvals <= 0)
389+
dosplitcolor = 0; domaxcolor = 1; domincolor = 1; % negative-only -> cool
390+
maxcolor = [0 1 1]; mincolor = [0 0 1];
391+
end
392+
% else: mixed +/- -> keep the mango split default
393+
end
394+
end
395+
303396
% Add relevant colors and args to varargin, because we may have passed in
304397
% keywords without following args, intending to use defaults specified above
305398
% These are passed to render_blobs and also used to update legend registry
306399
% in fmridisplay obj
307400

308-
if dosplitcolor
401+
if dosplitcolor
309402

310403
mysplitcolors = {minnegcolor maxnegcolor minposcolor maxposcolor};
311404

@@ -351,6 +444,14 @@
351444

352445
wh_to_display = length(obj.activation_maps);
353446

447+
% Retain the final render option set + targeting on the layer, so refresh /
448+
% set_colormap / set_opacity can replay the exact look. varargin has, by this
449+
% point, been augmented with the resolved splitcolor / maxcolor / mincolor
450+
% defaults that are passed on to render_blobs.
451+
obj.activation_maps{wh_to_display}.render_args = varargin;
452+
obj.activation_maps{wh_to_display}.wh_montage = wh_montage;
453+
obj.activation_maps{wh_to_display}.wh_surface = wh_surface;
454+
354455

355456
% Find valid handles and render blobs on them
356457
% -------------------------------------------------------------------------
@@ -407,60 +508,17 @@
407508

408509
% Surfaces
409510
% -------------------------------------------------------------------------
511+
% Render this layer onto every (targeted) registered surface view. The actual
512+
% drawing is shared with surface() pull-in and refresh() via the
513+
% render_layer_surfaces method, which derives colors from the layer's stored
514+
% render_args so all views stay in sync. See VISUALIZATION_OVERHAUL_NOTES.md.
410515

411-
if addsurfaceblobs
412-
for i = wh_surface
413-
414-
if length(obj.surface) < i
415-
warning('Requested surface does not exist! Check input surface indices.');
416-
continue
417-
end
418-
419-
% Set color maps for + / - values
420-
if dosplitcolor || doonecolor || domaxcolor || domincolor
421-
pos_colormap = colormap_tor(minposcolor, maxposcolor);
422-
neg_colormap = colormap_tor(minnegcolor, maxnegcolor);
423-
end
424-
425-
% OLD method: cluster_surf
426-
% cluster_surf(cl, depth, 'heatmap', 'colormaps', pos_colormap, neg_colormap, obj.surface{i}.object_handle);
427-
428-
img = region2imagevec(cl);
429-
if dosplitcolor
430-
431-
if exist('cmaprange', 'var')
432-
% Keep the same color mapping as before
433-
[~,bar1axis,bar2axis] = render_on_surface(img, obj.surface{i}.object_handle, 'pos_colormap', pos_colormap, 'neg_colormap', neg_colormap, 'cmaprange', cmaprange, varargin{:});
434-
else
435-
[~,bar1axis,bar2axis] = render_on_surface(img, obj.surface{i}.object_handle, 'pos_colormap', pos_colormap, 'neg_colormap', neg_colormap, varargin{:});
436-
end
437-
438-
% o2.activation_maps{1}.cmaprange
439-
else
440-
if isempty(indexmap)
441-
if exist('cmaprange', 'var')
442-
[~, bar1axis, bar2axis] = render_on_surface(img, obj.surface{i}.object_handle, 'cmaprange', cmaprange, varargin{:});
443-
else
444-
[~, bar1axis, bar2axis] = render_on_surface(img, obj.surface{i}.object_handle, varargin{:});
445-
end
446-
447-
else
448-
try
449-
[~, bar1axis, bar2axis] = render_on_surface(img, obj.surface{i}.object_handle, varargin{:},'colormap', indexmap, 'indexmap');
450-
catch
451-
keyboard
452-
end
453-
end
454-
end
455-
if ismember('legendhandle',fieldnames(obj.activation_maps{wh_to_display}))
456-
% if we plot multiple surfaces then their legends will overlap.
457-
% We only need to keep the exteriormost one
458-
delete(obj.activation_maps{wh_to_display}.legendhandle);
459-
end
460-
obj.activation_maps{wh_to_display}.legendhandle = [bar1axis, bar2axis];
461-
462-
end
516+
if addsurfaceblobs && ~isempty(obj.surface)
517+
obj = render_layer_surfaces(obj, wh_to_display, wh_surface);
463518
end
464519

520+
% Keep an open controller panel in sync with the new layer.
521+
obj = update_controller(obj);
522+
465523
end % main function
466524

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
function obj = composite_surfaces(obj, wh_surface, show_legend)
2+
% Re-render all blob layers onto the surface(s) as a true-colour RGB composite.
3+
%
4+
% Resets each surface to its saved anatomy gray, then paints every blob layer in
5+
% order (bottom to top), compositing in RGB so the top layer wins per vertex and
6+
% lower layers show through where the top layer has no colour. Used by refresh
7+
% and remove_layer (a full recompute); addblobs composites a single new layer on
8+
% top incrementally via render_layer_surfaces.
9+
%
10+
% :Usage:
11+
% ::
12+
%
13+
% obj = composite_surfaces(obj) % recomposite all surfaces
14+
% obj = composite_surfaces(obj, wh_surface) % only the listed surface indices
15+
%
16+
% :See also:
17+
% - render_layer_surfaces, refresh, removeblobs, remove_layer, render_on_surface
18+
%
19+
% ..
20+
% 2026 visualization overhaul — central colour pipeline
21+
% ..
22+
23+
if isempty(obj.surface), return, end
24+
if nargin < 2 || isempty(wh_surface), wh_surface = 1:numel(obj.surface); end
25+
if nargin < 3 || isempty(show_legend), show_legend = false; end % colorbars off by default
26+
27+
% Reset each target surface to its saved anatomy gray
28+
for s = wh_surface
29+
if s < 1 || s > numel(obj.surface), continue, end
30+
h = obj.surface{s}.object_handle;
31+
h = h(ishandle(h));
32+
if isempty(h), continue, end
33+
if ~isempty(get(h(1), 'UserData'))
34+
addbrain('eraseblobs', h);
35+
end
36+
end
37+
38+
% Paint every layer in order; each composites onto the running result
39+
for k = 1:numel(obj.activation_maps)
40+
obj = render_layer_surfaces(obj, k, wh_surface, show_legend);
41+
end
42+
43+
end

0 commit comments

Comments
 (0)