Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
55533f6
feat: add line clipping at the ends
seankmartin Aug 20, 2026
c8447d3
feat: allow to emit a custom depth
seankmartin Aug 20, 2026
a86efb9
feat: add raycast primitive generic contract and utils
seankmartin Aug 20, 2026
64bb759
refactor: simplify OBB to AABB in raycast
seankmartin Aug 20, 2026
4936ba0
feat: add sphere raycast primitive
seankmartin Aug 20, 2026
1e300cf
feat: change inside sphere to a miss
seankmartin Aug 20, 2026
7726525
feat: add OBB for objects with a long axis
seankmartin Aug 21, 2026
07501c5
refactor: clarify reason for NDC bound > 1.0
seankmartin Aug 21, 2026
fef2d98
feat: add raycast cylinder
seankmartin Aug 21, 2026
c106e0a
fix: GLSL nan is not always like IEEE 754 floating pt definition nan
seankmartin Aug 21, 2026
244de0e
feat: add frontend control over new cylinder modes
seankmartin Aug 24, 2026
a48f508
test(python): add new tests for cylinder rendering
seankmartin Aug 24, 2026
3acb3d3
fix: restore usage of correct lines and circles draw APIs
seankmartin Aug 24, 2026
7c9bb97
fix: properly cull based on depth in primitives
seankmartin Aug 24, 2026
efd932e
refactor: clarify operations based on drawings of the problem
seankmartin Aug 27, 2026
575f515
perf: draw all edges then draw all nodes avoid program switch
seankmartin Aug 27, 2026
8852130
refactor: clarify comments and namings
seankmartin Aug 28, 2026
ac1cb27
fix: correct anisotropic skeleton source viewing
seankmartin Aug 28, 2026
34c5516
fix: cull 0 radius cylinders and balls
seankmartin Aug 31, 2026
6901686
fix: more accurate clipping on lines and pack varyings
seankmartin Aug 31, 2026
aa982f6
fix: pack varyings for space and interpolate user attrs for cylinders
seankmartin Aug 31, 2026
0bf5986
feat: draw cones not cylinders to avoid depth shrinking and per node …
seankmartin Aug 31, 2026
35f8a5d
refactor: update naming and split after change to cone
seankmartin Aug 31, 2026
03d176b
feat: use sphere projection from Quilez for tighter bound
seankmartin Aug 31, 2026
2650bed
fix: correct line attribution for user shader
seankmartin Aug 31, 2026
ef45cc1
refactor: namings
seankmartin Aug 31, 2026
f3153f5
perf: reduce CPU work on skeleton draw passes
seankmartin Aug 31, 2026
02a10d6
refactor: update comments
seankmartin Aug 31, 2026
4ec39e7
refactor: update tests, flow, and glsl for clarity
seankmartin Sep 1, 2026
661c28b
test: clarify tests
seankmartin Sep 2, 2026
71478b0
fix: correct lighting and rendering instabilities at large depth ranges
seankmartin Sep 2, 2026
d9b0767
fix: also gate line clipping on depth range
seankmartin Sep 4, 2026
4f56d6e
fix: correct color emission
seankmartin Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 0 additions & 79 deletions python/tests/skeleton_options_test.py

This file was deleted.

226 changes: 226 additions & 0 deletions python/tests/skeleton_rendering_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# @license
# Copyright 2020 Google Inc.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Screenshot tests for skeleton rendering.

