FIX VertexRGB/VolumeRGB alpha attenuation in WebGL viewer#632
Conversation
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>
There was a problem hiding this comment.
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.
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>
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>
|
/gemini review |
There was a problem hiding this comment.
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.
- 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>

Summary
Fixes #631. The WebGL fragment shader composites with a premultiplied-alpha "over" formula:
…but
VertexRGB.vertices/VolumeRGB.volumeship 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.pyPackage.__init__). The.vertices/.volumeproperties stay non-premultiplied so the matplotlib (quickshow) path keeps using matplotlib's straight-alphaimshowcompositor 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 setstex.premultiplyAlpha = trueand WebGL'sUNPACK_PREMULTIPLY_ALPHA_WEBGLalready 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 viaBufferAttribute), 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_curvaturevia inherited references): it was a hack to mimic transparency forVertexobjects, 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/Volume2Dwith alpha-encoding 2D colormaps such asRdBu_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, soPackage.__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 atshaderlib.js:755: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 inmriview.jswas created withtex.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:33now setstex.premultiplyAlpha = trueon 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'svColor + (1-α)·bgthen correctly resolves toα·rgb + (1-α)·bg. Verified live (the user confirmed both viewers now match across all four patterns inexamples/datasets/plot_data_with_alpha.py).Equivalence with quickshow (matplotlib)
For VertexRGB, verified analytically and via test:
vColor + (1-α)·bg = (α·rgb) + (1-α)·bgimshowlayered over curvature, with non-premultiplied bytes:α·rgb + (1-α)·bgtest_quickshow_webgl_alpha_equivalence(intest_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 = trueon 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:.verticesstays 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. RendersVertex2D(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 inpytest.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.py→ 80 passed, 1 xfailed (the xfail is pre-existing).Before / After screenshots
Reproducer: uniform red
VertexRGBover the flatmap, with α=0 inside V1+V2+V3.vColor + (1-α)·bgclips RGB toward white when fed non-premultiplied bytes.α·rgb + (1-α)·bgcomposite.Before/after
Before
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.RdBu_r_alphablends correctly) — passes; verified to fail whenmriview.jscmappremultiplyAlphais reverted tofalse.test_datatype_renders[VertexRGB|VolumeRGB]— still passing (no regression in baseline RGBA rendering).cortex.quickshow) output of the same MRE remains byte-for-byte identical to pre-patch.examples/datasets/plot_data_with_alpha.py(Volume2D, Vertex2D, VolumeRGB, VertexRGB), the WebGL render now matches the correspondingquickshowrender.Files explicitly NOT modified
cortex/dataset/viewRGB.py—vertices/volumeproperties 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 existingtex.premultiplyAlpha = truealready does the right thing for VolumeRGB.🤖 Generated with Claude Code