Skip to content

FIX VertexRGB/VolumeRGB alpha attenuation in WebGL viewer#632

Merged
mvdoc merged 15 commits into
mainfrom
claude/friendly-varahamihira-a97ded
May 10, 2026
Merged

FIX VertexRGB/VolumeRGB alpha attenuation in WebGL viewer#632
mvdoc merged 15 commits into
mainfrom
claude/friendly-varahamihira-a97ded

Conversation

@mvdoc

@mvdoc mvdoc commented May 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #631. The WebGL fragment shader composites with a premultiplied-alpha "over" formula:

gl_FragColor = vColor + (1.0 - vColor.a) * gl_FragColor;

…but VertexRGB.vertices / VolumeRGB.volume ship raw, non-premultiplied RGB bytes. With α < 1 this rendered vertices brighter / clipped toward white instead of blending toward the curvature underlay.

Fix (Python-side, narrow): premultiply RGB by α at the WebGL serialization step (cortex/webgl/data.py Package.__init__). The .vertices / .volume properties stay non-premultiplied so the matplotlib (quickshow) path keeps using matplotlib's straight-alpha imshow compositor unchanged. Both viewers now produce the same composite α·rgb + (1-α)·bg.

Important: the premultiplication only applies to VertexRGB, not VolumeRGB. VolumeRGB ships through the PNG texture path (cortex/webgl/resources/js/dataset.js:335-338), where Three.js sets tex.premultiplyAlpha = true and WebGL's UNPACK_PREMULTIPLY_ALPHA_WEBGL already premultiplies the texture once on upload. Premultiplying again on the Python side would double-attenuate partial-alpha VolumeRGB. VertexRGB has no equivalent JS-side step (vertex attributes via BufferAttribute), so it still needs the Python premult.

This is the Python-side option suggested in the issue — narrower than touching the shader (which would also affect other dataviews and require broader regression checks).

Also deprecates BrainData.blend_curvature (Vertex.blend_curvature / VertexRGB.blend_curvature / Vertex2D.blend_curvature via inherited references): it was a hack to mimic transparency for Vertex objects, no longer needed now that per-vertex α works directly in both viewers.

Companion fix: 2D-cmap LUT texture (commit 0eafd8c)

While verifying the headless A/B against quickshow, we noticed that the 2D dataview path (Vertex2D / Volume2D with alpha-encoding 2D colormaps such as RdBu_r_alpha) still didn't match — WebGL panels rendered noticeably brighter / less attenuated than the matplotlib reference, while RGB dataviews matched after the Python fix above.

Why the Python-side premultiplication doesn't reach this path. Vertex2D.uniques() yields dim1 and dim2 as separate scalar maps, so Package.__init__ ships them as plain floats with no alpha channel — there's nothing for the Python code to premultiply. The actual LUT lookup happens on the GPU in the vertex shader at shaderlib.js:755:

vColor = texture2D(colormap, cuv);  // cuv = (data_norm, alpha_norm)

The fragment shader then runs the same premultiplied-over composite (vColor + (1-α)·bg). For that to produce the correct result, the colormap texture itself needs to be uploaded premultiplied — but the cmap texture in mriview.js was created with tex.premultiplyAlpha = false. Empirically, this leaves brain-region pixels at median R≈129 (out of an unattenuated 167 LSB ceiling) instead of the correct ~93.

Fix (one-line, JS-side). cortex/webgl/resources/js/mriview.js:33 now sets tex.premultiplyAlpha = true on the cmap textures, matching the existing convention for the per-vertex / volumetric texture path. For non-alpha colormaps (every row α=255), premultiplication is a no-op, so the change has no effect on existing colormaps. For alpha-bearing colormaps, the LUT texel (R, G, B, α) becomes (α·R, α·G, α·B, α) on upload, and the fragment shader's vColor + (1-α)·bg then correctly resolves to α·rgb + (1-α)·bg. Verified live (the user confirmed both viewers now match across all four patterns in examples/datasets/plot_data_with_alpha.py).

Equivalence with quickshow (matplotlib)

For VertexRGB, verified analytically and via test:

  • WebGL shader, with premultiplied bytes: vColor + (1-α)·bg = (α·rgb) + (1-α)·bg
  • matplotlib imshow layered over curvature, with non-premultiplied bytes: α·rgb + (1-α)·bg