`test_skeleton_options` checks that a skeleton layer draws, and that turning its
subsource off stops it. `test_skeleton_render_mode` checks that each render mode
produces the shading it is supposed to, and that the modes which enlarge the nodes
cover more than the ones that do not.
"""

import neuroglancer
import neuroglancer.skeleton
import numpy as np

dimensions = neuroglancer.CoordinateSpace(
names=["x", "y", "z"], units="nm", scales=[1, 1, 1]
)

USER_SHADER = """
#uicontrol vec3 color color(default="white")
void main () {
emitRGB(color);
}
"""


class SinglePointSkeletonSource(neuroglancer.skeleton.SkeletonSource):
def __init__(self):
super().__init__(dimensions=dimensions)

def get_skeleton(self, object_id):
return neuroglancer.skeleton.Skeleton(
vertex_positions=[[0, 0, 0]],
edges=[[0, 0]],
)


class TwoNodeSkeletonSource(neuroglancer.skeleton.SkeletonSource):
def __init__(self):
super().__init__(dimensions=dimensions)

def get_skeleton(self, object_id):
return neuroglancer.skeleton.Skeleton(
vertex_positions=[[-20, -20, 0], [20, 20, 0]],
edges=[[0, 1]],
)


def screenshot_pixels(webdriver, size):
return webdriver.viewer.screenshot(size=[size, size]).screenshot.image_pixels


def render_skeleton(webdriver, source, *, layout, line_width, size, mode=None):
"""Draws one red skeleton on black and returns the screenshot pixels."""
with webdriver.viewer.txn() as s:
s.dimensions = dimensions
s.position = [0, 0, 0]
s.layout = layout
s.projection_scale = 120
s.cross_section_scale = 0.6
# Otherwise grey in the slice view, and these tests read black as not drawn.
s.cross_section_background_color = "#000000"
s.show_axis_lines = False
s.show_scale_bar = False
s.layers.clear()
s.layers.append(
name="a",
layer=neuroglancer.SegmentationLayer(source=source, segments=[1]),
)
rendering = s.layers[0].skeleton_rendering
rendering.line_width2d = line_width
rendering.line_width3d = line_width
if mode is not None:
if layout == "3d":
rendering.mode3d = mode
else:
rendering.mode2d = mode
rendering.shader = USER_SHADER
rendering.shader_controls["color"] = "#f00"
return screenshot_pixels(webdriver, size)


def assert_solid_color(image, color):
np.testing.assert_array_equal(
image, np.tile(np.array(color, dtype=np.uint8), image.shape[:2] + (1,))
)


def test_skeleton_options(webdriver):
# A marker wider than the viewport, so the colour can be checked exactly.
image = render_skeleton(
webdriver,
SinglePointSkeletonSource(),
layout="xy",
line_width=100,
size=10,
)
assert_solid_color(image, [255, 0, 0, 255])

with webdriver.viewer.txn() as s:
s.layout = "3d"
assert_solid_color(screenshot_pixels(webdriver, 10), [255, 0, 0, 255])

with webdriver.viewer.txn() as s:
s.layers[0].source[0].subsources["default"] = False
assert_solid_color(screenshot_pixels(webdriver, 10), [0, 0, 0, 255])


# Each entry pairs a mode with the only shading signature it produces. What
# separates them is how the brightness of the drawn pixels is distributed, not how
# dark the darkest one is: a feathered edge and a lit surface both reach down toward
# zero, so the minimum alone cannot tell them apart.
#
# FLAT every drawn pixel is fully bright
# FEATHERED a majority fully bright, with a thin partial rim
# LIT few fully bright, because the surface normal turns across the whole
# surface. The lighting factor is `abs(dot(normal, light)) + ambient`
# with ambient 0.2 and directional 0.8, so a lit pixel runs over
# [0.2, 1.0] of full brightness.
FEATHERED = "feathered"
FLAT = "flat"
LIT = "lit"

FULL_BRIGHTNESS = 255
# A feathered rim is a perimeter effect, so most of the line is still fully bright.
MIN_FULLY_BRIGHT_FRACTION_WHEN_FEATHERED = 0.5
# A lit surface varies everywhere, so almost nothing sits at exactly full.
MAX_FULLY_BRIGHT_FRACTION_WHEN_LIT = 0.2
# Lighting runs over [0.2, 1.0], and a side-on view sweeps most of that range.
MIN_BRIGHTNESS_SPREAD_WHEN_LIT = 0.4 * FULL_BRIGHTNESS

RENDER_MODES = [
("xy", "lines", FEATHERED),
("xy", "lines_and_points", FEATHERED),
("3d", "lines", FLAT),
("3d", "lines_and_points", FLAT),
("3d", "cylinders", LIT),
("3d", "cylinders_and_balls", LIT),
]

ENLARGED_PAIRS = [
("xy", "lines", "lines_and_points"),
("3d", "lines", "lines_and_points"),
("3d", "cylinders", "cylinders_and_balls"),
]


def test_skeleton_render_mode(webdriver):
drawn_counts = {}
for layout, mode, shading in RENDER_MODES:
case = f"{layout}/{mode}"
image = render_skeleton(
webdriver,
TwoNodeSkeletonSource(),
layout=layout,
line_width=10,
size=100,
mode=mode,
)
red, green, blue = (image[..., i].astype(int) for i in range(3))
# A pure red shader leaves the other channels untouched in every mode.
np.testing.assert_array_equal(
green, 0, err_msg=f"{case} put light in the green channel"
)
np.testing.assert_array_equal(
blue, 0, err_msg=f"{case} put light in the blue channel"
)
drawn_red = red[red != 0]
assert len(drawn_red) > 200, (
f"{case} drew {len(drawn_red)} pixels, too few to judge the shading"
)
drawn_counts[(layout, mode)] = len(drawn_red)

fully_bright = (drawn_red == FULL_BRIGHTNESS).mean()
brightest, darkest = drawn_red.max(), drawn_red.min()

if shading is FLAT:
np.testing.assert_array_equal(
drawn_red,
FULL_BRIGHTNESS,
err_msg=(
f"{case} should shade nothing, so every drawn pixel should be "
f"{FULL_BRIGHTNESS}, but they run {darkest} to {brightest}"
),
)
elif shading is FEATHERED:
assert fully_bright < 1.0, (
f"{case} has every drawn pixel at {FULL_BRIGHTNESS}, so its edge "
"is not feathered"
)
assert fully_bright > MIN_FULLY_BRIGHT_FRACTION_WHEN_FEATHERED, (
f"{case} has only {fully_bright:.0%} of drawn pixels at full "
"brightness. A feather is a rim, so the interior should stay full. "
"This looks like shading across the whole surface"
)
else:
assert fully_bright < MAX_FULLY_BRIGHT_FRACTION_WHEN_LIT, (
f"{case} has {fully_bright:.0%} of drawn pixels at full brightness. "
"A lit surface turns its normal everywhere, so few should be flat "
"out. This looks like a feathered edge on flat colour"
)
assert brightest - darkest > MIN_BRIGHTNESS_SPREAD_WHEN_LIT, (
f"{case} spans only {brightest - darkest} brightness levels "
f"({darkest} to {brightest}). Lighting runs over "
f"[0.2, 1.0], so a side-on view should sweep most of it"
)

for layout, plain, enlarged in ENLARGED_PAIRS:
plain_count = drawn_counts[(layout, plain)]
enlarged_count = drawn_counts[(layout, enlarged)]
assert enlarged_count > plain_count, (
f"{layout}/{enlarged} drew {enlarged_count} pixels against "
f"{plain_count} for {layout}/{plain}. Enlarging the nodes should cover "
"strictly more"
)
2 changes: 1 addition & 1 deletion src/annotation/point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ emitAnnotation(color);
`annotation/point:2d:${extraDim}`,
(builder: ShaderBuilder) => {
defineVertexId(builder);
defineLineShader(builder, /*rounded=*/ true);
defineLineShader(builder, { rounded: true });
this.defineShaderCommon(builder);
builder.addVertexMain(`
vec3 subspacePositionA = projectModelVectorToSubspace(modelPosition);
Expand Down
8 changes: 2 additions & 6 deletions src/annotation/type_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import type {
} from "#src/webgl/dynamic_shader.js";
import {
parameterizedEmitterDependentShaderGetter,
shaderCodeWithLineDirective,
wrapUserShaderMain,
} from "#src/webgl/dynamic_shader.js";
import {
defineInvlerpShaderFunction,
Expand Down Expand Up @@ -560,11 +560,7 @@ void userMain();
}
defineShader(builder);
builder.addVertexCode(glsl_string);
builder.addVertexCode(
"\n#define main userMain\n" +
shaderCodeWithLineDirective(parameters.parseResult.code) +
"\n#undef main\n",
);
builder.addVertexCode(wrapUserShaderMain(parameters.parseResult.code));
},
});
}
Expand Down
Loading