Skip to content

Commit f805b54

Browse files
authored
feat: Add a transfer function shader widget (#582)
* feat: transfer function widget * refactor: fix linting warnings * refactor: TF uses uint64 as number, not string * chore: cleanup TODO and comments * refactor: clean up transfer function code a little * fix: TF control points didn't always make it to JSON * refactor: clearer comments and update loop * fix: no longer able to place control points on top of eachother * fix: can grab control points in TF that are close in X by breaking ties with Y * fix: bind remove TF point to shift+dblclick You could accidentally remove points trying to move them before * feat: clearer name of TF input value * feat: Python control over transfer function shader contro * docs: fix typo in python docs * test (Python): shader control transfer function test * fix: user can't specify transfer function points outside input range * docs: transfer function UI control * docs: code comment on transfer functions * chore: format and lint * chore(python): format * refactor(in progress): store control points in abs value * refactor(progress): transfer functino * progress(refactor): tf refactor * progress(refactor): tf refactor * progress(refactor): fix all type errors in tf file * progress(refactor): fix type errors * refactor(progress): fix more type errors * fix(tests): remove unused imports * tests(fix): browser test whole TF * fix: transfer function correct interpolation * fix: dynamic transfer function size and correct GPU control * feat: remove range and size as TF params Instead they are computed based on input * fix: parse shader directive for new TF * fix: JSON state parsing and saving for new TF * fix: new TF runs, but UI control is broken * fix: remove accidental log * fix: control points display in correct position in UI * fix: find control point near cursor in new TF * fix: moving control points and setting color * fix: correct number of lines * fix: display UI panel texture for TF * fix: render data from TF texture * fix: remove fixed size TF texture * fix: link UI to JSON for control points * fix: remove temps and TODOs * fix: handle control point range for 0 and 1 points * fix: test * fix: unused code * fix: can no longer lose control of point that you were trying to move * fix: compute range after removing a point * refactor: clearer range update * fix: tf UI has correct texture indicator * feat: default intensity for transfer functions * fix: don't crash on window[0] === window[1] in TF UI panel * fix: userIntensity always overwrites default * docs: update transfer function docs * tests: fix a test for TFs with uint64 data * feat: use non-interpolated value in TF for consistency and efficiency * fix: tests * Python(fix): fix transfer function control input * docs: fix formatting * Python: format * fix: remove accidental test change * refactor: clarifications * docs: while inverted windows are not supported, remove from docs * fix: correctly draw lines with a points beside window, one left, one right * feat: a little bit cleaner interaction with TF UI window * refactor: clarify the transfer function lines drawing
1 parent 3b2a8a3 commit f805b54

19 files changed

+3289
-13
lines changed

python/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ headlessly on Firefox using `xvfb-run`. On other platforms, tests can't be run
152152

153153
```shell
154154
# For headless using Firefox on xvfb (Linux only)
155-
sudo apt-get instrall xvfb # On Debian-based systems
156-
tox -e firefox-xvfb # Run tests using non-headless Firefox
155+
sudo apt-get install xvfb # On Debian-based systems
156+
tox -e firefox-xvfb # Run tests using headless Firefox
157157

158158
# For non-headless using Chrome
159159
tox -e chrome

python/neuroglancer/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
LayerDataSource, # noqa: F401
9090
LayerDataSources, # noqa: F401
9191
InvlerpParameters, # noqa: F401
92+
TransferFunctionParameters, # noqa: F401
9293
ImageLayer, # noqa: F401
9394
SkeletonRenderingOptions, # noqa: F401
9495
StarredSegments, # noqa: F401

python/neuroglancer/viewer_state.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,16 @@ class InvlerpParameters(JsonObjectWrapper):
518518
channel = wrapped_property("channel", optional(typed_list(int)))
519519

520520

521+
@export
522+
class TransferFunctionParameters(JsonObjectWrapper):
523+
window = wrapped_property("window", optional(array_wrapper(numbers.Number, 2)))
524+
channel = wrapped_property("channel", optional(typed_list(int)))
525+
controlPoints = wrapped_property(
526+
"controlPoints", optional(typed_list(typed_list(number_or_string)))
527+
)
528+
defaultColor = wrapped_property("defaultColor", optional(str))
529+
530+
521531
_UINT64_STR_PATTERN = re.compile("[0-9]+")
522532

523533

@@ -530,9 +540,13 @@ def _shader_control_parameters(v, _readonly=False):
530540
if isinstance(v, numbers.Number):
531541
return v
532542
if isinstance(v, dict):
543+
if "controlPoints" in v:
544+
return TransferFunctionParameters(v, _readonly=_readonly)
533545
return InvlerpParameters(v, _readonly=_readonly)
534546
if isinstance(v, InvlerpParameters):
535547
return v
548+
if isinstance(v, TransferFunctionParameters):
549+
return v
536550
raise TypeError(f"Unexpected shader control parameters type: {type(v)}")
537551

538552

python/tests/shader_controls_test.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,81 @@ def expect_color(color):
6262
expect_color([0, 0, 0, 255])
6363

6464

65+
def test_transfer_function(webdriver):
66+
shader = """
67+
#uicontrol transferFunction colormap
68+
void main() {
69+
emitRGBA(colormap());
70+
}
71+
"""
72+
shaderControls = {
73+
"colormap": {
74+
"controlPoints": [[0, "#000000", 0.0], [84, "#ffffff", 1.0]],
75+
"window": [0, 50],
76+
"channel": [],
77+
"defaultColor": "#ff00ff",
78+
}
79+
}
80+
with webdriver.viewer.txn() as s:
81+
s.dimensions = neuroglancer.CoordinateSpace(
82+
names=["x", "y"], units="nm", scales=[1, 1]
83+
)
84+
s.position = [0.5, 0.5]
85+
s.layers.append(
86+
name="image",
87+
layer=neuroglancer.ImageLayer(
88+
source=neuroglancer.LocalVolume(
89+
dimensions=s.dimensions,
90+
data=np.full(shape=(1, 1), dtype=np.uint64, fill_value=63),
91+
),
92+
),
93+
visible=True,
94+
shader=shader,
95+
shader_controls=shaderControls,
96+
opacity=1.0,
97+
blend="additive",
98+
)
99+
s.layout = "xy"
100+
s.cross_section_scale = 1e-6
101+
s.show_axis_lines = False
102+
control = webdriver.viewer.state.layers["image"].shader_controls["colormap"]
103+
assert isinstance(control, neuroglancer.TransferFunctionParameters)
104+
np.testing.assert_equal(control.window, [0, 50])
105+
assert control.defaultColor == "#ff00ff"
106+
107+
def expect_color(color):
108+
webdriver.sync()
109+
screenshot = webdriver.viewer.screenshot(size=[10, 10]).screenshot
110+
np.testing.assert_array_equal(
111+
screenshot.image_pixels,
112+
np.tile(np.array(color, dtype=np.uint8), (10, 10, 1)),
113+
)
114+
115+
# Ensure that the value 63 is mapped to the expected color.
116+
# The value 63 is 3/4 of the way between 0 and 84, so the expected color
117+
# is 3/4 of the way between black and white.
118+
# Additionally, the opacity is 0.75, and the mode is additive, so the
119+
# the final color is 0.75 * 0.75 * 255.
120+
mapped_opacity = 0.75
121+
mapped_color = 0.75 * 255
122+
mapped_value = int(mapped_color * mapped_opacity)
123+
expected_color = [mapped_value] * 3 + [255]
124+
expect_color(expected_color)
125+
with webdriver.viewer.txn() as s:
126+
s.layers["image"].shader_controls = {
127+
"colormap": neuroglancer.TransferFunctionParameters(
128+
controlPoints=[[0, "#000000", 0.0], [84, "#ffffff", 1.0]],
129+
window=[500, 5000],
130+
channel=[],
131+
defaultColor="#ff0000",
132+
)
133+
}
134+
control = webdriver.viewer.state.layers["image"].shader_controls["colormap"]
135+
np.testing.assert_equal(control.window, [500, 5000])
136+
assert control.defaultColor == "#ff0000"
137+
expect_color(expected_color)
138+
139+
65140
def test_slider(webdriver):
66141
with webdriver.viewer.txn() as s:
67142
s.dimensions = neuroglancer.CoordinateSpace(

src/sliceview/image_layer_rendering.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,42 @@ annotation layers). The one-parameter overload simply computes the inverse linea
155155
the specified value within the range specified by the control. The zero-parameter overload returns
156156
the inverse linear interpolation of the data value for configured channel/property.
157157

158+
### `transferFunction` controls
159+
160+
The `transferFunction` control type allows the user to specify a function which maps
161+
each data value in a numerical interval to an output color and opacity. The mapping function
162+
is defined by a series of control points. Each control point is a color and opacity value at a
163+
specific data input value. In between control points, the color and opacity is linearly interpolated.
164+
Any input data before the first control point is mapped to a completely transparent output.
165+
Any data point after the last control point is mapped to the same output as the last control point.
166+
167+
Directive syntax:
168+
169+
```glsl
170+
#uicontrol transferFunction <name>(window=[lower, higher], controlPoints=[[input, hexColorString, opacity]], channel=[], defaultColor="#rrggbb")
171+
#uicontrol transferFunction colormap(window=[0, 100], controlPoints=[[10.0, "#000000", 0.0], [100.0, "#ffffff", 1.0]], channel=[], defaultColor="#00ffaa")
172+
```
173+
174+
The following parameters are supported:
175+
176+
- `window`: Optional. The portion of the input range to view the transfer function over.
177+
Must be specified as an array. May be overridden using the UI control. Defaults to the min and max
178+
of the control point input values, if control points are specified, or otherwise to the full range of the
179+
data type for integer data types, and `[0, 1]` for float32. It is not valid to specify an
180+
inverted interval like `[50, 20]`, or an interval where the start and end points are the same, e.g. `[20, 20]`.
181+
182+
- `controlPoints`: Optional. The points which define the input to output mapping.
183+
Must be specified as an array, with each value in the array of the form `[inputValue, hexStringColor, floatOpacity]`.
184+
The default transfer function is a simple interpolation from transparent black to fully opaque white.
185+
186+
- `channel`: Optional. The channel to perform the mapping on.
187+
If the rank of the channel coordinate space is 1, may be specified as a single number,
188+
e.g. `channel=2`. Otherwise, must be specified as an array, e.g. `channel=[2, 3]`. May be
189+
overriden using the UI control. If not specified, defaults to all-zero channel coordinates.
190+
191+
- `defaultColor`: Optional. The default color for new control points added via the UI control.
192+
Defaults to `#ffffff`, and must be specified as a hex string if provided `#rrggbb`.
193+
158194
## API
159195

160196
### Retrieving voxel channel value

src/sliceview/volume/renderlayer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,7 @@ void main() {
582582

583583
const endShader = () => {
584584
if (shader === null) return;
585+
shader.unbindTransferFunctionTextures();
585586
if (prevChunkFormat !== null) {
586587
prevChunkFormat!.endDrawing(gl, shader);
587588
}

src/util/array.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
spliceArray,
2323
tile2dArray,
2424
transposeArray2d,
25+
findClosestMatchInSortedArray,
2526
} from "#src/util/array.js";
2627

2728
describe("partitionArray", () => {
@@ -206,3 +207,24 @@ describe("getMergeSplices", () => {
206207
]);
207208
});
208209
});
210+
211+
describe("findClosestMatchInSortedArray", () => {
212+
const compare = (a: number, b: number) => a - b;
213+
it("works for empty array", () => {
214+
expect(findClosestMatchInSortedArray([], 0, compare)).toEqual(-1);
215+
});
216+
it("works for simple examples", () => {
217+
expect(findClosestMatchInSortedArray([0, 1, 2, 3], 0, compare)).toEqual(0);
218+
expect(findClosestMatchInSortedArray([0, 1, 2, 3], 1, compare)).toEqual(1);
219+
expect(findClosestMatchInSortedArray([0, 1, 2, 3], 2, compare)).toEqual(2);
220+
expect(findClosestMatchInSortedArray([0, 1, 2, 3], 3, compare)).toEqual(3);
221+
expect(findClosestMatchInSortedArray([0, 1, 2, 3], 4, compare)).toEqual(3);
222+
expect(findClosestMatchInSortedArray([0, 1, 2, 3], -1, compare)).toEqual(0);
223+
expect(findClosestMatchInSortedArray([0, 1, 2, 3], 1.5, compare)).toEqual(
224+
1,
225+
);
226+
expect(findClosestMatchInSortedArray([0, 1, 2, 3], 1.6, compare)).toEqual(
227+
2,
228+
);
229+
});
230+
});