test_quickshow_webgl_alpha_equivalence (in test_quickflat.py) pins this equivalence at the per-vertex level for arbitrary curvature gray, with tolerance ≤ 2/255 (uint8 rounding).

For Vertex2D / Volume2D the equivalence is empirical: with tex.premultiplyAlpha = true on the cmap texture, the WebGL composite numerically matches the quickshow render against the same alpha-bearing 2D colormap, side-by-side across the four patterns in the new gallery example (volumetric / surface, scalar+α / RGB+α).

Tests added

  • cortex/tests/test_webgl_data.py (new) — Package serialization unit tests:
    • VertexRGB: serialized bytes are α-premultiplied (general α, α=1 passthrough, α=0 zeroes RGB).
    • VolumeRGB: serialized bytes are NOT premultiplied (Three.js will do it once on texture upload).
    • sanity checks that .vertices stays non-premultiplied (so quickshow keeps working).
  • test_quickflat.py::test_quickshow_webgl_alpha_equivalence — asserts WebGL and matplotlib paths agree per-vertex against arbitrary curvature.
  • test_quickflat.py::test_make_flatmap_image_vertexrgb_alpha_unchanged — guards the matplotlib path from accidental premultiplication regressions.
  • test_webgl_headless.py::test_vertexrgb_alpha_zero_renders_curvature_only — playwright pixel-level regression: with α=0 over the whole brain, expects ≪500 red-dominant pixels. Verified to fail without the fix and pass with it.
  • test_webgl_headless.py::test_volumergb_alpha_half_renders_correct_blend — playwright pixel-level regression for the VolumeRGB double-premult risk: uniform red with α=0.5 should produce median R ≈150 (correct), not ≈100 (double-premult). Verified to fail when Python-side premult is wrongly extended to VolumeRGB.
  • test_webgl_headless.py::test_vertex2d_alpha_half_renders_correct_blend (new) — playwright pixel-level regression for the cmap-LUT premult fix. Renders Vertex2D(data=+1, alpha=0.5, cmap=RdBu_r_alpha) and asserts the brain region's median R is ≈93 (correct) rather than ≈129 (buggy un-premultiplied LUT). Verified to fail in the buggy state and pass with the fix.
  • test_dataset.py::test_blend_curvature — wrapped existing assertions in pytest.warns(DeprecationWarning).

Full suite: pytest cortex/tests/test_dataset.py cortex/tests/test_webgl_data.py cortex/tests/test_quickflat.py cortex/tests/test_webgl_headless.py80 passed, 1 xfailed (the xfail is pre-existing).

Before / After screenshots

Reproducer: uniform red VertexRGB over the flatmap, with α=0 inside V1+V2+V3.

result
Before V1+V2+V3 region renders as bright pink/red — bug: shader's vColor + (1-α)·bg clips RGB toward white when fed non-premultiplied bytes.
After V1+V2+V3 region falls through to the gray curvature underlay — correct α·rgb + (1-α)·bg composite.
Before/after

Before before

After
after

Test plan

  • pytest cortex/tests/test_dataset.py cortex/tests/test_webgl_data.py cortex/tests/test_quickflat.py cortex/tests/test_webgl_headless.py — 80 passed.
  • Headless regression for VertexRGB (α=0 falls through to curvature) — passes; verified to fail when fix is reverted.
  • Headless regression for VolumeRGB (α=0.5 blends correctly, not double-attenuated) — passes; verified to fail when Python premult is wrongly extended to VolumeRGB.
  • Headless regression for Vertex2D (α=0.5 with RdBu_r_alpha blends correctly) — passes; verified to fail when mriview.js cmap premultiplyAlpha is reverted to false.
  • test_datatype_renders[VertexRGB|VolumeRGB] — still passing (no regression in baseline RGBA rendering).
  • Visual: the issue's MRE renders V1 as half-transparent (washed-out gray) instead of bright/clipped.
  • Quickshow (cortex.quickshow) output of the same MRE remains byte-for-byte identical to pre-patch.
  • Live-viewer A/B: across all four patterns in examples/datasets/plot_data_with_alpha.py (Volume2D, Vertex2D, VolumeRGB, VertexRGB), the WebGL render now matches the corresponding quickshow render.

