diff --git a/python/README.md b/python/README.md index 79ab5351b..4c7f039a7 100644 --- a/python/README.md +++ b/python/README.md @@ -152,8 +152,8 @@ headlessly on Firefox using `xvfb-run`. On other platforms, tests can't be run ```shell # For headless using Firefox on xvfb (Linux only) -sudo apt-get instrall xvfb # On Debian-based systems -tox -e firefox-xvfb # Run tests using non-headless Firefox +sudo apt-get install xvfb # On Debian-based systems +tox -e firefox-xvfb # Run tests using headless Firefox # For non-headless using Chrome tox -e chrome diff --git a/python/neuroglancer/__init__.py b/python/neuroglancer/__init__.py index 836bbebd1..edafa519b 100644 --- a/python/neuroglancer/__init__.py +++ b/python/neuroglancer/__init__.py @@ -89,6 +89,7 @@ LayerDataSource, # noqa: F401 LayerDataSources, # noqa: F401 InvlerpParameters, # noqa: F401 + TransferFunctionParameters, # noqa: F401 ImageLayer, # noqa: F401 SkeletonRenderingOptions, # noqa: F401 StarredSegments, # noqa: F401 diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index 301cc65f8..a2b14edba 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -518,6 +518,16 @@ class InvlerpParameters(JsonObjectWrapper): channel = wrapped_property("channel", optional(typed_list(int))) +@export +class TransferFunctionParameters(JsonObjectWrapper): + window = wrapped_property("window", optional(array_wrapper(numbers.Number, 2))) + channel = wrapped_property("channel", optional(typed_list(int))) + controlPoints = wrapped_property( + "controlPoints", optional(typed_list(typed_list(number_or_string))) + ) + defaultColor = wrapped_property("defaultColor", optional(str)) + + _UINT64_STR_PATTERN = re.compile("[0-9]+") @@ -530,9 +540,13 @@ def _shader_control_parameters(v, _readonly=False): if isinstance(v, numbers.Number): return v if isinstance(v, dict): + if "controlPoints" in v: + return TransferFunctionParameters(v, _readonly=_readonly) return InvlerpParameters(v, _readonly=_readonly) if isinstance(v, InvlerpParameters): return v + if isinstance(v, TransferFunctionParameters): + return v raise TypeError(f"Unexpected shader control parameters type: {type(v)}") diff --git a/python/tests/shader_controls_test.py b/python/tests/shader_controls_test.py index ee6bca39c..ece90fbf5 100644 --- a/python/tests/shader_controls_test.py +++ b/python/tests/shader_controls_test.py @@ -62,6 +62,81 @@ def expect_color(color): expect_color([0, 0, 0, 255]) +def test_transfer_function(webdriver): + shader = """ +#uicontrol transferFunction colormap +void main() { + emitRGBA(colormap()); +} +""" + shaderControls = { + "colormap": { + "controlPoints": [[0, "#000000", 0.0], [84, "#ffffff", 1.0]], + "window": [0, 50], + "channel": [], + "defaultColor": "#ff00ff", + } + } + with webdriver.viewer.txn() as s: + s.dimensions = neuroglancer.CoordinateSpace( + names=["x", "y"], units="nm", scales=[1, 1] + ) + s.position = [0.5, 0.5] + s.layers.append( + name="image", + layer=neuroglancer.ImageLayer( + source=neuroglancer.LocalVolume( + dimensions=s.dimensions, + data=np.full(shape=(1, 1), dtype=np.uint64, fill_value=63), + ), + ), + visible=True, + shader=shader, + shader_controls=shaderControls, + opacity=1.0, + blend="additive", + ) + s.layout = "xy" + s.cross_section_scale = 1e-6 + s.show_axis_lines = False + control = webdriver.viewer.state.layers["image"].shader_controls["colormap"] + assert isinstance(control, neuroglancer.TransferFunctionParameters) + np.testing.assert_equal(control.window, [0, 50]) + assert control.defaultColor == "#ff00ff" + + def expect_color(color): + webdriver.sync() + screenshot = webdriver.viewer.screenshot(size=[10, 10]).screenshot + np.testing.assert_array_equal( + screenshot.image_pixels, + np.tile(np.array(color, dtype=np.uint8), (10, 10, 1)), + ) + + # Ensure that the value 63 is mapped to the expected color. + # The value 63 is 3/4 of the way between 0 and 84, so the expected color + # is 3/4 of the way between black and white. + # Additionally, the opacity is 0.75, and the mode is additive, so the + # the final color is 0.75 * 0.75 * 255. + mapped_opacity = 0.75 + mapped_color = 0.75 * 255 + mapped_value = int(mapped_color * mapped_opacity) + expected_color = [mapped_value] * 3 + [255] + expect_color(expected_color) + with webdriver.viewer.txn() as s: + s.layers["image"].shader_controls = { + "colormap": neuroglancer.TransferFunctionParameters( + controlPoints=[[0, "#000000", 0.0], [84, "#ffffff", 1.0]], + window=[500, 5000], + channel=[], + defaultColor="#ff0000", + ) + } + control = webdriver.viewer.state.layers["image"].shader_controls["colormap"] + np.testing.assert_equal(control.window, [500, 5000]) + assert control.defaultColor == "#ff0000" + expect_color(expected_color) + + def test_slider(webdriver): with webdriver.viewer.txn() as s: s.dimensions = neuroglancer.CoordinateSpace( diff --git a/src/sliceview/image_layer_rendering.md b/src/sliceview/image_layer_rendering.md index 6aeb69dfd..71f2cd012 100644 --- a/src/sliceview/image_layer_rendering.md +++ b/src/sliceview/image_layer_rendering.md @@ -155,6 +155,42 @@ annotation layers). The one-parameter overload simply computes the inverse linea the specified value within the range specified by the control. The zero-parameter overload returns the inverse linear interpolation of the data value for configured channel/property. +### `transferFunction` controls + +The `transferFunction` control type allows the user to specify a function which maps +each data value in a numerical interval to an output color and opacity. The mapping function +is defined by a series of control points. Each control point is a color and opacity value at a +specific data input value. In between control points, the color and opacity is linearly interpolated. +Any input data before the first control point is mapped to a completely transparent output. +Any data point after the last control point is mapped to the same output as the last control point. + +Directive syntax: + +```glsl +#uicontrol transferFunction (window=[lower, higher], controlPoints=[[input, hexColorString, opacity]], channel=[], defaultColor="#rrggbb") +#uicontrol transferFunction colormap(window=[0, 100], controlPoints=[[10.0, "#000000", 0.0], [100.0, "#ffffff", 1.0]], channel=[], defaultColor="#00ffaa") +``` + +The following parameters are supported: + +- `window`: Optional. The portion of the input range to view the transfer function over. + Must be specified as an array. May be overridden using the UI control. Defaults to the min and max + of the control point input values, if control points are specified, or otherwise to the full range of the + data type for integer data types, and `[0, 1]` for float32. It is not valid to specify an + inverted interval like `[50, 20]`, or an interval where the start and end points are the same, e.g. `[20, 20]`. + +- `controlPoints`: Optional. The points which define the input to output mapping. + Must be specified as an array, with each value in the array of the form `[inputValue, hexStringColor, floatOpacity]`. + The default transfer function is a simple interpolation from transparent black to fully opaque white. + +- `channel`: Optional. The channel to perform the mapping on. + If the rank of the channel coordinate space is 1, may be specified as a single number, + e.g. `channel=2`. Otherwise, must be specified as an array, e.g. `channel=[2, 3]`. May be + overriden using the UI control. If not specified, defaults to all-zero channel coordinates. + +- `defaultColor`: Optional. The default color for new control points added via the UI control. + Defaults to `#ffffff`, and must be specified as a hex string if provided `#rrggbb`. + ## API ### Retrieving voxel channel value diff --git a/src/sliceview/volume/renderlayer.ts b/src/sliceview/volume/renderlayer.ts index 2a8fa2de0..a01325eb1 100644 --- a/src/sliceview/volume/renderlayer.ts +++ b/src/sliceview/volume/renderlayer.ts @@ -582,6 +582,7 @@ void main() { const endShader = () => { if (shader === null) return; + shader.unbindTransferFunctionTextures(); if (prevChunkFormat !== null) { prevChunkFormat!.endDrawing(gl, shader); } diff --git a/src/util/array.spec.ts b/src/util/array.spec.ts index d9b2a5c73..6319ca8f8 100644 --- a/src/util/array.spec.ts +++ b/src/util/array.spec.ts @@ -22,6 +22,7 @@ import { spliceArray, tile2dArray, transposeArray2d, + findClosestMatchInSortedArray, } from "#src/util/array.js"; describe("partitionArray", () => { @@ -206,3 +207,24 @@ describe("getMergeSplices", () => { ]); }); }); + +describe("findClosestMatchInSortedArray", () => { + const compare = (a: number, b: number) => a - b; + it("works for empty array", () => { + expect(findClosestMatchInSortedArray([], 0, compare)).toEqual(-1); + }); + it("works for simple examples", () => { + expect(findClosestMatchInSortedArray([0, 1, 2, 3], 0, compare)).toEqual(0); + expect(findClosestMatchInSortedArray([0, 1, 2, 3], 1, compare)).toEqual(1); + expect(findClosestMatchInSortedArray([0, 1, 2, 3], 2, compare)).toEqual(2); + expect(findClosestMatchInSortedArray([0, 1, 2, 3], 3, compare)).toEqual(3); + expect(findClosestMatchInSortedArray([0, 1, 2, 3], 4, compare)).toEqual(3); + expect(findClosestMatchInSortedArray([0, 1, 2, 3], -1, compare)).toEqual(0); + expect(findClosestMatchInSortedArray([0, 1, 2, 3], 1.5, compare)).toEqual( + 1, + ); + expect(findClosestMatchInSortedArray([0, 1, 2, 3], 1.6, compare)).toEqual( + 2, + ); + }); +}); diff --git a/src/util/array.ts b/src/util/array.ts index 93f628b77..c18e90cc8 100644 --- a/src/util/array.ts +++ b/src/util/array.ts @@ -184,6 +184,39 @@ export function binarySearch( return ~low; } +/** + * Returns the index of the element in `haystack` that is closest to `needle`, according to + * `compare`. If there are multiple elements that are equally close, the index of the first such + * element encountered is returned. If `haystack` is empty, returns -1. + */ +export function findClosestMatchInSortedArray( + haystack: ArrayLike, + needle: T, + compare: (a: T, b: T) => number, + low = 0, + high = haystack.length, +): number { + let bestIndex = -1; + let bestDistance = Infinity; + while (low < high) { + const mid = (low + high - 1) >> 1; + const compareResult = compare(needle, haystack[mid]); + if (compareResult > 0) { + low = mid + 1; + } else if (compareResult < 0) { + high = mid; + } else { + return mid; + } + const distance = Math.abs(compareResult); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = mid; + } + } + return bestIndex; +} + /** * Returns the first index in `[begin, end)` for which `predicate` is `true`, or returns `end` if no * such index exists. diff --git a/src/volume_rendering/volume_render_layer.ts b/src/volume_rendering/volume_render_layer.ts index 889f608c3..4361b1f2b 100644 --- a/src/volume_rendering/volume_render_layer.ts +++ b/src/volume_rendering/volume_render_layer.ts @@ -570,6 +570,7 @@ void main() { const endShader = () => { if (shader === null) return; + shader.unbindTransferFunctionTextures(); if (prevChunkFormat !== null) { prevChunkFormat!.endDrawing(gl, shader); } diff --git a/src/webgl/rectangle_grid_buffer.spec.ts b/src/webgl/rectangle_grid_buffer.spec.ts new file mode 100644 index 000000000..5d8a9697a --- /dev/null +++ b/src/webgl/rectangle_grid_buffer.spec.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2024 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. + */ + +import { describe, it, expect } from "vitest"; +import { createGriddedRectangleArray } from "#src/webgl/rectangle_grid_buffer.js"; + +describe("createGriddedRectangleArray", () => { + it("creates a set of two squares for grid size=2 and rectangle width&height=2", () => { + const result = createGriddedRectangleArray(2, -1, 1, 1, -1); + expect(result).toEqual( + new Float32Array([ + -1, 1, 0, 1, 0, -1 /* triangle in top right for first grid */, -1, 1, 0, + -1, -1, -1 /* triangle in bottom left for first grid */, 0, 1, 1, 1, 1, + -1 /* triangle in top right for second grid */, 0, 1, 1, -1, 0, + -1 /* triangle in bottom left for second grid */, + ]), + ); + const resultReverse = createGriddedRectangleArray(2, 1, -1, -1, 1); + expect(resultReverse).toEqual( + new Float32Array([ + 1, -1, 0, -1, 0, 1 /* triangle in top right for first grid */, 1, -1, 0, + 1, 1, 1 /* triangle in bottom left for first grid */, 0, -1, -1, -1, -1, + 1 /* triangle in top right for second grid */, 0, -1, -1, 1, 0, + 1 /* triangle in bottom left for second grid */, + ]), + ); + }); +}); diff --git a/src/webgl/rectangle_grid_buffer.ts b/src/webgl/rectangle_grid_buffer.ts new file mode 100644 index 000000000..acf8e848f --- /dev/null +++ b/src/webgl/rectangle_grid_buffer.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2024 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. + */ + +import { getMemoizedBuffer } from "#src/webgl/buffer.js"; +import type { GL } from "#src/webgl/context.js"; +import { VERTICES_PER_QUAD } from "#src/webgl/quad.js"; + +/** + * Create a Float32Array of vertices gridded in a rectangle + * Only grids along the x-axis are created, the y-axis is assumed to be the same for all grids + */ +export function createGriddedRectangleArray( + numGrids: number, + startX = -1, + endX = 1, + startY = 1, + endY = -1, +): Float32Array { + const result = new Float32Array(numGrids * VERTICES_PER_QUAD * 2); + const step = (endX - startX) / numGrids; + let currentx = startX; + for (let i = 0; i < numGrids; ++i) { + const index = i * VERTICES_PER_QUAD * 2; + + // Triangle 1 - top-left, top-right, bottom-right + result[index] = currentx; // top-left x + result[index + 1] = startY; // top-left y + result[index + 2] = currentx + step; // top-right x + result[index + 3] = startY; // top-right y + result[index + 4] = currentx + step; // bottom-right x + result[index + 5] = endY; // bottom-right y + + // Triangle 2 - top-left, bottom-right, bottom-left + result[index + 6] = currentx; // top-left x + result[index + 7] = startY; // top-left y + result[index + 8] = currentx + step; // bottom-right x + result[index + 9] = endY; // bottom-right y + result[index + 10] = currentx; // bottom-left x + result[index + 11] = endY; // bottom-left y + currentx += step; + } + return result; +} + +/** + * Get a buffer of vertices representing a rectangle that is gridded + * along the x dimension, useful for drawing grids, such as a lookup table / heatmap + */ +export function getGriddedRectangleBuffer( + gl: GL, + numGrids: number, + startX = -1, + endX = 1, + startY = 1, + endY = -1, +) { + return getMemoizedBuffer( + gl, + WebGL2RenderingContext.ARRAY_BUFFER, + createGriddedRectangleArray, + numGrids, + startX, + endX, + startY, + endY, + ).value; +} diff --git a/src/webgl/shader.ts b/src/webgl/shader.ts index 7d10c8d69..f87187c46 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -14,8 +14,13 @@ * limitations under the License. */ +import type { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; import type { GL } from "#src/webgl/context.js"; +import type { + ControlPointTexture, + SortedControlPoints, +} from "#src/widget/transfer_function.js"; const DEBUG_SHADER = false; @@ -164,6 +169,10 @@ export class ShaderProgram extends RefCounted { textureUnits: Map; vertexShaderInputBinders: { [name: string]: VertexShaderInputBinder } = {}; vertexDebugOutputs?: VertexDebugOutput[]; + transferFunctionTextures: Map = new Map< + any, + ControlPointTexture + >(); constructor( public gl: GL, @@ -237,7 +246,7 @@ export class ShaderProgram extends RefCounted { return this.attributes.get(name)!; } - textureUnit(symbol: symbol): number { + textureUnit(symbol: symbol | string): number { return this.textureUnits.get(symbol)!; } @@ -246,6 +255,41 @@ export class ShaderProgram extends RefCounted { this.gl.useProgram(this.program); } + bindAndUpdateTransferFunctionTexture( + symbol: symbol | string, + sortedControlPoints: SortedControlPoints, + dataType: DataType, + lookupTableSize: number, + ) { + const textureUnit = this.textureUnits.get(symbol); + if (textureUnit === undefined) { + throw new Error(`Invalid texture unit symbol: ${symbol.toString()}`); + } + const texture = this.transferFunctionTextures.get(symbol); + if (texture === undefined) { + throw new Error( + `Invalid transfer function texture symbol: ${symbol.toString()}`, + ); + } + return texture.updateAndActivate({ + textureUnit, + sortedControlPoints, + dataType, + lookupTableSize, + }); + } + + unbindTransferFunctionTextures() { + const gl = this.gl; + for (const key of this.transferFunctionTextures.keys()) { + const value = this.textureUnits.get(key); + if (value !== undefined) { + this.gl.activeTexture(gl.TEXTURE0 + value); + this.gl.bindTexture(gl.TEXTURE_2D, null); + } + } + } + disposed() { const { gl } = this; gl.deleteShader(this.vertexShader); @@ -257,6 +301,7 @@ export class ShaderProgram extends RefCounted { this.gl = undefined; this.attributes = undefined; this.uniforms = undefined; + this.transferFunctionTextures = undefined; } } @@ -448,7 +493,7 @@ export class ShaderBuilder { private uniforms = new Array(); private attributes = new Array(); private initializers: Array = []; - private textureUnits = new Map(); + private textureUnits = new Map(); private vertexDebugOutputs: VertexDebugOutput[] = []; constructor(public gl: GL) {} @@ -461,7 +506,7 @@ export class ShaderBuilder { this.vertexDebugOutputs.push({ typeName, name }); } - allocateTextureUnit(symbol: symbol, count = 1) { + allocateTextureUnit(symbol: symbol | string, count = 1) { if (this.textureUnits.has(symbol)) { throw new Error("Duplicate texture unit symbol: " + symbol.toString()); } @@ -474,7 +519,7 @@ export class ShaderBuilder { addTextureSampler( samplerType: ShaderSamplerType, name: string, - symbol: symbol, + symbol: symbol | string, extent?: number, ) { const textureUnit = this.allocateTextureUnit(symbol, extent); diff --git a/src/webgl/shader_ui_controls.browser_test.ts b/src/webgl/shader_ui_controls.browser_test.ts index 09d70c10c..a5cea04d8 100644 --- a/src/webgl/shader_ui_controls.browser_test.ts +++ b/src/webgl/shader_ui_controls.browser_test.ts @@ -16,11 +16,20 @@ import { expect, describe, it } from "vitest"; import { DataType } from "#src/util/data_type.js"; -import { vec3 } from "#src/util/geom.js"; +import { vec3, vec4 } from "#src/util/geom.js"; +import { defaultDataTypeRange } from "#src/util/lerp.js"; +import { Uint64 } from "#src/util/uint64.js"; import { + TrackableTransferFunctionParameters, parseShaderUiControls, + parseTransferFunctionParameters, stripComments, } from "#src/webgl/shader_ui_controls.js"; +import type { TransferFunctionParameters } from "#src/widget/transfer_function.js"; +import { + ControlPoint, + SortedControlPoints, +} from "#src/widget/transfer_function.js"; describe("stripComments", () => { it("handles code without comments", () => { @@ -562,4 +571,499 @@ void main() { ]), }); }); + it("handles transfer function control without channel", () => { + const code = ` +#uicontrol transferFunction colormap(controlPoints=[]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const range = defaultDataTypeRange[DataType.UINT8]; + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT8, channelRank: 0 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.UINT8, + default: { + sortedControlPoints: new SortedControlPoints([], DataType.UINT8), + channel: [], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, + }, + }, + ], + ]), + }); + }); + it("handles transfer function control without channel (rank 1)", () => { + const code = ` +#uicontrol transferFunction colormap(controlPoints=[]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const range = defaultDataTypeRange[DataType.UINT16]; + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT16, channelRank: 1 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.UINT16, + default: { + sortedControlPoints: new SortedControlPoints([], DataType.UINT16), + channel: [0], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with channel (rank 0)", () => { + const code = ` +#uicontrol transferFunction colormap(controlPoints=[], channel=[]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const range = defaultDataTypeRange[DataType.UINT64]; + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT64, channelRank: 0 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.UINT64, + default: { + sortedControlPoints: new SortedControlPoints([], DataType.UINT64), + channel: [], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with non-array channel (rank 1)", () => { + const code = ` +#uicontrol transferFunction colormap(controlPoints=[], channel=1) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const range = defaultDataTypeRange[DataType.FLOAT32]; + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.FLOAT32, channelRank: 1 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.FLOAT32, + default: { + sortedControlPoints: new SortedControlPoints( + [], + DataType.FLOAT32, + ), + channel: [1], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with array channel (rank 1)", () => { + const code = ` +#uicontrol transferFunction colormap(controlPoints=[], channel=[1]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const range = defaultDataTypeRange[DataType.FLOAT32]; + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.FLOAT32, channelRank: 1 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.FLOAT32, + default: { + sortedControlPoints: new SortedControlPoints( + [], + DataType.FLOAT32, + ), + channel: [1], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with array channel (rank 2)", () => { + const code = ` +#uicontrol transferFunction colormap(controlPoints=[], channel=[1,2]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const range = defaultDataTypeRange[DataType.FLOAT32]; + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.FLOAT32, channelRank: 2 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.FLOAT32, + default: { + sortedControlPoints: new SortedControlPoints( + [], + DataType.FLOAT32, + ), + channel: [1, 2], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with all properties non uint64 data", () => { + const code = ` +#uicontrol transferFunction colormap(controlPoints=[[200, "#00ff00", 0.1], [100, "#ff0000", 0.5], [0, "#000000", 0.0]], defaultColor="#0000ff", window=[0, 1000], channel=[]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const controlPoints = [ + new ControlPoint(0, vec4.fromValues(0, 0, 0, 0)), + new ControlPoint(200, vec4.fromValues(0, 255, 0, 26)), + new ControlPoint(100, vec4.fromValues(255, 0, 0, 128)), + ]; + const sortedControlPoints = new SortedControlPoints( + controlPoints, + DataType.UINT32, + ); + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT32, channelRank: 0 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.UINT32, + default: { + sortedControlPoints, + channel: [], + defaultColor: vec3.fromValues(0, 0, 1), + window: [0, 1000], + }, + }, + ], + ]), + }); + expect(sortedControlPoints.range).toEqual([0, 200]); + }); + it("handles transfer function control with all properties uint64 data", () => { + const code = ` +#uicontrol transferFunction colormap(controlPoints=[["18446744073709551615", "#00ff00", 0.1], ["9223372111111111111", "#ff0000", 0.5], [0, "#000000", 0.0]], defaultColor="#0000ff", channel=[], window=[0, 2000]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const controlPoints = [ + new ControlPoint( + Uint64.parseString("9223372111111111111"), + vec4.fromValues(255, 0, 0, 128), + ), + new ControlPoint(Uint64.fromNumber(0), vec4.fromValues(0, 0, 0, 0)), + new ControlPoint( + Uint64.parseString("18446744073709551615"), + vec4.fromValues(0, 255, 0, 26), + ), + ]; + const sortedControlPoints = new SortedControlPoints( + controlPoints, + DataType.UINT64, + ); + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT64, channelRank: 0 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.UINT64, + default: { + sortedControlPoints: sortedControlPoints, + channel: [], + defaultColor: vec3.fromValues(0, 0, 1), + window: [Uint64.fromNumber(0), Uint64.fromNumber(2000)], + }, + }, + ], + ]), + }); + }); + it("creates a default transfer function if no control points are provided", () => { + const code = ` +#uicontrol transferFunction colormap(window=[30, 200]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const window = [30, 200]; + const firstInput = window[0] + (window[1] - window[0]) * 0.4; + const secondInput = window[0] + (window[1] - window[0]) * 0.7; + const controlPoints = [ + new ControlPoint(Math.round(firstInput), vec4.fromValues(0, 0, 0, 0)), + new ControlPoint( + Math.round(secondInput), + vec4.fromValues(255, 255, 255, 255), + ), + ]; + const sortedControlPoints = new SortedControlPoints( + controlPoints, + DataType.UINT8, + ); + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT8, channelRank: 0 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.UINT8, + default: { + sortedControlPoints: sortedControlPoints, + channel: [], + defaultColor: vec3.fromValues(1, 1, 1), + window, + }, + }, + ], + ]), + }); + }); +}); + +describe("parseTransferFunctionParameters", () => { + it("parses transfer function from JSON", () => { + const code = ` +#uicontrol transferFunction tf +void main() { +} +`; + const parsed_val = parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT8, channelRank: 0 }, + }); + const default_val = parsed_val.controls.get("tf")!.default; + const json = { + controlPoints: [ + [150, "#ffffff", 1], + [0, "#000000", 0], + ], + defaultColor: "#ff0000", + window: [0, 200], + }; + const parsed = parseTransferFunctionParameters( + json, + DataType.UINT8, + default_val as TransferFunctionParameters, + ); + expect(parsed).toEqual({ + sortedControlPoints: new SortedControlPoints( + [ + new ControlPoint(0, vec4.fromValues(0, 0, 0, 0)), + new ControlPoint(150, vec4.fromValues(255, 255, 255, 255)), + ], + DataType.UINT8, + ), + channel: [], + defaultColor: vec3.fromValues(1, 0, 0), + window: [0, 200], + }); + }); + it("writes transfer function to JSON and detects changes from default", () => { + const code = ` +#uicontrol transferFunction tf +void main() { +} +`; + const parsed_val = parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT64, channelRank: 0 }, + }); + const default_val = parsed_val.controls.get("tf")! + .default as TransferFunctionParameters; + const transferFunctionParameters = new TrackableTransferFunctionParameters( + DataType.UINT64, + default_val, + ); + expect(transferFunctionParameters.toJSON()).toEqual(undefined); + + // Test setting a new control point + const sortedControlPoints = new SortedControlPoints( + [ + new ControlPoint(Uint64.fromNumber(0), vec4.fromValues(0, 0, 0, 10)), + new ControlPoint( + Uint64.parseString("18446744073709551615"), + vec4.fromValues(255, 255, 255, 255), + ), + ], + DataType.UINT64, + ); + transferFunctionParameters.value = { + ...default_val, + sortedControlPoints, + }; + expect(transferFunctionParameters.toJSON()).toEqual({ + channel: undefined, + defaultColor: undefined, + window: undefined, + controlPoints: [ + ["0", "#000000", 0.0392156862745098], + ["18446744073709551615", "#ffffff", 1], + ], + }); + + // Test setting a new default color + transferFunctionParameters.value = { + ...default_val, + defaultColor: vec3.fromValues(0, 1, 0), + }; + expect(transferFunctionParameters.toJSON()).toEqual({ + channel: undefined, + defaultColor: "#00ff00", + window: undefined, + controlPoints: undefined, + }); + + // Test setting a new window + transferFunctionParameters.value = { + ...default_val, + window: [0, 1000], + }; + expect(transferFunctionParameters.toJSON()).toEqual({ + channel: undefined, + defaultColor: undefined, + window: ["0", "1000"], + controlPoints: undefined, + }); + + // Test setting a new channel + transferFunctionParameters.value = { + ...default_val, + channel: [1], + }; + expect(transferFunctionParameters.toJSON()).toEqual({ + channel: [1], + defaultColor: undefined, + window: undefined, + controlPoints: undefined, + }); + }); }); diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 7439a9750..df3e19034 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -27,11 +27,16 @@ import { TrackableValue, } from "#src/trackable_value.js"; import { arraysEqual, arraysEqualWithPredicate } from "#src/util/array.js"; -import { parseRGBColorSpecification, TrackableRGB } from "#src/util/color.js"; -import type { DataType } from "#src/util/data_type.js"; +import { + parseRGBColorSpecification, + serializeColor, + TrackableRGB, +} from "#src/util/color.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 { kZeroVec4, vec3, vec4 } from "#src/util/geom.js"; import { + parseArray, parseFixedLengthArray, verifyFiniteFloat, verifyInt, @@ -41,16 +46,19 @@ import { } from "#src/util/json.js"; import type { DataTypeInterval } from "#src/util/lerp.js"; import { + computeLerp, convertDataTypeInterval, dataTypeIntervalToJson, defaultDataTypeRange, normalizeDataTypeInterval, parseDataTypeInterval, + parseDataTypeValue, parseUnknownDataTypeInterval, validateDataTypeInterval, } from "#src/util/lerp.js"; import { NullarySignal } from "#src/util/signal.js"; import type { Trackable } from "#src/util/trackable.js"; +import type { Uint64 } from "#src/util/uint64.js"; import type { GL } from "#src/webgl/context.js"; import type { HistogramChannelSpecification } from "#src/webgl/empirical_cdf.js"; import { HistogramSpecifications } from "#src/webgl/empirical_cdf.js"; @@ -59,6 +67,13 @@ import { enableLerpShaderFunction, } from "#src/webgl/lerp.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; +import type { TransferFunctionParameters } from "#src/widget/transfer_function.js"; +import { + defineTransferFunctionShader, + enableTransferFunctionShader, + SortedControlPoints, + ControlPoint, +} from "#src/widget/transfer_function.js"; export interface ShaderSliderControl { type: "slider"; @@ -98,12 +113,19 @@ export interface ShaderCheckboxControl { default: boolean; } +export interface ShaderTransferFunctionControl { + type: "transferFunction"; + dataType: DataType; + default: TransferFunctionParameters; +} + export type ShaderUiControl = | ShaderSliderControl | ShaderColorControl | ShaderImageInvlerpControl | ShaderPropertyInvlerpControl - | ShaderCheckboxControl; + | ShaderCheckboxControl + | ShaderTransferFunctionControl; export interface ShaderControlParseError { line: number; @@ -564,6 +586,101 @@ function parsePropertyInvlerpDirective( }; } +function parseTransferFunctionDirective( + valueType: string, + parameters: DirectiveParameters, + dataContext: ShaderDataContext, +): DirectiveParseResult { + const imageData = dataContext.imageData; + const dataType = imageData?.dataType; + const channelRank = imageData?.channelRank; + const errors = []; + let channel = new Array(channelRank).fill(0); + let defaultColor = vec3.fromValues(1.0, 1.0, 1.0); + let window: DataTypeInterval | undefined; + let sortedControlPoints = new SortedControlPoints( + [], + dataType !== undefined ? dataType : DataType.FLOAT32, + ); + let specifedPoints = false; + if (valueType !== "transferFunction") { + errors.push("type must be transferFunction"); + } + if (dataType === undefined) { + errors.push("image data must be provided to use a transfer function"); + } + for (const [key, value] of parameters) { + try { + switch (key) { + case "channel": { + channel = parseInvlerpChannel(value, channel.length); + break; + } + case "defaultColor": { + defaultColor = parseRGBColorSpecification(value); + break; + } + case "window": { + if (dataType !== undefined) { + window = validateDataTypeInterval( + parseDataTypeInterval(value, dataType), + ); + } + break; + } + case "controlPoints": { + specifedPoints = true; + if (dataType !== undefined) { + sortedControlPoints = parseTransferFunctionControlPoints( + value, + dataType, + ); + } + break; + } + default: + errors.push(`Invalid parameter: ${key}`); + break; + } + } catch (e) { + errors.push(`Invalid ${key} value: ${e.message}`); + } + } + + if (window === undefined) { + window = sortedControlPoints.range; + } + // Set a simple black to white transfer function if no control points are specified. + if ( + sortedControlPoints.length === 0 && + !specifedPoints && + dataType !== undefined + ) { + const startPoint = computeLerp(window, dataType, 0.4) as number; + const endPoint = computeLerp(window, dataType, 0.7) as number; + sortedControlPoints.addPoint(new ControlPoint(startPoint, kZeroVec4)); + sortedControlPoints.addPoint( + new ControlPoint(endPoint, vec4.fromValues(255, 255, 255, 255)), + ); + } + if (errors.length > 0) { + return { errors }; + } + return { + control: { + type: "transferFunction", + dataType, + default: { + sortedControlPoints, + channel, + defaultColor, + window, + }, + } as ShaderTransferFunctionControl, + errors: undefined, + }; +} + export interface ImageDataSpecification { dataType: DataType; channelRank: number; @@ -586,6 +703,7 @@ const controlParsers = new Map< ["color", parseColorDirective], ["invlerp", parseInvlerpDirective], ["checkbox", parseCheckboxDirective], + ["transferFunction", parseTransferFunctionDirective], ]); export function parseShaderUiControls( @@ -708,6 +826,18 @@ float ${uName}() { builder.addVertexCode(code); break; } + case "transferFunction": { + builder.addFragmentCode(`#define ${name} ${uName}\n`); + builder.addFragmentCode( + defineTransferFunctionShader( + builder, + uName, + control.dataType, + builderValue.channel, + ), + ); + break; + } default: { builder.addUniform(`highp ${control.valueType}`, uName); builder.addVertexCode(`#define ${name} ${uName}\n`); @@ -917,6 +1047,182 @@ class TrackablePropertyInvlerpParameters extends TrackableValue { + // Validate input length and types + const allowedInput = + dataType === DataType.UINT64 + ? typeof x[0] === "string" || typeof x[0] === "number" + : typeof x[0] === "number"; + if ( + x.length !== 3 || + !allowedInput || + typeof x[1] !== "string" || + typeof x[2] !== "number" + ) { + throw new Error( + `Expected array of length 3 (x, "#RRGGBB", A), but received: ${JSON.stringify( + x, + )}`, + ); + } + const inputValue = parseDataTypeValue(dataType, x[0]); + + if (x[1].length !== 7 || x[1][0] !== "#") { + throw new Error( + `Expected #RRGGBB, but received: ${JSON.stringify(x[1])}`, + ); + } + if (x[2] < 0 || x[2] > 1) { + throw new Error( + `Expected opacity in range [0, 1], but received: ${JSON.stringify( + x[2], + )}`, + ); + } + const color = parseRGBColorSpecification(x[1]); + function floatToUint8(float: number) { + return Math.min(255, Math.max(Math.round(float * 255), 0)); + } + return new ControlPoint( + inputValue, + vec4.fromValues( + floatToUint8(color[0]), + floatToUint8(color[1]), + floatToUint8(color[2]), + floatToUint8(x[2]), + ), + ); + }); + return new SortedControlPoints(parsedPoints, dataType); +} + +export function parseTransferFunctionParameters( + obj: unknown, + dataType: DataType, + defaultValue: TransferFunctionParameters, +): TransferFunctionParameters { + if (obj === undefined) return defaultValue; + verifyObject(obj); + const sortedControlPoints = verifyOptionalObjectProperty( + obj, + "controlPoints", + (x) => parseTransferFunctionControlPoints(x, dataType), + defaultValue.sortedControlPoints, + ); + const window = verifyOptionalObjectProperty( + obj, + "window", + (x) => parseDataTypeInterval(x, dataType), + defaultValue.window, + ); + return { + sortedControlPoints, + channel: verifyOptionalObjectProperty( + obj, + "channel", + (x) => parseInvlerpChannel(x, defaultValue.channel.length), + defaultValue.channel, + ), + defaultColor: verifyOptionalObjectProperty( + obj, + "defaultColor", + (x) => parseRGBColorSpecification(x), + defaultValue.defaultColor, + ), + window, + }; +} + +function copyTransferFunctionParameters( + defaultValue: TransferFunctionParameters, +) { + return { + ...defaultValue, + sortedControlPoints: defaultValue.sortedControlPoints.copy(), + }; +} + +export class TrackableTransferFunctionParameters extends TrackableValue { + constructor( + public dataType: DataType, + public defaultValue: TransferFunctionParameters, + ) { + // Create a copy of the default value to enable detecting changes + // to the control points in the trackable value. + const defaultValueCopy = copyTransferFunctionParameters(defaultValue); + super(defaultValueCopy, (obj) => + parseTransferFunctionParameters(obj, dataType, defaultValue), + ); + } + + controlPointsToJson(controlPoints: ControlPoint[], dataType: DataType) { + function inputToJson(inputValue: number | Uint64) { + if (dataType === DataType.UINT64) { + return (inputValue as Uint64).toJSON(); + } + return inputValue; + } + + return controlPoints.map((x) => [ + inputToJson(x.inputValue), + serializeColor( + vec3.fromValues( + x.outputColor[0] / 255, + x.outputColor[1] / 255, + x.outputColor[2] / 255, + ), + ), + x.outputColor[3] / 255, + ]); + } + + toJSON() { + const { + value: { channel, sortedControlPoints, defaultColor, window }, + dataType, + defaultValue, + } = this; + const windowJson = dataTypeIntervalToJson( + window, + dataType, + defaultValue.window, + ); + const channelJson = arraysEqual(defaultValue.channel, channel) + ? undefined + : channel; + const colorJson = arraysEqual(defaultValue.defaultColor, defaultColor) + ? undefined + : serializeColor(defaultColor); + const controlPointsJson = arraysEqualWithPredicate( + defaultValue.sortedControlPoints.controlPoints, + sortedControlPoints.controlPoints, + (a, b) => + arraysEqual(a.outputColor, b.outputColor) && + a.inputValue === b.inputValue, + ) + ? undefined + : this.controlPointsToJson(sortedControlPoints.controlPoints, dataType); + if ( + channelJson === undefined && + colorJson === undefined && + controlPointsJson === undefined && + windowJson === undefined + ) { + return undefined; + } + return { + channel: channelJson, + defaultColor: colorJson, + controlPoints: controlPointsJson, + window: windowJson, + }; + } +} + function getControlTrackable(control: ShaderUiControl): { trackable: TrackableValueInterface; getBuilderValue: (value: any) => any; @@ -974,6 +1280,17 @@ function getControlTrackable(control: ShaderUiControl): { trackable: new TrackableBoolean(control.default), getBuilderValue: (value) => ({ value }), }; + case "transferFunction": + return { + trackable: new TrackableTransferFunctionParameters( + control.dataType, + control.default, + ), + getBuilderValue: (value: TransferFunctionParameters) => ({ + channel: value.channel, + dataType: control.dataType, + }), + }; } } @@ -1346,6 +1663,13 @@ function setControlInShader( case "checkbox": // Value is hard-coded in shader. break; + case "transferFunction": + enableTransferFunctionShader( + shader, + uName, + control.dataType, + value.sortedControlPoints, + ); } } diff --git a/src/widget/invlerp.ts b/src/widget/invlerp.ts index 312e4de6a..263f620b7 100644 --- a/src/widget/invlerp.ts +++ b/src/widget/invlerp.ts @@ -657,14 +657,14 @@ function createRangeBoundInputs( return { container, inputs, spacers }; } -function updateInputBoundWidth(inputElement: HTMLInputElement) { +export function updateInputBoundWidth(inputElement: HTMLInputElement) { updateInputFieldWidth( inputElement, Math.max(1, inputElement.value.length + 0.1), ); } -function updateInputBoundValue( +export function updateInputBoundValue( inputElement: HTMLInputElement, bound: number | Uint64, ) { diff --git a/src/widget/shader_controls.ts b/src/widget/shader_controls.ts index 3f18f8300..aa19a7719 100644 --- a/src/widget/shader_controls.ts +++ b/src/widget/shader_controls.ts @@ -43,6 +43,7 @@ import { colorLayerControl } from "#src/widget/layer_control_color.js"; import { propertyInvlerpLayerControl } from "#src/widget/layer_control_property_invlerp.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; import { Tab } from "#src/widget/tab_view.js"; +import { transferFunctionLayerControl } from "#src/widget/transfer_function.js"; export interface LegendShaderOptions extends ParameterizedEmitterDependentShaderOptions { @@ -114,6 +115,15 @@ function getShaderLayerControlFactory( legendShaderOptions: layerShaderControls.legendShaderOptions, })); } + case "transferFunction": { + return transferFunctionLayerControl(() => ({ + dataType: control.dataType, + watchableValue: controlState.trackable, + channelCoordinateSpaceCombiner: + shaderControlState.channelCoordinateSpaceCombiner, + defaultChannel: control.default.channel, + })); + } } } diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts new file mode 100644 index 000000000..f9a81233b --- /dev/null +++ b/src/widget/transfer_function.browser_test.ts @@ -0,0 +1,327 @@ +/** + * @license + * Copyright 2024 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. + */ + +import { describe, it, expect } from "vitest"; +import { TrackableValue } from "#src/trackable_value.js"; +import { DataType } from "#src/util/data_type.js"; +import { vec3, vec4 } from "#src/util/geom.js"; +import { defaultDataTypeRange } from "#src/util/lerp.js"; + +import { Uint64 } from "#src/util/uint64.js"; +import { getShaderType } from "#src/webgl/shader_lib.js"; +import { fragmentShaderTest } from "#src/webgl/shader_testing.js"; +import type { TransferFunctionParameters } from "#src/widget/transfer_function.js"; +import { + SortedControlPoints, + ControlPoint, + LookupTable, + TransferFunction, + NUM_COLOR_CHANNELS, + defineTransferFunctionShader, + enableTransferFunctionShader, +} from "#src/widget/transfer_function.js"; + +const FIXED_TRANSFER_FUNCTION_LENGTH = 1024; + +function makeTransferFunction(controlPoints: ControlPoint[]) { + const sortedControlPoints = new SortedControlPoints( + controlPoints, + DataType.UINT8, + ); + return new TransferFunction( + DataType.UINT8, + new TrackableValue( + { + sortedControlPoints, + window: defaultDataTypeRange[DataType.UINT8], + defaultColor: vec3.fromValues(0, 0, 0), + channel: [], + }, + (x) => x, + ), + ); +} + +describe("lerpBetweenControlPoints", () => { + const output = new Uint8Array( + NUM_COLOR_CHANNELS * FIXED_TRANSFER_FUNCTION_LENGTH, + ); + it("returns transparent black when given no control points for base classes", () => { + const controlPoints: ControlPoint[] = []; + const sortedControlPoints = new SortedControlPoints( + controlPoints, + DataType.UINT8, + ); + const lookupTable = new LookupTable(FIXED_TRANSFER_FUNCTION_LENGTH); + lookupTable.updateFromControlPoints(sortedControlPoints); + + expect(output.every((value) => value === 0)).toBeTruthy(); + }); + it("returns transparent black when given no control points for the transfer function class", () => { + const transferFunction = makeTransferFunction([]); + expect( + transferFunction.lookupTable.outputValues.every((value) => value === 0), + ).toBeTruthy(); + }); + it("returns transparent black up to the first control point, and the last control point value after", () => { + const controlPoints: ControlPoint[] = [ + new ControlPoint(120, vec4.fromValues(21, 22, 254, 210)), + ]; + const transferFunction = makeTransferFunction(controlPoints); + const output = transferFunction.lookupTable.outputValues; + const firstPointTransferIndex = + transferFunction.sortedControlPoints.controlPoints[0].transferFunctionIndex( + transferFunction.sortedControlPoints.range, + transferFunction.size, + ); + + expect( + output + .slice(0, NUM_COLOR_CHANNELS * firstPointTransferIndex) + .every((value) => value === 0), + ).toBeTruthy(); + const endPiece = output.slice(NUM_COLOR_CHANNELS * firstPointTransferIndex); + const color = controlPoints[0].outputColor; + expect( + endPiece.every( + (value, index) => value === color[index % NUM_COLOR_CHANNELS], + ), + ).toBeTruthy(); + }); + it("correctly interpolates between three control points", () => { + function toLookupTableIndex( + transferFunction: TransferFunction, + index: number, + ) { + return transferFunction.sortedControlPoints.controlPoints[ + index + ].transferFunctionIndex( + transferFunction.sortedControlPoints.range, + transferFunction.size, + ); + } + const controlPoints: ControlPoint[] = [ + new ControlPoint(140, vec4.fromValues(0, 0, 0, 0)), + new ControlPoint(120, vec4.fromValues(21, 22, 254, 210)), + new ControlPoint(200, vec4.fromValues(255, 255, 255, 255)), + ]; + const transferFunction = makeTransferFunction(controlPoints); + const output = transferFunction.lookupTable.outputValues; + const firstPointTransferIndex = toLookupTableIndex(transferFunction, 0); + const secondPointTransferIndex = toLookupTableIndex(transferFunction, 1); + const thirdPointTransferIndex = toLookupTableIndex(transferFunction, 2); + const size = transferFunction.size; + const range = transferFunction.range as [number, number]; + expect(firstPointTransferIndex).toBe( + Math.floor(((120 - range[0]) / (range[1] - range[0])) * (size - 1)), + ); + expect(secondPointTransferIndex).toBe( + Math.floor(((140 - range[0]) / (range[1] - range[0])) * (size - 1)), + ); + expect(thirdPointTransferIndex).toBe( + Math.floor(((200 - range[0]) / (range[1] - range[0])) * (size - 1)), + ); + + // Transparent black up to the first control point + expect( + output + .slice(0, NUM_COLOR_CHANNELS * firstPointTransferIndex) + .every((value) => value === 0), + ).toBeTruthy(); + // The last control point value after the last control point + expect( + output + .slice(NUM_COLOR_CHANNELS * thirdPointTransferIndex) + .every((value) => value === 255), + ).toBeTruthy(); + + // Performs linear interpolation between the first and second control points + const firstColor = + transferFunction.sortedControlPoints.controlPoints[0].outputColor; + const secondColor = + transferFunction.sortedControlPoints.controlPoints[1].outputColor; + const firstToSecondDifference = + secondPointTransferIndex - firstPointTransferIndex; + for ( + let i = firstPointTransferIndex * NUM_COLOR_CHANNELS; + i < secondPointTransferIndex * NUM_COLOR_CHANNELS; + i++ + ) { + const t = + Math.floor(i / NUM_COLOR_CHANNELS - firstPointTransferIndex) / + firstToSecondDifference; + const difference = + secondColor[i % NUM_COLOR_CHANNELS] - + firstColor[i % NUM_COLOR_CHANNELS]; + const expectedValue = firstColor[i % NUM_COLOR_CHANNELS] + t * difference; + // If the decimal part is 0.5, it could be rounded up or down depending on precision. + const decimalPart = expectedValue - Math.floor(expectedValue); + if (Math.abs(decimalPart - 0.5) < 0.001) { + expect([Math.floor(expectedValue), Math.ceil(expectedValue)]).toContain( + output[i], + ); + } else { + expect(output[i]).toBe(Math.round(expectedValue)); + } + } + + // Performs linear interpolation between the second and third control points + const thirdColor = + transferFunction.sortedControlPoints.controlPoints[2].outputColor; + const secondToThirdDifference = + thirdPointTransferIndex - secondPointTransferIndex; + for ( + let i = secondPointTransferIndex * NUM_COLOR_CHANNELS; + i < thirdPointTransferIndex * NUM_COLOR_CHANNELS; + i++ + ) { + const t = + Math.floor(i / NUM_COLOR_CHANNELS - secondPointTransferIndex) / + secondToThirdDifference; + const difference = + thirdColor[i % NUM_COLOR_CHANNELS] - + secondColor[i % NUM_COLOR_CHANNELS]; + const expectedValue = + secondColor[i % NUM_COLOR_CHANNELS] + t * difference; + // If the decimal part is 0.5, it could be rounded up or down depending on precision. + const decimalPart = expectedValue - Math.floor(expectedValue); + if (Math.abs(decimalPart - 0.5) < 0.001) { + expect([Math.floor(expectedValue), Math.ceil(expectedValue)]).toContain( + output[i], + ); + } else { + expect(output[i]).toBe(Math.round(expectedValue)); + } + } + }); +}); + +const textureSizes = { + [DataType.UINT8]: 0xff, + [DataType.INT8]: 0xff, + [DataType.UINT16]: 200, + [DataType.INT16]: 8192, + [DataType.UINT32]: 0xffff, + [DataType.INT32]: 0xffff, + [DataType.UINT64]: 0xffff, + [DataType.FLOAT32]: 0xffff, +}; + +describe("compute transfer function on GPU", () => { + for (const dataType of Object.values(DataType)) { + if (typeof dataType === "string") continue; + const range = defaultDataTypeRange[dataType]; + const controlPoints = new SortedControlPoints( + [ + new ControlPoint(range[0], vec4.fromValues(0, 0, 0, 0)), + new ControlPoint(range[1], vec4.fromValues(255, 255, 255, 255)), + ], + dataType, + ); + it(`computes transfer function between transparent black and opaque white on GPU for ${DataType[dataType]}`, () => { + const shaderType = getShaderType(dataType); + fragmentShaderTest( + { inputValue: dataType }, + { + val1: "float", + val2: "float", + val3: "float", + val4: "float", + val5: "float", + }, + (tester) => { + const { builder } = tester; + builder.addFragmentCode(` +${shaderType} getInterpolatedDataValue() { + return inputValue; +} +${shaderType} getDataValue() { + return inputValue; +} +`); + builder.addFragmentCode( + defineTransferFunctionShader( + builder, + "doTransferFunction", + dataType, + [], + ), + ); + builder.setFragmentMain(` +vec4 result = doTransferFunction(inputValue); +val1 = result.r; +val2 = result.g; +val3 = result.b; +val4 = result.a; +val5 = uTransferFunctionEnd_doTransferFunction; +`); + const { shader } = tester; + const testShader = (point: any) => { + enableTransferFunctionShader( + shader, + "doTransferFunction", + dataType, + controlPoints, + textureSizes[dataType], + ); + tester.execute({ inputValue: point }); + const values = tester.values; + return { + color: vec4.fromValues( + values.val1, + values.val2, + values.val3, + values.val4, + ), + size: values.val5, + }; + }; + const minValue = defaultDataTypeRange[dataType][0]; + const maxValue = defaultDataTypeRange[dataType][1]; + const gl = tester.gl; + const usedSize = Math.min( + textureSizes[dataType], + gl.getParameter(gl.MAX_TEXTURE_SIZE), + ); + { + const { color, size } = testShader(minValue); + expect(size).toBe(usedSize - 1); + expect(color).toEqual(vec4.fromValues(0, 0, 0, 0)); + } + { + const { color, size } = testShader(maxValue); + expect(size).toBe(usedSize - 1); + expect(color).toEqual(vec4.fromValues(1, 1, 1, 1)); + } + let position: number | Uint64; + if (dataType !== DataType.UINT64) { + const minValueNumber = minValue as number; + const maxValueNumber = maxValue as number; + position = (maxValueNumber + minValueNumber) / 2; + } else { + const value = (maxValue as Uint64).toNumber() / 2; + position = Uint64.fromNumber(value); + } + const { color, size } = testShader(position); + expect(size).toBe(usedSize - 1); + for (let i = 0; i < 3; i++) { + expect(color[i]).toBeCloseTo(0.5); + } + }, + ); + }); + } +}); diff --git a/src/widget/transfer_function.css b/src/widget/transfer_function.css new file mode 100644 index 000000000..d402128d3 --- /dev/null +++ b/src/widget/transfer_function.css @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2024 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. + */ + +.neuroglancer-transfer-function-panel { + height: 60px; + border: 1px solid #666; + margin-top: 5px; +} + +.neuroglancer-transfer-function-color-picker { + text-align: right; +} + +.neuroglancer-transfer-function-widget-bound { + background-color: transparent; + border-color: transparent; + box-shadow: none; + border: 0; + margin: 0; + font-family: monospace; + font-size: medium; + color: cyan; +} + +.neuroglancer-transfer-function-window-bounds { + display: flex; + justify-content: space-between; +} diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts new file mode 100644 index 000000000..9f2fffed3 --- /dev/null +++ b/src/widget/transfer_function.ts @@ -0,0 +1,1721 @@ +/** + * @license + * Copyright 2024 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. + */ + +import "#src/widget/transfer_function.css"; + +import type { CoordinateSpaceCombiner } from "#src/coordinate_transform.js"; +import type { DisplayContext } from "#src/display_context.js"; +import { IndirectRenderedPanel } from "#src/display_context.js"; +import type { UserLayer } from "#src/layer/index.js"; +import { Position } from "#src/navigation_state.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { makeCachedDerivedWatchableValue } from "#src/trackable_value.js"; +import type { ToolActivation } from "#src/ui/tool.js"; +import { + arraysEqual, + arraysEqualWithPredicate, + findClosestMatchInSortedArray, +} from "#src/util/array.js"; +import { DATA_TYPE_SIGNED, DataType } from "#src/util/data_type.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { + EventActionMap, + registerActionListener, +} from "#src/util/event_action_map.js"; +import { kZeroVec4, vec3, vec4 } from "#src/util/geom.js"; +import type { DataTypeInterval } from "#src/util/lerp.js"; +import { + computeInvlerp, + computeLerp, + defaultDataTypeRange, + parseDataTypeValue, +} from "#src/util/lerp.js"; +import { MouseEventBinder } from "#src/util/mouse_bindings.js"; +import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; +import type { Uint64 } from "#src/util/uint64.js"; +import type { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; +import type { Buffer } from "#src/webgl/buffer.js"; +import { getMemoizedBuffer } from "#src/webgl/buffer.js"; +import type { GL } from "#src/webgl/context.js"; +import { + defineInvlerpShaderFunction, + enableLerpShaderFunction, +} from "#src/webgl/lerp.js"; +import { + defineLineShader, + drawLines, + initializeLineShader, + VERTICES_PER_LINE, +} from "#src/webgl/lines.js"; +import { drawQuads } from "#src/webgl/quad.js"; +import { createGriddedRectangleArray } from "#src/webgl/rectangle_grid_buffer.js"; +import type { ShaderCodePart, ShaderProgram } from "#src/webgl/shader.js"; +import { ShaderBuilder } from "#src/webgl/shader.js"; +import { getShaderType } from "#src/webgl/shader_lib.js"; +import { setRawTextureParameters } from "#src/webgl/texture.js"; +import { ColorWidget } from "#src/widget/color.js"; +import { + getUpdatedRangeAndWindowParameters, + updateInputBoundValue, + updateInputBoundWidth, +} from "#src/widget/invlerp.js"; +import type { + LayerControlFactory, + LayerControlTool, +} from "#src/widget/layer_control.js"; +import { PositionWidget } from "#src/widget/position_widget.js"; +import { Tab } from "#src/widget/tab_view.js"; + +const TRANSFER_FUNCTION_PANEL_SIZE = 512; +export const NUM_COLOR_CHANNELS = 4; +const POSITION_VALUES_PER_LINE = 4; // x1, y1, x2, y2 +const CONTROL_POINT_X_GRAB_DISTANCE = 0.05; +const TRANSFER_FUNCTION_BORDER_WIDTH = 0.05; + +const transferFunctionSamplerTextureUnit = Symbol( + "transferFunctionSamplerTexture", +); + +const defaultTransferFunctionSizes: Record = { + [DataType.UINT8]: 256, + [DataType.INT8]: 256, + [DataType.UINT16]: 8192, + [DataType.INT16]: 8192, + [DataType.UINT32]: 8192, + [DataType.INT32]: 8192, + [DataType.UINT64]: 8192, + [DataType.FLOAT32]: 8192, +}; + +/** + * Options to update a lookup table texture with a direct lookup table + */ +export interface LookupTableTextureOptions { + /** A lookup table is a series of color values (0 - 255) for each index in the transfer function texture + */ + lookupTable: LookupTable; + /** textureUnit to update with the new transfer function texture data */ + textureUnit: number | undefined; +} + +/** + * Options to update a transfer function texture using control points + */ +export interface ControlPointTextureOptions { + /** controlPoints will be used to generate a lookup table as a first step */ + sortedControlPoints: SortedControlPoints; + /** textureUnit to update with the new transfer function texture data */ + textureUnit: number | undefined; + /** Data type of the control points */ + dataType: DataType; + /** Lookup table number of elements*/ + lookupTableSize: number; +} + +export interface TransferFunctionParameters { + sortedControlPoints: SortedControlPoints; + window: DataTypeInterval; + channel: number[]; + defaultColor: vec3; +} + +interface CanvasPosition { + normalizedX: number; + normalizedY: number; +} + +/** + * Transfer functions are controlled via a set of control points + * with an input value and an output RGBA color (Uint8). + * These control points are interpolated between to form a lookup table + * which maps an input data value to an RGBA color. + * Such a lookup table is used to form a texture, which can be sampled + * from during rendering. + */ +export class ControlPoint { + constructor( + public inputValue: number | Uint64, + public outputColor: vec4 = kZeroVec4, + ) {} + + /** Convert the input value to a normalized value between 0 and 1 */ + normalizedInput(range: DataTypeInterval): number { + return computeInvlerp(range, this.inputValue); + } + + /** Convert the input value to an integer index into the transfer function lookup texture */ + transferFunctionIndex( + dataRange: DataTypeInterval, + transferFunctionSize: number, + ): number { + return Math.floor( + this.normalizedInput(dataRange) * (transferFunctionSize - 1), + ); + } + interpolateColor(other: ControlPoint, t: number): vec4 { + const outputColor = vec4.create(); + for (let i = 0; i < 4; ++i) { + outputColor[i] = computeLerp( + [this.outputColor[i], other.outputColor[i]], + DataType.UINT8, + t, + ) as number; + } + return outputColor; + } + static copyFrom(other: ControlPoint) { + const inputValue = other.inputValue; + const outputColor = vec4.clone(other.outputColor); + return new ControlPoint(inputValue, outputColor); + } +} + +export class SortedControlPoints { + public range: DataTypeInterval; + constructor( + public controlPoints: ControlPoint[] = [], + public dataType: DataType, + private autoComputeRange: boolean = true, + ) { + this.controlPoints = controlPoints; + this.range = defaultDataTypeRange[dataType]; + this.sortAndComputeRange(); + } + get length() { + return this.controlPoints.length; + } + addPoint(controlPoint: ControlPoint) { + const { inputValue, outputColor } = controlPoint; + const exactMatch = this.controlPoints.findIndex( + (point) => point.inputValue === inputValue, + ); + if (exactMatch !== -1) { + this.updatePointColor(exactMatch, outputColor); + } + const newPoint = new ControlPoint(inputValue, outputColor); + this.controlPoints.push(newPoint); + this.sortAndComputeRange(); + } + removePoint(index: number) { + this.controlPoints.splice(index, 1); + this.computeRange(); + } + updatePoint(index: number, controlPoint: ControlPoint): number { + this.controlPoints[index] = controlPoint; + const value = controlPoint.inputValue; + const outputValue = controlPoint.outputColor; + this.sortAndComputeRange(); + // Return the index of the original point after sorting + for (let i = 0; i < this.controlPoints.length; ++i) { + if ( + this.controlPoints[i].inputValue === value && + arraysEqual(this.controlPoints[i].outputColor, outputValue) + ) { + return i; + } + } + return -1; + } + updatePointColor(index: number, color: vec4 | vec3) { + let outputColor = vec4.create(); + if (color.length === 3) { + const opacity = this.controlPoints[index].outputColor[3]; + outputColor = vec4.fromValues(color[0], color[1], color[2], opacity); + } else { + outputColor = vec4.clone(color as vec4); + } + this.controlPoints[index].outputColor = outputColor; + } + findNearestControlPointIndex(inputValue: number | Uint64) { + const controlPoint = new ControlPoint(inputValue); + const valueToFind = controlPoint.normalizedInput(this.range); + return this.findNearestControlPointIndexByNormalizedInput(valueToFind); + } + findNearestControlPointIndexByNormalizedInput(normalizedInput: number) { + return findClosestMatchInSortedArray( + this.controlPoints.map((point) => point.normalizedInput(this.range)), + normalizedInput, + (a, b) => a - b, + ); + } + private sortAndComputeRange() { + this.controlPoints.sort( + (a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range), + ); + this.computeRange(); + } + private computeRange() { + if (this.autoComputeRange) { + if (this.controlPoints.length == 0) { + this.range = defaultDataTypeRange[this.dataType]; + } else if (this.controlPoints.length === 1) { + this.range = [ + this.controlPoints[0].inputValue, + defaultDataTypeRange[this.dataType][1], + ] as DataTypeInterval; + } else { + this.range = [ + this.controlPoints[0].inputValue, + this.controlPoints[this.controlPoints.length - 1].inputValue, + ] as DataTypeInterval; + } + } + if (this.range[0] === this.range[1]) { + this.range = defaultDataTypeRange[this.dataType]; + } + } + copy() { + const copy = new SortedControlPoints( + [], + this.dataType, + this.autoComputeRange, + ); + copy.range = this.range; + copy.controlPoints = this.controlPoints.map((point) => + ControlPoint.copyFrom(point), + ); + return copy; + } +} + +export class LookupTable { + outputValues: Uint8Array; + constructor(public lookupTableSize: number) { + this.outputValues = new Uint8Array( + lookupTableSize * NUM_COLOR_CHANNELS, + ).fill(0); + } + + resize(newSize: number) { + this.lookupTableSize = newSize; + this.outputValues = new Uint8Array(newSize * NUM_COLOR_CHANNELS).fill(0); + } + + /** + * Fill a lookup table with color values between control points via linear interpolation. + * Everything before the first point is transparent, + * everything after the last point has the color of the last point. + * + * @param controlPoints The control points to interpolate between + * @param dataRange The range of the input data space + */ + updateFromControlPoints( + sortedControlPoints: SortedControlPoints, + window: DataTypeInterval | undefined = undefined, + ) { + const range = window ? window : sortedControlPoints.range; + const { controlPoints } = sortedControlPoints; + const out = this.outputValues; + const size = this.lookupTableSize; + function addLookupValue(index: number, color: vec4) { + out[index] = color[0]; + out[index + 1] = color[1]; + out[index + 2] = color[2]; + out[index + 3] = color[3]; + } + /** + * Convert the control point input value to an index in the transfer function lookup table + */ + function toTransferFunctionSpace(controlPoint: ControlPoint) { + return controlPoint.transferFunctionIndex(range, size); + } + + // If no control points - return all transparent + if (controlPoints.length === 0) { + out.fill(0); + return; + } + + // If first control point not at 0 - fill in transparent values + // up to the first point + const firstInputValue = toTransferFunctionSpace(controlPoints[0]); + if (firstInputValue > 0) { + const transparent = vec4.fromValues(0, 0, 0, 0); + for (let i = 0; i < firstInputValue; ++i) { + const index = i * NUM_COLOR_CHANNELS; + addLookupValue(index, transparent); + } + } + + // Interpolate between control points and fill to end with last color + let controlPointIndex = 0; + for (let i = firstInputValue; i < size; ++i) { + const currentPoint = controlPoints[controlPointIndex]; + const lookupIndex = i * NUM_COLOR_CHANNELS; + if (controlPointIndex === controlPoints.length - 1) { + addLookupValue(lookupIndex, currentPoint.outputColor); + } else { + const nextPoint = controlPoints[controlPointIndex + 1]; + const currentPointIndex = toTransferFunctionSpace(currentPoint); + const nextPointIndex = toTransferFunctionSpace(nextPoint); + const t = + (i - currentPointIndex) / (nextPointIndex - currentPointIndex); + const lerpedColor = currentPoint.interpolateColor(nextPoint, t); + addLookupValue(lookupIndex, lerpedColor); + if (i >= nextPointIndex) { + controlPointIndex++; + } + } + } + } + static equal(a: LookupTable, b: LookupTable) { + return arraysEqual(a.outputValues, b.outputValues); + } + copy() { + const copy = new LookupTable(this.lookupTableSize); + copy.outputValues.set(this.outputValues); + return copy; + } +} + +/** + * Handles a linked lookup table and control points for a transfer function. + */ +export class TransferFunction extends RefCounted { + lookupTable: LookupTable; + constructor( + public dataType: DataType, + public trackable: WatchableValueInterface, + size: number = defaultTransferFunctionSizes[dataType], + ) { + super(); + this.lookupTable = new LookupTable(size); + this.updateLookupTable(); + } + get sortedControlPoints() { + return this.trackable.value.sortedControlPoints; + } + get range() { + return this.sortedControlPoints.range; + } + get size() { + return this.lookupTable.lookupTableSize; + } + updateLookupTable(window: DataTypeInterval | undefined = undefined) { + this.lookupTable.updateFromControlPoints(this.sortedControlPoints, window); + } + addPoint(controlPoint: ControlPoint) { + this.sortedControlPoints.addPoint(controlPoint); + } + updatePoint(index: number, controlPoint: ControlPoint): number { + return this.sortedControlPoints.updatePoint(index, controlPoint); + } + removePoint(index: number) { + this.sortedControlPoints.removePoint(index); + } + updatePointColor(index: number, color: vec4 | vec3) { + this.sortedControlPoints.updatePointColor(index, color); + } + findNearestControlPointIndex( + normalizedInputValue: number, + dataWindow: DataTypeInterval, + ) { + const absoluteValue = computeLerp( + dataWindow, + this.dataType, + normalizedInputValue, + ); + return this.sortedControlPoints.findNearestControlPointIndex(absoluteValue); + } +} + +abstract class BaseLookupTexture extends RefCounted { + texture: WebGLTexture | null = null; + protected width: number; + protected height = 1; + protected priorOptions: + | LookupTableTextureOptions + | ControlPointTextureOptions + | undefined = undefined; + constructor(public gl: GL | null) { + super(); + } + /** + * Compare the existing options to the new options to determine if the texture needs to be updated + */ + abstract optionsEqual( + newOptions: LookupTableTextureOptions | ControlPointTextureOptions, + ): boolean; + abstract createLookupTable( + options: LookupTableTextureOptions | ControlPointTextureOptions, + ): LookupTable; + abstract setOptions( + options: LookupTableTextureOptions | ControlPointTextureOptions, + ): void; + updateAndActivate( + options: LookupTableTextureOptions | ControlPointTextureOptions, + ) { + const { gl } = this; + if (gl === null) return; + let { texture } = this; + + function activateAndBindTexture(gl: GL, textureUnit: number | undefined) { + if (textureUnit === undefined) { + throw new Error( + "Texture unit must be defined for transfer function texture", + ); + } + gl.activeTexture(WebGL2RenderingContext.TEXTURE0 + textureUnit); + gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, texture); + } + // If the texture is already up to date, just bind and activate it + if (texture !== null && this.optionsEqual(options)) { + activateAndBindTexture(gl, options.textureUnit); + return this.width * this.height; + } + // If the texture has not been created yet, create it + if (texture === null) { + texture = this.texture = gl.createTexture(); + } + // Update the texture + activateAndBindTexture(gl, options.textureUnit); + setRawTextureParameters(gl); + const lookupTable = this.createLookupTable(options); + + gl.texImage2D( + WebGL2RenderingContext.TEXTURE_2D, + 0, + WebGL2RenderingContext.RGBA, + this.width, + this.height, + 0, + WebGL2RenderingContext.RGBA, + WebGL2RenderingContext.UNSIGNED_BYTE, + lookupTable.outputValues, + ); + + // Update the prior options to the current options for future comparisons + this.setOptions(options); + return this.width * this.height; + } + setTextureWidthAndHeightFromSize(size: number) { + this.width = size; + } + disposed() { + this.gl?.deleteTexture(this.texture); + this.texture = null; + this.priorOptions = undefined; + super.disposed(); + } +} + +/** + * Represent the underlying transfer function lookup table as a texture + */ +class DirectLookupTableTexture extends BaseLookupTexture { + texture: WebGLTexture | null = null; + protected priorOptions: LookupTableTextureOptions | undefined = undefined; + + constructor(public gl: GL | null) { + super(gl); + } + optionsEqual(newOptions: LookupTableTextureOptions) { + const existingOptions = this.priorOptions; + if (existingOptions === undefined) return false; + const lookupTableEqual = LookupTable.equal( + existingOptions.lookupTable, + newOptions.lookupTable, + ); + const textureUnitEqual = + existingOptions.textureUnit === newOptions.textureUnit; + return lookupTableEqual && textureUnitEqual; + } + createLookupTable(options: LookupTableTextureOptions): LookupTable { + this.setTextureWidthAndHeightFromSize(options.lookupTable.lookupTableSize); + return options.lookupTable; + } + setOptions(options: LookupTableTextureOptions) { + this.priorOptions = { + ...options, + lookupTable: options.lookupTable.copy(), + }; + } +} + +export class ControlPointTexture extends BaseLookupTexture { + protected priorOptions: ControlPointTextureOptions | undefined; + constructor(public gl: GL | null) { + super(gl); + } + optionsEqual(newOptions: ControlPointTextureOptions): boolean { + const existingOptions = this.priorOptions; + if (existingOptions === undefined) return false; + const controlPointsEqual = arraysEqualWithPredicate( + existingOptions.sortedControlPoints.controlPoints, + newOptions.sortedControlPoints.controlPoints, + (a, b) => + a.inputValue === b.inputValue && + arraysEqual(a.outputColor, b.outputColor), + ); + const textureUnitEqual = + existingOptions.textureUnit === newOptions.textureUnit; + const dataTypeEqual = existingOptions.dataType === newOptions.dataType; + return controlPointsEqual && textureUnitEqual && dataTypeEqual; + } + setOptions(options: ControlPointTextureOptions) { + this.priorOptions = { + ...options, + sortedControlPoints: options.sortedControlPoints.copy(), + }; + } + createLookupTable(options: ControlPointTextureOptions): LookupTable { + const lookupTableSize = this.ensureTextureSize(options.lookupTableSize); + if (lookupTableSize === undefined) return new LookupTable(0); + this.setTextureWidthAndHeightFromSize(lookupTableSize); + const lookupTable = new LookupTable(lookupTableSize); + const sortedControlPoints = options.sortedControlPoints; + lookupTable.updateFromControlPoints(sortedControlPoints); + return lookupTable; + } + ensureTextureSize(size: number) { + const gl = this.gl; + if (gl === null) return; + const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE); + const tableTextureSize = Math.min(size, maxTextureSize); + return tableTextureSize; + } +} + +/** + * Display the UI canvas for the transfer function widget and + * handle shader updates for elements of the canvas + */ +class TransferFunctionPanel extends IndirectRenderedPanel { + texture: DirectLookupTableTexture; + private textureVertexBuffer: Buffer; + private textureVertexBufferArray: Float32Array; + private controlPointsVertexBuffer: Buffer; + private controlPointsPositionArray = new Float32Array(); + private controlPointsColorBuffer: Buffer; + private controlPointsColorArray = new Float32Array(); + private linePositionBuffer: Buffer; + private linePositionArray = new Float32Array(); + get drawOrder() { + return 1; + } + transferFunction = this.registerDisposer( + new TransferFunction( + this.parent.dataType, + this.parent.trackable, + TRANSFER_FUNCTION_PANEL_SIZE, + ), + ); + controller = this.registerDisposer( + new TransferFunctionController( + this.element, + this.parent.dataType, + this.transferFunction, + () => this.parent.trackable.value, + (value: TransferFunctionParameters) => { + this.parent.trackable.value = value; + }, + ), + ); + constructor(public parent: TransferFunctionWidget) { + super(parent.display, document.createElement("div"), parent.visibility); + const { element, gl } = this; + element.classList.add("neuroglancer-transfer-function-panel"); + this.textureVertexBufferArray = createGriddedRectangleArray( + TRANSFER_FUNCTION_PANEL_SIZE, + ); + this.texture = this.registerDisposer(new DirectLookupTableTexture(gl)); + + function createBuffer(dataArray: Float32Array) { + return getMemoizedBuffer( + gl, + WebGL2RenderingContext.ARRAY_BUFFER, + () => dataArray, + ).value; + } + this.textureVertexBuffer = this.registerDisposer( + createBuffer(this.textureVertexBufferArray), + ); + this.controlPointsVertexBuffer = this.registerDisposer( + createBuffer(this.controlPointsPositionArray), + ); + this.controlPointsColorBuffer = this.registerDisposer( + createBuffer(this.controlPointsColorArray), + ); + this.linePositionBuffer = this.registerDisposer( + createBuffer(this.linePositionArray), + ); + } + + updateTransferFunctionPointsAndLines() { + // Normalize position to [-1, 1] for shader (x axis) + const window = this.parent.trackable.value.window; + function normalizeInput(input: number | Uint64) { + const lerpedInput = computeInvlerp(window, input); + return lerpedInput * 2 - 1; + } + // Normalize opacity to [-1, 1] for shader (y axis) + function normalizeOpacity(opacity: number) { + return (opacity / 255) * 2 - 1; + } + // Normalize color to [0, 1] for shader (color channels) + function normalizeColor(colorComponent: number) { + return colorComponent / 255; + } + function addLine( + array: Float32Array, + index: number, + positions: vec4, + ): number { + for (let i = 0; i < VERTICES_PER_LINE; ++i) { + array[index++] = positions[0]; + array[index++] = positions[1]; + array[index++] = positions[2]; + array[index++] = positions[3]; + } + return index; + } + function isInWindow(normalizedInput: number) { + return normalizedInput >= -1 && normalizedInput <= 1; + } + + const { transferFunction } = this; + const { controlPoints } = + transferFunction.trackable.value.sortedControlPoints; + let numLines = Math.max(controlPoints.length - 1, 0); + const colorChannels = NUM_COLOR_CHANNELS - 1; // ignore alpha + const colorArray = new Float32Array(controlPoints.length * colorChannels); + const positionArray = new Float32Array(controlPoints.length * 2); + let linePositionArrayIndex = 0; + let lineFromLeftEdge = null; + let lineToRightEdge = null; + // Map all control points to normalized values for the shader + const normalizedControlPoints = controlPoints.map((point) => { + const input = normalizeInput(point.inputValue); + const output = normalizeOpacity(point.outputColor[3]); + return { input, output }; + }); + + // Create start and end lines if there are any control points + if (controlPoints.length > 0) { + // Try to find the first and last point in the window + + const { + firstPointIndexInWindow, + lastPointIndexInWindow, + pointClosestToLeftEdge, + pointClosestToRightEdge, + } = findPointsNearWindowBounds(); + // If there are no points in the window, check if everything is left or right of the window + // Draw a single line from the left edge to the right edge if all points are left of the window + if (firstPointIndexInWindow === null) { + const allPointsLeftOfWindow = + normalizedControlPoints[controlPoints.length - 1].input < -1; + const allPointsRightOfWindow = normalizedControlPoints[0].input > 1; + if (allPointsLeftOfWindow) { + drawHorizontalLineFromPointOutsideLeftWindow(); + } + // There are no points in the window, but points on either side + // Draw lines from the leftmost and rightmost points starting + // from the left edge and ending at the right edge via interpolation + else if (!allPointsRightOfWindow && controlPoints.length > 1) { + drawLineBetweenPointsBothOutsideWindow( + pointClosestToLeftEdge, + pointClosestToRightEdge, + ); + } + } else { + const firstPointInWindow = + normalizedControlPoints[firstPointIndexInWindow]; + if (firstPointInWindow.input > -1) { + // If there is a value to the left, draw a line from the point outside the window to the first point in the window + if (firstPointIndexInWindow > 0) { + drawLineBetweenPointInWindowAndLeftPointOutsideWindow( + firstPointIndexInWindow, + firstPointInWindow, + ); + } + // If the first point in the window is the leftmost point, draw a 0 line up to the point + else { + lineFromLeftEdge = vec4.fromValues( + firstPointInWindow.input, + -1, + firstPointInWindow.input, + firstPointInWindow.output, + ); + } + numLines += 1; + } + // Need to draw a line from the last control point in the window to the right edge + const lastPointInWindow = + normalizedControlPoints[lastPointIndexInWindow!]; + if (lastPointInWindow.input < 1) { + // If there is a value to the right, draw a line from the last point in the window to the point outside the window + if (lastPointIndexInWindow! < controlPoints.length - 1) { + drawLineBetweenPointInWindowAndRightPointOutsideWindow( + lastPointIndexInWindow, + lastPointInWindow, + ); + } + // If the last point in the window is the rightmost point, draw a line from the point to the right edge + else { + lineToRightEdge = vec4.fromValues( + lastPointInWindow.input, + lastPointInWindow.output, + 1, + lastPointInWindow.output, + ); + } + numLines += 1; + } + } + } + + const lines: vec4[] = []; + // Update points and draw lines between control points + for (let i = 0; i < controlPoints.length; ++i) { + const colorIndex = i * colorChannels; + const positionIndex = i * 2; + const inputValue = normalizedControlPoints[i].input; + const outputValue = normalizedControlPoints[i].output; + const { outputColor } = controlPoints[i]; + colorArray[colorIndex] = normalizeColor(outputColor[0]); + colorArray[colorIndex + 1] = normalizeColor(outputColor[1]); + colorArray[colorIndex + 2] = normalizeColor(outputColor[2]); + positionArray[positionIndex] = inputValue; + positionArray[positionIndex + 1] = outputValue; + + // Don't create a line for the last point + if (i === controlPoints.length - 1) break; + if (!(isInWindow(inputValue) && isInWindow(outputValue))) continue; + numLines += 1; + const lineBetweenPoints = vec4.fromValues( + inputValue, + outputValue, + normalizedControlPoints[i + 1].input, + normalizedControlPoints[i + 1].output, + ); + lines.push(lineBetweenPoints); + } + + // Create and fill the line position array + const linePositionArray = new Float32Array( + numLines * POSITION_VALUES_PER_LINE * VERTICES_PER_LINE, + ); + + if (lineFromLeftEdge !== null) { + linePositionArrayIndex = addLine( + linePositionArray, + linePositionArrayIndex, + lineFromLeftEdge, + ); + } + + for (const lineBetweenPoints of lines) { + linePositionArrayIndex = addLine( + linePositionArray, + linePositionArrayIndex, + lineBetweenPoints, + ); + } + + // Draw a horizontal line out from the last point + if (lineToRightEdge !== null) { + addLine(linePositionArray, linePositionArrayIndex, lineToRightEdge); + } + + // Update buffers + this.controlPointsColorArray = colorArray; + this.controlPointsPositionArray = positionArray; + this.linePositionArray = linePositionArray; + this.controlPointsVertexBuffer.setData(this.controlPointsPositionArray); + this.controlPointsColorBuffer.setData(this.controlPointsColorArray); + this.linePositionBuffer.setData(this.linePositionArray); + + function drawLineBetweenPointInWindowAndRightPointOutsideWindow( + lastPointIndexInWindow: number | null, + lastPointInWindow: { input: number; output: number }, + ) { + const pointAfterWindow = + normalizedControlPoints[lastPointIndexInWindow! + 1]; + const interpFactor = computeInvlerp( + [lastPointInWindow.input, pointAfterWindow.input], + 1, + ); + const lineEndY = computeLerp( + [lastPointInWindow.output, pointAfterWindow.output], + DataType.FLOAT32, + interpFactor, + ) as number; + lineToRightEdge = vec4.fromValues( + lastPointInWindow.input, + lastPointInWindow.output, + 1, + lineEndY, + ); + } + + function drawLineBetweenPointInWindowAndLeftPointOutsideWindow( + firstPointIndexInWindow: number, + firstPointInWindow: { input: number; output: number }, + ) { + const pointBeforeWindow = + normalizedControlPoints[firstPointIndexInWindow - 1]; + const interpFactor = computeInvlerp( + [pointBeforeWindow.input, firstPointInWindow.input], + -1, + ); + const lineStartY = computeLerp( + [pointBeforeWindow.output, firstPointInWindow.output], + DataType.FLOAT32, + interpFactor, + ) as number; + lineFromLeftEdge = vec4.fromValues( + -1, + lineStartY, + firstPointInWindow.input, + firstPointInWindow.output, + ); + } + + function drawLineBetweenPointsBothOutsideWindow( + pointClosestToLeftEdge: { input: number; output: number } | null, + pointClosestToRightEdge: { input: number; output: number } | null, + ) { + numLines += 1; + if (pointClosestToLeftEdge === null || pointClosestToRightEdge === null) { + throw new Error( + "Could not find points closest to the left and right edges", + ); + } + const leftInterpFactor = computeInvlerp( + [pointClosestToLeftEdge.input, pointClosestToRightEdge.input], + -1, + ); + const rightInterpFactor = computeInvlerp( + [pointClosestToLeftEdge.input, pointClosestToRightEdge.input], + 1, + ); + const leftLineY = computeLerp( + [pointClosestToLeftEdge.output, pointClosestToRightEdge.output], + DataType.FLOAT32, + leftInterpFactor, + ) as number; + const rightLineY = computeLerp( + [pointClosestToLeftEdge.output, pointClosestToRightEdge.output], + DataType.FLOAT32, + rightInterpFactor, + ) as number; + lineFromLeftEdge = vec4.fromValues(-1, leftLineY, 1, rightLineY); + } + + function findPointsNearWindowBounds() { + let firstPointIndexInWindow = null; + let lastPointIndexInWindow = null; + let pointClosestToLeftEdge = null; + let pointClosestToRightEdge = null; + for (let i = 0; i < controlPoints.length; ++i) { + const point = normalizedControlPoints[i]; + if (isInWindow(point.input)) { + firstPointIndexInWindow = firstPointIndexInWindow ?? i; + lastPointIndexInWindow = i; + } + if (point.input < -1) { + pointClosestToLeftEdge = point; + } else if (point.input > 1) { + pointClosestToRightEdge = point; + break; + } + } + return { + firstPointIndexInWindow, + lastPointIndexInWindow, + pointClosestToLeftEdge, + pointClosestToRightEdge, + }; + } + + function drawHorizontalLineFromPointOutsideLeftWindow() { + const indexOfReferencePoint = controlPoints.length - 1; + numLines += 1; + const referenceOpacity = + normalizedControlPoints[indexOfReferencePoint].output; + lineFromLeftEdge = vec4.fromValues( + -1, + referenceOpacity, + 1, + referenceOpacity, + ); + } + } + + private transferFunctionLineShader = this.registerDisposer( + (() => { + const builder = new ShaderBuilder(this.gl); + defineLineShader(builder); + builder.addAttribute("vec4", "aLineStartEnd"); + builder.addOutputBuffer("vec4", "out_color", 0); + builder.addVarying("float", "vColor"); + builder.setVertexMain(` +vec4 start = vec4(aLineStartEnd[0], aLineStartEnd[1], 0.0, 1.0); +vec4 end = vec4(aLineStartEnd[2], aLineStartEnd[3], 0.0, 1.0); +emitLine(start, end, 1.0); +`); + builder.setFragmentMain(` +out_color = vec4(0.0, 1.0, 1.0, getLineAlpha()); +`); + return builder.build(); + })(), + ); + + private transferFunctionShader = this.registerDisposer( + (() => { + const builder = new ShaderBuilder(this.gl); + builder.addAttribute("vec2", "aVertexPosition"); + builder.addVarying("vec2", "vTexCoord"); + builder.addOutputBuffer("vec4", "out_color", 0); + builder.addTextureSampler( + "sampler2D", + "uSampler", + transferFunctionSamplerTextureUnit, + ); + builder.addUniform("float", "uTransferFunctionEnd"); + builder.setVertexMain(` +gl_Position = vec4(aVertexPosition, 0.0, 1.0); +vTexCoord = (aVertexPosition + 1.0) / 2.0; +`); + builder.setFragmentMain(` +ivec2 texel = ivec2(floor(vTexCoord.x * uTransferFunctionEnd), 0); +out_color = texelFetch(uSampler, texel, 0); +`); + return builder.build(); + })(), + ); + + private controlPointsShader = this.registerDisposer( + (() => { + const builder = new ShaderBuilder(this.gl); + builder.addAttribute("vec2", "aVertexPosition"); + builder.addAttribute("vec3", "aVertexColor"); + builder.addVarying("vec3", "vColor"); + builder.addOutputBuffer("vec4", "out_color", 0); + builder.setVertexMain(` +gl_Position = vec4(aVertexPosition, 0.0, 1.0); +gl_PointSize = 14.0; +vColor = aVertexColor; +`); + // Draw control points as circles with a border + // The border is white if the color is dark, black if the color is light + builder.setFragmentMain(` +float vColorSum = vColor.r + vColor.g + vColor.b; +vec3 bordercolor = vec3(0.0, 0.0, 0.0); +if (vColorSum < 0.4) { + bordercolor = vec3(1.0, 1.0, 1.0); +} +float dist = distance(gl_PointCoord, vec2(0.5, 0.5)); +float alpha = smoothstep(0.25, 0.4, dist); +vec4 tempColor = vec4(mix(vColor, bordercolor, alpha), 1.0); +alpha = 1.0 - smoothstep(0.4, 0.5, dist); +out_color = tempColor * alpha; +`); + return builder.build(); + })(), + ); + + drawIndirect() { + const { + transferFunctionLineShader, + gl, + transferFunctionShader, + controlPointsShader, + } = this; + this.setGLLogicalViewport(); + gl.clearColor(0.0, 0.0, 0.0, 0.0); + gl.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT); + gl.enable(WebGL2RenderingContext.BLEND); + gl.blendFunc( + WebGL2RenderingContext.SRC_ALPHA, + WebGL2RenderingContext.ONE_MINUS_SRC_ALPHA, + ); + gl.disable(WebGL2RenderingContext.DEPTH_TEST); + gl.disable(WebGL2RenderingContext.STENCIL_TEST); + { + // Draw transfer function texture + transferFunctionShader.bind(); + const aVertexPosition = + transferFunctionShader.attribute("aVertexPosition"); + gl.uniform1f( + transferFunctionShader.uniform("uTransferFunctionEnd"), + TRANSFER_FUNCTION_PANEL_SIZE - 1, + ); + this.textureVertexBuffer.bindToVertexAttrib( + aVertexPosition, + /*components=*/ 2, + /*attributeType=*/ WebGL2RenderingContext.FLOAT, + ); + const textureUnit = transferFunctionShader.textureUnit( + transferFunctionSamplerTextureUnit, + ); + this.texture.updateAndActivate({ + lookupTable: this.transferFunction.lookupTable, + textureUnit, + }); + drawQuads(this.gl, TRANSFER_FUNCTION_PANEL_SIZE, 1); + gl.disableVertexAttribArray(aVertexPosition); + gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, null); + } + // Draw lines and control points on top of transfer function - if there are any + if (this.controlPointsPositionArray.length > 0) { + const { renderViewport } = this; + + // Draw transfer function lerp indicator lines + transferFunctionLineShader.bind(); + const aLineStartEnd = + transferFunctionLineShader.attribute("aLineStartEnd"); + this.linePositionBuffer.bindToVertexAttrib( + aLineStartEnd, + /*components=*/ 4, + /*attributeType=*/ WebGL2RenderingContext.FLOAT, + ); + initializeLineShader( + transferFunctionLineShader, + { + width: renderViewport.logicalWidth, + height: renderViewport.logicalHeight, + }, + /*featherWidthInPixels=*/ 1, + ); + drawLines( + gl, + this.linePositionArray.length / + (VERTICES_PER_LINE * POSITION_VALUES_PER_LINE), + 1, + ); + gl.disableVertexAttribArray(aLineStartEnd); + + // Draw control points of the transfer function + controlPointsShader.bind(); + const aVertexPosition = controlPointsShader.attribute("aVertexPosition"); + this.controlPointsVertexBuffer.bindToVertexAttrib( + aVertexPosition, + /*components=*/ 2, + /*attributeType=*/ WebGL2RenderingContext.FLOAT, + ); + const aVertexColor = controlPointsShader.attribute("aVertexColor"); + this.controlPointsColorBuffer.bindToVertexAttrib( + aVertexColor, + /*components=*/ 3, + /*attributeType=*/ WebGL2RenderingContext.FLOAT, + ); + gl.drawArrays(gl.POINTS, 0, this.controlPointsPositionArray.length / 2); + gl.disableVertexAttribArray(aVertexPosition); + gl.disableVertexAttribArray(aVertexColor); + } + gl.disable(WebGL2RenderingContext.BLEND); + } + update() { + this.transferFunction.updateLookupTable(this.parent.trackable.value.window); + this.updateTransferFunctionPointsAndLines(); + } + isReady() { + return true; + } +} + +/** + * Create the bounds on the UI window inputs for the transfer function widget + */ +function createWindowBoundInputs( + dataType: DataType, + model: WatchableValueInterface, +) { + function createWindowBoundInput(endpoint: number): HTMLInputElement { + const e = document.createElement("input"); + e.addEventListener("focus", () => { + e.select(); + }); + e.classList.add("neuroglancer-transfer-function-widget-bound"); + e.type = "text"; + e.spellcheck = false; + e.autocomplete = "off"; + e.title = `${ + endpoint === 0 ? "Lower" : "Upper" + } window for transfer function`; + return e; + } + + const container = document.createElement("div"); + container.classList.add("neuroglancer-transfer-function-window-bounds"); + const inputs = [createWindowBoundInput(0), createWindowBoundInput(1)]; + for (let endpointIndex = 0; endpointIndex < 2; ++endpointIndex) { + const input = inputs[endpointIndex]; + input.addEventListener("input", () => { + updateInputBoundWidth(input); + }); + input.addEventListener("change", () => { + const existingBounds = model.value.window; + const intervals = { range: existingBounds, window: existingBounds }; + try { + const value = parseDataTypeValue(dataType, input.value); + const window = getUpdatedRangeAndWindowParameters( + intervals, + "window", + endpointIndex, + value, + /*fitRangeInWindow=*/ true, + ).window; + if (window[0] === window[1]) { + throw new Error("Window bounds cannot be equal"); + } + model.value = { ...model.value, window }; + } catch { + updateInputBoundValue(input, existingBounds[endpointIndex]); + } + }); + } + container.appendChild(inputs[0]); + container.appendChild(inputs[1]); + return { + container, + inputs, + }; +} + +const inputEventMap = EventActionMap.fromObject({ + "shift?+mousedown0": { action: "add-or-drag-point" }, + "shift+dblclick0": { action: "remove-point" }, + "shift?+mousedown2": { action: "change-point-color" }, +}); + +/** + * Controller for the transfer function widget. Handles mouse events and updates to the model. + */ +class TransferFunctionController extends RefCounted { + private currentGrabbedControlPointIndex = -1; + constructor( + public element: HTMLElement, + public dataType: DataType, + private transferFunction: TransferFunction, + public getModel: () => TransferFunctionParameters, + public setModel: (value: TransferFunctionParameters) => void, + ) { + super(); + element.title = inputEventMap.describe(); + this.registerDisposer(new MouseEventBinder(element, inputEventMap)); + registerActionListener( + element, + "add-or-drag-point", + (actionEvent) => { + const mouseEvent = actionEvent.detail; + this.updateValue(this.addControlPoint(mouseEvent)); + startRelativeMouseDrag(mouseEvent, (newEvent: MouseEvent) => { + this.updateValue(this.moveControlPoint(newEvent)); + }); + }, + ); + registerActionListener( + element, + "remove-point", + (actionEvent) => { + const mouseEvent = actionEvent.detail; + const nearestIndex = this.findControlPointIfNearCursor(mouseEvent); + if (nearestIndex !== -1) { + this.transferFunction.removePoint(nearestIndex); + this.updateValue({ + ...this.getModel(), + sortedControlPoints: + this.transferFunction.trackable.value.sortedControlPoints, + }); + } + }, + ); + registerActionListener( + element, + "change-point-color", + (actionEvent) => { + const mouseEvent = actionEvent.detail; + const nearestIndex = this.findControlPointIfNearCursor(mouseEvent); + if (nearestIndex !== -1) { + const color = this.transferFunction.trackable.value.defaultColor; + const colorInAbsoluteValue = + this.convertPanelSpaceColorToAbsoluteValue(color); + this.transferFunction.updatePointColor( + nearestIndex, + colorInAbsoluteValue, + ); + this.updateValue({ + ...this.getModel(), + sortedControlPoints: + this.transferFunction.trackable.value.sortedControlPoints, + }); + } + }, + ); + } + updateValue(value: TransferFunctionParameters | undefined) { + if (value === undefined) return; + this.setModel(value); + } + convertPanelSpaceInputToAbsoluteValue(inputValue: number) { + return computeLerp( + this.transferFunction.trackable.value.window, + this.dataType, + inputValue, + ); + } + convertPanelSpaceColorToAbsoluteValue(color: vec3 | vec4) { + if (color.length === 3) { + // If color is vec3 + return vec3.fromValues( + Math.round(color[0] * 255), + Math.round(color[1] * 255), + Math.round(color[2] * 255), + ); + } else { + // If color is vec4 + return vec4.fromValues( + Math.round(color[0] * 255), + Math.round(color[1] * 255), + Math.round(color[2] * 255), + Math.round(color[3] * 255), + ); + } + } + addControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { + const color = this.transferFunction.trackable.value.defaultColor; + const nearestIndex = this.findControlPointIfNearCursor(event); + if (nearestIndex !== -1) { + this.currentGrabbedControlPointIndex = nearestIndex; + return undefined; + } + const position = this.getControlPointPosition(event); + if (position === undefined) return undefined; + const { normalizedX, normalizedY } = position; + const outputColor = vec4.fromValues( + color[0], + color[1], + color[2], + normalizedY, + ); + this.transferFunction.addPoint( + new ControlPoint( + this.convertPanelSpaceInputToAbsoluteValue(normalizedX), + this.convertPanelSpaceColorToAbsoluteValue(outputColor) as vec4, + ), + ); + this.currentGrabbedControlPointIndex = + this.findControlPointIfNearCursor(event); + return { + ...this.getModel(), + sortedControlPoints: + this.transferFunction.trackable.value.sortedControlPoints, + }; + } + moveControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { + if (this.currentGrabbedControlPointIndex !== -1) { + const position = this.getControlPointPosition(event); + if (position === undefined) return undefined; + const { normalizedX, normalizedY } = position; + const newColor = + this.transferFunction.trackable.value.sortedControlPoints.controlPoints[ + this.currentGrabbedControlPointIndex + ].outputColor; + newColor[3] = Math.round(normalizedY * 255); + this.currentGrabbedControlPointIndex = this.transferFunction.updatePoint( + this.currentGrabbedControlPointIndex, + new ControlPoint( + this.convertPanelSpaceInputToAbsoluteValue(normalizedX), + newColor, + ), + ); + return { + ...this.getModel(), + sortedControlPoints: + this.transferFunction.trackable.value.sortedControlPoints, + }; + } + return undefined; + } + getControlPointPosition(event: MouseEvent): CanvasPosition | undefined { + const clientRect = this.element.getBoundingClientRect(); + let normalizedX = (event.clientX - clientRect.left) / clientRect.width; + let normalizedY = (clientRect.bottom - event.clientY) / clientRect.height; + if ( + normalizedX < 0 || + normalizedX > 1 || + normalizedY < 0 || + normalizedY > 1 + ) + return undefined; + + // Near the borders of the transfer function, snap the control point to the border + if (normalizedX < TRANSFER_FUNCTION_BORDER_WIDTH / 3) { + normalizedX = 0.0; + } else if (normalizedX > 1 - TRANSFER_FUNCTION_BORDER_WIDTH / 3) { + normalizedX = 1.0; + } + if (normalizedY < TRANSFER_FUNCTION_BORDER_WIDTH) { + normalizedY = 0.0; + } else if (normalizedY > 1 - TRANSFER_FUNCTION_BORDER_WIDTH) { + normalizedY = 1.0; + } + + return { normalizedX, normalizedY }; + } + /** + * Find the nearest control point to the cursor or -1 if no control point is near the cursor. + * If multiple control points are near the cursor in X, the control point with the smallest + * distance in the Y direction is returned. + */ + findControlPointIfNearCursor(event: MouseEvent) { + const { transferFunction } = this; + const { window } = transferFunction.trackable.value; + const numControlPoints = + transferFunction.sortedControlPoints.controlPoints.length; + function convertControlPointInputToPanelSpace(controlPointIndex: number) { + if (controlPointIndex < 0 || controlPointIndex >= numControlPoints) { + return null; + } + return computeInvlerp( + window, + transferFunction.sortedControlPoints.controlPoints[controlPointIndex] + .inputValue, + ); + } + function convertControlPointOpacityToPanelSpace(controlPointIndex: number) { + if (controlPointIndex < 0 || controlPointIndex >= numControlPoints) { + return null; + } + return ( + transferFunction.sortedControlPoints.controlPoints[controlPointIndex] + .outputColor[3] / 255 + ); + } + const position = this.getControlPointPosition(event); + if (position === undefined) return -1; + const mouseXPosition = position.normalizedX; + const mouseYPosition = position.normalizedY; + const nearestControlPointIndex = + transferFunction.findNearestControlPointIndex(mouseXPosition, window); + if (nearestControlPointIndex === -1) { + return -1; + } + const nearestControlPointPanelPosition = + convertControlPointInputToPanelSpace(nearestControlPointIndex)!; + if ( + Math.abs(mouseXPosition - nearestControlPointPanelPosition) > + CONTROL_POINT_X_GRAB_DISTANCE + ) { + return -1; + } + // If points are nearby in X space, use Y space to break ties + const possibleMatches: [number, number][] = [ + [ + nearestControlPointIndex, + Math.abs( + convertControlPointOpacityToPanelSpace(nearestControlPointIndex)! - + mouseYPosition, + ), + ], + ]; + const nextPosition = convertControlPointInputToPanelSpace( + nearestControlPointIndex + 1, + ); + const nextDistance = + nextPosition !== null + ? Math.abs(nextPosition - mouseXPosition) + : Infinity; + if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { + possibleMatches.push([ + nearestControlPointIndex + 1, + Math.abs( + convertControlPointOpacityToPanelSpace( + nearestControlPointIndex + 1, + )! - mouseYPosition, + ), + ]); + } + + const previousPosition = convertControlPointInputToPanelSpace( + nearestControlPointIndex - 1, + ); + const previousDistance = + previousPosition !== null + ? Math.abs(previousPosition - mouseXPosition) + : Infinity; + if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { + possibleMatches.push([ + nearestControlPointIndex - 1, + Math.abs( + convertControlPointOpacityToPanelSpace( + nearestControlPointIndex - 1, + )! - mouseYPosition, + ), + ]); + } + const bestMatch = possibleMatches.sort((a, b) => a[1] - b[1])[0][0]; + return bestMatch; + } +} + +/** + * Widget for the transfer function. Creates the UI elements required for the transfer function. + */ +class TransferFunctionWidget extends Tab { + private transferFunctionPanel = this.registerDisposer( + new TransferFunctionPanel(this), + ); + + window = createWindowBoundInputs(this.dataType, this.trackable); + constructor( + visibility: WatchableVisibilityPriority, + public display: DisplayContext, + public dataType: DataType, + public trackable: WatchableValueInterface, + ) { + super(visibility); + const { element } = this; + element.classList.add("neuroglancer-transfer-function-widget"); + element.appendChild(this.transferFunctionPanel.element); + + // Range bounds element + element.appendChild(this.window.container); + this.window.container.dispatchEvent(new Event("change")); + + // Color picker element + const colorPickerDiv = document.createElement("div"); + colorPickerDiv.classList.add("neuroglancer-transfer-function-color-picker"); + const colorPicker = this.registerDisposer( + new ColorWidget( + makeCachedDerivedWatchableValue( + (x: TransferFunctionParameters) => x.defaultColor, + [trackable], + ), + () => vec3.fromValues(1, 1, 1), + ), + ); + colorPicker.element.title = "Transfer Function Color Picker"; + colorPicker.element.id = "neuroglancer-tf-color-widget"; + colorPicker.element.addEventListener("change", () => { + trackable.value = { + ...this.trackable.value, + defaultColor: colorPicker.model.value, + }; + }); + colorPicker.element.addEventListener("input", () => { + trackable.value = { + ...this.trackable.value, + defaultColor: colorPicker.model.value, + }; + }); + colorPickerDiv.appendChild(colorPicker.element); + + element.appendChild(colorPickerDiv); + this.updateControlPointsAndDraw(); + this.registerDisposer( + this.trackable.changed.add(() => { + this.updateControlPointsAndDraw(); + }), + ); + updateInputBoundValue( + this.window.inputs[0], + this.trackable.value.window[0], + ); + updateInputBoundValue( + this.window.inputs[1], + this.trackable.value.window[1], + ); + } + updateView() { + this.transferFunctionPanel.scheduleRedraw(); + } + updateControlPointsAndDraw() { + this.transferFunctionPanel.update(); + this.updateView(); + } +} + +/** + * Create a shader function for the transfer function to grab the nearest lookup table value + */ +export function defineTransferFunctionShader( + builder: ShaderBuilder, + name: string, + dataType: DataType, + channel: number[], +) { + builder.addUniform("highp float", `uTransferFunctionEnd_${name}`); + builder.addTextureSampler( + "sampler2D", + `uTransferFunctionSampler_${name}`, + `TransferFunction.${name}`, + ); + const invlerpShaderCode = defineInvlerpShaderFunction( + builder, + name, + dataType, + true, + ) as ShaderCodePart[]; + const shaderType = getShaderType(dataType); + // Use ${name}_ to avoid name collisions with other shader functions in the case of FLOAT32 dtype + let code = ` +vec4 ${name}_(float inputValue) { + int index = clamp(int(round(inputValue * uTransferFunctionEnd_${name})), 0, int(uTransferFunctionEnd_${name})); + return texelFetch(uTransferFunctionSampler_${name}, ivec2(index, 0), 0); +} +vec4 ${name}(${shaderType} inputValue) { + float v = computeInvlerp(inputValue, uLerpParams_${name}); + defaultMaxProjectionIntensity = v; + return v < 0.0 ? vec4(0.0, 0.0, 0.0, 0.0) : ${name}_(clamp(v, 0.0, 1.0)); +} +vec4 ${name}() { + return ${name}(getDataValue(${channel.join(",")})); +} +`; + if (dataType !== DataType.UINT64 && dataType !== DataType.FLOAT32) { + const scalarType = DATA_TYPE_SIGNED[dataType] ? "int" : "uint"; + code += ` +vec4 ${name}(${scalarType} inputValue) { + return ${name}(${shaderType}(inputValue)); +} +`; + } + return [ + invlerpShaderCode[0], + invlerpShaderCode[1], + invlerpShaderCode[2], + code, + ]; +} + +/** + * Create a lookup table and bind that lookup table to a shader via uniforms + */ +export function enableTransferFunctionShader( + shader: ShaderProgram, + name: string, + dataType: DataType, + sortedControlPoints: SortedControlPoints, + lookupTableSize: number = defaultTransferFunctionSizes[dataType], +) { + const { gl } = shader; + const texture = shader.transferFunctionTextures.get( + `TransferFunction.${name}`, + ); + // Create a lookup table texture if it does not exist + if (texture === undefined) { + shader.transferFunctionTextures.set( + `TransferFunction.${name}`, + new ControlPointTexture(gl), + ); + } + const textureSize = shader.bindAndUpdateTransferFunctionTexture( + `TransferFunction.${name}`, + sortedControlPoints, + dataType, + lookupTableSize, + ); + if (textureSize === undefined) { + throw new Error("Failed to create transfer function texture"); + } + + // Bind the length of the lookup table to the shader as a uniform + gl.uniform1f(shader.uniform(`uTransferFunctionEnd_${name}`), textureSize - 1); + + // Use the lerp shader function to grab an index into the lookup table + const interval = sortedControlPoints.range; + enableLerpShaderFunction(shader, name, dataType, interval); +} + +/** + * Behaviour of the transfer function widget in the tool popup window + */ +export function activateTransferFunctionTool( + activation: ToolActivation, + control: TransferFunctionWidget, +) { + activation.bindInputEventMap(inputEventMap); + control; +} + +/** + * Create a layer control factory for the transfer function widget + */ +export function transferFunctionLayerControl( + getter: (layer: LayerType) => { + watchableValue: WatchableValueInterface; + defaultChannel: number[]; + channelCoordinateSpaceCombiner: CoordinateSpaceCombiner | undefined; + dataType: DataType; + }, +): LayerControlFactory { + return { + makeControl: (layer, context, options) => { + const { + watchableValue, + channelCoordinateSpaceCombiner, + defaultChannel, + dataType, + } = getter(layer); + + // Setup the ability to change the channel through the UI here + // but only if the data has multiple channels + if ( + channelCoordinateSpaceCombiner !== undefined && + defaultChannel.length !== 0 + ) { + const position = context.registerDisposer( + new Position(channelCoordinateSpaceCombiner.combined), + ); + const positiionWidget = context.registerDisposer( + new PositionWidget(position, channelCoordinateSpaceCombiner, { + copyButton: false, + }), + ); + context.registerDisposer( + position.changed.add(() => { + const value = position.value; + const newChannel = Array.from(value, (x) => Math.floor(x)); + const oldParams = watchableValue.value; + if (!arraysEqual(oldParams.channel, newChannel)) { + watchableValue.value = { + ...watchableValue.value, + channel: newChannel, + }; + } + }), + ); + const updatePosition = () => { + const value = position.value; + const params = watchableValue.value; + if (!arraysEqual(params.channel, value)) { + value.set(params.channel); + position.changed.dispatch(); + } + }; + updatePosition(); + context.registerDisposer(watchableValue.changed.add(updatePosition)); + options.labelContainer.appendChild(positiionWidget.element); + } + const control = context.registerDisposer( + new TransferFunctionWidget( + options.visibility, + options.display, + dataType, + watchableValue, + ), + ); + return { control, controlElement: control.element }; + }, + activateTool: (activation, control) => { + activateTransferFunctionTool(activation, control); + }, + }; +}