src/util/array.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,39 @@ export function binarySearch<T>(
184184
return ~low;
185185
}
186186

187+
/**
188+
* Returns the index of the element in `haystack` that is closest to `needle`, according to
189+
* `compare`. If there are multiple elements that are equally close, the index of the first such
190+
* element encountered is returned. If `haystack` is empty, returns -1.
191+
*/
192+
export function findClosestMatchInSortedArray<T>(
193+
haystack: ArrayLike<T>,
194+
needle: T,
195+
compare: (a: T, b: T) => number,
196+
low = 0,
197+
high = haystack.length,
198+
): number {
199+
let bestIndex = -1;
200+
let bestDistance = Infinity;
201+
while (low < high) {
202+
const mid = (low + high - 1) >> 1;
203+
const compareResult = compare(needle, haystack[mid]);
204+
if (compareResult > 0) {
205+
low = mid + 1;
206+
} else if (compareResult < 0) {
207+
high = mid;
208+
} else {
209+
return mid;
210+
}
211+
const distance = Math.abs(compareResult);
212+
if (distance < bestDistance) {
213+
bestDistance = distance;
214+
bestIndex = mid;
215+
}
216+
}
217+
return bestIndex;
218+
}
219+
187220
/**
188221
* Returns the first index in `[begin, end)` for which `predicate` is `true`, or returns `end` if no
189222
* such index exists.

src/volume_rendering/volume_render_layer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,7 @@ void main() {
570570

571571
const endShader = () => {
572572
if (shader === null) return;
573+
shader.unbindTransferFunctionTextures();
573574
if (prevChunkFormat !== null) {
574575
prevChunkFormat!.endDrawing(gl, shader);
575576
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Google Inc.
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { describe, it, expect } from "vitest";
18+
import { createGriddedRectangleArray } from "#src/webgl/rectangle_grid_buffer.js";
19+
20+
describe("createGriddedRectangleArray", () => {
21+
it("creates a set of two squares for grid size=2 and rectangle width&height=2", () => {
22+
const result = createGriddedRectangleArray(2, -1, 1, 1, -1);
23+
expect(result).toEqual(
24+
new Float32Array([
25+
-1, 1, 0, 1, 0, -1 /* triangle in top right for first grid */, -1, 1, 0,
26+
-1, -1, -1 /* triangle in bottom left for first grid */, 0, 1, 1, 1, 1,
27+
-1 /* triangle in top right for second grid */, 0, 1, 1, -1, 0,
28+
-1 /* triangle in bottom left for second grid */,
29+
]),
30+
);
31+
const resultReverse = createGriddedRectangleArray(2, 1, -1, -1, 1);
32+
expect(resultReverse).toEqual(
33+
new Float32Array([
34+
1, -1, 0, -1, 0, 1 /* triangle in top right for first grid */, 1, -1, 0,
35+
1, 1, 1 /* triangle in bottom left for first grid */, 0, -1, -1, -1, -1,
36+
1 /* triangle in top right for second grid */, 0, -1, -1, 1, 0,
37+
1 /* triangle in bottom left for second grid */,
38+
]),
39+
);
40+
});
41+
});

0 commit comments

Comments
 (0)