Files explicitly NOT modified

  • cortex/dataset/viewRGB.pyvertices/volume properties stay non-premultiplied so the matplotlib path keeps working.
  • cortex/webgl/resources/js/shaderlib.js — no shader change, avoiding the larger surface area of regression checks.
  • cortex/webgl/resources/js/dataset.js — no JS change; the existing tex.premultiplyAlpha = true already does the right thing for VolumeRGB.

🤖 Generated with Claude Code

The WebGL fragment shader composites with a premultiplied-alpha "over"
formula (gl_FragColor = vColor + (1-α)·bg), but VertexRGB.vertices /
VolumeRGB.volume ship raw, non-premultiplied RGB bytes. With α<1 this
caused vertices to render brighter / clipped toward white instead of
blending toward the curvature underlay.

Premultiply RGB by α only at the WebGL serialization step
(Package.__init__), so the bytes shipped to the browser satisfy the
shader's invariant. The .vertices/.volume properties stay
non-premultiplied so the matplotlib (quickshow) path keeps using
matplotlib's straight-alpha imshow compositor unchanged -- both viewers
now produce the same composite α·rgb + (1-α)·bg.

Also deprecate BrainData.blend_curvature: it was a hack to mimic
transparency for Vertex objects, no longer needed now that per-vertex α
works directly in both viewers.

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request deprecates the blend_curvature method in braindata.py and implements alpha premultiplication for VolumeRGB and VertexRGB during WebGL serialization to ensure correct rendering in the viewer (addressing Issue #631). It also includes comprehensive regression tests for alpha equivalence between WebGL and matplotlib paths. Review feedback suggests optimizing the serialization logic by removing a redundant array copy and an unnecessary type cast.

Comment thread cortex/webgl/data.py Outdated
mvdoc and others added 10 commits May 9, 2026 14:34
VolumeRGB ships through the PNG texture path: dataset.js:335-338 sets
`tex.premultiplyAlpha = true` for raw volume textures, which makes WebGL
apply UNPACK_PREMULTIPLY_ALPHA_WEBGL on upload. Premultiplying again on
the Python side double-attenuates partial-alpha VolumeRGB, rendering it
too dark. VertexRGB has no equivalent texture-upload step (data is
shipped as a vertex attribute via BufferAttribute) so it still needs
Python-side premultiplication.

Add a headless playwright regression test (uniform red, α=0.5) that
discriminates correct single-premultiplication (~150 median R) from
double-premultiplication (~100 median R), and update the unit test to
assert VolumeRGB Package output stays straight-alpha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pycortex-canonical way to attach a per-vertex/voxel transparency map
to scalar data is Vertex2D/Volume2D with a 2D colormap whose second axis
encodes alpha (e.g. 'fire_alpha', 'PU_RdBu_covar_alpha'). Pointing
deprecated callers there is the right migration; VertexRGB/VolumeRGB
with alpha= is still mentioned as the route for users who already have
raw RGB data.

Includes a side-by-side example in the docstring showing the new
Vertex2D call so the migration path is obvious.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit (04a57f6, "Update blend_curvature deprecation message
to point at Vertex2D/Volume2D") inadvertently reformatted the entire
braindata.py file via a PostToolUse hook, producing 283 lines of mostly
whitespace/quote-style churn around a ~30-line message change. This
commit restores the surrounding code to its pre-noise formatting while
keeping the intended deprecation-message update intact, so the PR diff
focuses on the actual change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Demonstrates the two pycortex idioms for plotting a primary map
attenuated by a secondary map (e.g. tuning maps masked by per-voxel
prediction accuracy):

  1. Scalar data: Volume2D / Vertex2D with a 2D alpha-encoding colormap
     (RdBu_r_alpha, fire_alpha, etc.). Recommended for the common
     "scalar value with confidence" case -- keeps cmap/vmin/vmax
     editable on the resulting object.

  2. RGB data: VolumeRGB / VertexRGB with the alpha= constructor
     argument. Recommended when the underlying data is already three
     independent channels.

Both patterns yield the same composite formula at the pixel level:
out = alpha * data + (1 - alpha) * curvature_underlay. The example
includes a synthetic Gaussian-bump "accuracy" mask so the alpha
attenuation is visually obvious in the rendered flatmaps.

This complements the deprecation of blend_curvature -- the Vertex2D
route in this example is the recommended replacement.

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

quickshow defaults with_curvature=False, which makes the alpha
attenuation in the example fade to the matplotlib canvas background
(white) instead of to the curvature gray. That defeats the purpose of
the example -- the whole point is showing how low-alpha regions blend
toward the curvature underlay (matching what the WebGL viewer does
unconditionally).

Pass with_curvature=True to all four quickshow calls so the rendered
panels show the gyri/sulci pattern under low-alpha cortex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the decorative "# --- / # Pattern Xx ... / # ---" header bars
with sphinx-gallery's canonical "# %%" cell separators (followed by a
proper rST section heading). This makes each Pattern its own gallery
cell, so each plt.show() figure renders as a full-width
sphx-glr-single-img instead of being grouped 2x2 as
sphx-glr-multi-img. The rendered page now shows one flatmap per row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-vertex random RGB renders as salt-and-pepper noise on the flatmap
because vertex indices are not arranged by spatial neighborhood,
making the panel uninterpretable. Encode each channel as a normalized
anatomical coordinate (R=x, G=y, B=z) so the three channels vary
smoothly across cortex and the result is a meaningful position map
attenuated by the per-vertex 'accuracy' alpha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Panel 2 (Vertex2D) used `np.linspace(-1, 1, num_verts) + noise` as
its scalar data, which is a ramp over vertex *index* -- and vertex
indices on a cortical surface are not arranged by spatial
neighborhood, so the result rendered as visual noise.

Replace with a smooth anterior-posterior spatial gradient (anatomical
y-coordinate, centred at zero) so the diverging RdBu_r colormap reads
naturally. Consolidate the `pts`/`xyz_norm` computation in Pattern 1b
and reuse it in Pattern 2b instead of recomputing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactor the example so the first cell defines all synthetic inputs
(volumetric + surface data, volumetric + surface alpha maps, RGB
channels for each) once, and each subsequent Pattern cell shows only
the plotting call -- a Volume2D / Vertex2D / VolumeRGB / VertexRGB
constructor plus a quickshow().

This keeps the four pattern cells terse and focused on the API
differences, instead of repeating data setup in each one. The rendered
gallery page still shows four full-width flatmaps, one per row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minor wording polish in the module docstring and a small trim in the
Pattern 1a comment block. No behavioral changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mvdoc mvdoc changed the title FIX VertexRGB/VolumeRGB alpha attenuation in WebGL viewer (#631) FIX VertexRGB/VolumeRGB alpha attenuation in WebGL viewer May 9, 2026
mvdoc and others added 2 commits May 9, 2026 16:10
The 2D dataview path in WebGL ships dim1 and dim2 as separate scalar
maps and does the colormap LUT lookup on the GPU at fragment time
(`texture2D(colormap, vec2(dim1_norm, dim2_norm))`), so it never goes
through the VertexRGB/VolumeRGB premultiplication branch in
`Package.__init__`. The colormap texture itself was loaded with
`premultiplyAlpha = false` (mriview.js:24), but the surface fragment
shader (shaderlib.js:851) composites with the premultiplied-over
formula `gl_FragColor = vColor + (1 - vColor.a) * cColor`.

So for any alpha-bearing colormap (RdBu_r_alpha, fire_alpha,
PU_RdBu_covar_alpha, plasma_alpha, autumn_alpha, ...), the shader
sampled straight-alpha RGBA from the LUT and applied a premultiplied
composite, producing `R + (1-α)·bg` instead of `α·R + (1-α)·bg`.
At α<1 this clipped toward white -- the same class of bug as #631 but
on the colormap texture path instead of the data texture path.

Setting `tex.premultiplyAlpha = true` on the colormap textures makes
WebGL's UNPACK_PREMULTIPLY_ALPHA_WEBGL hook premultiply the LUT once on
upload, which is exactly the invariant the shader's composite formula
expects. Non-alpha colormaps have α=255 everywhere so this is a no-op
for them.

This is the missing piece for the Vertex2D / Volume2D path to render
correctly in WebGL (the new gallery example
`examples/datasets/plot_data_with_alpha.py` recommends Vertex2D as the
replacement for the deprecated `blend_curvature`, but without this fix
that path renders incorrectly in WebGL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with 0eafd8c (mriview.js cmap premultiplyAlpha = true). The 2D
dataview path bypasses Package.__init__'s premultiplication branch
because Vertex2D.uniques() yields scalar dim1/dim2 maps and the LUT
lookup happens on the GPU in the vertex shader; the cmap *texture*
itself has to be uploaded premultiplied for the shader's premultiplied-
over composite to produce α·rgb + (1-α)·bg correctly.

The test renders Vertex2D(data=+1, alpha=0.5, cmap=RdBu_r_alpha) on
S1's inflated lateral_pivot view and reads the median R intensity of
the red-dominant brain region:

  - correct (premultiplied LUT):  median R ≈ 93
  - buggy (un-premultiplied LUT): median R ≈ 129

Threshold at 115 sits between the distributions.

α=0 doesn't catch this bug because most alpha colormaps store
(0,0,0,0) at the transparent end of the LUT, so neither path produces
foreground there. α=0.5 maximizes the divergence in the brain region.

The cmap <img> elements decode asynchronously in headless Chromium;
if the renderer's first draw fires before the image has decoded, the
LUT texture stays at the 1×1 default and the data layer renders as
gray (channels collapse to R==G==B). The test retries _set_view +
getImage up to 6 times waiting for a colored frame, and skips with a
specific message if the cmap never binds — so a Chrome timing race
produces a skip rather than a false regression.

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

mvdoc commented May 10, 2026

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses alpha blending inconsistencies in the WebGL viewer (Issue #631) by implementing alpha-premultiplication for VertexRGB during serialization and enabling it for colormap textures. It deprecates the blend_curvature method in favor of native alpha support in Vertex2D, Volume2D, and RGB data classes. The PR includes comprehensive regression tests and a new example script. Feedback suggests optimizing memory usage during the premultiplication process and using unittest.mock.patch for more idiomatic testing.

Comment thread cortex/webgl/data.py Outdated
Comment thread cortex/tests/test_webgl_data.py Outdated
mvdoc and others added 2 commits May 9, 2026 20:26
- cortex/webgl/data.py: drop the redundant `encdata.copy()` (the prior
  `astype(np.uint8)` already returns a fresh array) and the redundant
  `.astype(np.uint8)` cast on the rounded result (assignment to a
  uint8 slice handles the cast). Net effect identical; one fewer full
  copy of the RGBA array per VertexRGB serialization.

- cortex/tests/test_webgl_data.py: switch the volume.mosaic spy from a
  hand-rolled try/finally rebind to `unittest.mock.patch.object` with
  `side_effect=spy_mosaic`. mock.patch handles restoration cleanly
  even if the patched call raises, and matches the more idiomatic
  pytest pattern. Also flatten `captured` from a dict-of-list to a
  plain list now that there's only one bucket.

Per gemini-code-assist review on PR #632.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Skipped by default (run with RUN_VISUAL_COMPARISON=1). Renders all six
dataview types (Volume, Vertex, Volume2D, Vertex2D, VolumeRGB, VertexRGB)
through both quickshow and the headless WebGL flatmap path and assembles
a 6x2 comparison grid for manual A/B review. Volume2D / Vertex2D exercise
the 2D-alpha cmap path; VolumeRGB / VertexRGB exercise the alpha= kwarg;
plain Volume / Vertex serve as the no-alpha baseline.

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

mvdoc commented May 10, 2026

Copy link
Copy Markdown
Contributor Author

OK, verified that we finally get parity between quickshow and WebGL even with alpha 🎉 Image below shows the results, which can be generated by the test in test_webgl_headless.py with

RUN_VISUAL_COMPARISON=1 pytest cortex/tests/test_webgl_headless.py::test_visual_comparison_alpha_dataviews -s
alpha_dataview_comparison

@mvdoc mvdoc marked this pull request as ready for review May 10, 2026 15:46
@mvdoc mvdoc merged commit 6c37dd7 into main May 10, 2026
13 checks passed
@mvdoc mvdoc deleted the claude/friendly-varahamihira-a97ded branch May 10, 2026 16:26
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.

VertexRGB ignores alpha attenuation: foreground brightens instead of going transparent

1 participant