From 6d7d035c5778e23927207f60708db2a56f06cc42 Mon Sep 17 00:00:00 2001 From: Chris Jordan Date: Sat, 7 Nov 2026 11:43:25 -0500 Subject: [PATCH 1/2] fix(skeleton): correct premultiplication for node circles and RGB output Premultiply node-circle RGB by the final alpha after circle coverage is applied. Route emitRGB and emitDefault through emitRGBA so all output helpers apply opacity consistently. Unpremultiply the shared CPU-side segment color before uploading it to skeleton shaders, then premultiply once at emission in perspective views. Preserve straight-alpha output in slice views. --- python/tests/skeleton_options_test.py | 25 ++++++++++++--- src/skeleton/frontend.ts | 44 ++++++++++++++++----------- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/python/tests/skeleton_options_test.py b/python/tests/skeleton_options_test.py index 2d01eae19d..ccc6518c87 100644 --- a/python/tests/skeleton_options_test.py +++ b/python/tests/skeleton_options_test.py @@ -89,9 +89,13 @@ def test_skeleton_options(webdriver): @pytest.mark.parametrize("layout", ["xy", "3d"]) @pytest.mark.parametrize("primitive", ["edge", "node"]) @pytest.mark.parametrize( - "shader_alpha,layer_alpha", [(1, 1), (0.5, 1), (1, 0.5), (0.5, 0.5)] + "shader_output,shader_alpha", + [("rgba", 1), ("rgba", 0.5), ("rgb", 1), ("default", 1), ("segment-rgb", 1)], ) -def test_skeleton_rgba_opacity(webdriver, layout, primitive, shader_alpha, layer_alpha): +@pytest.mark.parametrize("layer_alpha", [1, 0.5]) +def test_skeleton_opacity( + webdriver, layout, primitive, shader_output, shader_alpha, layer_alpha +): if primitive == "edge": source = SkeletonSource( vertex_positions=[[-100, 0, 0], [100, 0, 0]], @@ -117,6 +121,7 @@ def test_skeleton_rgba_opacity(webdriver, layout, primitive, shader_alpha, layer source=source, segments=[1], object_alpha=layer_alpha, + segment_colors={1: "#ff8040"}, ), ) rendering = s.layers[0].skeleton_rendering @@ -124,13 +129,25 @@ def test_skeleton_rgba_opacity(webdriver, layout, primitive, shader_alpha, layer rendering.mode3d = "lines" rendering.line_width2d = 16 rendering.line_width3d = 16 - rendering.shader = f""" + if shader_output == "rgba": + rendering.shader = f""" void main() {{ emitRGBA(vec4(1.0, 0.5, 0.25, {shader_alpha:.1f})); }} """ + elif shader_output == "rgb": + rendering.shader = "void main() { emitRGB(vec3(1.0, 0.5, 0.25)); }" + elif shader_output == "default": + rendering.shader = "void main() { emitDefault(); }" + else: + rendering.shader = "void main() { emitRGB(segmentColor().rgb); }" pixels = webdriver.viewer.screenshot(size=[32, 32]).screenshot.image_pixels - expected_rgb = np.array([1.0, 0.5, 0.25]) * shader_alpha * layer_alpha * 255 + color = ( + np.array([255, 128, 64]) + if shader_output in ("default", "segment-rgb") + else np.array([255, 127.5, 63.75]) + ) + expected_rgb = color * shader_alpha * layer_alpha expected = np.tile(np.append(expected_rgb, 255), (4, 4, 1)) np.testing.assert_allclose(pixels[14:18, 14:18], expected, atol=2, rtol=0) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index cb3d2540c5..a99ee5a318 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -45,7 +45,7 @@ import { TrackableBoolean } from "#src/trackable_boolean.js"; import { TrackableValue, WatchableValue } from "#src/trackable_value.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; -import type { vec3 } from "#src/util/geom.js"; +import type { vec4 } from "#src/util/geom.js"; import { mat4 } from "#src/util/geom.js"; import { verifyFinitePositiveFloat } from "#src/util/json.js"; import { NullarySignal } from "#src/util/signal.js"; @@ -127,9 +127,15 @@ class RenderHelper extends RefCounted { defineCommonShader(builder: ShaderBuilder) { defineVertexId(builder); + // Skeleton shaders use straight-alpha colors until the final emission. builder.addUniform("highp vec4", "uColor"); builder.addUniform("highp mat4", "uProjection"); builder.addUniform("highp uint", "uPickID"); + builder.addFragmentCode(` +vec4 premultiplyAlpha(vec4 color) { + return ${this.targetIsSliceView ? "color" : "vec4(color.rgb * color.a, color.a)"}; +} +`); } edgeShaderGetter; @@ -183,15 +189,14 @@ vec4 segmentColor() { return uColor; } void emitRGBA(vec4 color) { - color.a *= uColor.a; - color.rgb *= ${this.getPremultiplyAlphaFactor()}; - emit(vec4(color.rgb, color.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), uPickID); + color.a *= uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}; + emit(premultiplyAlpha(color), uPickID); } void emitRGB(vec3 color) { - emit(vec4(color * uColor.a, uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), uPickID); + emitRGBA(vec4(color, 1.0)); } void emitDefault() { - emit(vec4(uColor.rgb, uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), uPickID); + emitRGB(uColor.rgb); } `); builder.addFragmentCode(glsl_COLORMAPS); @@ -255,17 +260,15 @@ vec4 segmentColor() { } void emitRGBA(vec4 color) { color.a *= uColor.a; - color.rgb *= ${this.getPremultiplyAlphaFactor()}; vec4 borderColor = color; vec4 circleColor = getCircleColor(color, borderColor); - emit(circleColor, uPickID); + emit(premultiplyAlpha(circleColor), uPickID); } void emitRGB(vec3 color) { emitRGBA(vec4(color, 1.0)); } void emitDefault() { - vec4 circleColor = getCircleColor(uColor, uColor); - emit(circleColor, uPickID); + emitRGB(uColor.rgb); } `); builder.addFragmentCode(glsl_COLORMAPS); @@ -319,10 +322,6 @@ void emitDefault() { }); } - getPremultiplyAlphaFactor() { - return this.targetIsSliceView ? "1.0" : "color.a"; - } - getCrossSectionFadeFactor() { if (this.targetIsSliceView) { return "(clamp(1.0 - 2.0 * abs(0.5 - gl_FragCoord.z), 0.0, 1.0))"; @@ -342,8 +341,17 @@ void emitDefault() { this.vertexIdHelper.enable(); } - setColor(gl: GL, shader: ShaderProgram, color: vec3) { - gl.uniform4fv(shader.uniform("uColor"), color); + setColor(gl: GL, shader: ShaderProgram, color: vec4) { + // The shared segmentation color is already premultiplied by layer alpha. + const alpha = color[3]; + const scale = alpha > 0 ? 1 / alpha : 0; + gl.uniform4f( + shader.uniform("uColor"), + color[0] * scale, + color[1] * scale, + color[2] * scale, + alpha, + ); } setPickID(gl: GL, shader: ShaderProgram, pickID: number) { @@ -641,9 +649,9 @@ export class SkeletonLayer extends RefCounted { } if (color !== undefined) { edgeShader.bind(); - renderHelper.setColor(gl, edgeShader, (color)); + renderHelper.setColor(gl, edgeShader, color); nodeShader.bind(); - renderHelper.setColor(gl, nodeShader, (color)); + renderHelper.setColor(gl, nodeShader, color); } if (pickIndex !== undefined) { edgeShader.bind(); From 1b21be015f16678dd7caee98b938b45e8ee5d44a Mon Sep 17 00:00:00 2001 From: Chris Jordan Date: Mon, 9 Nov 2026 01:34:50 -0500 Subject: [PATCH 2/2] fix(skeleton): return opaque alpha from segmentColor --- python/tests/skeleton_options_test.py | 15 ++++++++++++--- src/skeleton/frontend.ts | 10 ++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/python/tests/skeleton_options_test.py b/python/tests/skeleton_options_test.py index ccc6518c87..7765c3d535 100644 --- a/python/tests/skeleton_options_test.py +++ b/python/tests/skeleton_options_test.py @@ -90,7 +90,14 @@ def test_skeleton_options(webdriver): @pytest.mark.parametrize("primitive", ["edge", "node"]) @pytest.mark.parametrize( "shader_output,shader_alpha", - [("rgba", 1), ("rgba", 0.5), ("rgb", 1), ("default", 1), ("segment-rgb", 1)], + [ + ("rgba", 1), + ("rgba", 0.5), + ("rgb", 1), + ("default", 1), + ("segment-rgb", 1), + ("segment-rgba", 1), + ], ) @pytest.mark.parametrize("layer_alpha", [1, 0.5]) def test_skeleton_opacity( @@ -139,13 +146,15 @@ def test_skeleton_opacity( rendering.shader = "void main() { emitRGB(vec3(1.0, 0.5, 0.25)); }" elif shader_output == "default": rendering.shader = "void main() { emitDefault(); }" - else: + elif shader_output == "segment-rgb": rendering.shader = "void main() { emitRGB(segmentColor().rgb); }" + else: + rendering.shader = "void main() { emitRGBA(segmentColor()); }" pixels = webdriver.viewer.screenshot(size=[32, 32]).screenshot.image_pixels color = ( np.array([255, 128, 64]) - if shader_output in ("default", "segment-rgb") + if shader_output in ("default", "segment-rgb", "segment-rgba") else np.array([255, 127.5, 63.75]) ) expected_rgb = color * shader_alpha * layer_alpha diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index a99ee5a318..de379750dc 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -132,6 +132,10 @@ class RenderHelper extends RefCounted { builder.addUniform("highp mat4", "uProjection"); builder.addUniform("highp uint", "uPickID"); builder.addFragmentCode(` +// Layer opacity is applied by emitRGBA. +vec4 segmentColor() { + return vec4(uColor.rgb, 1.0); +} vec4 premultiplyAlpha(vec4 color) { return ${this.targetIsSliceView ? "color" : "vec4(color.rgb * color.a, color.a)"}; } @@ -185,9 +189,6 @@ highp uint vertexIndex = aVertexIndex.x * (1u - lineEndpointIndex) + aVertexInde `; builder.addFragmentCode(` -vec4 segmentColor() { - return uColor; -} void emitRGBA(vec4 color) { color.a *= uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}; emit(premultiplyAlpha(color), uPickID); @@ -255,9 +256,6 @@ emitCircle(uProjection * vec4(vertexPosition, 1.0), uNodeDiameter, 0.0); `; builder.addFragmentCode(` -vec4 segmentColor() { - return uColor; -} void emitRGBA(vec4 color) { color.a *= uColor.a; vec4 borderColor = color;