From 379febd353162d918ce3d151662e641abadc13b2 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 31 Jan 2024 18:40:00 +0100 Subject: [PATCH 01/60] feat: transfer function widget --- src/sliceview/volume/renderlayer.ts | 1 + src/util/array.spec.ts | 22 + src/util/array.ts | 33 + src/volume_rendering/volume_render_layer.ts | 1 + src/webgl/rectangle_grid_buffer.spec.ts | 36 + src/webgl/rectangle_grid_buffer.ts | 79 ++ src/webgl/shader.ts | 45 +- src/webgl/shader_ui_controls.spec.ts | 314 ++++- src/webgl/shader_ui_controls.ts | 457 ++++++- src/widget/invlerp.ts | 4 +- src/widget/shader_controls.ts | 10 + src/widget/transfer_function.css | 41 + src/widget/transfer_function.spec.ts | 198 +++ src/widget/transfer_function.ts | 1223 +++++++++++++++++++ 14 files changed, 2454 insertions(+), 10 deletions(-) create mode 100644 src/webgl/rectangle_grid_buffer.spec.ts create mode 100644 src/webgl/rectangle_grid_buffer.ts create mode 100644 src/widget/transfer_function.css create mode 100644 src/widget/transfer_function.spec.ts create mode 100644 src/widget/transfer_function.ts diff --git a/src/sliceview/volume/renderlayer.ts b/src/sliceview/volume/renderlayer.ts index 55419a627..635a99dad 100644 --- a/src/sliceview/volume/renderlayer.ts +++ b/src/sliceview/volume/renderlayer.ts @@ -579,6 +579,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 a02ba9923..a98444589 100644 --- a/src/util/array.spec.ts +++ b/src/util/array.spec.ts @@ -21,6 +21,7 @@ import { spliceArray, tile2dArray, transposeArray2d, + findClosestMatchInSortedArray, } from "#/util/array"; describe("partitionArray", () => { @@ -205,3 +206,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 14536d220..8c042334c 100644 --- a/src/volume_rendering/volume_render_layer.ts +++ b/src/volume_rendering/volume_render_layer.ts @@ -431,6 +431,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..fd642ec39 --- /dev/null +++ b/src/webgl/rectangle_grid_buffer.spec.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2023 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 { createGriddedRectangleArray } from "#/webgl/rectangle_grid_buffer"; + +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, -1, 1, 0, -1, -1, -1, 0, 1, 1, 1, 1, -1, 0, 1, 1, + -1, 0, -1, + ]), + ); + const resultReverse = createGriddedRectangleArray(2, 1, -1, -1, 1); + expect(resultReverse).toEqual( + new Float32Array([ + 1, -1, 0, -1, 0, 1, 1, -1, 0, 1, 1, 1, 0, -1, -1, -1, -1, 1, 0, -1, -1, + 1, 0, 1, + ]), + ); + }); +}); diff --git a/src/webgl/rectangle_grid_buffer.ts b/src/webgl/rectangle_grid_buffer.ts new file mode 100644 index 000000000..5bbe6906a --- /dev/null +++ b/src/webgl/rectangle_grid_buffer.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2023 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 "#/webgl/buffer"; +import { GL } from "#/webgl/context"; +import { VERTICES_PER_QUAD } from "#/webgl/quad"; + +/** + * Create a Float32Array of vertices gridded in a rectangle + */ +export function createGriddedRectangleArray( + numGrids: number, + startX: number = -1, + endX: number = 1, + startY: number = 1, + endY: number = -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 gridded in a rectangle, useful for drawing grids, e.g. for a histogram + * or a lookup table / heatmap + */ +export function getGriddedRectangleBuffer( + gl: GL, + numGrids: number, + startX: number = -1, + endX: number = 1, + startY: number = 1, + endY: number = -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 8c8c8c33f..716a7d7df 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -16,6 +16,10 @@ import { RefCounted } from "#/util/disposable"; import { GL } from "#/webgl/context"; +import { + ControlPoint, + TransferFunctionTexture, +} from "#/widget/transfer_function"; const DEBUG_SHADER = false; @@ -162,6 +166,10 @@ export class ShaderProgram extends RefCounted { textureUnits: Map; vertexShaderInputBinders: { [name: string]: VertexShaderInputBinder } = {}; vertexDebugOutputs?: VertexDebugOutput[]; + transferFunctionTextures: Map = new Map< + any, + TransferFunctionTexture + >(); constructor( public gl: GL, @@ -235,7 +243,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)!; } @@ -244,6 +252,34 @@ export class ShaderProgram extends RefCounted { this.gl.useProgram(this.program); } + bindAndUpdateTransferFunctionTexture( + symbol: Symbol | string, + controlPoints: ControlPoint[], + ) { + 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()}`, + ); + } + texture.updateAndActivate({ textureUnit, controlPoints }); + } + + unbindTransferFunctionTextures() { + const gl = this.gl; + for (let 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); @@ -255,6 +291,7 @@ export class ShaderProgram extends RefCounted { this.gl = undefined; this.attributes = undefined; this.uniforms = undefined; + this.transferFunctionTextures = undefined; } } @@ -446,7 +483,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) {} @@ -459,7 +496,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()); } @@ -472,7 +509,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.spec.ts b/src/webgl/shader_ui_controls.spec.ts index d0f3fe95e..b5ff05d7e 100644 --- a/src/webgl/shader_ui_controls.spec.ts +++ b/src/webgl/shader_ui_controls.spec.ts @@ -15,11 +15,13 @@ */ import { DataType } from "#/util/data_type"; -import { vec3 } from "#/util/geom"; +import { vec3, vec4 } from "#/util/geom"; import { parseShaderUiControls, stripComments, } from "#/webgl/shader_ui_controls"; +import { TRANSFER_FUNCTION_LENGTH } from "#/widget/transfer_function"; +import { defaultDataTypeRange } from "#/util/lerp"; describe("stripComments", () => { it("handles code without comments", () => { @@ -561,4 +563,314 @@ void main() { ]), }); }); + it("handles transfer function control without channel", () => { + const code = ` +#uicontrol transferFunction colormap(points=[]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + 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: { + controlPoints: [], + channel: [], + color: vec3.fromValues(1, 1, 1), + range: [0, 255], + }, + }, + ], + ]), + }); + }); + it("handles transfer function control without channel (rank 1)", () => { + const code = ` +#uicontrol transferFunction colormap(points=[]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT8, channelRank: 1 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.UINT8, + default: { + controlPoints: [], + channel: [0], + color: vec3.fromValues(1, 1, 1), + range: [0, 255], + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with channel (rank 0)", () => { + const code = ` +#uicontrol transferFunction colormap(points=[], channel=[]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + 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: { + controlPoints: [], + channel: [], + color: vec3.fromValues(1, 1, 1), + range: [0, 255], + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with non-array channel (rank 1)", () => { + const code = ` +#uicontrol transferFunction colormap(points=[], channel=1) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT8, channelRank: 1 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.UINT8, + default: { + controlPoints: [], + channel: [1], + color: vec3.fromValues(1, 1, 1), + range: [0, 255], + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with array channel (rank 1)", () => { + const code = ` +#uicontrol transferFunction colormap(points=[], channel=[1]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + expect( + parseShaderUiControls(code, { + imageData: { dataType: DataType.UINT8, channelRank: 1 }, + }), + ).toEqual({ + source: code, + code: newCode, + errors: [], + controls: new Map([ + [ + "colormap", + { + type: "transferFunction", + dataType: DataType.UINT8, + default: { + controlPoints: [], + channel: [1], + color: vec3.fromValues(1, 1, 1), + range: [0, 255], + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with array channel (rank 2)", () => { + const code = ` +#uicontrol transferFunction colormap(points=[], channel=[1,2]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + 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: { + controlPoints: [], + channel: [1, 2], + color: vec3.fromValues(1, 1, 1), + range: [0, 1], + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with all properties non uint64 data", () => { + const code = ` +#uicontrol transferFunction colormap(points=[[200, "#00ff00", 0.1], [100, "#ff0000", 0.5], [0, "#000000", 0.0]], color="#0000ff", range=[0, 200], channel=[]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; + 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: { + controlPoints: [ + { position: 0, color: vec4.fromValues(0, 0, 0, 0) }, + { + position: Math.ceil(maxTransferFunctionPoints / 2), + color: vec4.fromValues(255, 0, 0, 128), + }, + { + position: maxTransferFunctionPoints, + color: vec4.fromValues(0, 255, 0, 26), + }, + ], + channel: [], + color: vec3.fromValues(0, 0, 1), + range: [0, 200], + }, + }, + ], + ]), + }); + }); + it("handles transfer function control with all properties uint64 data", () => { + const code = ` +#uicontrol transferFunction colormap(points=[["18446744073709551615", "#00ff00", 0.1], ["9223372111111111111", "#ff0000", 0.5], ["0", "#000000", 0.0]], color="#0000ff", channel=[]) +void main() { +} +`; + const newCode = ` + +void main() { +} +`; + const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; + 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: { + controlPoints: [ + { position: 0, color: vec4.fromValues(0, 0, 0, 0) }, + { + position: Math.ceil(maxTransferFunctionPoints / 2), + color: vec4.fromValues(255, 0, 0, 128), + }, + { + position: maxTransferFunctionPoints, + color: vec4.fromValues(0, 255, 0, 26), + }, + ], + channel: [], + color: vec3.fromValues(0, 0, 1), + range: defaultDataTypeRange[DataType.UINT64], + }, + }, + ], + ]), + }); + }); }); diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 884dabb8f..b712bc7bd 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -25,11 +25,16 @@ import { WatchableValueInterface, } from "#/trackable_value"; import { arraysEqual, arraysEqualWithPredicate } from "#/util/array"; -import { parseRGBColorSpecification, TrackableRGB } from "#/util/color"; +import { + parseRGBColorSpecification, + serializeColor, + TrackableRGB, +} from "#/util/color"; import { DataType } from "#/util/data_type"; import { RefCounted } from "#/util/disposable"; -import { vec3 } from "#/util/geom"; +import { vec3, vec4 } from "#/util/geom"; import { + parseArray, parseFixedLengthArray, verifyFiniteFloat, verifyInt, @@ -38,6 +43,8 @@ import { verifyString, } from "#/util/json"; import { + computeInvlerp, + computeLerp, convertDataTypeInterval, DataTypeInterval, dataTypeIntervalToJson, @@ -59,6 +66,15 @@ import { enableLerpShaderFunction, } from "#/webgl/lerp"; import { ShaderBuilder, ShaderProgram } from "#/webgl/shader"; +import { + ControlPoint, + defineTransferFunctionShader, + enableTransferFunctionShader, + floatToUint8, + ParsedControlPoint, + TRANSFER_FUNCTION_LENGTH, +} from "#/widget/transfer_function"; +import { Uint64 } from "#/util/uint64"; export interface ShaderSliderControl { type: "slider"; @@ -98,12 +114,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 +587,128 @@ function parsePropertyInvlerpDirective( }; } +function parseTransferFunctionDirective( + valueType: string, + parameters: DirectiveParameters, + dataContext: ShaderDataContext, +): DirectiveParseResult { + const imageData = dataContext.imageData; + const dataType = imageData?.dataType; + const channelRank = imageData?.channelRank; + let errors = []; + let channel = new Array(channelRank).fill(0); + let color = vec3.fromValues(1.0, 1.0, 1.0); + let range: DataTypeInterval | undefined; + const controlPoints = new Array(); + const parsedControlPoints = new Array(); + 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"); + } else { + range = defaultDataTypeRange[dataType]; + } + for (let [key, value] of parameters) { + try { + switch (key) { + case "channel": { + channel = parseInvlerpChannel(value, channel.length); + break; + } + case "color": { + color = parseRGBColorSpecification(value); + break; + } + case "range": { + if (dataType !== undefined) { + range = validateDataTypeInterval( + parseDataTypeInterval(value, dataType), + ); + } + break; + } + case "points": { + specifedPoints = true; + if (dataType !== undefined) { + parsedControlPoints.push( + ...convertTransferFunctionControlPoints(value, dataType), + ); + } + break; + } + default: + errors.push(`Invalid parameter: ${key}`); + break; + } + } catch (e) { + errors.push(`Invalid ${key} value: ${e.message}`); + } + } + + if (range === undefined) { + if (dataType !== undefined) range = defaultDataTypeRange[dataType]; + else range = [0, 1] as [number, number]; + } + if (controlPoints.length === 0 && !specifedPoints) { + const transferFunctionRange = [0, TRANSFER_FUNCTION_LENGTH - 1] as [ + number, + number, + ]; + const startPoint = computeLerp( + transferFunctionRange, + DataType.UINT16, + 0.4, + ) as number; + const endPoint = computeLerp( + transferFunctionRange, + DataType.UINT16, + 0.7, + ) as number; + controlPoints.push({ + position: startPoint, + color: vec4.fromValues(0, 0, 0, 0), + }); + controlPoints.push({ + position: endPoint, + color: vec4.fromValues(255, 255, 255, 255), + }); + } else { + for (const controlPoint of parsedControlPoints) { + const normalizedPosition = computeInvlerp(range, controlPoint.position); + const position = computeLerp( + [0, TRANSFER_FUNCTION_LENGTH - 1], + DataType.UINT16, + normalizedPosition, + ) as number; + controlPoints.push({ position: position, color: controlPoint.color }); + } + const pointPositions = new Set(); + for (let i = 0; i < controlPoints.length; i++) { + const controlPoint = controlPoints[i]; + if (pointPositions.has(controlPoint.position)) { + errors.push( + `Duplicate control point position: ${parsedControlPoints[i].position}`, + ); + } + pointPositions.add(controlPoint.position); + } + controlPoints.sort((a, b) => a.position - b.position); + } + if (errors.length > 0) { + return { errors }; + } + return { + control: { + type: "transferFunction", + dataType, + default: { controlPoints, channel, color, range }, + } as ShaderTransferFunctionControl, + errors: undefined, + }; +} + export interface ImageDataSpecification { dataType: DataType; channelRank: number; @@ -586,6 +731,7 @@ const controlParsers = new Map< ["color", parseColorDirective], ["invlerp", parseInvlerpDirective], ["checkbox", parseCheckboxDirective], + ["transferFunction", parseTransferFunctionDirective], ]); export function parseShaderUiControls( @@ -708,6 +854,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 +1075,280 @@ class TrackablePropertyInvlerpParameters extends TrackableValue { + // Validate input length and types + if ( + x.length !== 3 || + (typeof x[0] !== "number" && typeof x[0] !== "string") || + typeof x[1] !== "string" || + typeof x[2] !== "number" + ) { + throw new Error( + `Expected array of length 3 (x, "#RRGGBB", A), but received: ${JSON.stringify( + x, + )}`, + ); + } + + // Validate values + let position: number | Uint64; + if (dataType != DataType.UINT64) { + const defaultRange = defaultDataTypeRange[dataType] as [number, number]; + position = verifyFiniteFloat(x[0]); + if (position < defaultRange[0] || position > defaultRange[1]) { + throw new Error( + `Expected x in range [${defaultRange[0]}, ${ + defaultRange[1] + }], but received: ${JSON.stringify(x[0])}`, + ); + } + } else { + const defaultRange = defaultDataTypeRange[dataType] as [Uint64, Uint64]; + if (typeof x[0] !== "string") { + throw new Error( + `Expected string for Uint64, but received: ${JSON.stringify(x[0])}`, + ); + } + position = Uint64.parseString(x[0]); + if ( + Uint64.less(position, defaultRange[0]) || + Uint64.less(defaultRange[1], position) + ) { + throw new Error( + `Expected x in range [${defaultRange[0]}, ${ + defaultRange[1] + }], but received: ${JSON.stringify(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]); + return { + position: position, + color: vec4.fromValues( + floatToUint8(color[0]), + floatToUint8(color[1]), + floatToUint8(color[2]), + floatToUint8(x[2]), + ), + }; + }); +} + +function parseTransferFunctionControlPoints( + value: unknown, + range: DataTypeInterval, + dataType: DataType, +) { + function parsePosition(position: number | string): number { + const toConvert = + dataType === DataType.UINT64 + ? Uint64.parseString(position as string) + : (position as number); + const normalizedPosition = computeInvlerp(range, toConvert); + const positionInRange = computeLerp( + [0, TRANSFER_FUNCTION_LENGTH - 1], + DataType.UINT16, + normalizedPosition, + ) as number; + return positionInRange; + } + return parseArray(value, (x) => { + if ( + x.position === undefined || + x.color === undefined || + x.opacity === undefined + ) { + throw new Error( + `Expected object with position and color and opacity properties, but received: ${JSON.stringify( + x, + )}`, + ); + } + if (typeof x.position !== "number" && typeof x.position !== "string") { + throw new Error( + `Expected number or Uint64 string, but received: ${JSON.stringify( + x.position, + )}`, + ); + } + const color = parseRGBColorSpecification(x.color); + if (typeof x.opacity !== "number") { + throw new Error( + `Expected number but received: ${JSON.stringify(x.opacity)}`, + ); + } + const opacity = floatToUint8(Math.max(0, Math.min(1, x.opacity))); + const rgbaColor = vec4.fromValues( + floatToUint8(color[0]), + floatToUint8(color[1]), + floatToUint8(color[2]), + opacity, + ); + return { + position: parsePosition(x.position), + color: rgbaColor, + }; + }); +} + +function parseTransferFunctionParameters( + obj: unknown, + dataType: DataType, + defaultValue: TransferFunctionParameters, +): TransferFunctionParameters { + if (obj === undefined) return defaultValue; + verifyObject(obj); + const range = verifyOptionalObjectProperty( + obj, + "range", + (x) => parseDataTypeInterval(x, dataType), + defaultValue.range, + ); + const controlPoints = verifyOptionalObjectProperty( + obj, + "controlPoints", + (x) => parseTransferFunctionControlPoints(x, range, dataType), + defaultValue.controlPoints, + ); + return { + controlPoints: controlPoints, + channel: verifyOptionalObjectProperty( + obj, + "channel", + (x) => parseInvlerpChannel(x, defaultValue.channel.length), + defaultValue.channel, + ), + color: verifyOptionalObjectProperty( + obj, + "color", + (x) => parseRGBColorSpecification(x), + defaultValue.color, + ), + range: range, + }; +} + +function copyTransferFunctionParameters( + defaultValue: TransferFunctionParameters, +) { + return { + controlPoints: defaultValue.controlPoints.map((x) => ({ + position: x.position, + color: vec4.clone(x.color), + })), + channel: defaultValue.channel, + color: vec3.clone(defaultValue.color), + range: [defaultValue.range[0], defaultValue.range[1]] as [number, number], + }; +} + +class TrackableTransferFunctionParameters extends TrackableValue { + constructor( + public dataType: DataType, + public defaultValue: TransferFunctionParameters, + ) { + const defaultValueCopy = copyTransferFunctionParameters(defaultValue); + super(defaultValueCopy, (obj) => + parseTransferFunctionParameters(obj, dataType, defaultValue), + ); + } + + controlPointsToJson( + controlPoints: ControlPoint[], + range: DataTypeInterval, + dataType: DataType, + ) { + function positionToJson(position: number) { + const normalizedPosition = computeInvlerp( + [0, TRANSFER_FUNCTION_LENGTH - 1], + position, + ); + const positionInOriginalRange = computeLerp( + range, + dataType, + normalizedPosition, + ); + if (dataType === DataType.UINT64) { + return positionInOriginalRange.toString(); + } + return positionInOriginalRange; + } + + return controlPoints.map((x) => ({ + position: positionToJson(x.position), + color: serializeColor( + vec3.fromValues(x.color[0] / 255, x.color[1] / 255, x.color[2] / 255), + ), + opacity: x.color[3] / 255, + })); + } + + toJSON() { + const { + value: { channel, controlPoints, color }, + dataType, + defaultValue, + } = this; + const range = this.value.range; + const rangeJson = dataTypeIntervalToJson( + range, + dataType, + defaultValue.range, + ); + const channelJson = arraysEqual(defaultValue.channel, channel) + ? undefined + : channel; + const colorJson = arraysEqual(defaultValue.color, color) + ? undefined + : serializeColor(this.value.color); + const controlPointsJson = arraysEqualWithPredicate( + defaultValue.controlPoints, + controlPoints, + (a, b) => arraysEqual(a.color, b.color) && a.position == b.position, + ) + ? undefined + : this.controlPointsToJson(this.value.controlPoints, range, dataType); + if ( + rangeJson === undefined && + channelJson === undefined && + colorJson === undefined && + controlPointsJson === undefined + ) { + return undefined; + } + return { + range: rangeJson, + channel: channelJson, + color: colorJson, + controlPoints: controlPointsJson, + }; + } +} + function getControlTrackable(control: ShaderUiControl): { trackable: TrackableValueInterface; getBuilderValue: (value: any) => any; @@ -974,6 +1406,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 +1789,14 @@ function setControlInShader( case "checkbox": // Value is hard-coded in shader. break; + case "transferFunction": + enableTransferFunctionShader( + shader, + uName, + control.dataType, + value.controlPoints, + value.range, + ); } } diff --git a/src/widget/invlerp.ts b/src/widget/invlerp.ts index 7a2a3d0ed..d2427ec78 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 460255538..82d54e604 100644 --- a/src/widget/shader_controls.ts +++ b/src/widget/shader_controls.ts @@ -40,6 +40,7 @@ import { colorLayerControl } from "#/widget/layer_control_color"; import { propertyInvlerpLayerControl } from "#/widget/layer_control_property_invlerp"; import { rangeLayerControl } from "#/widget/layer_control_range"; import { Tab } from "#/widget/tab_view"; +import { transferFunctionLayerControl } from "#/widget/transfer_function"; export interface LegendShaderOptions extends ParameterizedEmitterDependentShaderOptions { @@ -111,6 +112,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.css b/src/widget/transfer_function.css new file mode 100644 index 000000000..5b0faeb25 --- /dev/null +++ b/src/widget/transfer_function.css @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2020 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.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-range-bounds { + display: flex; + justify-content: space-between; +} diff --git a/src/widget/transfer_function.spec.ts b/src/widget/transfer_function.spec.ts new file mode 100644 index 000000000..b2a4e7f80 --- /dev/null +++ b/src/widget/transfer_function.spec.ts @@ -0,0 +1,198 @@ +/** + * @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 { + lerpBetweenControlPoints, + TRANSFER_FUNCTION_LENGTH, + NUM_COLOR_CHANNELS, + ControlPoint, + defineTransferFunctionShader, + enableTransferFunctionShader, +} from "#/widget/transfer_function"; +import { vec4 } from "#/util/geom"; +import { DataType } from "#/util/data_type"; +import { fragmentShaderTest } from "#/webgl/shader_testing"; +import { defaultDataTypeRange } from "#/util/lerp"; +import { Uint64 } from "#/util/uint64"; +import { getShaderType } from "#/webgl/shader_lib"; + +describe("lerpBetweenControlPoints", () => { + const output = new Uint8Array(NUM_COLOR_CHANNELS * TRANSFER_FUNCTION_LENGTH); + it("returns transparent black when given no control points", () => { + const controlPoints: ControlPoint[] = []; + lerpBetweenControlPoints(output, controlPoints); + expect(output.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[] = [ + { position: 120, color: vec4.fromValues(21, 22, 254, 210) }, + ]; + lerpBetweenControlPoints(output, controlPoints); + expect( + output.slice(0, NUM_COLOR_CHANNELS * 120).every((value) => value === 0), + ).toBeTruthy(); + const endPiece = output.slice(NUM_COLOR_CHANNELS * 120); + const color = controlPoints[0].color; + expect( + endPiece.every( + (value, index) => value === color[index % NUM_COLOR_CHANNELS], + ), + ).toBeTruthy(); + }); + it("correctly interpolates between three control points", () => { + const controlPoints: ControlPoint[] = [ + { position: 120, color: vec4.fromValues(21, 22, 254, 210) }, + { position: 140, color: vec4.fromValues(0, 0, 0, 0) }, + { position: 200, color: vec4.fromValues(255, 255, 255, 255) }, + ]; + lerpBetweenControlPoints(output, controlPoints); + expect( + output.slice(0, NUM_COLOR_CHANNELS * 120).every((value) => value === 0), + ).toBeTruthy(); + expect( + output.slice(NUM_COLOR_CHANNELS * 200).every((value) => value === 255), + ).toBeTruthy(); + + const firstColor = controlPoints[0].color; + const secondColor = controlPoints[1].color; + for (let i = 120 * NUM_COLOR_CHANNELS; i < 140 * NUM_COLOR_CHANNELS; i++) { + const difference = Math.floor((i - 120 * NUM_COLOR_CHANNELS) / 4); + const expectedValue = + firstColor[i % NUM_COLOR_CHANNELS] + + ((secondColor[i % NUM_COLOR_CHANNELS] - + firstColor[i % NUM_COLOR_CHANNELS]) * + difference) / + 20; + const decimalPart = expectedValue - Math.floor(expectedValue); + // If the decimal part is 0.5, it could be rounded up or down depending on precision. + 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 thirdColor = controlPoints[2].color; + for (let i = 140 * NUM_COLOR_CHANNELS; i < 200 * NUM_COLOR_CHANNELS; i++) { + const difference = Math.floor((i - 140 * NUM_COLOR_CHANNELS) / 4); + const expectedValue = + secondColor[i % NUM_COLOR_CHANNELS] + + ((thirdColor[i % NUM_COLOR_CHANNELS] - + secondColor[i % NUM_COLOR_CHANNELS]) * + difference) / + 60; + const decimalPart = expectedValue - Math.floor(expectedValue); + // If the decimal part is 0.5, it could be rounded up or down depending on precision. + 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)); + } + } + }); +}); + +describe("compute transfer function on GPU", () => { + const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; + const controlPoints: ControlPoint[] = [ + { position: 0, color: vec4.fromValues(0, 0, 0, 0) }, + { + position: maxTransferFunctionPoints, + color: vec4.fromValues(255, 255, 255, 255), + }, + ]; + for (const dataType of Object.values(DataType)) { + if (typeof dataType === "string") continue; + it(`computes transfer function on GPU for ${DataType[dataType]}`, () => { + console.log("Testing " + DataType[dataType]); + const shaderType = getShaderType(dataType); + fragmentShaderTest( + { inputValue: dataType }, + { val1: "float", val2: "float", val3: "float", val4: "float" }, + (tester) => { + const { builder } = tester; + // TODO (SKM) might need this for max projection + // builder.addFragmentCode(` + // #define MAX_PROJECTION false + // float maxIntensity = 0.0; + // `); + builder.addFragmentCode(` +${shaderType} getInterpolatedDataValue() { + 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; +`); + const { shader } = tester; + const testShader = (point: any) => { + enableTransferFunctionShader( + shader, + "doTransferFunction", + dataType, + controlPoints, + defaultDataTypeRange[dataType], + ); + tester.execute({ inputValue: point }); + const values = tester.values; + return vec4.fromValues( + values.val1, + values.val2, + values.val3, + values.val4, + ); + }; + const minValue = defaultDataTypeRange[dataType][0]; + const maxValue = defaultDataTypeRange[dataType][1]; + let color = testShader(minValue); + expect(color).toEqual(vec4.fromValues(0, 0, 0, 0)); + color = testShader(maxValue); + expect(color).toEqual(vec4.fromValues(1, 1, 1, 1)); + if (dataType !== DataType.UINT64) { + const minValueNumber = minValue as number; + const maxValueNumber = maxValue as number; + color = testShader((maxValueNumber + minValueNumber) / 2); + for (let i = 0; i < 3; i++) { + expect(color[i]).toBeCloseTo(0.5); + } + } else { + const value = (maxValue as Uint64).toNumber() / 2; + const position = Uint64.fromNumber(value); + color = testShader(position); + for (let i = 0; i < 3; i++) { + expect(color[i]).toBeCloseTo(0.5); + } + } + }, + ); + }); + } +}); diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts new file mode 100644 index 000000000..1340efeac --- /dev/null +++ b/src/widget/transfer_function.ts @@ -0,0 +1,1223 @@ +/** + * @license + * Copyright 2023 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 "#/widget/transfer_function.css"; + +import { CoordinateSpaceCombiner } from "#/coordinate_transform"; +import { DisplayContext, IndirectRenderedPanel } from "#/display_context"; +import { UserLayer } from "#/layer"; +import { Position } from "#/navigation_state"; +import { + makeCachedDerivedWatchableValue, + WatchableValueInterface, +} from "#/trackable_value"; +import { ToolActivation } from "#/ui/tool"; +import { + arraysEqual, + arraysEqualWithPredicate, + findClosestMatchInSortedArray, +} from "#/util/array"; +import { DATA_TYPE_SIGNED, DataType } from "#/util/data_type"; +import { RefCounted } from "#/util/disposable"; +import { + EventActionMap, + registerActionListener, +} from "#/util/event_action_map"; +import { vec3, vec4 } from "#/util/geom"; +import { computeLerp, DataTypeInterval, parseDataTypeValue } from "#/util/lerp"; +import { MouseEventBinder } from "#/util/mouse_bindings"; +import { startRelativeMouseDrag } from "#/util/mouse_drag"; +import { WatchableVisibilityPriority } from "#/visibility_priority/frontend"; +import { Buffer, getMemoizedBuffer } from "#/webgl/buffer"; +import { GL } from "#/webgl/context"; +import { + defineInvlerpShaderFunction, + enableLerpShaderFunction, +} from "#/webgl/lerp"; +import { + defineLineShader, + drawLines, + initializeLineShader, + VERTICES_PER_LINE, +} from "#/webgl/lines"; +import { drawQuads } from "#/webgl/quad"; +import { createGriddedRectangleArray } from "#/webgl/rectangle_grid_buffer"; +import { ShaderBuilder, ShaderCodePart, ShaderProgram } from "#/webgl/shader"; +import { getShaderType } from "#/webgl/shader_lib"; +import { TransferFunctionParameters } from "#/webgl/shader_ui_controls"; +import { setRawTextureParameters } from "#/webgl/texture"; +import { ColorWidget } from "#/widget/color"; +import { + getUpdatedRangeAndWindowParameters, + updateInputBoundValue, + updateInputBoundWidth, +} from "#/widget/invlerp"; +import { LayerControlFactory, LayerControlTool } from "#/widget/layer_control"; +import { PositionWidget } from "#/widget/position_widget"; +import { Tab } from "#/widget/tab_view"; +import { Uint64 } from "#/util/uint64"; + +export const TRANSFER_FUNCTION_LENGTH = 1024; +export const NUM_COLOR_CHANNELS = 4; +const POSITION_VALUES_PER_LINE = 4; // x1, y1, x2, y2 +const CONTROL_POINT_GRAB_DISTANCE = TRANSFER_FUNCTION_LENGTH / 40; +const TRANSFER_FUNCTION_BORDER_WIDTH = 255 / 10; + +const transferFunctionSamplerTextureUnit = Symbol( + "transferFunctionSamplerTexture", +); + +/** + * The position of a control point on the canvas is represented as an integer value between 0 and TRANSFER_FUNCTION_LENGTH - 1. + * The color of a control point is represented as four component vector of uint8 values between 0 and 255 + */ +export interface ControlPoint { + position: number; + color: vec4; +} + +/** + * A parsed control point could have a position represented as a Uint64 + * This will later be converted to a number between 0 and TRANSFER_FUNCTION_LENGTH - 1 + * And then stored as a control point + */ +export interface ParsedControlPoint { + position: number | Uint64; + color: vec4; +} + +export interface TransferFunctionTextureOptions { + lookupTable?: Uint8Array; + controlPoints?: ControlPoint[]; + textureUnit: number | undefined; +} + +interface CanvasPosition { + normalizedX: number; + normalizedY: number; +} + +/** + * 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 out The lookup table to fill + * @param controlPoints The control points to interpolate between + */ +export function lerpBetweenControlPoints( + out: Int32Array | Uint8Array, + controlPoints: Array, +) { + 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]; + } + + // Edge case: no control points - all transparent + if (controlPoints.length === 0) { + out.fill(0); + return; + } + const firstPoint = controlPoints[0]; + + // Edge case: first control point is not at 0 - fill in transparent values + if (firstPoint.position > 0) { + const transparent = vec4.fromValues(0, 0, 0, 0); + for (let i = 0; i < firstPoint.position; ++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 = firstPoint.position; i < TRANSFER_FUNCTION_LENGTH; ++i) { + const currentPoint = controlPoints[controlPointIndex]; + const nextPoint = + controlPoints[Math.min(controlPointIndex + 1, controlPoints.length - 1)]; + const lookupIndex = i * NUM_COLOR_CHANNELS; + if (currentPoint === nextPoint) { + addLookupValue(lookupIndex, currentPoint.color); + } else { + const t = + (i - currentPoint.position) / + (nextPoint.position - currentPoint.position); + const lerpedColor = lerpUint8Color( + currentPoint.color, + nextPoint.color, + t, + ); + addLookupValue(lookupIndex, lerpedColor); + if (i === nextPoint.position) { + controlPointIndex++; + } + } + } +} + +/** + * Convert a [0, 1] float to a uint8 value between 0 and 255 + */ +export function floatToUint8(float: number) { + return Math.min(255, Math.max(Math.round(float * 255), 0)); +} + +/** + * Linearly interpolate between each component of two vec4s (color values) + */ +function lerpUint8Color(startColor: vec4, endColor: vec4, t: number) { + const color = vec4.create(); + for (let i = 0; i < 4; ++i) { + color[i] = computeLerp( + [startColor[i], endColor[i]], + DataType.UINT8, + t, + ) as number; + } + return color; +} + +/** + * Represent the underlying transfer function lookup table as a texture + */ +export class TransferFunctionTexture extends RefCounted { + texture: WebGLTexture | null = null; + width: number = TRANSFER_FUNCTION_LENGTH; + height = 1; + private priorOptions: TransferFunctionTextureOptions | undefined = undefined; + + constructor(public gl: GL | null) { + super(); + } + + optionsEqual( + existingOptions: TransferFunctionTextureOptions | undefined, + newOptions: TransferFunctionTextureOptions, + ) { + if (existingOptions === undefined) return false; + let lookupTableEqual = true; + if ( + existingOptions.lookupTable !== undefined && + newOptions.lookupTable !== undefined + ) { + lookupTableEqual = arraysEqual( + existingOptions.lookupTable, + newOptions.lookupTable, + ); + } + let controlPointsEqual = true; + if ( + existingOptions.controlPoints !== undefined && + newOptions.controlPoints !== undefined + ) { + controlPointsEqual = arraysEqualWithPredicate( + existingOptions.controlPoints, + newOptions.controlPoints, + (a, b) => a.position === b.position && arraysEqual(a.color, b.color), + ); + } + const textureUnitEqual = + existingOptions.textureUnit === newOptions.textureUnit; + + return lookupTableEqual && controlPointsEqual && textureUnitEqual; + } + + updateAndActivate(options: TransferFunctionTextureOptions) { + const { gl } = this; + if (gl === null) return; + if ( + options.lookupTable === undefined && + options.controlPoints === undefined + ) { + throw new Error( + "Either lookupTable or controlPoints must be defined for transfer function texture", + ); + } + let { texture } = this; + + function bindAndActivateTexture() { + if (options.textureUnit === undefined) { + throw new Error( + "Texture unit must be defined for transfer function texture", + ); + } + gl!.activeTexture(WebGL2RenderingContext.TEXTURE0 + options.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(this.priorOptions, options)) { + bindAndActivateTexture(); + return; + } + // If the texture has not been created yet, create it + if (texture === null) { + texture = this.texture = gl.createTexture(); + } + // Update the texture + bindAndActivateTexture(); + setRawTextureParameters(gl); + let lookupTable = options.lookupTable; + if (lookupTable === undefined) { + lookupTable = new Uint8Array( + TRANSFER_FUNCTION_LENGTH * NUM_COLOR_CHANNELS, + ); + lerpBetweenControlPoints(lookupTable, options.controlPoints!); + } + gl.texImage2D( + WebGL2RenderingContext.TEXTURE_2D, + 0, + WebGL2RenderingContext.RGBA, + this.width, + 1, + 0, + WebGL2RenderingContext.RGBA, + WebGL2RenderingContext.UNSIGNED_BYTE, + lookupTable, + ); + + // Update the prior options to the current options for future comparisons + // Make a copy of the options for the purpose of comparison + this.priorOptions = { + textureUnit: options.textureUnit, + lookupTable: options.lookupTable?.slice(), + controlPoints: options.controlPoints?.map((point) => ({ + position: point.position, + color: vec4.clone(point.color), + })), + }; + } + + disposed() { + this.gl?.deleteTexture(this.texture); + this.texture = null; + super.disposed(); + } +} + +/** + * Display the UI canvas for the transfer function widget and handle shader updates for elements of + * the canvas + */ +class TransferFunctionPanel extends IndirectRenderedPanel { + texture: TransferFunctionTexture; + 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; + } + controlPointsLookupTable = this.registerDisposer( + new ControlPointsLookupTable(this.parent.dataType, this.parent.trackable), + ); + controller = this.registerDisposer( + new TransferFunctionController( + this.element, + this.parent.dataType, + this.controlPointsLookupTable, + () => 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 } = this; + element.classList.add("neuroglancer-transfer-function-panel"); + this.textureVertexBufferArray = createGriddedRectangleArray( + TRANSFER_FUNCTION_LENGTH, + ); + this.texture = this.registerDisposer(new TransferFunctionTexture(this.gl)); + this.textureVertexBuffer = this.registerDisposer( + getMemoizedBuffer( + this.gl, + WebGL2RenderingContext.ARRAY_BUFFER, + () => this.textureVertexBufferArray, + ), + ).value; + this.controlPointsVertexBuffer = this.registerDisposer( + getMemoizedBuffer( + this.gl, + WebGL2RenderingContext.ARRAY_BUFFER, + () => this.controlPointsPositionArray, + ), + ).value; + this.controlPointsColorBuffer = this.registerDisposer( + getMemoizedBuffer( + this.gl, + WebGL2RenderingContext.ARRAY_BUFFER, + () => this.controlPointsColorArray, + ), + ).value; + this.linePositionBuffer = this.registerDisposer( + getMemoizedBuffer( + this.gl, + WebGL2RenderingContext.ARRAY_BUFFER, + () => this.linePositionArray, + ), + ).value; + } + + updateTransferFunctionPanelLines() { + // Normalize position to [-1, 1] for shader (x axis) + function normalizePosition(position: number) { + return (position / (TRANSFER_FUNCTION_LENGTH - 1)) * 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 createLinePoints( + array: Float32Array, + index: number, + positions: vec4, + ): number { + for (let i = 0; i < VERTICES_PER_LINE; ++i) { + array[index++] = normalizePosition(positions[0]); + array[index++] = normalizeOpacity(positions[1]); + array[index++] = normalizePosition(positions[2]); + array[index++] = normalizeOpacity(positions[3]); + } + return index; + } + + const colorChannels = NUM_COLOR_CHANNELS - 1; // ignore alpha + const controlPoints = + this.controlPointsLookupTable.trackable.value.controlPoints; + const colorArray = new Float32Array(controlPoints.length * colorChannels); + const positionArray = new Float32Array(controlPoints.length * 2); + let numLines = controlPoints.length - 1; + let startAdd = null; + let endAdd = null; + let lineIndex = 0; + + // Add lines to the beginning and end if necessary + if (controlPoints.length > 0) { + if (controlPoints[0].position > 0) { + numLines += 1; + startAdd = { + position: controlPoints[0].position, + color: vec4.fromValues(0, 0, 0, 0), + }; + } + if ( + controlPoints[controlPoints.length - 1].position < + TRANSFER_FUNCTION_LENGTH - 1 + ) { + numLines += 1; + endAdd = { + position: TRANSFER_FUNCTION_LENGTH - 1, + color: controlPoints[controlPoints.length - 1].color, + }; + } + } else { + numLines = 0; + } + + // Create line positions + const linePositionArray = new Float32Array( + numLines * VERTICES_PER_LINE * POSITION_VALUES_PER_LINE, + ); + if (startAdd !== null) { + const linePosition = vec4.fromValues( + startAdd.position, + startAdd.color[3], + controlPoints[0].position, + controlPoints[0].color[3], + ); + lineIndex = createLinePoints(linePositionArray, lineIndex, linePosition); + } + for (let i = 0; i < controlPoints.length; ++i) { + const colorIndex = i * colorChannels; + const positionIndex = i * 2; + const { color, position } = controlPoints[i]; + colorArray[colorIndex] = normalizeColor(color[0]); + colorArray[colorIndex + 1] = normalizeColor(color[1]); + colorArray[colorIndex + 2] = normalizeColor(color[2]); + positionArray[positionIndex] = normalizePosition(position); + positionArray[positionIndex + 1] = normalizeOpacity(color[3]); + if (i < controlPoints.length - 1) { + const linePosition = vec4.fromValues( + position, + color[3], + controlPoints[i + 1].position, + controlPoints[i + 1].color[3], + ); + lineIndex = createLinePoints( + linePositionArray, + lineIndex, + linePosition, + ); + } + } + if (endAdd !== null) { + const linePosition = vec4.fromValues( + controlPoints[controlPoints.length - 1].position, + controlPoints[controlPoints.length - 1].color[3], + endAdd.position, + endAdd.color[3], + ); + lineIndex = createLinePoints(linePositionArray, lineIndex, linePosition); + } + + // 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); + } + + 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; +`); + 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_LENGTH - 1, + ); + this.textureVertexBuffer.bindToVertexAttrib( + aVertexPosition, + /*components=*/ 2, + /*attributeType=*/ WebGL2RenderingContext.FLOAT, + ); + const textureUnit = transferFunctionShader.textureUnit( + transferFunctionSamplerTextureUnit, + ); + this.texture.updateAndActivate({ + lookupTable: this.controlPointsLookupTable.lookupTable, + textureUnit, + }); + drawQuads(this.gl, TRANSFER_FUNCTION_LENGTH, 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.controlPointsLookupTable.lookupTableFromControlPoints(); + this.updateTransferFunctionPanelLines(); + } + isReady() { + return true; + } +} + +/** + * Lookup table for control points. Handles adding, removing, and updating control points as well as + * consequent updates to the underlying lookup table formed from the control points. + */ +class ControlPointsLookupTable extends RefCounted { + lookupTable: Uint8Array; + constructor( + public dataType: DataType, + public trackable: WatchableValueInterface, + ) { + super(); + this.lookupTable = new Uint8Array( + TRANSFER_FUNCTION_LENGTH * NUM_COLOR_CHANNELS, + ).fill(0); + } + positionToIndex(position: number) { + return Math.floor(position * (TRANSFER_FUNCTION_LENGTH - 1)); + } + opacityToIndex(opacity: number) { + let opacityAsUint8 = floatToUint8(opacity); + if (opacityAsUint8 <= TRANSFER_FUNCTION_BORDER_WIDTH) { + opacityAsUint8 = 0; + } else if (opacityAsUint8 >= 255 - TRANSFER_FUNCTION_BORDER_WIDTH) { + opacityAsUint8 = 255; + } + return opacityAsUint8; + } + findNearestControlPointIndex(position: number) { + return findClosestMatchInSortedArray( + this.trackable.value.controlPoints.map((point) => point.position), + this.positionToIndex(position), + (a, b) => a - b, + ); + } + grabControlPoint(position: number) { + const nearestIndex = this.findNearestControlPointIndex(position); + if (nearestIndex === -1) { + return -1; + } + const nearestPosition = + this.trackable.value.controlPoints[nearestIndex].position; + const desiredPosition = this.positionToIndex(position); + if ( + Math.abs(nearestPosition - desiredPosition) < CONTROL_POINT_GRAB_DISTANCE + ) { + return nearestIndex; + } else { + return -1; + } + } + addPoint(position: number, opacity: number, color: vec3) { + const colorAsUint8 = vec3.fromValues( + floatToUint8(color[0]), + floatToUint8(color[1]), + floatToUint8(color[2]), + ); + const opacityAsUint8 = this.opacityToIndex(opacity); + const controlPoints = this.trackable.value.controlPoints; + const positionAsIndex = this.positionToIndex(position); + const existingIndex = controlPoints.findIndex( + (point) => point.position === positionAsIndex, + ); + if (existingIndex !== -1) { + controlPoints.splice(existingIndex, 1); + } + controlPoints.push({ + position: positionAsIndex, + color: vec4.fromValues( + colorAsUint8[0], + colorAsUint8[1], + colorAsUint8[2], + opacityAsUint8, + ), + }); + controlPoints.sort((a, b) => a.position - b.position); + } + lookupTableFromControlPoints() { + const { lookupTable } = this; + const { controlPoints } = this.trackable.value; + lerpBetweenControlPoints(lookupTable, controlPoints); + } + updatePoint(index: number, position: number, opacity: number) { + const { controlPoints } = this.trackable.value; + const positionAsIndex = this.positionToIndex(position); + const opacityAsUint8 = this.opacityToIndex(opacity); + const color = controlPoints[index].color; + controlPoints[index] = { + position: positionAsIndex, + color: vec4.fromValues(color[0], color[1], color[2], opacityAsUint8), + }; + controlPoints.sort((a, b) => a.position - b.position); + const exsitingPositions = new Set(); + for (const point of controlPoints) { + if (exsitingPositions.has(point.position)) { + return index; + } + exsitingPositions.add(point.position); + } + const newControlPointIndex = controlPoints.findIndex( + (point) => point.position === positionAsIndex, + ); + return newControlPointIndex; + } + setPointColor(index: number, color: vec3) { + const { controlPoints } = this.trackable.value; + const colorAsUint8 = vec3.fromValues( + floatToUint8(color[0]), + floatToUint8(color[1]), + floatToUint8(color[2]), + ); + controlPoints[index].color = vec4.fromValues( + colorAsUint8[0], + colorAsUint8[1], + colorAsUint8[2], + controlPoints[index].color[3], + ); + } +} + +/** + * Create the bounds on the UI range inputs for the transfer function widget + */ +function createRangeBoundInputs( + dataType: DataType, + model: WatchableValueInterface, +) { + function createRangeBoundInput(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" + } bound for transfer function range`; + return e; + } + + const container = document.createElement("div"); + container.classList.add("neuroglancer-transfer-function-range-bounds"); + const inputs = [createRangeBoundInput(0), createRangeBoundInput(1)]; + for (let endpointIndex = 0; endpointIndex < 2; ++endpointIndex) { + const input = inputs[endpointIndex]; + input.addEventListener("input", () => { + updateInputBoundWidth(input); + }); + input.addEventListener("change", () => { + const existingBounds = model.value.range; + const intervals = { range: existingBounds, window: existingBounds }; + try { + const value = parseDataTypeValue(dataType, input.value); + const range = getUpdatedRangeAndWindowParameters( + intervals, + "window", + endpointIndex, + value, + /*fitRangeInWindow=*/ true, + ).window; + model.value = { ...model.value, range }; + } 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 controlPointsLookupTable: ControlPointsLookupTable, + 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.findNearestControlPointIndex(mouseEvent); + if (nearestIndex !== -1) { + this.controlPointsLookupTable.trackable.value.controlPoints.splice( + nearestIndex, + 1, + ); + this.updateValue({ + ...this.getModel(), + controlPoints: + this.controlPointsLookupTable.trackable.value.controlPoints, + }); + } + }, + ); + registerActionListener( + element, + "change-point-color", + (actionEvent) => { + const mouseEvent = actionEvent.detail; + const nearestIndex = this.findNearestControlPointIndex(mouseEvent); + if (nearestIndex !== -1) { + const color = this.controlPointsLookupTable.trackable.value.color; + this.controlPointsLookupTable.setPointColor(nearestIndex, color); + this.updateValue({ + ...this.getModel(), + controlPoints: + this.controlPointsLookupTable.trackable.value.controlPoints, + }); + } + }, + ); + } + updateValue(value: TransferFunctionParameters | undefined) { + if (value === undefined) return; + this.setModel(value); + } + findNearestControlPointIndex(event: MouseEvent) { + const { normalizedX } = this.getControlPointPosition( + event, + ) as CanvasPosition; + return this.controlPointsLookupTable.grabControlPoint(normalizedX); + } + addControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { + const color = this.controlPointsLookupTable.trackable.value.color; + const nearestIndex = this.findNearestControlPointIndex(event); + if (nearestIndex !== -1) { + this.currentGrabbedControlPointIndex = nearestIndex; + return undefined; + } else { + const { normalizedX, normalizedY } = this.getControlPointPosition( + event, + ) as CanvasPosition; + this.controlPointsLookupTable.addPoint(normalizedX, normalizedY, color); + this.currentGrabbedControlPointIndex = + this.findNearestControlPointIndex(event); + return { + ...this.getModel(), + controlPoints: + this.controlPointsLookupTable.trackable.value.controlPoints, + }; + } + } + moveControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { + if (this.currentGrabbedControlPointIndex !== -1) { + const position = this.getControlPointPosition(event); + if (position === undefined) return undefined; + const { normalizedX, normalizedY } = position; + this.currentGrabbedControlPointIndex = + this.controlPointsLookupTable.updatePoint( + this.currentGrabbedControlPointIndex, + normalizedX, + normalizedY, + ); + return { + ...this.getModel(), + controlPoints: + this.controlPointsLookupTable.trackable.value.controlPoints, + }; + } + return undefined; + } + getControlPointPosition(event: MouseEvent): CanvasPosition | undefined { + const clientRect = this.element.getBoundingClientRect(); + const normalizedX = (event.clientX - clientRect.left) / clientRect.width; + const normalizedY = (clientRect.bottom - event.clientY) / clientRect.height; + if ( + normalizedX < 0 || + normalizedX > 1 || + normalizedY < 0 || + normalizedY > 1 + ) + return undefined; + return { normalizedX, normalizedY }; + } +} + +/** + * 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), + ); + + range = createRangeBoundInputs(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.range.container); + this.range.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.color, + [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, + color: colorPicker.model.value, + }; + }); + colorPicker.element.addEventListener("input", () => { + trackable.value = { + ...this.trackable.value, + color: colorPicker.model.value, + }; + }); + colorPickerDiv.appendChild(colorPicker.element); + + element.appendChild(colorPickerDiv); + this.updateControlPointsAndDraw(); + this.registerDisposer( + this.trackable.changed.add(() => { + this.updateControlPointsAndDraw(); + }), + ); + updateInputBoundValue(this.range.inputs[0], this.trackable.value.range[0]); + updateInputBoundValue(this.range.inputs[1], this.trackable.value.range[1]); + } + updateView() { + this.transferFunctionPanel.scheduleRedraw(); + } + updateControlPointsAndDraw() { + this.transferFunctionPanel.update(); + this.updateView(); + } +} +// TODO (skm) may need to follow the VariableDataTypeInvlerpWidget pattern + +/** + * 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}); + return ${name}_(clamp(v, 0.0, 1.0)); +} +vec4 ${name}() { + return ${name}(getInterpolatedDataValue(${channel.join(",")})); +} +`; + // TODO (SKM) add back in max projection at a later date + 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, + controlPoints: ControlPoint[], + interval: DataTypeInterval, +) { + 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 TransferFunctionTexture(gl), + ); + } + shader.bindAndUpdateTransferFunctionTexture( + `TransferFunction.${name}`, + controlPoints, + ); + + // Bind the length of the lookup table to the shader as a uniform + gl.uniform1f( + shader.uniform(`uTransferFunctionEnd_${name}`), + TRANSFER_FUNCTION_LENGTH - 1, + ); + + // Use the lerp shader function to grab an index into the lookup table + 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); + + // We 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); + }, + }; +} From 5927e3426da46532dc7ea10fd752d0b565502e1d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 31 Jan 2024 18:44:46 +0100 Subject: [PATCH 02/60] refactor: fix linting warnings --- src/webgl/rectangle_grid_buffer.ts | 16 ++++++++-------- src/webgl/shader.ts | 2 +- src/webgl/shader_ui_controls.ts | 8 ++++---- src/widget/transfer_function.ts | 28 +++++++++++++--------------- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/webgl/rectangle_grid_buffer.ts b/src/webgl/rectangle_grid_buffer.ts index 5bbe6906a..4a5d6be2e 100644 --- a/src/webgl/rectangle_grid_buffer.ts +++ b/src/webgl/rectangle_grid_buffer.ts @@ -23,10 +23,10 @@ import { VERTICES_PER_QUAD } from "#/webgl/quad"; */ export function createGriddedRectangleArray( numGrids: number, - startX: number = -1, - endX: number = 1, - startY: number = 1, - endY: number = -1, + startX = -1, + endX = 1, + startY = 1, + endY = -1, ): Float32Array { const result = new Float32Array(numGrids * VERTICES_PER_QUAD * 2); const step = (endX - startX) / numGrids; @@ -61,10 +61,10 @@ export function createGriddedRectangleArray( export function getGriddedRectangleBuffer( gl: GL, numGrids: number, - startX: number = -1, - endX: number = 1, - startY: number = 1, - endY: number = -1, + startX = -1, + endX = 1, + startY = 1, + endY = -1, ) { return getMemoizedBuffer( gl, diff --git a/src/webgl/shader.ts b/src/webgl/shader.ts index 716a7d7df..7ec8ece79 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -271,7 +271,7 @@ export class ShaderProgram extends RefCounted { unbindTransferFunctionTextures() { const gl = this.gl; - for (let key of this.transferFunctionTextures.keys()) { + for (const key of this.transferFunctionTextures.keys()) { const value = this.textureUnits.get(key); if (value !== undefined) { this.gl.activeTexture(gl.TEXTURE0 + value); diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index b712bc7bd..72527706f 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -595,7 +595,7 @@ function parseTransferFunctionDirective( const imageData = dataContext.imageData; const dataType = imageData?.dataType; const channelRank = imageData?.channelRank; - let errors = []; + const errors = []; let channel = new Array(channelRank).fill(0); let color = vec3.fromValues(1.0, 1.0, 1.0); let range: DataTypeInterval | undefined; @@ -610,7 +610,7 @@ function parseTransferFunctionDirective( } else { range = defaultDataTypeRange[dataType]; } - for (let [key, value] of parameters) { + for (const [key, value] of parameters) { try { switch (key) { case "channel": { @@ -1103,7 +1103,7 @@ function convertTransferFunctionControlPoints( // Validate values let position: number | Uint64; - if (dataType != DataType.UINT64) { + if (dataType !== DataType.UINT64) { const defaultRange = defaultDataTypeRange[dataType] as [number, number]; position = verifyFiniteFloat(x[0]); if (position < defaultRange[0] || position > defaultRange[1]) { @@ -1328,7 +1328,7 @@ class TrackableTransferFunctionParameters extends TrackableValue arraysEqual(a.color, b.color) && a.position == b.position, + (a, b) => arraysEqual(a.color, b.color) && a.position === b.position, ) ? undefined : this.controlPointsToJson(this.value.controlPoints, range, dataType); diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 1340efeac..92931a232 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -712,9 +712,8 @@ class ControlPointsLookupTable extends RefCounted { Math.abs(nearestPosition - desiredPosition) < CONTROL_POINT_GRAB_DISTANCE ) { return nearestIndex; - } else { - return -1; } + return -1; } addPoint(position: number, opacity: number, color: vec3) { const colorAsUint8 = vec3.fromValues( @@ -926,19 +925,18 @@ class TransferFunctionController extends RefCounted { if (nearestIndex !== -1) { this.currentGrabbedControlPointIndex = nearestIndex; return undefined; - } else { - const { normalizedX, normalizedY } = this.getControlPointPosition( - event, - ) as CanvasPosition; - this.controlPointsLookupTable.addPoint(normalizedX, normalizedY, color); - this.currentGrabbedControlPointIndex = - this.findNearestControlPointIndex(event); - return { - ...this.getModel(), - controlPoints: - this.controlPointsLookupTable.trackable.value.controlPoints, - }; } + const { normalizedX, normalizedY } = this.getControlPointPosition( + event, + ) as CanvasPosition; + this.controlPointsLookupTable.addPoint(normalizedX, normalizedY, color); + this.currentGrabbedControlPointIndex = + this.findNearestControlPointIndex(event); + return { + ...this.getModel(), + controlPoints: + this.controlPointsLookupTable.trackable.value.controlPoints, + }; } moveControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { if (this.currentGrabbedControlPointIndex !== -1) { @@ -1055,7 +1053,7 @@ export function defineTransferFunctionShader( dataType: DataType, channel: number[], ) { - builder.addUniform(`highp float`, `uTransferFunctionEnd_${name}`); + builder.addUniform("highp float", `uTransferFunctionEnd_${name}`); builder.addTextureSampler( "sampler2D", `uTransferFunctionSampler_${name}`, From 7b427f275ebb9681ec7e3208f659da25db05b66f Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 7 Feb 2024 15:26:12 +0100 Subject: [PATCH 03/60] refactor: TF uses uint64 as number, not string --- src/webgl/shader_ui_controls.spec.ts | 2 +- src/webgl/shader_ui_controls.ts | 28 +++++++++++++--------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/webgl/shader_ui_controls.spec.ts b/src/webgl/shader_ui_controls.spec.ts index b5ff05d7e..4ab449e26 100644 --- a/src/webgl/shader_ui_controls.spec.ts +++ b/src/webgl/shader_ui_controls.spec.ts @@ -828,7 +828,7 @@ void main() { }); it("handles transfer function control with all properties uint64 data", () => { const code = ` -#uicontrol transferFunction colormap(points=[["18446744073709551615", "#00ff00", 0.1], ["9223372111111111111", "#ff0000", 0.5], ["0", "#000000", 0.0]], color="#0000ff", channel=[]) +#uicontrol transferFunction colormap(points=[[18446744073709551615, "#00ff00", 0.1], [9223372111111111111, "#ff0000", 0.5], [0, "#000000", 0.0]], color="#0000ff", channel=[]) void main() { } `; diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 72527706f..67f63ee49 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -1090,7 +1090,7 @@ function convertTransferFunctionControlPoints( // Validate input length and types if ( x.length !== 3 || - (typeof x[0] !== "number" && typeof x[0] !== "string") || + typeof x[0] !== "number" || typeof x[1] !== "string" || typeof x[2] !== "number" ) { @@ -1115,12 +1115,7 @@ function convertTransferFunctionControlPoints( } } else { const defaultRange = defaultDataTypeRange[dataType] as [Uint64, Uint64]; - if (typeof x[0] !== "string") { - throw new Error( - `Expected string for Uint64, but received: ${JSON.stringify(x[0])}`, - ); - } - position = Uint64.parseString(x[0]); + position = Uint64.fromNumber(x[0]); if ( Uint64.less(position, defaultRange[0]) || Uint64.less(defaultRange[1], position) @@ -1163,11 +1158,11 @@ function parseTransferFunctionControlPoints( range: DataTypeInterval, dataType: DataType, ) { - function parsePosition(position: number | string): number { + function parsePosition(position: number): number { const toConvert = dataType === DataType.UINT64 - ? Uint64.parseString(position as string) - : (position as number); + ? Uint64.fromNumber(position) + : position; const normalizedPosition = computeInvlerp(range, toConvert); const positionInRange = computeLerp( [0, TRANSFER_FUNCTION_LENGTH - 1], @@ -1188,9 +1183,9 @@ function parseTransferFunctionControlPoints( )}`, ); } - if (typeof x.position !== "number" && typeof x.position !== "string") { + if (typeof x.position !== "number") { throw new Error( - `Expected number or Uint64 string, but received: ${JSON.stringify( + `Expected position as number but received: ${JSON.stringify( x.position, )}`, ); @@ -1198,7 +1193,7 @@ function parseTransferFunctionControlPoints( const color = parseRGBColorSpecification(x.color); if (typeof x.opacity !== "number") { throw new Error( - `Expected number but received: ${JSON.stringify(x.opacity)}`, + `Expected opacity as number but received: ${JSON.stringify(x.opacity)}`, ); } const opacity = floatToUint8(Math.max(0, Math.min(1, x.opacity))); @@ -1262,7 +1257,7 @@ function copyTransferFunctionParameters( })), channel: defaultValue.channel, color: vec3.clone(defaultValue.color), - range: [defaultValue.range[0], defaultValue.range[1]] as [number, number], + range: [defaultValue.range[0], defaultValue.range[1]] as DataTypeInterval, }; } @@ -1271,6 +1266,9 @@ class TrackableTransferFunctionParameters extends TrackableValue which is necessary for the changed signal to be emitted. const defaultValueCopy = copyTransferFunctionParameters(defaultValue); super(defaultValueCopy, (obj) => parseTransferFunctionParameters(obj, dataType, defaultValue), @@ -1293,7 +1291,7 @@ class TrackableTransferFunctionParameters extends TrackableValue Date: Wed, 7 Feb 2024 15:37:13 +0100 Subject: [PATCH 04/60] chore: cleanup TODO and comments --- src/webgl/rectangle_grid_buffer.ts | 1 + src/webgl/shader_ui_controls.ts | 6 +++--- src/widget/transfer_function.spec.ts | 6 ------ src/widget/transfer_function.ts | 2 -- 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/webgl/rectangle_grid_buffer.ts b/src/webgl/rectangle_grid_buffer.ts index 4a5d6be2e..529243827 100644 --- a/src/webgl/rectangle_grid_buffer.ts +++ b/src/webgl/rectangle_grid_buffer.ts @@ -20,6 +20,7 @@ import { VERTICES_PER_QUAD } from "#/webgl/quad"; /** * 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, diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 67f63ee49..c8ee5f92c 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -651,6 +651,7 @@ function parseTransferFunctionDirective( if (dataType !== undefined) range = defaultDataTypeRange[dataType]; else range = [0, 1] as [number, number]; } + // Set a simple black to white transfer function if no control points are specified. if (controlPoints.length === 0 && !specifedPoints) { const transferFunctionRange = [0, TRANSFER_FUNCTION_LENGTH - 1] as [ number, @@ -675,6 +676,7 @@ function parseTransferFunctionDirective( color: vec4.fromValues(255, 255, 255, 255), }); } else { + // Parse control points from the shader code and sort them for (const controlPoint of parsedControlPoints) { const normalizedPosition = computeInvlerp(range, controlPoint.position); const position = computeLerp( @@ -1160,9 +1162,7 @@ function parseTransferFunctionControlPoints( ) { function parsePosition(position: number): number { const toConvert = - dataType === DataType.UINT64 - ? Uint64.fromNumber(position) - : position; + dataType === DataType.UINT64 ? Uint64.fromNumber(position) : position; const normalizedPosition = computeInvlerp(range, toConvert); const positionInRange = computeLerp( [0, TRANSFER_FUNCTION_LENGTH - 1], diff --git a/src/widget/transfer_function.spec.ts b/src/widget/transfer_function.spec.ts index b2a4e7f80..f364494c7 100644 --- a/src/widget/transfer_function.spec.ts +++ b/src/widget/transfer_function.spec.ts @@ -121,18 +121,12 @@ describe("compute transfer function on GPU", () => { for (const dataType of Object.values(DataType)) { if (typeof dataType === "string") continue; it(`computes transfer function on GPU for ${DataType[dataType]}`, () => { - console.log("Testing " + DataType[dataType]); const shaderType = getShaderType(dataType); fragmentShaderTest( { inputValue: dataType }, { val1: "float", val2: "float", val3: "float", val4: "float" }, (tester) => { const { builder } = tester; - // TODO (SKM) might need this for max projection - // builder.addFragmentCode(` - // #define MAX_PROJECTION false - // float maxIntensity = 0.0; - // `); builder.addFragmentCode(` ${shaderType} getInterpolatedDataValue() { return inputValue; diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 92931a232..e2e30990c 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -1042,7 +1042,6 @@ class TransferFunctionWidget extends Tab { this.updateView(); } } -// TODO (skm) may need to follow the VariableDataTypeInvlerpWidget pattern /** * Create a shader function for the transfer function to grab the nearest lookup table value @@ -1080,7 +1079,6 @@ vec4 ${name}() { return ${name}(getInterpolatedDataValue(${channel.join(",")})); } `; - // TODO (SKM) add back in max projection at a later date if (dataType !== DataType.UINT64 && dataType !== DataType.FLOAT32) { const scalarType = DATA_TYPE_SIGNED[dataType] ? "int" : "uint"; code += ` From 2e356a836ccefe996c428b6559020825cc727393 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 7 Feb 2024 16:04:41 +0100 Subject: [PATCH 05/60] refactor: clean up transfer function code a little --- src/widget/transfer_function.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index e2e30990c..6d5f817ca 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -99,6 +99,13 @@ export interface ParsedControlPoint { color: vec4; } +/** + * Used to update the transfer function texture + * If lookupTable is defined, it will be used to update the texture directly + * Otherwise, controlPoints will be used to generate a lookup table as a first step + * textureUnit is the texture unit to use for the transfer function texture + * A lookup table is a series of color values (0 - 255) between control points + */ export interface TransferFunctionTextureOptions { lookupTable?: Uint8Array; controlPoints?: ControlPoint[]; @@ -118,8 +125,8 @@ interface CanvasPosition { * @param controlPoints The control points to interpolate between */ export function lerpBetweenControlPoints( - out: Int32Array | Uint8Array, - controlPoints: Array, + out: Uint8Array, + controlPoints: ControlPoint[], ) { function addLookupValue(index: number, color: vec4) { out[index] = color[0]; @@ -250,19 +257,19 @@ export class TransferFunctionTexture extends RefCounted { } let { texture } = this; - function bindAndActivateTexture() { + function bindAndActivateTexture(gl: GL) { if (options.textureUnit === undefined) { throw new Error( "Texture unit must be defined for transfer function texture", ); } - gl!.activeTexture(WebGL2RenderingContext.TEXTURE0 + options.textureUnit); - gl!.bindTexture(WebGL2RenderingContext.TEXTURE_2D, texture); + gl.activeTexture(WebGL2RenderingContext.TEXTURE0 + options.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(this.priorOptions, options)) { - bindAndActivateTexture(); + bindAndActivateTexture(gl); return; } // If the texture has not been created yet, create it @@ -270,7 +277,7 @@ export class TransferFunctionTexture extends RefCounted { texture = this.texture = gl.createTexture(); } // Update the texture - bindAndActivateTexture(); + bindAndActivateTexture(gl); setRawTextureParameters(gl); let lookupTable = options.lookupTable; if (lookupTable === undefined) { From eebec726fd56b3f1a871233a3d26ee41311f972a Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 8 Feb 2024 12:59:40 +0100 Subject: [PATCH 06/60] fix: TF control points didn't always make it to JSON --- src/webgl/shader_ui_controls.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index c8ee5f92c..ede7c4919 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -1227,7 +1227,10 @@ function parseTransferFunctionParameters( obj, "controlPoints", (x) => parseTransferFunctionControlPoints(x, range, dataType), - defaultValue.controlPoints, + defaultValue.controlPoints.map((x) => ({ + position: x.position, + color: x.color, + })), ); return { controlPoints: controlPoints, @@ -1253,11 +1256,11 @@ function copyTransferFunctionParameters( return { controlPoints: defaultValue.controlPoints.map((x) => ({ position: x.position, - color: vec4.clone(x.color), + color: x.color, })), channel: defaultValue.channel, - color: vec3.clone(defaultValue.color), - range: [defaultValue.range[0], defaultValue.range[1]] as DataTypeInterval, + color: defaultValue.color, + range: defaultValue.range, }; } From c8986c039c98e5217a70105e2cc9b904b124cf86 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 13 Feb 2024 12:39:34 +0100 Subject: [PATCH 07/60] refactor: clearer comments and update loop --- src/widget/transfer_function.ts | 161 +++++++++++++++++--------------- 1 file changed, 85 insertions(+), 76 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 6d5f817ca..4953beb7b 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -73,19 +73,17 @@ import { Uint64 } from "#/util/uint64"; export const TRANSFER_FUNCTION_LENGTH = 1024; export const NUM_COLOR_CHANNELS = 4; const POSITION_VALUES_PER_LINE = 4; // x1, y1, x2, y2 -const CONTROL_POINT_GRAB_DISTANCE = TRANSFER_FUNCTION_LENGTH / 40; -const TRANSFER_FUNCTION_BORDER_WIDTH = 255 / 10; +const CONTROL_POINT_X_GRAB_DISTANCE = TRANSFER_FUNCTION_LENGTH / 40; +const TRANSFER_FUNCTION_Y_BORDER_WIDTH = 255 / 20; const transferFunctionSamplerTextureUnit = Symbol( "transferFunctionSamplerTexture", ); -/** - * The position of a control point on the canvas is represented as an integer value between 0 and TRANSFER_FUNCTION_LENGTH - 1. - * The color of a control point is represented as four component vector of uint8 values between 0 and 255 - */ export interface ControlPoint { + /** The bin that the point's x value lies in - int between 0 and TRANSFER_FUNCTION_LENGTH - 1 */ position: number; + /** Color of the point as 4 uint8 values */ color: vec4; } @@ -100,15 +98,16 @@ export interface ParsedControlPoint { } /** - * Used to update the transfer function texture - * If lookupTable is defined, it will be used to update the texture directly - * Otherwise, controlPoints will be used to generate a lookup table as a first step - * textureUnit is the texture unit to use for the transfer function texture - * A lookup table is a series of color values (0 - 255) between control points + * Options to update the transfer function texture */ export interface TransferFunctionTextureOptions { + /** If lookupTable is defined, it will be used to update the texture directly. + * A lookup table is a series of color values (0 - 255) between control points + */ lookupTable?: Uint8Array; + /** If lookupTable is undefined, controlPoints will be used to generate a lookup table as a first step */ controlPoints?: ControlPoint[]; + /** textureUnit to update with the new transfer function texture data */ textureUnit: number | undefined; } @@ -118,9 +117,10 @@ interface CanvasPosition { } /** - * 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. + * 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 out The lookup table to fill * @param controlPoints The control points to interpolate between */ @@ -247,6 +247,9 @@ export class TransferFunctionTexture extends RefCounted { updateAndActivate(options: TransferFunctionTextureOptions) { const { gl } = this; if (gl === null) return; + let { texture } = this; + + // Verify input if ( options.lookupTable === undefined && options.controlPoints === undefined @@ -255,21 +258,20 @@ export class TransferFunctionTexture extends RefCounted { "Either lookupTable or controlPoints must be defined for transfer function texture", ); } - let { texture } = this; - function bindAndActivateTexture(gl: GL) { - if (options.textureUnit === undefined) { + 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 + options.textureUnit); + 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(this.priorOptions, options)) { - bindAndActivateTexture(gl); + activateAndBindTexture(gl, options.textureUnit); return; } // If the texture has not been created yet, create it @@ -277,7 +279,7 @@ export class TransferFunctionTexture extends RefCounted { texture = this.texture = gl.createTexture(); } // Update the texture - bindAndActivateTexture(gl); + activateAndBindTexture(gl, options.textureUnit); setRawTextureParameters(gl); let lookupTable = options.lookupTable; if (lookupTable === undefined) { @@ -318,8 +320,8 @@ export class TransferFunctionTexture extends RefCounted { } /** - * Display the UI canvas for the transfer function widget and handle shader updates for elements of - * the canvas + * Display the UI canvas for the transfer function widget and + * handle shader updates for elements of the canvas */ class TransferFunctionPanel extends IndirectRenderedPanel { texture: TransferFunctionTexture; @@ -386,7 +388,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { ).value; } - updateTransferFunctionPanelLines() { + updateTransferFunctionPointsAndLines() { // Normalize position to [-1, 1] for shader (x axis) function normalizePosition(position: number) { return (position / (TRANSFER_FUNCTION_LENGTH - 1)) * 2 - 1; @@ -400,7 +402,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { return colorComponent / 255; } - function createLinePoints( + function addLine( array: Float32Array, index: number, positions: vec4, @@ -414,52 +416,63 @@ class TransferFunctionPanel extends IndirectRenderedPanel { return index; } - const colorChannels = NUM_COLOR_CHANNELS - 1; // ignore alpha const controlPoints = this.controlPointsLookupTable.trackable.value.controlPoints; + let numLines = controlPoints.length === 0 ? 0 : controlPoints.length; + const colorChannels = NUM_COLOR_CHANNELS - 1; // ignore alpha const colorArray = new Float32Array(controlPoints.length * colorChannels); const positionArray = new Float32Array(controlPoints.length * 2); - let numLines = controlPoints.length - 1; - let startAdd = null; - let endAdd = null; - let lineIndex = 0; + let positionArrayIndex = 0; + let lineFromLeftEdge = null; + let lineToRightEdge = null; - // Add lines to the beginning and end if necessary if (controlPoints.length > 0) { + // If the start point is above 0, need to draw a line from the left edge if (controlPoints[0].position > 0) { numLines += 1; - startAdd = { - position: controlPoints[0].position, - color: vec4.fromValues(0, 0, 0, 0), - }; + lineFromLeftEdge = vec4.fromValues(0, 0, controlPoints[0].position, 0); } - if ( - controlPoints[controlPoints.length - 1].position < - TRANSFER_FUNCTION_LENGTH - 1 - ) { + // If the end point is less than the transfer function length, need to draw a line to the right edge + const endPoint = controlPoints[controlPoints.length - 1]; + if (endPoint.position < TRANSFER_FUNCTION_LENGTH - 1) { numLines += 1; - endAdd = { - position: TRANSFER_FUNCTION_LENGTH - 1, - color: controlPoints[controlPoints.length - 1].color, - }; + lineToRightEdge = vec4.fromValues( + endPoint.position, + endPoint.color[3], + TRANSFER_FUNCTION_LENGTH - 1, + endPoint.color[3], + ); } - } else { - numLines = 0; } // Create line positions const linePositionArray = new Float32Array( numLines * VERTICES_PER_LINE * POSITION_VALUES_PER_LINE, ); - if (startAdd !== null) { - const linePosition = vec4.fromValues( - startAdd.position, - startAdd.color[3], - controlPoints[0].position, - controlPoints[0].color[3], + if (lineFromLeftEdge !== null) { + positionArrayIndex = addLine( + linePositionArray, + positionArrayIndex, + lineFromLeftEdge, ); - lineIndex = createLinePoints(linePositionArray, lineIndex, linePosition); } + + // Draw a vertical line up to the first control point + if (numLines !== 0) { + const startPoint = controlPoints[0]; + const lineStartEndPoints = vec4.fromValues( + startPoint.position, + 0, + startPoint.position, + startPoint.color[3], + ); + positionArrayIndex = addLine( + linePositionArray, + positionArrayIndex, + lineStartEndPoints, + ); + } + // Update points and draw lines between control points for (let i = 0; i < controlPoints.length; ++i) { const colorIndex = i * colorChannels; const positionIndex = i * 2; @@ -469,28 +482,24 @@ class TransferFunctionPanel extends IndirectRenderedPanel { colorArray[colorIndex + 2] = normalizeColor(color[2]); positionArray[positionIndex] = normalizePosition(position); positionArray[positionIndex + 1] = normalizeOpacity(color[3]); - if (i < controlPoints.length - 1) { - const linePosition = vec4.fromValues( - position, - color[3], - controlPoints[i + 1].position, - controlPoints[i + 1].color[3], - ); - lineIndex = createLinePoints( - linePositionArray, - lineIndex, - linePosition, - ); - } - } - if (endAdd !== null) { + + // Don't create a line for the last point + if (i === controlPoints.length - 1) break; const linePosition = vec4.fromValues( - controlPoints[controlPoints.length - 1].position, - controlPoints[controlPoints.length - 1].color[3], - endAdd.position, - endAdd.color[3], + position, + color[3], + controlPoints[i + 1].position, + controlPoints[i + 1].color[3], ); - lineIndex = createLinePoints(linePositionArray, lineIndex, linePosition); + positionArrayIndex = addLine( + linePositionArray, + positionArrayIndex, + linePosition, + ); + } + + if (lineToRightEdge !== null) { + addLine(linePositionArray, positionArrayIndex, lineToRightEdge); } // Update buffers @@ -666,7 +675,7 @@ out_color = tempColor * alpha; } update() { this.controlPointsLookupTable.lookupTableFromControlPoints(); - this.updateTransferFunctionPanelLines(); + this.updateTransferFunctionPointsAndLines(); } isReady() { return true; @@ -693,9 +702,9 @@ class ControlPointsLookupTable extends RefCounted { } opacityToIndex(opacity: number) { let opacityAsUint8 = floatToUint8(opacity); - if (opacityAsUint8 <= TRANSFER_FUNCTION_BORDER_WIDTH) { + if (opacityAsUint8 <= TRANSFER_FUNCTION_Y_BORDER_WIDTH) { opacityAsUint8 = 0; - } else if (opacityAsUint8 >= 255 - TRANSFER_FUNCTION_BORDER_WIDTH) { + } else if (opacityAsUint8 >= 255 - TRANSFER_FUNCTION_Y_BORDER_WIDTH) { opacityAsUint8 = 255; } return opacityAsUint8; @@ -716,7 +725,7 @@ class ControlPointsLookupTable extends RefCounted { this.trackable.value.controlPoints[nearestIndex].position; const desiredPosition = this.positionToIndex(position); if ( - Math.abs(nearestPosition - desiredPosition) < CONTROL_POINT_GRAB_DISTANCE + Math.abs(nearestPosition - desiredPosition) < CONTROL_POINT_X_GRAB_DISTANCE ) { return nearestIndex; } @@ -1170,7 +1179,7 @@ export function transferFunctionLayerControl( dataType, } = getter(layer); - // We setup the ability to change the channel through the UI here + // Setup the ability to change the channel through the UI here // but only if the data has multiple channels if ( channelCoordinateSpaceCombiner !== undefined && From 5101386310d08ba5ece15cc8f92aa7787fd89a21 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 13 Feb 2024 16:30:34 +0100 Subject: [PATCH 08/60] fix: no longer able to place control points on top of eachother --- src/webgl/shader_ui_controls.spec.ts | 16 +++++++------- src/webgl/shader_ui_controls.ts | 19 ++++++++++++---- src/widget/transfer_function.ts | 33 ++++++++++++++++++++-------- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/webgl/shader_ui_controls.spec.ts b/src/webgl/shader_ui_controls.spec.ts index 4ab449e26..7d42baccf 100644 --- a/src/webgl/shader_ui_controls.spec.ts +++ b/src/webgl/shader_ui_controls.spec.ts @@ -565,7 +565,7 @@ void main() { }); it("handles transfer function control without channel", () => { const code = ` -#uicontrol transferFunction colormap(points=[]) +#uicontrol transferFunction colormap(controlPoints=[]) void main() { } `; @@ -601,7 +601,7 @@ void main() { }); it("handles transfer function control without channel (rank 1)", () => { const code = ` -#uicontrol transferFunction colormap(points=[]) +#uicontrol transferFunction colormap(controlPoints=[]) void main() { } `; @@ -637,7 +637,7 @@ void main() { }); it("handles transfer function control with channel (rank 0)", () => { const code = ` -#uicontrol transferFunction colormap(points=[], channel=[]) +#uicontrol transferFunction colormap(controlPoints=[], channel=[]) void main() { } `; @@ -673,7 +673,7 @@ void main() { }); it("handles transfer function control with non-array channel (rank 1)", () => { const code = ` -#uicontrol transferFunction colormap(points=[], channel=1) +#uicontrol transferFunction colormap(controlPoints=[], channel=1) void main() { } `; @@ -709,7 +709,7 @@ void main() { }); it("handles transfer function control with array channel (rank 1)", () => { const code = ` -#uicontrol transferFunction colormap(points=[], channel=[1]) +#uicontrol transferFunction colormap(controlPoints=[], channel=[1]) void main() { } `; @@ -745,7 +745,7 @@ void main() { }); it("handles transfer function control with array channel (rank 2)", () => { const code = ` -#uicontrol transferFunction colormap(points=[], channel=[1,2]) +#uicontrol transferFunction colormap(controlPoints=[], channel=[1,2]) void main() { } `; @@ -781,7 +781,7 @@ void main() { }); it("handles transfer function control with all properties non uint64 data", () => { const code = ` -#uicontrol transferFunction colormap(points=[[200, "#00ff00", 0.1], [100, "#ff0000", 0.5], [0, "#000000", 0.0]], color="#0000ff", range=[0, 200], channel=[]) +#uicontrol transferFunction colormap(controlPoints=[[200, "#00ff00", 0.1], [100, "#ff0000", 0.5], [0, "#000000", 0.0]], color="#0000ff", range=[0, 200], channel=[]) void main() { } `; @@ -828,7 +828,7 @@ void main() { }); it("handles transfer function control with all properties uint64 data", () => { const code = ` -#uicontrol transferFunction colormap(points=[[18446744073709551615, "#00ff00", 0.1], [9223372111111111111, "#ff0000", 0.5], [0, "#000000", 0.0]], color="#0000ff", channel=[]) +#uicontrol transferFunction colormap(controlPoints=[[18446744073709551615, "#00ff00", 0.1], [9223372111111111111, "#ff0000", 0.5], [0, "#000000", 0.0]], color="#0000ff", channel=[]) void main() { } `; diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index ede7c4919..6dbea4cbc 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -629,7 +629,7 @@ function parseTransferFunctionDirective( } break; } - case "points": { + case "controlPoints": { specifedPoints = true; if (dataType !== undefined) { parsedControlPoints.push( @@ -1164,14 +1164,14 @@ function parseTransferFunctionControlPoints( const toConvert = dataType === DataType.UINT64 ? Uint64.fromNumber(position) : position; const normalizedPosition = computeInvlerp(range, toConvert); - const positionInRange = computeLerp( + const positionInTransferFunction = computeLerp( [0, TRANSFER_FUNCTION_LENGTH - 1], DataType.UINT16, normalizedPosition, ) as number; - return positionInRange; + return positionInTransferFunction; } - return parseArray(value, (x) => { + const parsedPoints = parseArray(value, (x) => { if ( x.position === undefined || x.color === undefined || @@ -1208,6 +1208,17 @@ function parseTransferFunctionControlPoints( color: rgbaColor, }; }); + // Check all points are in separate bins + const encounteredBins: number[] = []; + for (const point of parsedPoints) { + if (encounteredBins.includes(point.position)) { + throw new Error( + `Transfer function control points fall in the same bin. Increase the distance between the points, or reduce the range of the input space`, + ); + } + encounteredBins.push(point.position); + } + return parsedPoints; } function parseTransferFunctionParameters( diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 4953beb7b..eee405524 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -74,7 +74,7 @@ export const TRANSFER_FUNCTION_LENGTH = 1024; export const NUM_COLOR_CHANNELS = 4; const POSITION_VALUES_PER_LINE = 4; // x1, y1, x2, y2 const CONTROL_POINT_X_GRAB_DISTANCE = TRANSFER_FUNCTION_LENGTH / 40; -const TRANSFER_FUNCTION_Y_BORDER_WIDTH = 255 / 20; +const TRANSFER_FUNCTION_BORDER_WIDTH = 10; const transferFunctionSamplerTextureUnit = Symbol( "transferFunctionSamplerTexture", @@ -698,13 +698,23 @@ class ControlPointsLookupTable extends RefCounted { ).fill(0); } positionToIndex(position: number) { - return Math.floor(position * (TRANSFER_FUNCTION_LENGTH - 1)); + let positionAsIndex = Math.floor(position * (TRANSFER_FUNCTION_LENGTH - 1)); + if (positionAsIndex < TRANSFER_FUNCTION_BORDER_WIDTH) { + positionAsIndex = 0; + } + if ( + TRANSFER_FUNCTION_LENGTH - 1 - positionAsIndex < + TRANSFER_FUNCTION_BORDER_WIDTH + ) { + positionAsIndex = TRANSFER_FUNCTION_LENGTH - 1; + } + return positionAsIndex; } opacityToIndex(opacity: number) { let opacityAsUint8 = floatToUint8(opacity); - if (opacityAsUint8 <= TRANSFER_FUNCTION_Y_BORDER_WIDTH) { + if (opacityAsUint8 <= TRANSFER_FUNCTION_BORDER_WIDTH) { opacityAsUint8 = 0; - } else if (opacityAsUint8 >= 255 - TRANSFER_FUNCTION_Y_BORDER_WIDTH) { + } else if (opacityAsUint8 >= 255 - TRANSFER_FUNCTION_BORDER_WIDTH) { opacityAsUint8 = 255; } return opacityAsUint8; @@ -725,7 +735,8 @@ class ControlPointsLookupTable extends RefCounted { this.trackable.value.controlPoints[nearestIndex].position; const desiredPosition = this.positionToIndex(position); if ( - Math.abs(nearestPosition - desiredPosition) < CONTROL_POINT_X_GRAB_DISTANCE + Math.abs(nearestPosition - desiredPosition) < + CONTROL_POINT_X_GRAB_DISTANCE ) { return nearestIndex; } @@ -764,23 +775,27 @@ class ControlPointsLookupTable extends RefCounted { } updatePoint(index: number, position: number, opacity: number) { const { controlPoints } = this.trackable.value; - const positionAsIndex = this.positionToIndex(position); + let positionAsIndex = this.positionToIndex(position); const opacityAsUint8 = this.opacityToIndex(opacity); const color = controlPoints[index].color; controlPoints[index] = { position: positionAsIndex, color: vec4.fromValues(color[0], color[1], color[2], opacityAsUint8), }; - controlPoints.sort((a, b) => a.position - b.position); const exsitingPositions = new Set(); + let positionToFind = positionAsIndex; for (const point of controlPoints) { if (exsitingPositions.has(point.position)) { - return index; + positionToFind = (positionToFind === 0) ? 1 : positionToFind - 1; + controlPoints[index].position = positionToFind; + break; } exsitingPositions.add(point.position); } + console.log(positionToFind, opacityAsUint8); + controlPoints.sort((a, b) => a.position - b.position); const newControlPointIndex = controlPoints.findIndex( - (point) => point.position === positionAsIndex, + (point) => point.position === positionToFind, ); return newControlPointIndex; } From 53a578bc4c5d66f427ed4dfc9c73497c1096d606 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 13 Feb 2024 17:08:28 +0100 Subject: [PATCH 09/60] fix: can grab control points in TF that are close in X by breaking ties with Y --- src/widget/transfer_function.ts | 55 ++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index eee405524..67a066bd9 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -710,7 +710,7 @@ class ControlPointsLookupTable extends RefCounted { } return positionAsIndex; } - opacityToIndex(opacity: number) { + opacityToUint8(opacity: number) { let opacityAsUint8 = floatToUint8(opacity); if (opacityAsUint8 <= TRANSFER_FUNCTION_BORDER_WIDTH) { opacityAsUint8 = 0; @@ -726,21 +726,46 @@ class ControlPointsLookupTable extends RefCounted { (a, b) => a - b, ); } - grabControlPoint(position: number) { + grabControlPoint(position: number, opacity: number) { + const desiredPosition = this.positionToIndex(position); + const desiredOpacity = this.opacityToUint8(opacity); const nearestIndex = this.findNearestControlPointIndex(position); if (nearestIndex === -1) { return -1; } - const nearestPosition = - this.trackable.value.controlPoints[nearestIndex].position; - const desiredPosition = this.positionToIndex(position); + const controlPoints = this.trackable.value.controlPoints; + const nearestPosition = controlPoints[nearestIndex].position; if ( - Math.abs(nearestPosition - desiredPosition) < + Math.abs(nearestPosition - desiredPosition) > CONTROL_POINT_X_GRAB_DISTANCE ) { - return nearestIndex; + return -1; } - return -1; + + // If points are nearby in X space, use Y space to break ties + const nextPosition = controlPoints[nearestIndex + 1]?.position; + const nextDistance = nextPosition !== undefined ? Math.abs(nextPosition - desiredPosition) : CONTROL_POINT_X_GRAB_DISTANCE + 1; + const previousPosition = controlPoints[nearestIndex - 1]?.position; + const previousDistance = previousPosition !== undefined ? Math.abs(previousPosition - desiredPosition) : CONTROL_POINT_X_GRAB_DISTANCE + 1; + const possibleValues: [number, number][] = []; + if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { + possibleValues.push([ + nearestIndex + 1, + Math.abs(controlPoints[nearestIndex + 1].color[3] - desiredOpacity), + ]); + } + if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { + possibleValues.push([ + nearestIndex - 1, + Math.abs(controlPoints[nearestIndex - 1].color[3] - desiredOpacity), + ]); + } + possibleValues.push([ + nearestIndex, + Math.abs(controlPoints[nearestIndex].color[3] - desiredOpacity), + ]); + possibleValues.sort((a, b) => a[1] - b[1]); + return possibleValues[0][0]; } addPoint(position: number, opacity: number, color: vec3) { const colorAsUint8 = vec3.fromValues( @@ -748,7 +773,7 @@ class ControlPointsLookupTable extends RefCounted { floatToUint8(color[1]), floatToUint8(color[2]), ); - const opacityAsUint8 = this.opacityToIndex(opacity); + const opacityAsUint8 = this.opacityToUint8(opacity); const controlPoints = this.trackable.value.controlPoints; const positionAsIndex = this.positionToIndex(position); const existingIndex = controlPoints.findIndex( @@ -776,7 +801,7 @@ class ControlPointsLookupTable extends RefCounted { updatePoint(index: number, position: number, opacity: number) { const { controlPoints } = this.trackable.value; let positionAsIndex = this.positionToIndex(position); - const opacityAsUint8 = this.opacityToIndex(opacity); + const opacityAsUint8 = this.opacityToUint8(opacity); const color = controlPoints[index].color; controlPoints[index] = { position: positionAsIndex, @@ -786,13 +811,12 @@ class ControlPointsLookupTable extends RefCounted { let positionToFind = positionAsIndex; for (const point of controlPoints) { if (exsitingPositions.has(point.position)) { - positionToFind = (positionToFind === 0) ? 1 : positionToFind - 1; + positionToFind = positionToFind === 0 ? 1 : positionToFind - 1; controlPoints[index].position = positionToFind; break; } exsitingPositions.add(point.position); } - console.log(positionToFind, opacityAsUint8); controlPoints.sort((a, b) => a.position - b.position); const newControlPointIndex = controlPoints.findIndex( (point) => point.position === positionToFind, @@ -945,10 +969,13 @@ class TransferFunctionController extends RefCounted { this.setModel(value); } findNearestControlPointIndex(event: MouseEvent) { - const { normalizedX } = this.getControlPointPosition( + const { normalizedX, normalizedY } = this.getControlPointPosition( event, ) as CanvasPosition; - return this.controlPointsLookupTable.grabControlPoint(normalizedX); + return this.controlPointsLookupTable.grabControlPoint( + normalizedX, + normalizedY, + ); } addControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { const color = this.controlPointsLookupTable.trackable.value.color; From 2aa0561f4f9b5de55b7903cf9f07974701e828d8 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 13 Feb 2024 17:15:05 +0100 Subject: [PATCH 10/60] fix: bind remove TF point to shift+dblclick You could accidentally remove points trying to move them before --- src/widget/transfer_function.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 67a066bd9..a465b62cc 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -897,7 +897,7 @@ function createRangeBoundInputs( const inputEventMap = EventActionMap.fromObject({ "shift?+mousedown0": { action: "add-or-drag-point" }, - "shift?+dblclick0": { action: "remove-point" }, + "shift+dblclick0": { action: "remove-point" }, "shift?+mousedown2": { action: "change-point-color" }, }); From 49466b26b3c39ed7a2b0830284c4a1a4b198c40c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Feb 2024 15:29:36 +0100 Subject: [PATCH 11/60] feat: clearer name of TF input value --- src/webgl/shader_ui_controls.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 6dbea4cbc..aa08501f7 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -1173,7 +1173,7 @@ function parseTransferFunctionControlPoints( } const parsedPoints = parseArray(value, (x) => { if ( - x.position === undefined || + x.input === undefined || x.color === undefined || x.opacity === undefined ) { @@ -1183,10 +1183,10 @@ function parseTransferFunctionControlPoints( )}`, ); } - if (typeof x.position !== "number") { + if (typeof x.input !== "number") { throw new Error( `Expected position as number but received: ${JSON.stringify( - x.position, + x.input, )}`, ); } @@ -1204,7 +1204,7 @@ function parseTransferFunctionControlPoints( opacity, ); return { - position: parsePosition(x.position), + position: parsePosition(x.input), color: rgbaColor, }; }); @@ -1311,7 +1311,7 @@ class TrackableTransferFunctionParameters extends TrackableValue ({ - position: positionToJson(x.position), + input: positionToJson(x.position), color: serializeColor( vec3.fromValues(x.color[0] / 255, x.color[1] / 255, x.color[2] / 255), ), From 1b142e528898945e6e9af13aba7755bd90cd4539 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Feb 2024 15:30:15 +0100 Subject: [PATCH 12/60] feat: Python control over transfer function shader contro --- python/neuroglancer/__init__.py | 1 + python/neuroglancer/viewer_state.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/python/neuroglancer/__init__.py b/python/neuroglancer/__init__.py index 5275b5c87..0d3333280 100644 --- a/python/neuroglancer/__init__.py +++ b/python/neuroglancer/__init__.py @@ -88,6 +88,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 7431a300f..bf588f402 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -518,6 +518,23 @@ class InvlerpParameters(JsonObjectWrapper): channel = wrapped_property("channel", optional(typed_list(int))) +@export +class ControlPointsSpec(JsonObjectWrapper): + input = wrapped_property("input", optional(numbers.Number)) + color = wrapped_property("color", optional(str)) + opacity = wrapped_property("opacity", optional(float)) + + +@export +class TransferFunctionParameters(JsonObjectWrapper): + range = wrapped_property("range", optional(array_wrapper(numbers.Number, 2))) + channel = wrapped_property("channel", optional(typed_list(int))) + controlPoints = wrapped_property( + "controlPoints", optional(typed_list(ControlPointsSpec)) + ) + color = wrapped_property("color", optional(str)) + + _UINT64_STR_PATTERN = re.compile("[0-9]+") @@ -530,9 +547,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)}") From 36ca253f8aea9265e199fe300deac306b0bd2d79 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Feb 2024 15:30:34 +0100 Subject: [PATCH 13/60] docs: fix typo in python docs --- python/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/README.md b/python/README.md index cc48f335d..bcd85e0ac 100644 --- a/python/README.md +++ b/python/README.md @@ -156,8 +156,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 From 764dfdb4f47c51b4ea44f9c2e202525825f67a69 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Feb 2024 15:30:53 +0100 Subject: [PATCH 14/60] test (Python): shader control transfer function test --- ...er_controls.py => shader_controls_test.py} | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) rename python/tests/{shader_controls.py => shader_controls_test.py} (61%) diff --git a/python/tests/shader_controls.py b/python/tests/shader_controls_test.py similarity index 61% rename from python/tests/shader_controls.py rename to python/tests/shader_controls_test.py index ee6bca39c..b11b75258 100644 --- a/python/tests/shader_controls.py +++ b/python/tests/shader_controls_test.py @@ -37,6 +37,7 @@ def test_invlerp(webdriver): "range": [0, 42], }, }, + opacity=1.0 ) s.layout = "xy" s.cross_section_scale = 1e-6 @@ -62,6 +63,74 @@ 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": [ + {"input": 0, "color": "#000000", "opacity": 0.0}, + {"input": 84, "color": "#ffffff", "opacity": 1.0}, + ], + "range": [0, 100], + "channel": [], + "color": "#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.uint32, fill_value=42), + ), + ), + 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.range, [0, 100]) + + 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)), + ) + + expect_color([64, 64, 64, 255]) + with webdriver.viewer.txn() as s: + s.layers["image"].shader_controls = { + "colormap": neuroglancer.TransferFunctionParameters( + controlPoints=[ + {"input": 0, "color": "#000000", "opacity": 1.0}, + {"input": 84, "color": "#ffffff", "opacity": 1.0}, + ], + range=[50, 90], + channel=[], + color="#ff00ff", + ) + } + expect_color([0, 0, 0, 255]) + + def test_slider(webdriver): with webdriver.viewer.txn() as s: s.dimensions = neuroglancer.CoordinateSpace( From 12069f04cb9d955417fae9bf27a373a4c62bae26 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Feb 2024 15:43:22 +0100 Subject: [PATCH 15/60] fix: user can't specify transfer function points outside input range --- src/webgl/shader_ui_controls.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index aa08501f7..aefd29825 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -678,7 +678,8 @@ function parseTransferFunctionDirective( } else { // Parse control points from the shader code and sort them for (const controlPoint of parsedControlPoints) { - const normalizedPosition = computeInvlerp(range, controlPoint.position); + let normalizedPosition = computeInvlerp(range, controlPoint.position); + normalizedPosition = Math.min(Math.max(0, normalizedPosition), 1) const position = computeLerp( [0, TRANSFER_FUNCTION_LENGTH - 1], DataType.UINT16, @@ -1163,7 +1164,8 @@ function parseTransferFunctionControlPoints( function parsePosition(position: number): number { const toConvert = dataType === DataType.UINT64 ? Uint64.fromNumber(position) : position; - const normalizedPosition = computeInvlerp(range, toConvert); + let normalizedPosition = computeInvlerp(range, toConvert); + normalizedPosition = Math.min(Math.max(0, normalizedPosition), 1); const positionInTransferFunction = computeLerp( [0, TRANSFER_FUNCTION_LENGTH - 1], DataType.UINT16, @@ -1295,10 +1297,11 @@ class TrackableTransferFunctionParameters extends TrackableValue Date: Fri, 16 Feb 2024 18:04:28 +0100 Subject: [PATCH 16/60] docs: transfer function UI control --- src/sliceview/image_layer_rendering.md | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/sliceview/image_layer_rendering.md b/src/sliceview/image_layer_rendering.md index 6aeb69dfd..46844c6ab 100644 --- a/src/sliceview/image_layer_rendering.md +++ b/src/sliceview/image_layer_rendering.md @@ -155,6 +155,34 @@ 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 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. Any data point in the range that lies before the first control point is completely transparent. Any data point in the range that lies after the last control point has the value of the last control point. Any data point outside the range is clamped to lie within the range. In between control points, the color and opacity is linearly interpolated. + +Directive syntax: + +```glsl +#uicontrol transferFunction (range=[lower, higher], controlPoints=[[input, hexColorString, opacity]], channel=[], color="#rrggbb") +// For example: +#uicontrol transferFunction colormap(range=[0, 100], controlPoints=[[0.0, "#000000", 0.0], [100.0, "#ffffff", 1.0]], channel=[], color="#rrggbb") +``` + +The following parameters are supported: + +- `range`: Optional. The default input range to map to an output. Must be specified as an array. May be overridden using the UI control. If not specified, defaults to the full range of + the data type for integer data types, and `[0, 1]` for float32. It is valid to specify an + inverted interval like `[50, 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 knee 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. + +- `color`: 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 From abcfa33cfc8f7b99bb928d1231456579026c3692 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 27 Feb 2024 10:34:09 +0100 Subject: [PATCH 17/60] docs: code comment on transfer functions --- src/widget/transfer_function.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index a465b62cc..60bab6f8b 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -80,6 +80,14 @@ const transferFunctionSamplerTextureUnit = Symbol( "transferFunctionSamplerTexture", ); +/** + * Transfer functions are controlled via a set of control points + * with an input value and an output RGBA color. + * 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 interface ControlPoint { /** The bin that the point's x value lies in - int between 0 and TRANSFER_FUNCTION_LENGTH - 1 */ position: number; From a76cd3371ca0e3feae63e8ceb696bff35a66040b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 19 Mar 2024 12:13:56 +0100 Subject: [PATCH 18/60] chore: format and lint --- src/webgl/rectangle_grid_buffer.spec.ts | 3 +- src/webgl/rectangle_grid_buffer.ts | 6 +- src/webgl/shader.ts | 8 +- src/webgl/shader_ui_controls.browser_test.ts | 5 +- src/webgl/shader_ui_controls.ts | 26 +++--- src/widget/shader_controls.ts | 2 +- src/widget/transfer_function.spec.ts | 17 ++-- src/widget/transfer_function.ts | 87 +++++++++++--------- 8 files changed, 85 insertions(+), 69 deletions(-) diff --git a/src/webgl/rectangle_grid_buffer.spec.ts b/src/webgl/rectangle_grid_buffer.spec.ts index fd642ec39..b038ba963 100644 --- a/src/webgl/rectangle_grid_buffer.spec.ts +++ b/src/webgl/rectangle_grid_buffer.spec.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { createGriddedRectangleArray } from "#/webgl/rectangle_grid_buffer"; +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", () => { diff --git a/src/webgl/rectangle_grid_buffer.ts b/src/webgl/rectangle_grid_buffer.ts index 529243827..d17dc2af4 100644 --- a/src/webgl/rectangle_grid_buffer.ts +++ b/src/webgl/rectangle_grid_buffer.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { getMemoizedBuffer } from "#/webgl/buffer"; -import { GL } from "#/webgl/context"; -import { VERTICES_PER_QUAD } from "#/webgl/quad"; +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 diff --git a/src/webgl/shader.ts b/src/webgl/shader.ts index e5d5f117c..2a6d003b3 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { +import { RefCounted } from "#src/util/disposable.js"; +import type { GL } from "#src/webgl/context.js"; +import type { ControlPoint, TransferFunctionTexture, } from "#src/widget/transfer_function.js"; -import { RefCounted } from "#src/util/disposable.js"; -import type { GL } from "#src/webgl/context.js"; const DEBUG_SHADER = false; @@ -255,7 +255,7 @@ export class ShaderProgram extends RefCounted { } bindAndUpdateTransferFunctionTexture( - symbol: Symbol | string, + symbol: symbol | string, controlPoints: ControlPoint[], ) { const textureUnit = this.textureUnits.get(symbol); diff --git a/src/webgl/shader_ui_controls.browser_test.ts b/src/webgl/shader_ui_controls.browser_test.ts index 7f36e2c0d..b844d2420 100644 --- a/src/webgl/shader_ui_controls.browser_test.ts +++ b/src/webgl/shader_ui_controls.browser_test.ts @@ -14,16 +14,15 @@ * limitations under the License. */ - -import { TRANSFER_FUNCTION_LENGTH } from "#src/widget/transfer_function.js"; -import { defaultDataTypeRange } from "#src/util/lerp.js"; import { expect, describe, it } from "vitest"; import { DataType } from "#src/util/data_type.js"; import { vec3, vec4 } from "#src/util/geom.js"; +import { defaultDataTypeRange } from "#src/util/lerp.js"; import { parseShaderUiControls, stripComments, } from "#src/webgl/shader_ui_controls.js"; +import { TRANSFER_FUNCTION_LENGTH } from "#src/widget/transfer_function.js"; describe("stripComments", () => { it("handles code without comments", () => { diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 47f792f1b..9879c1b47 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -27,10 +27,14 @@ import { TrackableValue, } from "#src/trackable_value.js"; import { arraysEqual, arraysEqualWithPredicate } from "#src/util/array.js"; -import { parseRGBColorSpecification, serializeColor, 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, vec4 } from "#src/util/geom.js"; +import { vec3, vec4 } from "#src/util/geom.js"; import { parseArray, parseFixedLengthArray, @@ -54,6 +58,7 @@ import { } from "#src/util/lerp.js"; import { NullarySignal } from "#src/util/signal.js"; import type { Trackable } from "#src/util/trackable.js"; +import { 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"; @@ -62,15 +67,16 @@ import { enableLerpShaderFunction, } from "#src/webgl/lerp.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; -import { +import type { ControlPoint, + ParsedControlPoint, +} from "#src/widget/transfer_function.js"; +import { defineTransferFunctionShader, enableTransferFunctionShader, floatToUint8, - ParsedControlPoint, TRANSFER_FUNCTION_LENGTH, } from "#src/widget/transfer_function.js"; -import { Uint64 } from "#src/util/uint64.js"; export interface ShaderSliderControl { type: "slider"; @@ -675,7 +681,7 @@ function parseTransferFunctionDirective( // Parse control points from the shader code and sort them for (const controlPoint of parsedControlPoints) { let normalizedPosition = computeInvlerp(range, controlPoint.position); - normalizedPosition = Math.min(Math.max(0, normalizedPosition), 1) + normalizedPosition = Math.min(Math.max(0, normalizedPosition), 1); const position = computeLerp( [0, TRANSFER_FUNCTION_LENGTH - 1], DataType.UINT16, @@ -1183,9 +1189,7 @@ function parseTransferFunctionControlPoints( } if (typeof x.input !== "number") { throw new Error( - `Expected position as number but received: ${JSON.stringify( - x.input, - )}`, + `Expected position as number but received: ${JSON.stringify(x.input)}`, ); } const color = parseRGBColorSpecification(x.color); @@ -1297,7 +1301,7 @@ class TrackableTransferFunctionParameters extends TrackableValue { const output = new Uint8Array(NUM_COLOR_CHANNELS * TRANSFER_FUNCTION_LENGTH); diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 60bab6f8b..58c7ece6b 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -14,61 +14,66 @@ * limitations under the License. */ -import "#/widget/transfer_function.css"; +import "#src/widget/transfer_function.css"; -import { CoordinateSpaceCombiner } from "#/coordinate_transform"; -import { DisplayContext, IndirectRenderedPanel } from "#/display_context"; -import { UserLayer } from "#/layer"; -import { Position } from "#/navigation_state"; -import { - makeCachedDerivedWatchableValue, - WatchableValueInterface, -} from "#/trackable_value"; -import { ToolActivation } from "#/ui/tool"; +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 "#/util/array"; -import { DATA_TYPE_SIGNED, DataType } from "#/util/data_type"; -import { RefCounted } from "#/util/disposable"; +} 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 "#/util/event_action_map"; -import { vec3, vec4 } from "#/util/geom"; -import { computeLerp, DataTypeInterval, parseDataTypeValue } from "#/util/lerp"; -import { MouseEventBinder } from "#/util/mouse_bindings"; -import { startRelativeMouseDrag } from "#/util/mouse_drag"; -import { WatchableVisibilityPriority } from "#/visibility_priority/frontend"; -import { Buffer, getMemoizedBuffer } from "#/webgl/buffer"; -import { GL } from "#/webgl/context"; +} from "#src/util/event_action_map.js"; +import { vec3, vec4 } from "#src/util/geom.js"; +import type { DataTypeInterval } from "#src/util/lerp.js"; +import { computeLerp, 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 "#/webgl/lerp"; +} from "#src/webgl/lerp.js"; import { defineLineShader, drawLines, initializeLineShader, VERTICES_PER_LINE, -} from "#/webgl/lines"; -import { drawQuads } from "#/webgl/quad"; -import { createGriddedRectangleArray } from "#/webgl/rectangle_grid_buffer"; -import { ShaderBuilder, ShaderCodePart, ShaderProgram } from "#/webgl/shader"; -import { getShaderType } from "#/webgl/shader_lib"; -import { TransferFunctionParameters } from "#/webgl/shader_ui_controls"; -import { setRawTextureParameters } from "#/webgl/texture"; -import { ColorWidget } from "#/widget/color"; +} 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 type { TransferFunctionParameters } from "#src/webgl/shader_ui_controls.js"; +import { setRawTextureParameters } from "#src/webgl/texture.js"; +import { ColorWidget } from "#src/widget/color.js"; import { getUpdatedRangeAndWindowParameters, updateInputBoundValue, updateInputBoundWidth, -} from "#/widget/invlerp"; -import { LayerControlFactory, LayerControlTool } from "#/widget/layer_control"; -import { PositionWidget } from "#/widget/position_widget"; -import { Tab } from "#/widget/tab_view"; -import { Uint64 } from "#/util/uint64"; +} 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"; export const TRANSFER_FUNCTION_LENGTH = 1024; export const NUM_COLOR_CHANNELS = 4; @@ -752,9 +757,15 @@ class ControlPointsLookupTable extends RefCounted { // If points are nearby in X space, use Y space to break ties const nextPosition = controlPoints[nearestIndex + 1]?.position; - const nextDistance = nextPosition !== undefined ? Math.abs(nextPosition - desiredPosition) : CONTROL_POINT_X_GRAB_DISTANCE + 1; + const nextDistance = + nextPosition !== undefined + ? Math.abs(nextPosition - desiredPosition) + : CONTROL_POINT_X_GRAB_DISTANCE + 1; const previousPosition = controlPoints[nearestIndex - 1]?.position; - const previousDistance = previousPosition !== undefined ? Math.abs(previousPosition - desiredPosition) : CONTROL_POINT_X_GRAB_DISTANCE + 1; + const previousDistance = + previousPosition !== undefined + ? Math.abs(previousPosition - desiredPosition) + : CONTROL_POINT_X_GRAB_DISTANCE + 1; const possibleValues: [number, number][] = []; if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { possibleValues.push([ @@ -808,7 +819,7 @@ class ControlPointsLookupTable extends RefCounted { } updatePoint(index: number, position: number, opacity: number) { const { controlPoints } = this.trackable.value; - let positionAsIndex = this.positionToIndex(position); + const positionAsIndex = this.positionToIndex(position); const opacityAsUint8 = this.opacityToUint8(opacity); const color = controlPoints[index].color; controlPoints[index] = { From 880e1b6892ae64a48f88111dd618523cef9682cc Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 19 Mar 2024 12:15:11 +0100 Subject: [PATCH 19/60] chore(python): format --- python/tests/shader_controls_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/tests/shader_controls_test.py b/python/tests/shader_controls_test.py index b11b75258..7aa9809f5 100644 --- a/python/tests/shader_controls_test.py +++ b/python/tests/shader_controls_test.py @@ -37,7 +37,7 @@ def test_invlerp(webdriver): "range": [0, 42], }, }, - opacity=1.0 + opacity=1.0, ) s.layout = "xy" s.cross_section_scale = 1e-6 @@ -98,7 +98,7 @@ def test_transfer_function(webdriver): shader=shader, shader_controls=shaderControls, opacity=1.0, - blend="additive" + blend="additive", ) s.layout = "xy" s.cross_section_scale = 1e-6 From 9363963cce88b8f0b7d74a4397644338a1ed5941 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 19 Mar 2024 13:48:35 +0100 Subject: [PATCH 20/60] refactor(in progress): store control points in abs value --- src/webgl/shader_ui_controls.ts | 36 ++-- src/widget/transfer_function.spec.ts | 22 +-- src/widget/transfer_function.ts | 266 +++++++++++++++++++-------- 3 files changed, 220 insertions(+), 104 deletions(-) diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 9879c1b47..2ee9a68c3 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -670,36 +670,36 @@ function parseTransferFunctionDirective( 0.7, ) as number; controlPoints.push({ - position: startPoint, - color: vec4.fromValues(0, 0, 0, 0), + inputValue: startPoint, + outputColor: vec4.fromValues(0, 0, 0, 0), }); controlPoints.push({ - position: endPoint, - color: vec4.fromValues(255, 255, 255, 255), + inputValue: endPoint, + outputColor: vec4.fromValues(255, 255, 255, 255), }); } else { // Parse control points from the shader code and sort them for (const controlPoint of parsedControlPoints) { - let normalizedPosition = computeInvlerp(range, controlPoint.position); + let normalizedPosition = computeInvlerp(range, controlPoint.inputValue); normalizedPosition = Math.min(Math.max(0, normalizedPosition), 1); const position = computeLerp( [0, TRANSFER_FUNCTION_LENGTH - 1], DataType.UINT16, normalizedPosition, ) as number; - controlPoints.push({ position: position, color: controlPoint.color }); + controlPoints.push({ inputValue: position, outputColor: controlPoint.outputColor }); } const pointPositions = new Set(); for (let i = 0; i < controlPoints.length; i++) { const controlPoint = controlPoints[i]; - if (pointPositions.has(controlPoint.position)) { + if (pointPositions.has(controlPoint.inputValue)) { errors.push( - `Duplicate control point position: ${parsedControlPoints[i].position}`, + `Duplicate control point position: ${parsedControlPoints[i].inputValue}`, ); } - pointPositions.add(controlPoint.position); + pointPositions.add(controlPoint.inputValue); } - controlPoints.sort((a, b) => a.position - b.position); + controlPoints.sort((a, b) => a.inputValue - b.inputValue); } if (errors.length > 0) { return { errors }; @@ -1241,8 +1241,8 @@ function parseTransferFunctionParameters( "controlPoints", (x) => parseTransferFunctionControlPoints(x, range, dataType), defaultValue.controlPoints.map((x) => ({ - position: x.position, - color: x.color, + position: x.inputValue, + color: x.outputColor, })), ); return { @@ -1268,8 +1268,8 @@ function copyTransferFunctionParameters( ) { return { controlPoints: defaultValue.controlPoints.map((x) => ({ - position: x.position, - color: x.color, + position: x.inputValue, + color: x.outputColor, })), channel: defaultValue.channel, color: defaultValue.color, @@ -1314,11 +1314,11 @@ class TrackableTransferFunctionParameters extends TrackableValue ({ - input: positionToJson(x.position), + input: positionToJson(x.inputValue), color: serializeColor( - vec3.fromValues(x.color[0] / 255, x.color[1] / 255, x.color[2] / 255), + vec3.fromValues(x.outputColor[0] / 255, x.outputColor[1] / 255, x.outputColor[2] / 255), ), - opacity: x.color[3] / 255, + opacity: x.outputColor[3] / 255, })); } @@ -1343,7 +1343,7 @@ class TrackableTransferFunctionParameters extends TrackableValue arraysEqual(a.color, b.color) && a.position === b.position, + (a, b) => arraysEqual(a.outputColor, b.outputColor) && a.inputValue === b.inputValue, ) ? undefined : this.controlPointsToJson(this.value.controlPoints, range, dataType); diff --git a/src/widget/transfer_function.spec.ts b/src/widget/transfer_function.spec.ts index c5fb7ce79..ebeed2c4c 100644 --- a/src/widget/transfer_function.spec.ts +++ b/src/widget/transfer_function.spec.ts @@ -39,14 +39,14 @@ describe("lerpBetweenControlPoints", () => { }); it("returns transparent black up to the first control point, and the last control point value after", () => { const controlPoints: ControlPoint[] = [ - { position: 120, color: vec4.fromValues(21, 22, 254, 210) }, + { inputValue: 120, outputColor: vec4.fromValues(21, 22, 254, 210) }, ]; lerpBetweenControlPoints(output, controlPoints); expect( output.slice(0, NUM_COLOR_CHANNELS * 120).every((value) => value === 0), ).toBeTruthy(); const endPiece = output.slice(NUM_COLOR_CHANNELS * 120); - const color = controlPoints[0].color; + const color = controlPoints[0].outputColor; expect( endPiece.every( (value, index) => value === color[index % NUM_COLOR_CHANNELS], @@ -55,9 +55,9 @@ describe("lerpBetweenControlPoints", () => { }); it("correctly interpolates between three control points", () => { const controlPoints: ControlPoint[] = [ - { position: 120, color: vec4.fromValues(21, 22, 254, 210) }, - { position: 140, color: vec4.fromValues(0, 0, 0, 0) }, - { position: 200, color: vec4.fromValues(255, 255, 255, 255) }, + { inputValue: 120, outputColor: vec4.fromValues(21, 22, 254, 210) }, + { inputValue: 140, outputColor: vec4.fromValues(0, 0, 0, 0) }, + { inputValue: 200, outputColor: vec4.fromValues(255, 255, 255, 255) }, ]; lerpBetweenControlPoints(output, controlPoints); expect( @@ -67,8 +67,8 @@ describe("lerpBetweenControlPoints", () => { output.slice(NUM_COLOR_CHANNELS * 200).every((value) => value === 255), ).toBeTruthy(); - const firstColor = controlPoints[0].color; - const secondColor = controlPoints[1].color; + const firstColor = controlPoints[0].outputColor; + const secondColor = controlPoints[1].outputColor; for (let i = 120 * NUM_COLOR_CHANNELS; i < 140 * NUM_COLOR_CHANNELS; i++) { const difference = Math.floor((i - 120 * NUM_COLOR_CHANNELS) / 4); const expectedValue = @@ -88,7 +88,7 @@ describe("lerpBetweenControlPoints", () => { } } - const thirdColor = controlPoints[2].color; + const thirdColor = controlPoints[2].outputColor; for (let i = 140 * NUM_COLOR_CHANNELS; i < 200 * NUM_COLOR_CHANNELS; i++) { const difference = Math.floor((i - 140 * NUM_COLOR_CHANNELS) / 4); const expectedValue = @@ -113,10 +113,10 @@ describe("lerpBetweenControlPoints", () => { describe("compute transfer function on GPU", () => { const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; const controlPoints: ControlPoint[] = [ - { position: 0, color: vec4.fromValues(0, 0, 0, 0) }, + { inputValue: 0, outputColor: vec4.fromValues(0, 0, 0, 0) }, { - position: maxTransferFunctionPoints, - color: vec4.fromValues(255, 255, 255, 255), + inputValue: maxTransferFunctionPoints, + outputColor: vec4.fromValues(255, 255, 255, 255), }, ]; for (const dataType of Object.values(DataType)) { diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 58c7ece6b..85d263494 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -37,10 +37,14 @@ import { } from "#src/util/event_action_map.js"; import { vec3, vec4 } from "#src/util/geom.js"; import type { DataTypeInterval } from "#src/util/lerp.js"; -import { computeLerp, parseDataTypeValue } from "#src/util/lerp.js"; +import { + computeInvlerp, + computeLerp, + 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 { 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"; @@ -75,10 +79,10 @@ import type { import { PositionWidget } from "#src/widget/position_widget.js"; import { Tab } from "#src/widget/tab_view.js"; -export const TRANSFER_FUNCTION_LENGTH = 1024; +const TRANSFER_FUNCTION_PANEL_SIZE = 1024; export const NUM_COLOR_CHANNELS = 4; const POSITION_VALUES_PER_LINE = 4; // x1, y1, x2, y2 -const CONTROL_POINT_X_GRAB_DISTANCE = TRANSFER_FUNCTION_LENGTH / 40; +const CONTROL_POINT_X_GRAB_DISTANCE = TRANSFER_FUNCTION_PANEL_SIZE / 40; const TRANSFER_FUNCTION_BORDER_WIDTH = 10; const transferFunctionSamplerTextureUnit = Symbol( @@ -93,21 +97,82 @@ const transferFunctionSamplerTextureUnit = Symbol( * Such a lookup table is used to form a texture, which can be sampled * from during rendering. */ -export interface ControlPoint { - /** The bin that the point's x value lies in - int between 0 and TRANSFER_FUNCTION_LENGTH - 1 */ - position: number; +export interface ControlPointa { + /** The input data value for this control point */ + inputValue: number | Uint64; /** Color of the point as 4 uint8 values */ - color: vec4; + outputColor: vec4; } +export class ControlPoint { + constructor( + public inputValue: number | Uint64, + public outputColor: vec4, + ) {} + + /** Convert the input value to a normalized value between 0 and 1 */ + toNormalizedInputValue(range: DataTypeInterval): number { + return computeInvlerp(range, this.inputValue); + } + + /** Convert the input value to an integer index into the transfer function lookup texture */ + toTransferFunctionIndex( + dataRange: DataTypeInterval, + transferFunctionSize: number, + ): number { + return Math.floor( + this.toNormalizedInputValue(dataRange) * (transferFunctionSize - 1), + ); + } + + static copyFrom(other: ControlPoint) { + const inputValue = other.inputValue; + const outputColor = vec4.clone(other.outputColor); + return new ControlPoint(inputValue, outputColor); + } +} + +// export class ControlPointNumber extends ControlPoint { +// inputValue: number; +// outputColor: vec4; +// constructor() { +// super(); +// } + +// isPositive(value: number): boolean { +// return value > 0; +// } + +// isBefore(controlPoint: ControlPointNumber): boolean { +// return this.inputValue < controlPoint.inputValue; +// } +// } + +// export class ControlPointUint64 extends ControlPoint { +// inputValue: Uint64; +// outputColor: vec4; +// constructor() { +// super(); +// } + +// isPositive(value: Uint64): boolean { +// return Uint64.less(Uint64.ZERO, value); +// } + +// isBefore(controlPoint: ControlPointUint64): boolean { +// return Uint64.less(this.inputValue(controlPoint.inputValue); +// } +// } + /** * A parsed control point could have a position represented as a Uint64 * This will later be converted to a number between 0 and TRANSFER_FUNCTION_LENGTH - 1 * And then stored as a control point + * TODO(skm) - remove parsed control points */ export interface ParsedControlPoint { - position: number | Uint64; - color: vec4; + inputValue: number | Uint64; + outputColor: vec4; } /** @@ -115,13 +180,15 @@ export interface ParsedControlPoint { */ export interface TransferFunctionTextureOptions { /** If lookupTable is defined, it will be used to update the texture directly. - * A lookup table is a series of color values (0 - 255) between control points + * A lookup table is a series of color values (0 - 255) for each index in the transfer function texture */ lookupTable?: Uint8Array; /** If lookupTable is undefined, controlPoints will be used to generate a lookup table as a first step */ controlPoints?: ControlPoint[]; /** textureUnit to update with the new transfer function texture data */ textureUnit: number | undefined; + /** range of the input space I, where T: I -> O */ + inputRange: DataTypeInterval; } interface CanvasPosition { @@ -140,6 +207,8 @@ interface CanvasPosition { export function lerpBetweenControlPoints( out: Uint8Array, controlPoints: ControlPoint[], + dataRange: DataTypeInterval, + transferFunctionSize: number, ) { function addLookupValue(index: number, color: vec4) { out[index] = color[0]; @@ -147,18 +216,25 @@ export function lerpBetweenControlPoints( out[index + 2] = color[2]; out[index + 3] = color[3]; } + function toTransferFunctionSpace(controlPoint: ControlPoint) { + return controlPoint.toTransferFunctionIndex( + dataRange, + transferFunctionSize, + ); + } // Edge case: no control points - all transparent if (controlPoints.length === 0) { out.fill(0); return; } - const firstPoint = controlPoints[0]; + const firstInputValue = toTransferFunctionSpace(controlPoints[0]); // Edge case: first control point is not at 0 - fill in transparent values - if (firstPoint.position > 0) { + // up to the first point + if (firstInputValue > 0) { const transparent = vec4.fromValues(0, 0, 0, 0); - for (let i = 0; i < firstPoint.position; ++i) { + for (let i = 0; i < firstInputValue; ++i) { const index = i * NUM_COLOR_CHANNELS; addLookupValue(index, transparent); } @@ -166,24 +242,24 @@ export function lerpBetweenControlPoints( // Interpolate between control points and fill to end with last color let controlPointIndex = 0; - for (let i = firstPoint.position; i < TRANSFER_FUNCTION_LENGTH; ++i) { + for (let i = firstInputValue; i < transferFunctionSize; ++i) { const currentPoint = controlPoints[controlPointIndex]; const nextPoint = controlPoints[Math.min(controlPointIndex + 1, controlPoints.length - 1)]; const lookupIndex = i * NUM_COLOR_CHANNELS; if (currentPoint === nextPoint) { - addLookupValue(lookupIndex, currentPoint.color); + addLookupValue(lookupIndex, currentPoint.outputColor); } else { - const t = - (i - currentPoint.position) / - (nextPoint.position - currentPoint.position); + const currentInputValue = toTransferFunctionSpace(currentPoint); + const nextInputValue = toTransferFunctionSpace(nextPoint); + const t = (i - currentInputValue) / (nextInputValue - currentInputValue); const lerpedColor = lerpUint8Color( - currentPoint.color, - nextPoint.color, + currentPoint.outputColor, + nextPoint.outputColor, t, ); addLookupValue(lookupIndex, lerpedColor); - if (i === nextPoint.position) { + if (i === nextPoint.inputValue) { controlPointIndex++; } } @@ -214,10 +290,11 @@ function lerpUint8Color(startColor: vec4, endColor: vec4, t: number) { /** * Represent the underlying transfer function lookup table as a texture + * TODO(skm) consider if height can be used for more efficiency */ export class TransferFunctionTexture extends RefCounted { texture: WebGLTexture | null = null; - width: number = TRANSFER_FUNCTION_LENGTH; + width: number; height = 1; private priorOptions: TransferFunctionTextureOptions | undefined = undefined; @@ -248,7 +325,9 @@ export class TransferFunctionTexture extends RefCounted { controlPointsEqual = arraysEqualWithPredicate( existingOptions.controlPoints, newOptions.controlPoints, - (a, b) => a.position === b.position && arraysEqual(a.color, b.color), + (a, b) => + a.inputValue === b.inputValue && + arraysEqual(a.outputColor, b.outputColor), ); } const textureUnitEqual = @@ -297,9 +376,14 @@ export class TransferFunctionTexture extends RefCounted { let lookupTable = options.lookupTable; if (lookupTable === undefined) { lookupTable = new Uint8Array( - TRANSFER_FUNCTION_LENGTH * NUM_COLOR_CHANNELS, + this.width * this.height * NUM_COLOR_CHANNELS, + ); + lerpBetweenControlPoints( + lookupTable, + options.controlPoints!, + options.inputRange, + this.width * this.height, ); - lerpBetweenControlPoints(lookupTable, options.controlPoints!); } gl.texImage2D( WebGL2RenderingContext.TEXTURE_2D, @@ -315,13 +399,14 @@ export class TransferFunctionTexture extends RefCounted { // Update the prior options to the current options for future comparisons // Make a copy of the options for the purpose of comparison + // TODO(skm) is this copy needed? this.priorOptions = { textureUnit: options.textureUnit, lookupTable: options.lookupTable?.slice(), - controlPoints: options.controlPoints?.map((point) => ({ - position: point.position, - color: vec4.clone(point.color), - })), + controlPoints: options.controlPoints?.map((point) => + ControlPoint.copyFrom(point), + ), + inputRange: options.inputRange, }; } @@ -363,12 +448,13 @@ class TransferFunctionPanel extends IndirectRenderedPanel { }, ), ); + // TODO (skm) - the non-fixed length might be tricky here constructor(public parent: TransferFunctionWidget) { super(parent.display, document.createElement("div"), parent.visibility); const { element } = this; element.classList.add("neuroglancer-transfer-function-panel"); this.textureVertexBufferArray = createGriddedRectangleArray( - TRANSFER_FUNCTION_LENGTH, + TRANSFER_FUNCTION_PANEL_SIZE, ); this.texture = this.registerDisposer(new TransferFunctionTexture(this.gl)); this.textureVertexBuffer = this.registerDisposer( @@ -404,7 +490,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { updateTransferFunctionPointsAndLines() { // Normalize position to [-1, 1] for shader (x axis) function normalizePosition(position: number) { - return (position / (TRANSFER_FUNCTION_LENGTH - 1)) * 2 - 1; + return (position / (TRANSFER_FUNCTION_PANEL_SIZE - 1)) * 2 - 1; } // Normalize opacity to [-1, 1] for shader (y axis) function normalizeOpacity(opacity: number) { @@ -431,6 +517,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { const controlPoints = this.controlPointsLookupTable.trackable.value.controlPoints; + const dataRange = this.controlPointsLookupTable.trackable.value.range; let numLines = controlPoints.length === 0 ? 0 : controlPoints.length; const colorChannels = NUM_COLOR_CHANNELS - 1; // ignore alpha const colorArray = new Float32Array(controlPoints.length * colorChannels); @@ -440,20 +527,29 @@ class TransferFunctionPanel extends IndirectRenderedPanel { let lineToRightEdge = null; if (controlPoints.length > 0) { + const firstPoint = controlPoints[0]; + const firstInputValue = firstPoint.toTransferFunctionIndex( + dataRange, + TRANSFER_FUNCTION_PANEL_SIZE, + ); // If the start point is above 0, need to draw a line from the left edge - if (controlPoints[0].position > 0) { + if (firstInputValue > 0) { numLines += 1; - lineFromLeftEdge = vec4.fromValues(0, 0, controlPoints[0].position, 0); + lineFromLeftEdge = vec4.fromValues(0, 0, firstInputValue, 0); } // If the end point is less than the transfer function length, need to draw a line to the right edge - const endPoint = controlPoints[controlPoints.length - 1]; - if (endPoint.position < TRANSFER_FUNCTION_LENGTH - 1) { + const finalPoint = controlPoints[controlPoints.length - 1]; + const finalInputValue = finalPoint.toTransferFunctionIndex( + dataRange, + TRANSFER_FUNCTION_PANEL_SIZE, + ); + if (finalInputValue < TRANSFER_FUNCTION_PANEL_SIZE - 1) { numLines += 1; lineToRightEdge = vec4.fromValues( - endPoint.position, - endPoint.color[3], - TRANSFER_FUNCTION_LENGTH - 1, - endPoint.color[3], + finalInputValue, + finalPoint.outputColor[3], + TRANSFER_FUNCTION_PANEL_SIZE - 1, + finalPoint.outputColor[3], ); } } @@ -472,12 +568,16 @@ class TransferFunctionPanel extends IndirectRenderedPanel { // Draw a vertical line up to the first control point if (numLines !== 0) { - const startPoint = controlPoints[0]; + const firstPoint = controlPoints[0]; + const firstInputValue = firstPoint.toTransferFunctionIndex( + dataRange, + TRANSFER_FUNCTION_PANEL_SIZE, + ); const lineStartEndPoints = vec4.fromValues( - startPoint.position, + firstInputValue, 0, - startPoint.position, - startPoint.color[3], + firstInputValue, + firstPoint.outputColor[3], ); positionArrayIndex = addLine( linePositionArray, @@ -489,20 +589,27 @@ class TransferFunctionPanel extends IndirectRenderedPanel { for (let i = 0; i < controlPoints.length; ++i) { const colorIndex = i * colorChannels; const positionIndex = i * 2; - const { color, position } = controlPoints[i]; - colorArray[colorIndex] = normalizeColor(color[0]); - colorArray[colorIndex + 1] = normalizeColor(color[1]); - colorArray[colorIndex + 2] = normalizeColor(color[2]); - positionArray[positionIndex] = normalizePosition(position); - positionArray[positionIndex + 1] = normalizeOpacity(color[3]); + const { outputColor } = controlPoints[i]; + const inputValue = controlPoints[i].toTransferFunctionIndex( + dataRange, + TRANSFER_FUNCTION_PANEL_SIZE, + ); + colorArray[colorIndex] = normalizeColor(outputColor[0]); + colorArray[colorIndex + 1] = normalizeColor(outputColor[1]); + colorArray[colorIndex + 2] = normalizeColor(outputColor[2]); + positionArray[positionIndex] = normalizePosition(inputValue); + positionArray[positionIndex + 1] = normalizeOpacity(outputColor[3]); // Don't create a line for the last point if (i === controlPoints.length - 1) break; const linePosition = vec4.fromValues( - position, - color[3], - controlPoints[i + 1].position, - controlPoints[i + 1].color[3], + inputValue, + outputColor[3], + controlPoints[i + 1].toTransferFunctionIndex( + dataRange, + TRANSFER_FUNCTION_PANEL_SIZE, + ), + controlPoints[i + 1].outputColor[3], ); positionArrayIndex = addLine( linePositionArray, @@ -734,7 +841,7 @@ class ControlPointsLookupTable extends RefCounted { } findNearestControlPointIndex(position: number) { return findClosestMatchInSortedArray( - this.trackable.value.controlPoints.map((point) => point.position), + this.trackable.value.controlPoints.map((point) => point.inputValue), this.positionToIndex(position), (a, b) => a - b, ); @@ -747,7 +854,7 @@ class ControlPointsLookupTable extends RefCounted { return -1; } const controlPoints = this.trackable.value.controlPoints; - const nearestPosition = controlPoints[nearestIndex].position; + const nearestPosition = controlPoints[nearestIndex].inputValue; if ( Math.abs(nearestPosition - desiredPosition) > CONTROL_POINT_X_GRAB_DISTANCE @@ -756,12 +863,12 @@ class ControlPointsLookupTable extends RefCounted { } // If points are nearby in X space, use Y space to break ties - const nextPosition = controlPoints[nearestIndex + 1]?.position; + const nextPosition = controlPoints[nearestIndex + 1]?.inputValue; const nextDistance = nextPosition !== undefined ? Math.abs(nextPosition - desiredPosition) : CONTROL_POINT_X_GRAB_DISTANCE + 1; - const previousPosition = controlPoints[nearestIndex - 1]?.position; + const previousPosition = controlPoints[nearestIndex - 1]?.inputValue; const previousDistance = previousPosition !== undefined ? Math.abs(previousPosition - desiredPosition) @@ -770,18 +877,22 @@ class ControlPointsLookupTable extends RefCounted { if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { possibleValues.push([ nearestIndex + 1, - Math.abs(controlPoints[nearestIndex + 1].color[3] - desiredOpacity), + Math.abs( + controlPoints[nearestIndex + 1].outputColor[3] - desiredOpacity, + ), ]); } if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { possibleValues.push([ nearestIndex - 1, - Math.abs(controlPoints[nearestIndex - 1].color[3] - desiredOpacity), + Math.abs( + controlPoints[nearestIndex - 1].outputColor[3] - desiredOpacity, + ), ]); } possibleValues.push([ nearestIndex, - Math.abs(controlPoints[nearestIndex].color[3] - desiredOpacity), + Math.abs(controlPoints[nearestIndex].outputColor[3] - desiredOpacity), ]); possibleValues.sort((a, b) => a[1] - b[1]); return possibleValues[0][0]; @@ -796,21 +907,21 @@ class ControlPointsLookupTable extends RefCounted { const controlPoints = this.trackable.value.controlPoints; const positionAsIndex = this.positionToIndex(position); const existingIndex = controlPoints.findIndex( - (point) => point.position === positionAsIndex, + (point) => point.inputValue === positionAsIndex, ); if (existingIndex !== -1) { controlPoints.splice(existingIndex, 1); } controlPoints.push({ - position: positionAsIndex, - color: vec4.fromValues( + inputValue: positionAsIndex, + outputColor: vec4.fromValues( colorAsUint8[0], colorAsUint8[1], colorAsUint8[2], opacityAsUint8, ), }); - controlPoints.sort((a, b) => a.position - b.position); + controlPoints.sort((a, b) => a.inputValue - b.inputValue); } lookupTableFromControlPoints() { const { lookupTable } = this; @@ -821,24 +932,29 @@ class ControlPointsLookupTable extends RefCounted { const { controlPoints } = this.trackable.value; const positionAsIndex = this.positionToIndex(position); const opacityAsUint8 = this.opacityToUint8(opacity); - const color = controlPoints[index].color; + const color = controlPoints[index].outputColor; controlPoints[index] = { - position: positionAsIndex, - color: vec4.fromValues(color[0], color[1], color[2], opacityAsUint8), + inputValue: positionAsIndex, + outputColor: vec4.fromValues( + color[0], + color[1], + color[2], + opacityAsUint8, + ), }; const exsitingPositions = new Set(); let positionToFind = positionAsIndex; for (const point of controlPoints) { - if (exsitingPositions.has(point.position)) { + if (exsitingPositions.has(point.inputValue)) { positionToFind = positionToFind === 0 ? 1 : positionToFind - 1; - controlPoints[index].position = positionToFind; + controlPoints[index].inputValue = positionToFind; break; } - exsitingPositions.add(point.position); + exsitingPositions.add(point.inputValue); } - controlPoints.sort((a, b) => a.position - b.position); + controlPoints.sort((a, b) => a.inputValue - b.inputValue); const newControlPointIndex = controlPoints.findIndex( - (point) => point.position === positionToFind, + (point) => point.inputValue === positionToFind, ); return newControlPointIndex; } @@ -849,11 +965,11 @@ class ControlPointsLookupTable extends RefCounted { floatToUint8(color[1]), floatToUint8(color[2]), ); - controlPoints[index].color = vec4.fromValues( + controlPoints[index].outputColor = vec4.fromValues( colorAsUint8[0], colorAsUint8[1], colorAsUint8[2], - controlPoints[index].color[3], + controlPoints[index].outputColor[3], ); } } From 3efed2620fb9394a401ecd4e4a5b8193eb101383 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 19 Mar 2024 19:32:24 +0100 Subject: [PATCH 21/60] refactor(progress): transfer functino --- src/widget/transfer_function.ts | 631 +++++++++++++++++--------------- 1 file changed, 326 insertions(+), 305 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 85d263494..ea1cf186a 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -89,6 +89,29 @@ const transferFunctionSamplerTextureUnit = Symbol( "transferFunctionSamplerTexture", ); +/** + * Options to update a transfer function texture + */ +export interface TransferFunctionTextureOptions { + /** If lookupTable is defined, it will be used to update the texture directly. + * A lookup table is a series of color values (0 - 255) for each index in the transfer function texture + */ + lookupTable?: Uint8Array; + /** If lookupTable is undefined, controlPoints will be used to generate a lookup table as a first step */ + controlPoints?: ControlPoint[]; + /** textureUnit to update with the new transfer function texture data */ + textureUnit: number | undefined; + /** range of the input space I, where T: I -> O. Allows for more precision in the + * transfer function texture generation due to texture size limitations + */ + inputRange: DataTypeInterval; +} + +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. @@ -97,13 +120,6 @@ const transferFunctionSamplerTextureUnit = Symbol( * Such a lookup table is used to form a texture, which can be sampled * from during rendering. */ -export interface ControlPointa { - /** The input data value for this control point */ - inputValue: number | Uint64; - /** Color of the point as 4 uint8 values */ - outputColor: vec4; -} - export class ControlPoint { constructor( public inputValue: number | Uint64, @@ -111,17 +127,17 @@ export class ControlPoint { ) {} /** Convert the input value to a normalized value between 0 and 1 */ - toNormalizedInputValue(range: DataTypeInterval): number { + normalizedInput(range: DataTypeInterval): number { return computeInvlerp(range, this.inputValue); } /** Convert the input value to an integer index into the transfer function lookup texture */ - toTransferFunctionIndex( + transferFunctionIndex( dataRange: DataTypeInterval, transferFunctionSize: number, ): number { return Math.floor( - this.toNormalizedInputValue(dataRange) * (transferFunctionSize - 1), + this.normalizedInput(dataRange) * (transferFunctionSize - 1), ); } @@ -132,140 +148,316 @@ export class ControlPoint { } } -// export class ControlPointNumber extends ControlPoint { -// inputValue: number; -// outputColor: vec4; -// constructor() { -// super(); -// } - -// isPositive(value: number): boolean { -// return value > 0; -// } - -// isBefore(controlPoint: ControlPointNumber): boolean { -// return this.inputValue < controlPoint.inputValue; -// } -// } - -// export class ControlPointUint64 extends ControlPoint { -// inputValue: Uint64; -// outputColor: vec4; -// constructor() { -// super(); -// } - -// isPositive(value: Uint64): boolean { -// return Uint64.less(Uint64.ZERO, value); -// } - -// isBefore(controlPoint: ControlPointUint64): boolean { -// return Uint64.less(this.inputValue(controlPoint.inputValue); -// } -// } - -/** - * A parsed control point could have a position represented as a Uint64 - * This will later be converted to a number between 0 and TRANSFER_FUNCTION_LENGTH - 1 - * And then stored as a control point - * TODO(skm) - remove parsed control points - */ -export interface ParsedControlPoint { - inputValue: number | Uint64; - outputColor: vec4; -} - -/** - * Options to update the transfer function texture - */ -export interface TransferFunctionTextureOptions { - /** If lookupTable is defined, it will be used to update the texture directly. - * A lookup table is a series of color values (0 - 255) for each index in the transfer function texture - */ - lookupTable?: Uint8Array; - /** If lookupTable is undefined, controlPoints will be used to generate a lookup table as a first step */ - controlPoints?: ControlPoint[]; - /** textureUnit to update with the new transfer function texture data */ - textureUnit: number | undefined; - /** range of the input space I, where T: I -> O */ - inputRange: DataTypeInterval; -} - -interface CanvasPosition { - normalizedX: number; - normalizedY: number; -} - -/** - * 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 out The lookup table to fill - * @param controlPoints The control points to interpolate between - */ -export function lerpBetweenControlPoints( - out: Uint8Array, - controlPoints: ControlPoint[], - dataRange: DataTypeInterval, - transferFunctionSize: number, -) { - 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]; +class ControlPointArray { + controlPoints: ControlPoint[]; + constructor( + public parent: TransferFunction + ) { + this.controlPoints = parent.trackable.value.controlPoints.map((point) => + ControlPoint.copyFrom(point), + ); + } + get range() { + return this.parent.trackable.value.range; } - function toTransferFunctionSpace(controlPoint: ControlPoint) { - return controlPoint.toTransferFunctionIndex( - dataRange, - transferFunctionSize, + addPoint(inputValue: number | Uint64, color: vec4) { + const nearestPoint = this.findIndexOfNearest(inputValue); + if (nearestPoint !== -1) { + this.controlPoints[nearestPoint].inputValue = inputValue; + this.controlPoints[nearestPoint].outputColor = color; + return; + } + const controlPoint = new ControlPoint(inputValue, color); + this.controlPoints.push(controlPoint); + this.controlPoints.sort((a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range)); + } + findIndexOfNearest(inputValue: number | Uint64) { + const controlPoint = new ControlPoint(inputValue, vec4.create()); + const valueToFind = controlPoint.normalizedInput(this.range); + return findClosestMatchInSortedArray( + this.controlPoints.map((point) => point.normalizedInput(this.range)), + valueToFind, + (a, b) => a - b, ); } +} - // Edge case: no control points - all transparent - if (controlPoints.length === 0) { - out.fill(0); - return; +class LookupTable { + outputValues: Uint8Array; + constructor( + public lookupTableSize: number, + ) { + this.outputValues = new Uint8Array(lookupTableSize * NUM_COLOR_CHANNELS).fill(0); } - const firstInputValue = toTransferFunctionSpace(controlPoints[0]); - - // Edge case: first control point is not at 0 - fill in transparent values - // up to the first point - 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); - } + + resize(newSize: number) { + this.lookupTableSize = newSize; + this.outputValues = new Uint8Array(newSize * NUM_COLOR_CHANNELS).fill(0); } - // Interpolate between control points and fill to end with last color - let controlPointIndex = 0; - for (let i = firstInputValue; i < transferFunctionSize; ++i) { - const currentPoint = controlPoints[controlPointIndex]; - const nextPoint = - controlPoints[Math.min(controlPointIndex + 1, controlPoints.length - 1)]; - const lookupIndex = i * NUM_COLOR_CHANNELS; - if (currentPoint === nextPoint) { - addLookupValue(lookupIndex, currentPoint.outputColor); - } else { - const currentInputValue = toTransferFunctionSpace(currentPoint); - const nextInputValue = toTransferFunctionSpace(nextPoint); - const t = (i - currentInputValue) / (nextInputValue - currentInputValue); - const lerpedColor = lerpUint8Color( - currentPoint.outputColor, - nextPoint.outputColor, - t, + /** + * 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( + controlPoints: ControlPoint[], + dataRange: DataTypeInterval, + ) { + 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]; + } + function toTransferFunctionSpace(controlPoint: ControlPoint) { + return controlPoint.transferFunctionIndex(dataRange, size ); - addLookupValue(lookupIndex, lerpedColor); - if (i === nextPoint.inputValue) { - controlPointIndex++; + } + + // 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 nextPoint = + controlPoints[Math.min(controlPointIndex + 1, controlPoints.length - 1)]; + const lookupIndex = i * NUM_COLOR_CHANNELS; + if (currentPoint === nextPoint) { + addLookupValue(lookupIndex, currentPoint.outputColor); + } else { + const currentInputValue = toTransferFunctionSpace(currentPoint); + const nextInputValue = toTransferFunctionSpace(nextPoint); + const t = (i - currentInputValue) / (nextInputValue - currentInputValue); + const lerpedColor = lerpUint8Color( + currentPoint.outputColor, + nextPoint.outputColor, + t, + ); + addLookupValue(lookupIndex, lerpedColor); + if (i === nextPoint.inputValue) { + controlPointIndex++; + } } } } } +/** + * Handles a linked lookup table and control points for a transfer function. + */ +class TransferFunction extends RefCounted { + lookupTable: LookupTable; + controlPoints: ControlPointArray; + constructor( + public dataType: DataType, + public trackable: WatchableValueInterface, + ) { + super(); + this.lookupTable = new LookupTable(this.trackable.value.transferFunctionSize); + this.controlPoints = new ControlPointArray(this); + } +} + +// old tf class +// positionToIndex(position: number) { +// let positionAsIndex = Math.floor( +// position * (TRANSFER_FUNCTION_PANEL_SIZE - 1), +// ); +// if (positionAsIndex < TRANSFER_FUNCTION_BORDER_WIDTH) { +// positionAsIndex = 0; +// } +// if ( +// TRANSFER_FUNCTION_PANEL_SIZE - 1 - positionAsIndex < +// TRANSFER_FUNCTION_BORDER_WIDTH +// ) { +// positionAsIndex = TRANSFER_FUNCTION_PANEL_SIZE - 1; +// } +// return positionAsIndex; +// } +// opacityToUint8(opacity: number) { +// let opacityAsUint8 = floatToUint8(opacity); +// if (opacityAsUint8 <= TRANSFER_FUNCTION_BORDER_WIDTH) { +// opacityAsUint8 = 0; +// } else if (opacityAsUint8 >= 255 - TRANSFER_FUNCTION_BORDER_WIDTH) { +// opacityAsUint8 = 255; +// } +// return opacityAsUint8; +// } +// findNearestControlPointIndex(position: number) { +// return findClosestMatchInSortedArray( +// this.trackable.value.controlPoints.map((point) => +// point.toTransferFunctionIndex( +// this.trackable.value.range, +// TRANSFER_FUNCTION_PANEL_SIZE, +// ), +// ), +// this.positionToIndex(position), +// (a, b) => a - b, +// ); +// } +// grabControlPoint(position: number, opacity: number) { +// const desiredPosition = this.positionToIndex(position); +// const desiredOpacity = this.opacityToUint8(opacity); +// const nearestIndex = this.findNearestControlPointIndex(position); +// if (nearestIndex === -1) { +// return -1; +// } +// const controlPoints = this.trackable.value.controlPoints; +// const nearestPosition = controlPoints[nearestIndex].toTransferFunctionIndex( +// this.trackable.value.range, +// TRANSFER_FUNCTION_PANEL_SIZE, +// ); +// if ( +// Math.abs(nearestPosition - desiredPosition) > +// CONTROL_POINT_X_GRAB_DISTANCE +// ) { +// return -1; +// } + +// // If points are nearby in X space, use Y space to break ties +// const nextPosition = controlPoints[ +// nearestIndex + 1 +// ]?.toTransferFunctionIndex( +// this.trackable.value.range, +// TRANSFER_FUNCTION_PANEL_SIZE, +// ); +// const nextDistance = +// nextPosition !== undefined +// ? Math.abs(nextPosition - desiredPosition) +// : CONTROL_POINT_X_GRAB_DISTANCE + 1; +// const previousPosition = controlPoints[ +// nearestIndex - 1 +// ]?.toTransferFunctionIndex( +// this.trackable.value.range, +// TRANSFER_FUNCTION_PANEL_SIZE, +// ); +// const previousDistance = +// previousPosition !== undefined +// ? Math.abs(previousPosition - desiredPosition) +// : CONTROL_POINT_X_GRAB_DISTANCE + 1; +// const possibleValues: [number, number][] = []; +// if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { +// possibleValues.push([ +// nearestIndex + 1, +// Math.abs( +// controlPoints[nearestIndex + 1].outputColor[3] - desiredOpacity, +// ), +// ]); +// } +// if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { +// possibleValues.push([ +// nearestIndex - 1, +// Math.abs( +// controlPoints[nearestIndex - 1].outputColor[3] - desiredOpacity, +// ), +// ]); +// } +// possibleValues.push([ +// nearestIndex, +// Math.abs(controlPoints[nearestIndex].outputColor[3] - desiredOpacity), +// ]); +// possibleValues.sort((a, b) => a[1] - b[1]); +// return possibleValues[0][0]; +// } +// addPoint(position: number, opacity: number, color: vec3) { +// const colorAsUint8 = vec3.fromValues( +// floatToUint8(color[0]), +// floatToUint8(color[1]), +// floatToUint8(color[2]), +// ); +// const opacityAsUint8 = this.opacityToUint8(opacity); +// const controlPoints = this.trackable.value.controlPoints; +// const positionAsIndex = this.positionToIndex(position); +// const existingIndex = controlPoints.findIndex( +// (point) => point.inputValue === positionAsIndex, +// ); +// if (existingIndex !== -1) { +// controlPoints.splice(existingIndex, 1); +// } +// controlPoints.push({ +// inputValue: positionAsIndex, +// outputColor: vec4.fromValues( +// colorAsUint8[0], +// colorAsUint8[1], +// colorAsUint8[2], +// opacityAsUint8, +// ), +// }); +// controlPoints.sort((a, b) => a.inputValue - b.inputValue); +// } +// lookupTableFromControlPoints() { +// const { lookupTable } = this; +// const { controlPoints } = this.trackable.value; +// lerpBetweenControlPoints(lookupTable, controlPoints); +// } +// updatePoint(index: number, position: number, opacity: number) { +// const { controlPoints } = this.trackable.value; +// const positionAsIndex = this.positionToIndex(position); +// const opacityAsUint8 = this.opacityToUint8(opacity); +// const color = controlPoints[index].outputColor; +// controlPoints[index] = { +// inputValue: positionAsIndex, +// outputColor: vec4.fromValues( +// color[0], +// color[1], +// color[2], +// opacityAsUint8, +// ), +// }; +// const exsitingPositions = new Set(); +// let positionToFind = positionAsIndex; +// for (const point of controlPoints) { +// if (exsitingPositions.has(point.inputValue)) { +// positionToFind = positionToFind === 0 ? 1 : positionToFind - 1; +// controlPoints[index].inputValue = positionToFind; +// break; +// } +// exsitingPositions.add(point.inputValue); +// } +// controlPoints.sort((a, b) => a.inputValue - b.inputValue); +// const newControlPointIndex = controlPoints.findIndex( +// (point) => point.inputValue === positionToFind, +// ); +// return newControlPointIndex; +// } +// setPointColor(index: number, color: vec3) { +// const { controlPoints } = this.trackable.value; +// const colorAsUint8 = vec3.fromValues( +// floatToUint8(color[0]), +// floatToUint8(color[1]), +// floatToUint8(color[2]), +// ); +// controlPoints[index].outputColor = vec4.fromValues( +// colorAsUint8[0], +// colorAsUint8[1], +// colorAsUint8[2], +// controlPoints[index].outputColor[3], +// ); +// } +// } + /** * Convert a [0, 1] float to a uint8 value between 0 and 255 */ @@ -726,7 +918,7 @@ out_color = tempColor * alpha; transferFunctionShader.attribute("aVertexPosition"); gl.uniform1f( transferFunctionShader.uniform("uTransferFunctionEnd"), - TRANSFER_FUNCTION_LENGTH - 1, + TRANSFER_FUNCTION_PANEL_SIZE - 1, ); this.textureVertexBuffer.bindToVertexAttrib( aVertexPosition, @@ -739,8 +931,9 @@ out_color = tempColor * alpha; this.texture.updateAndActivate({ lookupTable: this.controlPointsLookupTable.lookupTable, textureUnit, + inputRange: this.controlPointsLookupTable.trackable.value.range, }); - drawQuads(this.gl, TRANSFER_FUNCTION_LENGTH, 1); + drawQuads(this.gl, TRANSFER_FUNCTION_PANEL_SIZE, 1); gl.disableVertexAttribArray(aVertexPosition); gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, null); } @@ -802,178 +995,6 @@ out_color = tempColor * alpha; } } -/** - * Lookup table for control points. Handles adding, removing, and updating control points as well as - * consequent updates to the underlying lookup table formed from the control points. - */ -class ControlPointsLookupTable extends RefCounted { - lookupTable: Uint8Array; - constructor( - public dataType: DataType, - public trackable: WatchableValueInterface, - ) { - super(); - this.lookupTable = new Uint8Array( - TRANSFER_FUNCTION_LENGTH * NUM_COLOR_CHANNELS, - ).fill(0); - } - positionToIndex(position: number) { - let positionAsIndex = Math.floor(position * (TRANSFER_FUNCTION_LENGTH - 1)); - if (positionAsIndex < TRANSFER_FUNCTION_BORDER_WIDTH) { - positionAsIndex = 0; - } - if ( - TRANSFER_FUNCTION_LENGTH - 1 - positionAsIndex < - TRANSFER_FUNCTION_BORDER_WIDTH - ) { - positionAsIndex = TRANSFER_FUNCTION_LENGTH - 1; - } - return positionAsIndex; - } - opacityToUint8(opacity: number) { - let opacityAsUint8 = floatToUint8(opacity); - if (opacityAsUint8 <= TRANSFER_FUNCTION_BORDER_WIDTH) { - opacityAsUint8 = 0; - } else if (opacityAsUint8 >= 255 - TRANSFER_FUNCTION_BORDER_WIDTH) { - opacityAsUint8 = 255; - } - return opacityAsUint8; - } - findNearestControlPointIndex(position: number) { - return findClosestMatchInSortedArray( - this.trackable.value.controlPoints.map((point) => point.inputValue), - this.positionToIndex(position), - (a, b) => a - b, - ); - } - grabControlPoint(position: number, opacity: number) { - const desiredPosition = this.positionToIndex(position); - const desiredOpacity = this.opacityToUint8(opacity); - const nearestIndex = this.findNearestControlPointIndex(position); - if (nearestIndex === -1) { - return -1; - } - const controlPoints = this.trackable.value.controlPoints; - const nearestPosition = controlPoints[nearestIndex].inputValue; - if ( - Math.abs(nearestPosition - desiredPosition) > - CONTROL_POINT_X_GRAB_DISTANCE - ) { - return -1; - } - - // If points are nearby in X space, use Y space to break ties - const nextPosition = controlPoints[nearestIndex + 1]?.inputValue; - const nextDistance = - nextPosition !== undefined - ? Math.abs(nextPosition - desiredPosition) - : CONTROL_POINT_X_GRAB_DISTANCE + 1; - const previousPosition = controlPoints[nearestIndex - 1]?.inputValue; - const previousDistance = - previousPosition !== undefined - ? Math.abs(previousPosition - desiredPosition) - : CONTROL_POINT_X_GRAB_DISTANCE + 1; - const possibleValues: [number, number][] = []; - if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { - possibleValues.push([ - nearestIndex + 1, - Math.abs( - controlPoints[nearestIndex + 1].outputColor[3] - desiredOpacity, - ), - ]); - } - if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { - possibleValues.push([ - nearestIndex - 1, - Math.abs( - controlPoints[nearestIndex - 1].outputColor[3] - desiredOpacity, - ), - ]); - } - possibleValues.push([ - nearestIndex, - Math.abs(controlPoints[nearestIndex].outputColor[3] - desiredOpacity), - ]); - possibleValues.sort((a, b) => a[1] - b[1]); - return possibleValues[0][0]; - } - addPoint(position: number, opacity: number, color: vec3) { - const colorAsUint8 = vec3.fromValues( - floatToUint8(color[0]), - floatToUint8(color[1]), - floatToUint8(color[2]), - ); - const opacityAsUint8 = this.opacityToUint8(opacity); - const controlPoints = this.trackable.value.controlPoints; - const positionAsIndex = this.positionToIndex(position); - const existingIndex = controlPoints.findIndex( - (point) => point.inputValue === positionAsIndex, - ); - if (existingIndex !== -1) { - controlPoints.splice(existingIndex, 1); - } - controlPoints.push({ - inputValue: positionAsIndex, - outputColor: vec4.fromValues( - colorAsUint8[0], - colorAsUint8[1], - colorAsUint8[2], - opacityAsUint8, - ), - }); - controlPoints.sort((a, b) => a.inputValue - b.inputValue); - } - lookupTableFromControlPoints() { - const { lookupTable } = this; - const { controlPoints } = this.trackable.value; - lerpBetweenControlPoints(lookupTable, controlPoints); - } - updatePoint(index: number, position: number, opacity: number) { - const { controlPoints } = this.trackable.value; - const positionAsIndex = this.positionToIndex(position); - const opacityAsUint8 = this.opacityToUint8(opacity); - const color = controlPoints[index].outputColor; - controlPoints[index] = { - inputValue: positionAsIndex, - outputColor: vec4.fromValues( - color[0], - color[1], - color[2], - opacityAsUint8, - ), - }; - const exsitingPositions = new Set(); - let positionToFind = positionAsIndex; - for (const point of controlPoints) { - if (exsitingPositions.has(point.inputValue)) { - positionToFind = positionToFind === 0 ? 1 : positionToFind - 1; - controlPoints[index].inputValue = positionToFind; - break; - } - exsitingPositions.add(point.inputValue); - } - controlPoints.sort((a, b) => a.inputValue - b.inputValue); - const newControlPointIndex = controlPoints.findIndex( - (point) => point.inputValue === positionToFind, - ); - return newControlPointIndex; - } - setPointColor(index: number, color: vec3) { - const { controlPoints } = this.trackable.value; - const colorAsUint8 = vec3.fromValues( - floatToUint8(color[0]), - floatToUint8(color[1]), - floatToUint8(color[2]), - ); - controlPoints[index].outputColor = vec4.fromValues( - colorAsUint8[0], - colorAsUint8[1], - colorAsUint8[2], - controlPoints[index].outputColor[3], - ); - } -} - /** * Create the bounds on the UI range inputs for the transfer function widget */ From 8b26b63d583c310060ebab0cbc8fe0b3f773bc2c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 20 Mar 2024 20:06:20 +0100 Subject: [PATCH 22/60] progress(refactor): tf refactor --- src/widget/transfer_function.ts | 459 ++++++++++++++++++-------------- 1 file changed, 262 insertions(+), 197 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index ea1cf186a..37699adaf 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -92,11 +92,27 @@ const transferFunctionSamplerTextureUnit = Symbol( /** * Options to update a transfer function texture */ -export interface TransferFunctionTextureOptions { +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; + /** range of the input space I, where T: I -> O. Allows for more precision in the + * transfer function texture generation due to texture size limitations + */ + inputRange: DataTypeInterval; +} + +/** + * Options to update a transfer function texture + * TODO remove + */ +export interface LookupTableTextureOptionsOld { /** If lookupTable is defined, it will be used to update the texture directly. * A lookup table is a series of color values (0 - 255) for each index in the transfer function texture */ - lookupTable?: Uint8Array; + lookupTable: LookupTable; /** If lookupTable is undefined, controlPoints will be used to generate a lookup table as a first step */ controlPoints?: ControlPoint[]; /** textureUnit to update with the new transfer function texture data */ @@ -122,8 +138,8 @@ interface CanvasPosition { */ export class ControlPoint { constructor( - public inputValue: number | Uint64, - public outputColor: vec4, + public inputValue: number | Uint64 = 0, + public outputColor: vec4 = vec4.create(), ) {} /** Convert the input value to a normalized value between 0 and 1 */ @@ -140,7 +156,17 @@ export class ControlPoint { this.normalizedInput(dataRange) * (transferFunctionSize - 1), ); } - + interpolateColor(other: ControlPoint, t: number): vec4 { + const outputColor = new vec4(); + 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); @@ -148,11 +174,10 @@ export class ControlPoint { } } -class ControlPointArray { +// TODO (SKM does this make good sense?) +class SortedControlPoints { controlPoints: ControlPoint[]; - constructor( - public parent: TransferFunction - ) { + constructor(public parent: TransferFunction) { this.controlPoints = parent.trackable.value.controlPoints.map((point) => ControlPoint.copyFrom(point), ); @@ -160,18 +185,26 @@ class ControlPointArray { get range() { return this.parent.trackable.value.range; } - addPoint(inputValue: number | Uint64, color: vec4) { - const nearestPoint = this.findIndexOfNearest(inputValue); + addPoint(controlPoint: ControlPoint) { + const { inputValue, outputColor } = controlPoint; + const nearestPoint = this.findNearestControlPointIndex(inputValue); if (nearestPoint !== -1) { this.controlPoints[nearestPoint].inputValue = inputValue; - this.controlPoints[nearestPoint].outputColor = color; + this.controlPoints[nearestPoint].outputColor = outputColor; return; } - const controlPoint = new ControlPoint(inputValue, color); - this.controlPoints.push(controlPoint); - this.controlPoints.sort((a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range)); + const newPoint = new ControlPoint(inputValue, outputColor); + this.controlPoints.push(newPoint); + this.sort(); + } + updatePoint(index: number, controlPoint: ControlPoint) { + this.controlPoints[index] = controlPoint; + this.sort(); + } + updatePointColor(index: number, color: vec4) { + this.controlPoints[index].outputColor = color; } - findIndexOfNearest(inputValue: number | Uint64) { + findNearestControlPointIndex(inputValue: number | Uint64) { const controlPoint = new ControlPoint(inputValue, vec4.create()); const valueToFind = controlPoint.normalizedInput(this.range); return findClosestMatchInSortedArray( @@ -180,14 +213,19 @@ class ControlPointArray { (a, b) => a - b, ); } + sort() { + this.controlPoints.sort( + (a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range), + ); + } } class LookupTable { outputValues: Uint8Array; - constructor( - public lookupTableSize: number, - ) { - this.outputValues = new Uint8Array(lookupTableSize * NUM_COLOR_CHANNELS).fill(0); + constructor(public lookupTableSize: number) { + this.outputValues = new Uint8Array( + lookupTableSize * NUM_COLOR_CHANNELS, + ).fill(0); } resize(newSize: number) { @@ -202,7 +240,7 @@ class LookupTable { * * @param controlPoints The control points to interpolate between * @param dataRange The range of the input data space - */ + */ updateFromControlPoints( controlPoints: ControlPoint[], dataRange: DataTypeInterval, @@ -216,8 +254,7 @@ class LookupTable { out[index + 3] = color[3]; } function toTransferFunctionSpace(controlPoint: ControlPoint) { - return controlPoint.transferFunctionIndex(dataRange, size - ); + return controlPoint.transferFunctionIndex(dataRange, size); } // If no control points - return all transparent @@ -225,7 +262,7 @@ class LookupTable { 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]); @@ -242,19 +279,18 @@ class LookupTable { for (let i = firstInputValue; i < size; ++i) { const currentPoint = controlPoints[controlPointIndex]; const nextPoint = - controlPoints[Math.min(controlPointIndex + 1, controlPoints.length - 1)]; + controlPoints[ + Math.min(controlPointIndex + 1, controlPoints.length - 1) + ]; const lookupIndex = i * NUM_COLOR_CHANNELS; if (currentPoint === nextPoint) { addLookupValue(lookupIndex, currentPoint.outputColor); } else { const currentInputValue = toTransferFunctionSpace(currentPoint); const nextInputValue = toTransferFunctionSpace(nextPoint); - const t = (i - currentInputValue) / (nextInputValue - currentInputValue); - const lerpedColor = lerpUint8Color( - currentPoint.outputColor, - nextPoint.outputColor, - t, - ); + const t = + (i - currentInputValue) / (nextInputValue - currentInputValue); + const lerpedColor = currentPoint.interpolateColor(nextPoint, t); addLookupValue(lookupIndex, lerpedColor); if (i === nextPoint.inputValue) { controlPointIndex++; @@ -262,6 +298,9 @@ class LookupTable { } } } + static equal(a: LookupTable, b: LookupTable) { + return arraysEqual(a.outputValues, b.outputValues); + } } /** @@ -269,14 +308,74 @@ class LookupTable { */ class TransferFunction extends RefCounted { lookupTable: LookupTable; - controlPoints: ControlPointArray; + sortedControlPoints: SortedControlPoints; constructor( public dataType: DataType, public trackable: WatchableValueInterface, ) { super(); - this.lookupTable = new LookupTable(this.trackable.value.transferFunctionSize); - this.controlPoints = new ControlPointArray(this); + this.lookupTable = new LookupTable( + this.trackable.value.transferFunctionSize, + ); + this.sortedControlPoints = new SortedControlPoints(this); + } + /** Supports negative indexing */ + toLookupTableIndex(controlPointIndex: number) { + let index = + controlPointIndex >= 0 + ? controlPointIndex + : this.sortedControlPoints.controlPoints.length + controlPointIndex; + return this.sortedControlPoints.controlPoints[index].transferFunctionIndex( + this.trackable.value.range, + this.trackable.value.transferFunctionSize, + ); + } + toNormalizedInput(controlPoint: ControlPoint) { + return controlPoint.normalizedInput(this.trackable.value.range); + } + updateLookupTable() { + this.lookupTable.updateFromControlPoints( + this.sortedControlPoints.controlPoints, + ); + } + addPoint(controlPoint: ControlPoint) { + this.sortedControlPoints.addPoint(controlPoint); + } + updatePoint(index: number, controlPoint: ControlPoint) { + this.sortedControlPoints.updatePoint(index, controlPoint); + } + updatePointColor(index: number, color: vec4) { + this.sortedControlPoints.updatePointColor(index, color); + } + findNearestControlPointIndex( + normalizedInputValue: number, + dataWindow: DataTypeInterval, + ) { + const absoluteValue = computeLerp( + dataWindow, + this.dataType, + normalizedInputValue, + ); + return this.sortedControlPoints.findNearestControlPointIndex(absoluteValue); + } + /** If a control point has neighbouring control points that are close by, select between them via match on the opacity */ + // TODO (skm) - complete this, needs check for -1 / +1 exist and distance check + matchNeighbouringPointsByOpacity(startingIndex: number) { + const controlPoints = this.sortedControlPoints.controlPoints; + const startingPoint = controlPoints[startingIndex]; + const previousPoint = controlPoints[startingIndex - 1]?; + const nextPoint = controlPoints[startingIndex + 1]; + const previousDistance = previousPoint + ? Math.abs(previousPoint.outputColor[3] - startingPoint.outputColor[3]) + : Infinity; + const nextDistance = nextPoint + ? Math.abs(nextPoint.outputColor[3] - startingPoint.outputColor[3]) + : Infinity; + if (previousDistance < nextDistance) { + return startingIndex - 1; + } else { + return startingIndex + 1; + } } } @@ -460,43 +559,32 @@ class TransferFunction extends RefCounted { /** * Convert a [0, 1] float to a uint8 value between 0 and 255 + * TODO (SKM) belong here? Maybe utils? */ export function floatToUint8(float: number) { return Math.min(255, Math.max(Math.round(float * 255), 0)); } -/** - * Linearly interpolate between each component of two vec4s (color values) - */ -function lerpUint8Color(startColor: vec4, endColor: vec4, t: number) { - const color = vec4.create(); - for (let i = 0; i < 4; ++i) { - color[i] = computeLerp( - [startColor[i], endColor[i]], - DataType.UINT8, - t, - ) as number; - } - return color; -} - /** * Represent the underlying transfer function lookup table as a texture * TODO(skm) consider if height can be used for more efficiency */ -export class TransferFunctionTexture extends RefCounted { +export class LookupTableTexture extends RefCounted { texture: WebGLTexture | null = null; width: number; height = 1; - private priorOptions: TransferFunctionTextureOptions | undefined = undefined; + private priorOptions: LookupTableTextureOptions | 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 + */ optionsEqual( - existingOptions: TransferFunctionTextureOptions | undefined, - newOptions: TransferFunctionTextureOptions, + existingOptions: LookupTableTextureOptions | undefined, + newOptions: LookupTableTextureOptions, ) { if (existingOptions === undefined) return false; let lookupTableEqual = true; @@ -504,44 +592,44 @@ export class TransferFunctionTexture extends RefCounted { existingOptions.lookupTable !== undefined && newOptions.lookupTable !== undefined ) { - lookupTableEqual = arraysEqual( + lookupTableEqual = LookupTable.equal( existingOptions.lookupTable, newOptions.lookupTable, ); } - let controlPointsEqual = true; - if ( - existingOptions.controlPoints !== undefined && - newOptions.controlPoints !== undefined - ) { - controlPointsEqual = arraysEqualWithPredicate( - existingOptions.controlPoints, - newOptions.controlPoints, - (a, b) => - a.inputValue === b.inputValue && - arraysEqual(a.outputColor, b.outputColor), - ); - } + // let controlPointsEqual = true; + // if ( + // existingOptions.controlPoints !== undefined && + // newOptions.controlPoints !== undefined + // ) { + // controlPointsEqual = arraysEqualWithPredicate( + // existingOptions.controlPoints, + // newOptions.controlPoints, + // (a, b) => + // a.inputValue === b.inputValue && + // arraysEqual(a.outputColor, b.outputColor), + // ); + // } const textureUnitEqual = existingOptions.textureUnit === newOptions.textureUnit; - return lookupTableEqual && controlPointsEqual && textureUnitEqual; + return lookupTableEqual && textureUnitEqual; } - updateAndActivate(options: TransferFunctionTextureOptions) { + updateAndActivate(options: LookupTableTextureOptions) { const { gl } = this; if (gl === null) return; let { texture } = this; // Verify input - if ( - options.lookupTable === undefined && - options.controlPoints === undefined - ) { - throw new Error( - "Either lookupTable or controlPoints must be defined for transfer function texture", - ); - } + // if ( + // options.lookupTable === undefined && + // options.controlPoints === undefined + // ) { + // throw new Error( + // "Either lookupTable or controlPoints must be defined for transfer function texture", + // ); + // } function activateAndBindTexture(gl: GL, textureUnit: number | undefined) { if (textureUnit === undefined) { @@ -566,17 +654,15 @@ export class TransferFunctionTexture extends RefCounted { activateAndBindTexture(gl, options.textureUnit); setRawTextureParameters(gl); let lookupTable = options.lookupTable; - if (lookupTable === undefined) { - lookupTable = new Uint8Array( - this.width * this.height * NUM_COLOR_CHANNELS, - ); - lerpBetweenControlPoints( - lookupTable, - options.controlPoints!, - options.inputRange, - this.width * this.height, - ); - } + // if (lookupTable === undefined) { + // lookupTable = new LookupTable(options.size); + // lerpBetweenControlPoints( + // lookupTable, + // options.controlPoints!, + // options.inputRange, + // this.width * this.height, + // ); + // } gl.texImage2D( WebGL2RenderingContext.TEXTURE_2D, 0, @@ -586,25 +672,25 @@ export class TransferFunctionTexture extends RefCounted { 0, WebGL2RenderingContext.RGBA, WebGL2RenderingContext.UNSIGNED_BYTE, - lookupTable, + lookupTable.outputValues, ); // Update the prior options to the current options for future comparisons // Make a copy of the options for the purpose of comparison // TODO(skm) is this copy needed? - this.priorOptions = { - textureUnit: options.textureUnit, - lookupTable: options.lookupTable?.slice(), - controlPoints: options.controlPoints?.map((point) => - ControlPoint.copyFrom(point), - ), - inputRange: options.inputRange, - }; + this.priorOptions = { ...options }; + // textureUnit: options.textureUnit, + // lookupTable: options.lookupTable, + // // controlPoints: options.controlPoints?.map((point) => + // // ControlPoint.copyFrom(point), + // // ), + // inputRange: options.inputRange, } disposed() { this.gl?.deleteTexture(this.texture); this.texture = null; + this.priorOptions = undefined; super.disposed(); } } @@ -614,7 +700,7 @@ export class TransferFunctionTexture extends RefCounted { * handle shader updates for elements of the canvas */ class TransferFunctionPanel extends IndirectRenderedPanel { - texture: TransferFunctionTexture; + texture: LookupTableTexture; private textureVertexBuffer: Buffer; private textureVertexBufferArray: Float32Array; private controlPointsVertexBuffer: Buffer; @@ -626,57 +712,48 @@ class TransferFunctionPanel extends IndirectRenderedPanel { get drawOrder() { return 1; } - controlPointsLookupTable = this.registerDisposer( - new ControlPointsLookupTable(this.parent.dataType, this.parent.trackable), + transferFunction = this.registerDisposer( + new TransferFunction(this.parent.dataType, this.parent.trackable), ); controller = this.registerDisposer( new TransferFunctionController( this.element, this.parent.dataType, - this.controlPointsLookupTable, + this.transferFunction, () => this.parent.trackable.value, (value: TransferFunctionParameters) => { this.parent.trackable.value = value; }, ), ); - // TODO (skm) - the non-fixed length might be tricky here constructor(public parent: TransferFunctionWidget) { super(parent.display, document.createElement("div"), parent.visibility); - const { element } = this; + const { element, gl } = this; element.classList.add("neuroglancer-transfer-function-panel"); this.textureVertexBufferArray = createGriddedRectangleArray( TRANSFER_FUNCTION_PANEL_SIZE, ); - this.texture = this.registerDisposer(new TransferFunctionTexture(this.gl)); - this.textureVertexBuffer = this.registerDisposer( - getMemoizedBuffer( - this.gl, + this.texture = this.registerDisposer(new LookupTableTexture(gl)); + + function createBuffer(dataArray: Float32Array) { + return getMemoizedBuffer( + gl, WebGL2RenderingContext.ARRAY_BUFFER, - () => this.textureVertexBufferArray, - ), - ).value; + () => dataArray, + ).value; + } + this.textureVertexBuffer = this.registerDisposer( + createBuffer(this.textureVertexBufferArray), + ); this.controlPointsVertexBuffer = this.registerDisposer( - getMemoizedBuffer( - this.gl, - WebGL2RenderingContext.ARRAY_BUFFER, - () => this.controlPointsPositionArray, - ), - ).value; + createBuffer(this.controlPointsPositionArray), + ); this.controlPointsColorBuffer = this.registerDisposer( - getMemoizedBuffer( - this.gl, - WebGL2RenderingContext.ARRAY_BUFFER, - () => this.controlPointsColorArray, - ), - ).value; + createBuffer(this.controlPointsColorArray), + ); this.linePositionBuffer = this.registerDisposer( - getMemoizedBuffer( - this.gl, - WebGL2RenderingContext.ARRAY_BUFFER, - () => this.linePositionArray, - ), - ).value; + createBuffer(this.linePositionArray), + ); } updateTransferFunctionPointsAndLines() { @@ -692,7 +769,6 @@ class TransferFunctionPanel extends IndirectRenderedPanel { function normalizeColor(colorComponent: number) { return colorComponent / 255; } - function addLine( array: Float32Array, index: number, @@ -707,10 +783,9 @@ class TransferFunctionPanel extends IndirectRenderedPanel { return index; } - const controlPoints = - this.controlPointsLookupTable.trackable.value.controlPoints; - const dataRange = this.controlPointsLookupTable.trackable.value.range; - let numLines = controlPoints.length === 0 ? 0 : controlPoints.length; + const { transferFunction } = this; + const { controlPoints } = transferFunction.trackable.value; + let numLines = controlPoints.length; const colorChannels = NUM_COLOR_CHANNELS - 1; // ignore alpha const colorArray = new Float32Array(controlPoints.length * colorChannels); const positionArray = new Float32Array(controlPoints.length * 2); @@ -718,23 +793,17 @@ class TransferFunctionPanel extends IndirectRenderedPanel { let lineFromLeftEdge = null; let lineToRightEdge = null; + // Create start and end lines if there are any control points if (controlPoints.length > 0) { - const firstPoint = controlPoints[0]; - const firstInputValue = firstPoint.toTransferFunctionIndex( - dataRange, - TRANSFER_FUNCTION_PANEL_SIZE, - ); // If the start point is above 0, need to draw a line from the left edge + const firstInputValue = transferFunction.toLookupTableIndex(0); if (firstInputValue > 0) { numLines += 1; lineFromLeftEdge = vec4.fromValues(0, 0, firstInputValue, 0); } // If the end point is less than the transfer function length, need to draw a line to the right edge const finalPoint = controlPoints[controlPoints.length - 1]; - const finalInputValue = finalPoint.toTransferFunctionIndex( - dataRange, - TRANSFER_FUNCTION_PANEL_SIZE, - ); + const finalInputValue = transferFunction.toLookupTableIndex(-1); if (finalInputValue < TRANSFER_FUNCTION_PANEL_SIZE - 1) { numLines += 1; lineToRightEdge = vec4.fromValues( @@ -750,6 +819,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { const linePositionArray = new Float32Array( numLines * VERTICES_PER_LINE * POSITION_VALUES_PER_LINE, ); + // Draw a vertical line up to the first control point if (lineFromLeftEdge !== null) { positionArrayIndex = addLine( linePositionArray, @@ -758,35 +828,12 @@ class TransferFunctionPanel extends IndirectRenderedPanel { ); } - // Draw a vertical line up to the first control point - if (numLines !== 0) { - const firstPoint = controlPoints[0]; - const firstInputValue = firstPoint.toTransferFunctionIndex( - dataRange, - TRANSFER_FUNCTION_PANEL_SIZE, - ); - const lineStartEndPoints = vec4.fromValues( - firstInputValue, - 0, - firstInputValue, - firstPoint.outputColor[3], - ); - positionArrayIndex = addLine( - linePositionArray, - positionArrayIndex, - lineStartEndPoints, - ); - } // 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 { outputColor } = controlPoints[i]; - const inputValue = controlPoints[i].toTransferFunctionIndex( - dataRange, - TRANSFER_FUNCTION_PANEL_SIZE, - ); - colorArray[colorIndex] = normalizeColor(outputColor[0]); + const inputValue = transferFunction.toLookupTableIndex(i); colorArray[colorIndex + 1] = normalizeColor(outputColor[1]); colorArray[colorIndex + 2] = normalizeColor(outputColor[2]); positionArray[positionIndex] = normalizePosition(inputValue); @@ -797,10 +844,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { const linePosition = vec4.fromValues( inputValue, outputColor[3], - controlPoints[i + 1].toTransferFunctionIndex( - dataRange, - TRANSFER_FUNCTION_PANEL_SIZE, - ), + transferFunction.toLookupTableIndex(i + 1), controlPoints[i + 1].outputColor[3], ); positionArrayIndex = addLine( @@ -810,6 +854,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { ); } + // Draw a horizontal line out from the last point if (lineToRightEdge !== null) { addLine(linePositionArray, positionArrayIndex, lineToRightEdge); } @@ -929,9 +974,9 @@ out_color = tempColor * alpha; transferFunctionSamplerTextureUnit, ); this.texture.updateAndActivate({ - lookupTable: this.controlPointsLookupTable.lookupTable, + lookupTable: this.transferFunction.lookupTable, textureUnit, - inputRange: this.controlPointsLookupTable.trackable.value.range, + inputRange: this.transferFunction.trackable.value.range, }); drawQuads(this.gl, TRANSFER_FUNCTION_PANEL_SIZE, 1); gl.disableVertexAttribArray(aVertexPosition); @@ -986,8 +1031,36 @@ out_color = tempColor * alpha; } gl.disable(WebGL2RenderingContext.BLEND); } + grabControlPointNearCursor(mouseXPosition: number, mouseYPosition: number) { + const { transferFunction, window } = this; + + const nearestControlPointIndex = + transferFunction.findNearestControlPointIndex(mouseXPosition, window); + if (nearestControlPointIndex === -1) { + return nearestControlPointIndex; + } + const lookupTableIndex = transferFunction.toLookupTableIndex( + nearestControlPointIndex, + ); + // TODO(skm) this looks awkward + const mousePositionTableIndex = Math.floor( + computeInvlerp( + this.range, + computeLerp(window, this.dataType, mouseXPosition), + ) * + TRANSFER_FUNCTION_PANEL_SIZE - + 1, + ); + if ( + Math.abs(lookupTableIndex - mousePositionTableIndex) > + CONTROL_POINT_X_GRAB_DISTANCE + ) { + return -1; + } + return transferFunction.matchNeighbouringPointsByOpacity(nearestControlPointIndex); + } update() { - this.controlPointsLookupTable.lookupTableFromControlPoints(); + this.transferFunction.updateLookupTable(); this.updateTransferFunctionPointsAndLines(); } isReady() { @@ -1065,7 +1138,7 @@ class TransferFunctionController extends RefCounted { constructor( public element: HTMLElement, public dataType: DataType, - private controlPointsLookupTable: ControlPointsLookupTable, + private transferFunction: TransferFunction, public getModel: () => TransferFunctionParameters, public setModel: (value: TransferFunctionParameters) => void, ) { @@ -1090,14 +1163,13 @@ class TransferFunctionController extends RefCounted { const mouseEvent = actionEvent.detail; const nearestIndex = this.findNearestControlPointIndex(mouseEvent); if (nearestIndex !== -1) { - this.controlPointsLookupTable.trackable.value.controlPoints.splice( + this.transferFunction.trackable.value.controlPoints.splice( nearestIndex, 1, ); this.updateValue({ ...this.getModel(), - controlPoints: - this.controlPointsLookupTable.trackable.value.controlPoints, + controlPoints: this.transferFunction.trackable.value.controlPoints, }); } }, @@ -1109,12 +1181,11 @@ class TransferFunctionController extends RefCounted { const mouseEvent = actionEvent.detail; const nearestIndex = this.findNearestControlPointIndex(mouseEvent); if (nearestIndex !== -1) { - const color = this.controlPointsLookupTable.trackable.value.color; - this.controlPointsLookupTable.setPointColor(nearestIndex, color); + const color = this.transferFunction.trackable.value.color; + this.transferFunction.setPointColor(nearestIndex, color); this.updateValue({ ...this.getModel(), - controlPoints: - this.controlPointsLookupTable.trackable.value.controlPoints, + controlPoints: this.transferFunction.trackable.value.controlPoints, }); } }, @@ -1128,13 +1199,10 @@ class TransferFunctionController extends RefCounted { const { normalizedX, normalizedY } = this.getControlPointPosition( event, ) as CanvasPosition; - return this.controlPointsLookupTable.grabControlPoint( - normalizedX, - normalizedY, - ); + return this.transferFunction.grabControlPoint(normalizedX, normalizedY); } addControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { - const color = this.controlPointsLookupTable.trackable.value.color; + const color = this.transferFunction.trackable.value.color; const nearestIndex = this.findNearestControlPointIndex(event); if (nearestIndex !== -1) { this.currentGrabbedControlPointIndex = nearestIndex; @@ -1143,13 +1211,12 @@ class TransferFunctionController extends RefCounted { const { normalizedX, normalizedY } = this.getControlPointPosition( event, ) as CanvasPosition; - this.controlPointsLookupTable.addPoint(normalizedX, normalizedY, color); + this.transferFunction.addPoint(normalizedX, normalizedY, color); this.currentGrabbedControlPointIndex = this.findNearestControlPointIndex(event); return { ...this.getModel(), - controlPoints: - this.controlPointsLookupTable.trackable.value.controlPoints, + controlPoints: this.transferFunction.trackable.value.controlPoints, }; } moveControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { @@ -1157,16 +1224,14 @@ class TransferFunctionController extends RefCounted { const position = this.getControlPointPosition(event); if (position === undefined) return undefined; const { normalizedX, normalizedY } = position; - this.currentGrabbedControlPointIndex = - this.controlPointsLookupTable.updatePoint( - this.currentGrabbedControlPointIndex, - normalizedX, - normalizedY, - ); + this.currentGrabbedControlPointIndex = this.transferFunction.updatePoint( + this.currentGrabbedControlPointIndex, + normalizedX, + normalizedY, + ); return { ...this.getModel(), - controlPoints: - this.controlPointsLookupTable.trackable.value.controlPoints, + controlPoints: this.transferFunction.trackable.value.controlPoints, }; } return undefined; From 5f7e0c25247d73bc6922f5c1b359a25b1ab8a69a Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 21 Mar 2024 12:01:27 +0100 Subject: [PATCH 23/60] progress(refactor): tf refactor --- src/webgl/shader_ui_controls.ts | 9 +- src/widget/transfer_function.spec.ts | 253 ++++++++++++-------- src/widget/transfer_function.ts | 339 ++++++++------------------- 3 files changed, 251 insertions(+), 350 deletions(-) diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 2ee9a68c3..520772f44 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -69,13 +69,11 @@ import { import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; import type { ControlPoint, - ParsedControlPoint, } from "#src/widget/transfer_function.js"; import { defineTransferFunctionShader, enableTransferFunctionShader, floatToUint8, - TRANSFER_FUNCTION_LENGTH, } from "#src/widget/transfer_function.js"; export interface ShaderSliderControl { @@ -1080,12 +1078,7 @@ class TrackablePropertyInvlerpParameters extends TrackableValue( + { + sortedControlPoints, + channel: [], + defaultColor: vec3.fromValues(0.0, 0.0, 0.0), + range: range, + size: TRANSFER_FUNCTION_LENGTH, + }, + (x) => x, + ), + ); +} describe("lerpBetweenControlPoints", () => { + const range = defaultDataTypeRange[DataType.UINT8]; const output = new Uint8Array(NUM_COLOR_CHANNELS * TRANSFER_FUNCTION_LENGTH); - it("returns transparent black when given no control points", () => { + it("returns transparent black when given no control points for raw classes", () => { const controlPoints: ControlPoint[] = []; - lerpBetweenControlPoints(output, controlPoints); + const sortedControlPoints = new SortedControlPoints(controlPoints, range); + const lookupTable = new LookupTable(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[] = [ - { inputValue: 120, outputColor: vec4.fromValues(21, 22, 254, 210) }, + new ControlPoint(120, vec4.fromValues(21, 22, 254, 210)), ]; - lerpBetweenControlPoints(output, controlPoints); + const transferFunction = makeTransferFunction(controlPoints); + const output = transferFunction.lookupTable.outputValues; + const firstPointTransferIndex = transferFunction.toLookupTableIndex(0)!; + expect( - output.slice(0, NUM_COLOR_CHANNELS * 120).every((value) => value === 0), + output + .slice(0, NUM_COLOR_CHANNELS * firstPointTransferIndex) + .every((value) => value === 0), ).toBeTruthy(); - const endPiece = output.slice(NUM_COLOR_CHANNELS * 120); + const endPiece = output.slice(NUM_COLOR_CHANNELS * firstPointTransferIndex); const color = controlPoints[0].outputColor; expect( endPiece.every( @@ -55,21 +93,34 @@ describe("lerpBetweenControlPoints", () => { }); it("correctly interpolates between three control points", () => { const controlPoints: ControlPoint[] = [ - { inputValue: 120, outputColor: vec4.fromValues(21, 22, 254, 210) }, - { inputValue: 140, outputColor: vec4.fromValues(0, 0, 0, 0) }, - { inputValue: 200, outputColor: vec4.fromValues(255, 255, 255, 255) }, + 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)), ]; - lerpBetweenControlPoints(output, controlPoints); + const transferFunction = makeTransferFunction(controlPoints); + const output = transferFunction.lookupTable.outputValues; + const firstPointTransferIndex = transferFunction.toLookupTableIndex(0)!; + const secondPointTransferIndex = transferFunction.toLookupTableIndex(1)!; + const thirdPointTransferIndex = transferFunction.toLookupTableIndex(2)!; + expect( - output.slice(0, NUM_COLOR_CHANNELS * 120).every((value) => value === 0), + output + .slice(0, NUM_COLOR_CHANNELS * firstPointTransferIndex) + .every((value) => value === 0), ).toBeTruthy(); expect( - output.slice(NUM_COLOR_CHANNELS * 200).every((value) => value === 255), + output + .slice(NUM_COLOR_CHANNELS * thirdPointTransferIndex) + .every((value) => value === 255), ).toBeTruthy(); const firstColor = controlPoints[0].outputColor; const secondColor = controlPoints[1].outputColor; - for (let i = 120 * NUM_COLOR_CHANNELS; i < 140 * NUM_COLOR_CHANNELS; i++) { + for ( + let i = firstPointTransferIndex * NUM_COLOR_CHANNELS; + i < secondPointTransferIndex * NUM_COLOR_CHANNELS; + i++ + ) { const difference = Math.floor((i - 120 * NUM_COLOR_CHANNELS) / 4); const expectedValue = firstColor[i % NUM_COLOR_CHANNELS] + @@ -89,7 +140,11 @@ describe("lerpBetweenControlPoints", () => { } const thirdColor = controlPoints[2].outputColor; - for (let i = 140 * NUM_COLOR_CHANNELS; i < 200 * NUM_COLOR_CHANNELS; i++) { + for ( + let i = secondPointTransferIndex * NUM_COLOR_CHANNELS; + i < thirdPointTransferIndex * NUM_COLOR_CHANNELS; + i++ + ) { const difference = Math.floor((i - 140 * NUM_COLOR_CHANNELS) / 4); const expectedValue = secondColor[i % NUM_COLOR_CHANNELS] + @@ -110,84 +165,84 @@ describe("lerpBetweenControlPoints", () => { }); }); -describe("compute transfer function on GPU", () => { - const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; - const controlPoints: ControlPoint[] = [ - { inputValue: 0, outputColor: vec4.fromValues(0, 0, 0, 0) }, - { - inputValue: maxTransferFunctionPoints, - outputColor: vec4.fromValues(255, 255, 255, 255), - }, - ]; - for (const dataType of Object.values(DataType)) { - if (typeof dataType === "string") continue; - it(`computes transfer function on GPU for ${DataType[dataType]}`, () => { - const shaderType = getShaderType(dataType); - fragmentShaderTest( - { inputValue: dataType }, - { val1: "float", val2: "float", val3: "float", val4: "float" }, - (tester) => { - const { builder } = tester; - builder.addFragmentCode(` -${shaderType} getInterpolatedDataValue() { - 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; -`); - const { shader } = tester; - const testShader = (point: any) => { - enableTransferFunctionShader( - shader, - "doTransferFunction", - dataType, - controlPoints, - defaultDataTypeRange[dataType], - ); - tester.execute({ inputValue: point }); - const values = tester.values; - return vec4.fromValues( - values.val1, - values.val2, - values.val3, - values.val4, - ); - }; - const minValue = defaultDataTypeRange[dataType][0]; - const maxValue = defaultDataTypeRange[dataType][1]; - let color = testShader(minValue); - expect(color).toEqual(vec4.fromValues(0, 0, 0, 0)); - color = testShader(maxValue); - expect(color).toEqual(vec4.fromValues(1, 1, 1, 1)); - if (dataType !== DataType.UINT64) { - const minValueNumber = minValue as number; - const maxValueNumber = maxValue as number; - color = testShader((maxValueNumber + minValueNumber) / 2); - for (let i = 0; i < 3; i++) { - expect(color[i]).toBeCloseTo(0.5); - } - } else { - const value = (maxValue as Uint64).toNumber() / 2; - const position = Uint64.fromNumber(value); - color = testShader(position); - for (let i = 0; i < 3; i++) { - expect(color[i]).toBeCloseTo(0.5); - } - } - }, - ); - }); - } -}); +// describe("compute transfer function on GPU", () => { +// const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; +// const controlPoints: ControlPoint[] = [ +// new ControlPoint(0, vec4.fromValues(0, 0, 0, 0)), +// new ControlPoint( +// maxTransferFunctionPoints, +// vec4.fromValues(255, 255, 255, 255), +// ), +// ]; +// for (const dataType of Object.values(DataType)) { +// if (typeof dataType === "string") continue; +// it(`computes transfer function on GPU for ${DataType[dataType]}`, () => { +// const shaderType = getShaderType(dataType); +// fragmentShaderTest( +// { inputValue: dataType }, +// { val1: "float", val2: "float", val3: "float", val4: "float" }, +// (tester) => { +// const { builder } = tester; +// builder.addFragmentCode(` +// ${shaderType} getInterpolatedDataValue() { +// 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; +// `); +// const { shader } = tester; +// const testShader = (point: any) => { +// enableTransferFunctionShader( +// shader, +// "doTransferFunction", +// dataType, +// controlPoints, +// defaultDataTypeRange[dataType], +// ); +// tester.execute({ inputValue: point }); +// const values = tester.values; +// return vec4.fromValues( +// values.val1, +// values.val2, +// values.val3, +// values.val4, +// ); +// }; +// const minValue = defaultDataTypeRange[dataType][0]; +// const maxValue = defaultDataTypeRange[dataType][1]; +// let color = testShader(minValue); +// expect(color).toEqual(vec4.fromValues(0, 0, 0, 0)); +// color = testShader(maxValue); +// expect(color).toEqual(vec4.fromValues(1, 1, 1, 1)); +// if (dataType !== DataType.UINT64) { +// const minValueNumber = minValue as number; +// const maxValueNumber = maxValue as number; +// color = testShader((maxValueNumber + minValueNumber) / 2); +// for (let i = 0; i < 3; i++) { +// expect(color[i]).toBeCloseTo(0.5); +// } +// } else { +// const value = (maxValue as Uint64).toNumber() / 2; +// const position = Uint64.fromNumber(value); +// color = testShader(position); +// for (let i = 0; i < 3; i++) { +// expect(color[i]).toBeCloseTo(0.5); +// } +// } +// }, +// ); +// }); +// } +// }); diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 37699adaf..3f16689fb 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -26,7 +26,6 @@ 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"; @@ -64,7 +63,6 @@ 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 type { TransferFunctionParameters } from "#src/webgl/shader_ui_controls.js"; import { setRawTextureParameters } from "#src/webgl/texture.js"; import { ColorWidget } from "#src/widget/color.js"; import { @@ -90,7 +88,7 @@ const transferFunctionSamplerTextureUnit = Symbol( ); /** - * Options to update a transfer function texture + * Options to update a lookup table texture */ export interface LookupTableTextureOptions { /** A lookup table is a series of color values (0 - 255) for each index in the transfer function texture @@ -104,6 +102,14 @@ export interface LookupTableTextureOptions { inputRange: DataTypeInterval; } +export interface TransferFunctionParameters { + sortedControlPoints: SortedControlPoints; + channel: number[]; + defaultColor: vec3; + range: DataTypeInterval; + size: number; +} + /** * Options to update a transfer function texture * TODO remove @@ -174,16 +180,15 @@ export class ControlPoint { } } -// TODO (SKM does this make good sense?) -class SortedControlPoints { - controlPoints: ControlPoint[]; - constructor(public parent: TransferFunction) { - this.controlPoints = parent.trackable.value.controlPoints.map((point) => - ControlPoint.copyFrom(point), - ); - } - get range() { - return this.parent.trackable.value.range; +export class SortedControlPoints { + // TODO (skm) is the copy needed? Does the constucture make sense? + constructor( + public controlPoints: ControlPoint[] = [], + public range: DataTypeInterval, + ) { + this.controlPoints = controlPoints; + this.range = range; + this.sort(); } addPoint(controlPoint: ControlPoint) { const { inputValue, outputColor } = controlPoint; @@ -220,7 +225,7 @@ class SortedControlPoints { } } -class LookupTable { +export class LookupTable { outputValues: Uint8Array; constructor(public lookupTableSize: number) { this.outputValues = new Uint8Array( @@ -241,10 +246,8 @@ class LookupTable { * @param controlPoints The control points to interpolate between * @param dataRange The range of the input data space */ - updateFromControlPoints( - controlPoints: ControlPoint[], - dataRange: DataTypeInterval, - ) { + updateFromControlPoints(sortedControlPoints: SortedControlPoints) { + const { controlPoints, range } = sortedControlPoints; const out = this.outputValues; const size = this.lookupTableSize; function addLookupValue(index: number, color: vec4) { @@ -254,7 +257,7 @@ class LookupTable { out[index + 3] = color[3]; } function toTransferFunctionSpace(controlPoint: ControlPoint) { - return controlPoint.transferFunctionIndex(dataRange, size); + return controlPoint.transferFunctionIndex(range, size); } // If no control points - return all transparent @@ -306,7 +309,7 @@ class LookupTable { /** * Handles a linked lookup table and control points for a transfer function. */ -class TransferFunction extends RefCounted { +export class TransferFunction extends RefCounted { lookupTable: LookupTable; sortedControlPoints: SortedControlPoints; constructor( @@ -314,29 +317,29 @@ class TransferFunction extends RefCounted { public trackable: WatchableValueInterface, ) { super(); - this.lookupTable = new LookupTable( - this.trackable.value.transferFunctionSize, - ); - this.sortedControlPoints = new SortedControlPoints(this); + this.lookupTable = new LookupTable(this.trackable.value.size); + this.sortedControlPoints = this.trackable.value.sortedControlPoints; + this.updateLookupTable(); } - /** Supports negative indexing */ - toLookupTableIndex(controlPointIndex: number) { - let index = - controlPointIndex >= 0 - ? controlPointIndex - : this.sortedControlPoints.controlPoints.length + controlPointIndex; - return this.sortedControlPoints.controlPoints[index].transferFunctionIndex( + /** The index of the vec4 in the lookup table corresponding to the given control point. Supports negative indexing */ + toLookupTableIndex( + controlPointIndex: number, + rollIndex: boolean = true, + ): number | undefined { + let index = controlPointIndex; + if (rollIndex && index < 0) { + index = this.sortedControlPoints.controlPoints.length + controlPointIndex; + } + return this.sortedControlPoints.controlPoints[index]?.transferFunctionIndex( this.trackable.value.range, - this.trackable.value.transferFunctionSize, + this.trackable.value.size, ); } toNormalizedInput(controlPoint: ControlPoint) { return controlPoint.normalizedInput(this.trackable.value.range); } updateLookupTable() { - this.lookupTable.updateFromControlPoints( - this.sortedControlPoints.controlPoints, - ); + this.lookupTable.updateFromControlPoints(this.sortedControlPoints); } addPoint(controlPoint: ControlPoint) { this.sortedControlPoints.addPoint(controlPoint); @@ -358,205 +361,8 @@ class TransferFunction extends RefCounted { ); return this.sortedControlPoints.findNearestControlPointIndex(absoluteValue); } - /** If a control point has neighbouring control points that are close by, select between them via match on the opacity */ - // TODO (skm) - complete this, needs check for -1 / +1 exist and distance check - matchNeighbouringPointsByOpacity(startingIndex: number) { - const controlPoints = this.sortedControlPoints.controlPoints; - const startingPoint = controlPoints[startingIndex]; - const previousPoint = controlPoints[startingIndex - 1]?; - const nextPoint = controlPoints[startingIndex + 1]; - const previousDistance = previousPoint - ? Math.abs(previousPoint.outputColor[3] - startingPoint.outputColor[3]) - : Infinity; - const nextDistance = nextPoint - ? Math.abs(nextPoint.outputColor[3] - startingPoint.outputColor[3]) - : Infinity; - if (previousDistance < nextDistance) { - return startingIndex - 1; - } else { - return startingIndex + 1; - } - } } -// old tf class -// positionToIndex(position: number) { -// let positionAsIndex = Math.floor( -// position * (TRANSFER_FUNCTION_PANEL_SIZE - 1), -// ); -// if (positionAsIndex < TRANSFER_FUNCTION_BORDER_WIDTH) { -// positionAsIndex = 0; -// } -// if ( -// TRANSFER_FUNCTION_PANEL_SIZE - 1 - positionAsIndex < -// TRANSFER_FUNCTION_BORDER_WIDTH -// ) { -// positionAsIndex = TRANSFER_FUNCTION_PANEL_SIZE - 1; -// } -// return positionAsIndex; -// } -// opacityToUint8(opacity: number) { -// let opacityAsUint8 = floatToUint8(opacity); -// if (opacityAsUint8 <= TRANSFER_FUNCTION_BORDER_WIDTH) { -// opacityAsUint8 = 0; -// } else if (opacityAsUint8 >= 255 - TRANSFER_FUNCTION_BORDER_WIDTH) { -// opacityAsUint8 = 255; -// } -// return opacityAsUint8; -// } -// findNearestControlPointIndex(position: number) { -// return findClosestMatchInSortedArray( -// this.trackable.value.controlPoints.map((point) => -// point.toTransferFunctionIndex( -// this.trackable.value.range, -// TRANSFER_FUNCTION_PANEL_SIZE, -// ), -// ), -// this.positionToIndex(position), -// (a, b) => a - b, -// ); -// } -// grabControlPoint(position: number, opacity: number) { -// const desiredPosition = this.positionToIndex(position); -// const desiredOpacity = this.opacityToUint8(opacity); -// const nearestIndex = this.findNearestControlPointIndex(position); -// if (nearestIndex === -1) { -// return -1; -// } -// const controlPoints = this.trackable.value.controlPoints; -// const nearestPosition = controlPoints[nearestIndex].toTransferFunctionIndex( -// this.trackable.value.range, -// TRANSFER_FUNCTION_PANEL_SIZE, -// ); -// if ( -// Math.abs(nearestPosition - desiredPosition) > -// CONTROL_POINT_X_GRAB_DISTANCE -// ) { -// return -1; -// } - -// // If points are nearby in X space, use Y space to break ties -// const nextPosition = controlPoints[ -// nearestIndex + 1 -// ]?.toTransferFunctionIndex( -// this.trackable.value.range, -// TRANSFER_FUNCTION_PANEL_SIZE, -// ); -// const nextDistance = -// nextPosition !== undefined -// ? Math.abs(nextPosition - desiredPosition) -// : CONTROL_POINT_X_GRAB_DISTANCE + 1; -// const previousPosition = controlPoints[ -// nearestIndex - 1 -// ]?.toTransferFunctionIndex( -// this.trackable.value.range, -// TRANSFER_FUNCTION_PANEL_SIZE, -// ); -// const previousDistance = -// previousPosition !== undefined -// ? Math.abs(previousPosition - desiredPosition) -// : CONTROL_POINT_X_GRAB_DISTANCE + 1; -// const possibleValues: [number, number][] = []; -// if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { -// possibleValues.push([ -// nearestIndex + 1, -// Math.abs( -// controlPoints[nearestIndex + 1].outputColor[3] - desiredOpacity, -// ), -// ]); -// } -// if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { -// possibleValues.push([ -// nearestIndex - 1, -// Math.abs( -// controlPoints[nearestIndex - 1].outputColor[3] - desiredOpacity, -// ), -// ]); -// } -// possibleValues.push([ -// nearestIndex, -// Math.abs(controlPoints[nearestIndex].outputColor[3] - desiredOpacity), -// ]); -// possibleValues.sort((a, b) => a[1] - b[1]); -// return possibleValues[0][0]; -// } -// addPoint(position: number, opacity: number, color: vec3) { -// const colorAsUint8 = vec3.fromValues( -// floatToUint8(color[0]), -// floatToUint8(color[1]), -// floatToUint8(color[2]), -// ); -// const opacityAsUint8 = this.opacityToUint8(opacity); -// const controlPoints = this.trackable.value.controlPoints; -// const positionAsIndex = this.positionToIndex(position); -// const existingIndex = controlPoints.findIndex( -// (point) => point.inputValue === positionAsIndex, -// ); -// if (existingIndex !== -1) { -// controlPoints.splice(existingIndex, 1); -// } -// controlPoints.push({ -// inputValue: positionAsIndex, -// outputColor: vec4.fromValues( -// colorAsUint8[0], -// colorAsUint8[1], -// colorAsUint8[2], -// opacityAsUint8, -// ), -// }); -// controlPoints.sort((a, b) => a.inputValue - b.inputValue); -// } -// lookupTableFromControlPoints() { -// const { lookupTable } = this; -// const { controlPoints } = this.trackable.value; -// lerpBetweenControlPoints(lookupTable, controlPoints); -// } -// updatePoint(index: number, position: number, opacity: number) { -// const { controlPoints } = this.trackable.value; -// const positionAsIndex = this.positionToIndex(position); -// const opacityAsUint8 = this.opacityToUint8(opacity); -// const color = controlPoints[index].outputColor; -// controlPoints[index] = { -// inputValue: positionAsIndex, -// outputColor: vec4.fromValues( -// color[0], -// color[1], -// color[2], -// opacityAsUint8, -// ), -// }; -// const exsitingPositions = new Set(); -// let positionToFind = positionAsIndex; -// for (const point of controlPoints) { -// if (exsitingPositions.has(point.inputValue)) { -// positionToFind = positionToFind === 0 ? 1 : positionToFind - 1; -// controlPoints[index].inputValue = positionToFind; -// break; -// } -// exsitingPositions.add(point.inputValue); -// } -// controlPoints.sort((a, b) => a.inputValue - b.inputValue); -// const newControlPointIndex = controlPoints.findIndex( -// (point) => point.inputValue === positionToFind, -// ); -// return newControlPointIndex; -// } -// setPointColor(index: number, color: vec3) { -// const { controlPoints } = this.trackable.value; -// const colorAsUint8 = vec3.fromValues( -// floatToUint8(color[0]), -// floatToUint8(color[1]), -// floatToUint8(color[2]), -// ); -// controlPoints[index].outputColor = vec4.fromValues( -// colorAsUint8[0], -// colorAsUint8[1], -// colorAsUint8[2], -// controlPoints[index].outputColor[3], -// ); -// } -// } - /** * Convert a [0, 1] float to a uint8 value between 0 and 255 * TODO (SKM) belong here? Maybe utils? @@ -784,7 +590,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { } const { transferFunction } = this; - const { controlPoints } = transferFunction.trackable.value; + const { controlPoints } = transferFunction.trackable.value.sortedControlPoints; let numLines = controlPoints.length; const colorChannels = NUM_COLOR_CHANNELS - 1; // ignore alpha const colorArray = new Float32Array(controlPoints.length * colorChannels); @@ -796,14 +602,14 @@ class TransferFunctionPanel extends IndirectRenderedPanel { // Create start and end lines if there are any control points if (controlPoints.length > 0) { // If the start point is above 0, need to draw a line from the left edge - const firstInputValue = transferFunction.toLookupTableIndex(0); + const firstInputValue = transferFunction.toLookupTableIndex(0)!; if (firstInputValue > 0) { numLines += 1; lineFromLeftEdge = vec4.fromValues(0, 0, firstInputValue, 0); } // If the end point is less than the transfer function length, need to draw a line to the right edge const finalPoint = controlPoints[controlPoints.length - 1]; - const finalInputValue = transferFunction.toLookupTableIndex(-1); + const finalInputValue = transferFunction.toLookupTableIndex(-1)!; if (finalInputValue < TRANSFER_FUNCTION_PANEL_SIZE - 1) { numLines += 1; lineToRightEdge = vec4.fromValues( @@ -833,7 +639,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { const colorIndex = i * colorChannels; const positionIndex = i * 2; const { outputColor } = controlPoints[i]; - const inputValue = transferFunction.toLookupTableIndex(i); + const inputValue = transferFunction.toLookupTableIndex(i)!; colorArray[colorIndex + 1] = normalizeColor(outputColor[1]); colorArray[colorIndex + 2] = normalizeColor(outputColor[2]); positionArray[positionIndex] = normalizePosition(inputValue); @@ -844,7 +650,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { const linePosition = vec4.fromValues( inputValue, outputColor[3], - transferFunction.toLookupTableIndex(i + 1), + transferFunction.toLookupTableIndex(i + 1)!, controlPoints[i + 1].outputColor[3], ); positionArrayIndex = addLine( @@ -1057,7 +863,54 @@ out_color = tempColor * alpha; ) { return -1; } - return transferFunction.matchNeighbouringPointsByOpacity(nearestControlPointIndex); + // If points are nearby in X space, use Y space to break ties + const possibleMatches: [number, number][] = [ + [ + nearestControlPointIndex, + Math.abs( + transferFunction.sortedControlPoints.controlPoints[ + nearestControlPointIndex + ].outputColor[3] - mouseYPosition, + ), + ], + ]; + const nextPosition = transferFunction.toLookupTableIndex( + nearestControlPointIndex + 1, + ); + const nextDistance = + nextPosition !== undefined + ? Math.abs(nextPosition - mousePositionTableIndex) + : Infinity; + if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { + possibleMatches.push([ + nearestControlPointIndex + 1, + Math.abs( + transferFunction.sortedControlPoints.controlPoints[ + nearestControlPointIndex + 1 + ].outputColor[3] - mouseYPosition, + ), + ]); + } + + const previousPosition = transferFunction.toLookupTableIndex( + nearestControlPointIndex - 1, + false, + ); + const previousDistance = + previousPosition !== undefined + ? Math.abs(previousPosition - mousePositionTableIndex) + : Infinity; + if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { + possibleMatches.push([ + nearestControlPointIndex - 1, + Math.abs( + transferFunction.sortedControlPoints.controlPoints[ + nearestControlPointIndex - 1 + ].outputColor[3] - mouseYPosition, + ), + ]); + } + return possibleMatches.sort((a, b) => a[1] - b[1])[0][0]; } update() { this.transferFunction.updateLookupTable(); @@ -1181,7 +1034,7 @@ class TransferFunctionController extends RefCounted { const mouseEvent = actionEvent.detail; const nearestIndex = this.findNearestControlPointIndex(mouseEvent); if (nearestIndex !== -1) { - const color = this.transferFunction.trackable.value.color; + const color = this.transferFunction.trackable.value.defaultColor; this.transferFunction.setPointColor(nearestIndex, color); this.updateValue({ ...this.getModel(), @@ -1202,7 +1055,7 @@ class TransferFunctionController extends RefCounted { return this.transferFunction.grabControlPoint(normalizedX, normalizedY); } addControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { - const color = this.transferFunction.trackable.value.color; + const color = this.transferFunction.trackable.value.defaultColor; const nearestIndex = this.findNearestControlPointIndex(event); if (nearestIndex !== -1) { this.currentGrabbedControlPointIndex = nearestIndex; @@ -1281,7 +1134,7 @@ class TransferFunctionWidget extends Tab { const colorPicker = this.registerDisposer( new ColorWidget( makeCachedDerivedWatchableValue( - (x: TransferFunctionParameters) => x.color, + (x: TransferFunctionParameters) => x.defaultColor, [trackable], ), () => vec3.fromValues(1, 1, 1), @@ -1292,13 +1145,13 @@ class TransferFunctionWidget extends Tab { colorPicker.element.addEventListener("change", () => { trackable.value = { ...this.trackable.value, - color: colorPicker.model.value, + defaultColor: colorPicker.model.value, }; }); colorPicker.element.addEventListener("input", () => { trackable.value = { ...this.trackable.value, - color: colorPicker.model.value, + defaultColor: colorPicker.model.value, }; }); colorPickerDiv.appendChild(colorPicker.element); From 2d36889df85dd2d1584602caf0abfdf7814add37 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 21 Mar 2024 12:42:01 +0100 Subject: [PATCH 24/60] progress(refactor): fix all type errors in tf file --- src/widget/transfer_function.spec.ts | 5 +- src/widget/transfer_function.ts | 256 ++++++++++++++++----------- 2 files changed, 152 insertions(+), 109 deletions(-) diff --git a/src/widget/transfer_function.spec.ts b/src/widget/transfer_function.spec.ts index 2577de3a7..0d5b48b2c 100644 --- a/src/widget/transfer_function.spec.ts +++ b/src/widget/transfer_function.spec.ts @@ -43,9 +43,10 @@ function makeTransferFunction(controlPoints: ControlPoint[]) { new TrackableValue( { sortedControlPoints, - channel: [], - defaultColor: vec3.fromValues(0.0, 0.0, 0.0), range: range, + window: range, + defaultColor: vec3.fromValues(0, 0, 0), + channel: [], size: TRANSFER_FUNCTION_LENGTH, }, (x) => x, diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 3f16689fb..6393d8969 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -24,10 +24,7 @@ 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, - findClosestMatchInSortedArray, -} from "#src/util/array.js"; +import { arraysEqual, 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 { @@ -81,7 +78,7 @@ const TRANSFER_FUNCTION_PANEL_SIZE = 1024; export const NUM_COLOR_CHANNELS = 4; const POSITION_VALUES_PER_LINE = 4; // x1, y1, x2, y2 const CONTROL_POINT_X_GRAB_DISTANCE = TRANSFER_FUNCTION_PANEL_SIZE / 40; -const TRANSFER_FUNCTION_BORDER_WIDTH = 10; +const TRANSFER_FUNCTION_BORDER_WIDTH = 0.05; const transferFunctionSamplerTextureUnit = Symbol( "transferFunctionSamplerTexture", @@ -104,10 +101,11 @@ export interface LookupTableTextureOptions { export interface TransferFunctionParameters { sortedControlPoints: SortedControlPoints; - channel: number[]; - defaultColor: vec3; range: DataTypeInterval; + window: DataTypeInterval; size: number; + channel: number[]; + defaultColor: vec3; } /** @@ -202,19 +200,38 @@ export class SortedControlPoints { this.controlPoints.push(newPoint); this.sort(); } - updatePoint(index: number, controlPoint: ControlPoint) { + removePoint(index: number) { + this.controlPoints.splice(index, 1); + } + updatePoint(index: number, controlPoint: ControlPoint): number { this.controlPoints[index] = controlPoint; + const value = controlPoint.inputValue; this.sort(); + return this.findNearestControlPointIndex(value); } - updatePointColor(index: number, color: vec4) { - this.controlPoints[index].outputColor = color; + updatePointColor(index: number, color: vec4 | vec3) { + let outputColor = new vec4(); + if (outputColor.length === 3) { + outputColor = vec4.fromValues( + color[0], + color[1], + color[2], + this.controlPoints[index].outputColor[3], + ); + } else { + outputColor = vec4.clone(color as vec4); + } + this.controlPoints[index].outputColor = outputColor; } findNearestControlPointIndex(inputValue: number | Uint64) { const controlPoint = new ControlPoint(inputValue, vec4.create()); const valueToFind = controlPoint.normalizedInput(this.range); + return this.findNearestControlPointIndexByNormalizedInput(valueToFind); + } + findNearestControlPointIndexByNormalizedInput(normalizedInput: number) { return findClosestMatchInSortedArray( this.controlPoints.map((point) => point.normalizedInput(this.range)), - valueToFind, + normalizedInput, (a, b) => a - b, ); } @@ -344,10 +361,13 @@ export class TransferFunction extends RefCounted { addPoint(controlPoint: ControlPoint) { this.sortedControlPoints.addPoint(controlPoint); } - updatePoint(index: number, controlPoint: ControlPoint) { - this.sortedControlPoints.updatePoint(index, 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) { + updatePointColor(index: number, color: vec4 | vec3) { this.sortedControlPoints.updatePointColor(index, color); } findNearestControlPointIndex( @@ -590,7 +610,8 @@ class TransferFunctionPanel extends IndirectRenderedPanel { } const { transferFunction } = this; - const { controlPoints } = transferFunction.trackable.value.sortedControlPoints; + const { controlPoints } = + transferFunction.trackable.value.sortedControlPoints; let numLines = controlPoints.length; const colorChannels = NUM_COLOR_CHANNELS - 1; // ignore alpha const colorArray = new Float32Array(controlPoints.length * colorChannels); @@ -837,81 +858,6 @@ out_color = tempColor * alpha; } gl.disable(WebGL2RenderingContext.BLEND); } - grabControlPointNearCursor(mouseXPosition: number, mouseYPosition: number) { - const { transferFunction, window } = this; - - const nearestControlPointIndex = - transferFunction.findNearestControlPointIndex(mouseXPosition, window); - if (nearestControlPointIndex === -1) { - return nearestControlPointIndex; - } - const lookupTableIndex = transferFunction.toLookupTableIndex( - nearestControlPointIndex, - ); - // TODO(skm) this looks awkward - const mousePositionTableIndex = Math.floor( - computeInvlerp( - this.range, - computeLerp(window, this.dataType, mouseXPosition), - ) * - TRANSFER_FUNCTION_PANEL_SIZE - - 1, - ); - if ( - Math.abs(lookupTableIndex - mousePositionTableIndex) > - 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( - transferFunction.sortedControlPoints.controlPoints[ - nearestControlPointIndex - ].outputColor[3] - mouseYPosition, - ), - ], - ]; - const nextPosition = transferFunction.toLookupTableIndex( - nearestControlPointIndex + 1, - ); - const nextDistance = - nextPosition !== undefined - ? Math.abs(nextPosition - mousePositionTableIndex) - : Infinity; - if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { - possibleMatches.push([ - nearestControlPointIndex + 1, - Math.abs( - transferFunction.sortedControlPoints.controlPoints[ - nearestControlPointIndex + 1 - ].outputColor[3] - mouseYPosition, - ), - ]); - } - - const previousPosition = transferFunction.toLookupTableIndex( - nearestControlPointIndex - 1, - false, - ); - const previousDistance = - previousPosition !== undefined - ? Math.abs(previousPosition - mousePositionTableIndex) - : Infinity; - if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { - possibleMatches.push([ - nearestControlPointIndex - 1, - Math.abs( - transferFunction.sortedControlPoints.controlPoints[ - nearestControlPointIndex - 1 - ].outputColor[3] - mouseYPosition, - ), - ]); - } - return possibleMatches.sort((a, b) => a[1] - b[1])[0][0]; - } update() { this.transferFunction.updateLookupTable(); this.updateTransferFunctionPointsAndLines(); @@ -1016,13 +962,11 @@ class TransferFunctionController extends RefCounted { const mouseEvent = actionEvent.detail; const nearestIndex = this.findNearestControlPointIndex(mouseEvent); if (nearestIndex !== -1) { - this.transferFunction.trackable.value.controlPoints.splice( - nearestIndex, - 1, - ); + this.transferFunction.removePoint(nearestIndex); this.updateValue({ ...this.getModel(), - controlPoints: this.transferFunction.trackable.value.controlPoints, + sortedControlPoints: + this.transferFunction.trackable.value.sortedControlPoints, }); } }, @@ -1035,10 +979,11 @@ class TransferFunctionController extends RefCounted { const nearestIndex = this.findNearestControlPointIndex(mouseEvent); if (nearestIndex !== -1) { const color = this.transferFunction.trackable.value.defaultColor; - this.transferFunction.setPointColor(nearestIndex, color); + this.transferFunction.updatePointColor(nearestIndex, color); this.updateValue({ ...this.getModel(), - controlPoints: this.transferFunction.trackable.value.controlPoints, + sortedControlPoints: + this.transferFunction.trackable.value.sortedControlPoints, }); } }, @@ -1052,7 +997,7 @@ class TransferFunctionController extends RefCounted { const { normalizedX, normalizedY } = this.getControlPointPosition( event, ) as CanvasPosition; - return this.transferFunction.grabControlPoint(normalizedX, normalizedY); + return this.grabControlPointNearCursor(normalizedX, normalizedY); } addControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { const color = this.transferFunction.trackable.value.defaultColor; @@ -1064,12 +1009,18 @@ class TransferFunctionController extends RefCounted { const { normalizedX, normalizedY } = this.getControlPointPosition( event, ) as CanvasPosition; - this.transferFunction.addPoint(normalizedX, normalizedY, color); + this.transferFunction.addPoint( + new ControlPoint( + normalizedX, + vec4.fromValues(color[0], color[1], color[2], normalizedY), + ), + ); this.currentGrabbedControlPointIndex = this.findNearestControlPointIndex(event); return { ...this.getModel(), - controlPoints: this.transferFunction.trackable.value.controlPoints, + sortedControlPoints: + this.transferFunction.trackable.value.sortedControlPoints, }; } moveControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { @@ -1077,22 +1028,26 @@ class TransferFunctionController extends RefCounted { 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] = normalizedY; this.currentGrabbedControlPointIndex = this.transferFunction.updatePoint( this.currentGrabbedControlPointIndex, - normalizedX, - normalizedY, + new ControlPoint(normalizedX, newColor), ); return { ...this.getModel(), - controlPoints: this.transferFunction.trackable.value.controlPoints, + sortedControlPoints: this.transferFunction.trackable.value.sortedControlPoints, }; } return undefined; } getControlPointPosition(event: MouseEvent): CanvasPosition | undefined { const clientRect = this.element.getBoundingClientRect(); - const normalizedX = (event.clientX - clientRect.left) / clientRect.width; - const normalizedY = (clientRect.bottom - event.clientY) / clientRect.height; + let normalizedX = (event.clientX - clientRect.left) / clientRect.width; + let normalizedY = (clientRect.bottom - event.clientY) / clientRect.height; if ( normalizedX < 0 || normalizedX > 1 || @@ -1100,8 +1055,94 @@ class TransferFunctionController extends RefCounted { normalizedY > 1 ) return undefined; + + // Near the borders of the transfer function, clamp the control point to the border + if (normalizedX < TRANSFER_FUNCTION_BORDER_WIDTH) { + normalizedX = 0.0; + } + else if (normalizedX > 1 - TRANSFER_FUNCTION_BORDER_WIDTH) { + 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 }; } + grabControlPointNearCursor(mouseXPosition: number, mouseYPosition: number) { + const { transferFunction, dataType } = this; + const { range, window } = transferFunction.trackable.value; + const nearestControlPointIndex = + transferFunction.findNearestControlPointIndex(mouseXPosition, window); + if (nearestControlPointIndex === -1) { + return nearestControlPointIndex; + } + const lookupTableIndex = transferFunction.toLookupTableIndex( + nearestControlPointIndex, + )!; + const mousePositionTableIndex = Math.floor( + computeInvlerp(range, computeLerp(window, dataType, mouseXPosition)) * + TRANSFER_FUNCTION_PANEL_SIZE - + 1, + ); + if ( + Math.abs(lookupTableIndex - mousePositionTableIndex) > + 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( + transferFunction.sortedControlPoints.controlPoints[ + nearestControlPointIndex + ].outputColor[3] - mouseYPosition, + ), + ], + ]; + const nextPosition = transferFunction.toLookupTableIndex( + nearestControlPointIndex + 1, + ); + const nextDistance = + nextPosition !== undefined + ? Math.abs(nextPosition - mousePositionTableIndex) + : Infinity; + if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { + possibleMatches.push([ + nearestControlPointIndex + 1, + Math.abs( + transferFunction.sortedControlPoints.controlPoints[ + nearestControlPointIndex + 1 + ].outputColor[3] - mouseYPosition, + ), + ]); + } + + const previousPosition = transferFunction.toLookupTableIndex( + nearestControlPointIndex - 1, + false, + ); + const previousDistance = + previousPosition !== undefined + ? Math.abs(previousPosition - mousePositionTableIndex) + : Infinity; + if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { + possibleMatches.push([ + nearestControlPointIndex - 1, + Math.abs( + transferFunction.sortedControlPoints.controlPoints[ + nearestControlPointIndex - 1 + ].outputColor[3] - mouseYPosition, + ), + ]); + } + return possibleMatches.sort((a, b) => a[1] - b[1])[0][0]; + } } /** @@ -1236,6 +1277,7 @@ export function enableTransferFunctionShader( dataType: DataType, controlPoints: ControlPoint[], interval: DataTypeInterval, + lookupTableSize: number, ) { const { gl } = shader; @@ -1246,7 +1288,7 @@ export function enableTransferFunctionShader( if (texture === undefined) { shader.transferFunctionTextures.set( `TransferFunction.${name}`, - new TransferFunctionTexture(gl), + new LookupTableTexture(gl), ); } shader.bindAndUpdateTransferFunctionTexture( @@ -1257,7 +1299,7 @@ export function enableTransferFunctionShader( // Bind the length of the lookup table to the shader as a uniform gl.uniform1f( shader.uniform(`uTransferFunctionEnd_${name}`), - TRANSFER_FUNCTION_LENGTH - 1, + lookupTableSize - 1, ); // Use the lerp shader function to grab an index into the lookup table From 1225170c17535c292fc3f5c0769af1fafd910d1d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 21 Mar 2024 16:18:02 +0100 Subject: [PATCH 25/60] progress(refactor): fix type errors --- src/webgl/shader_ui_controls.ts | 237 +++++++++++++++----------------- src/widget/transfer_function.ts | 25 ++-- 2 files changed, 124 insertions(+), 138 deletions(-) diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 520772f44..7e58bbeb8 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -34,7 +34,7 @@ import { } from "#src/util/color.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; -import { vec3, vec4 } from "#src/util/geom.js"; +import { kOneVec, kZeroVec4, vec3, vec4 } from "#src/util/geom.js"; import { parseArray, parseFixedLengthArray, @@ -67,15 +67,18 @@ import { enableLerpShaderFunction, } from "#src/webgl/lerp.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; -import type { - ControlPoint, -} from "#src/widget/transfer_function.js"; import { defineTransferFunctionShader, enableTransferFunctionShader, + TransferFunctionParameters, floatToUint8, + SortedControlPoints, + ControlPoint, } from "#src/widget/transfer_function.js"; +// TODO (SKM) - remove temp +const TRANSFER_FUNCTION_LENGTH = 512; + export interface ShaderSliderControl { type: "slider"; valueType: "int" | "uint" | "float"; @@ -597,10 +600,13 @@ function parseTransferFunctionDirective( const channelRank = imageData?.channelRank; const errors = []; let channel = new Array(channelRank).fill(0); - let color = vec3.fromValues(1.0, 1.0, 1.0); + let defaultColor = vec3.fromValues(1.0, 1.0, 1.0); let range: DataTypeInterval | undefined; - const controlPoints = new Array(); - const parsedControlPoints = new Array(); + const sortedControlPoints = new SortedControlPoints( + [], + dataType ? defaultDataTypeRange[dataType] : [0, 1], + ); + // TODO (skm) - support parsing window and size let specifedPoints = false; if (valueType !== "transferFunction") { errors.push("type must be transferFunction"); @@ -618,7 +624,7 @@ function parseTransferFunctionDirective( break; } case "color": { - color = parseRGBColorSpecification(value); + defaultColor = parseRGBColorSpecification(value); break; } case "range": { @@ -632,9 +638,13 @@ function parseTransferFunctionDirective( case "controlPoints": { specifedPoints = true; if (dataType !== undefined) { - parsedControlPoints.push( - ...convertTransferFunctionControlPoints(value, dataType), + const convertedPoints = convertTransferFunctionControlPoints( + value, + dataType, ); + for (const point of convertedPoints) { + sortedControlPoints.addPoint(point); + } } break; } @@ -652,61 +662,34 @@ function parseTransferFunctionDirective( else range = [0, 1] as [number, number]; } // Set a simple black to white transfer function if no control points are specified. - if (controlPoints.length === 0 && !specifedPoints) { - const transferFunctionRange = [0, TRANSFER_FUNCTION_LENGTH - 1] as [ - number, - number, - ]; - const startPoint = computeLerp( - transferFunctionRange, - DataType.UINT16, - 0.4, - ) as number; - const endPoint = computeLerp( - transferFunctionRange, - DataType.UINT16, - 0.7, - ) as number; - controlPoints.push({ - inputValue: startPoint, - outputColor: vec4.fromValues(0, 0, 0, 0), - }); - controlPoints.push({ - inputValue: endPoint, - outputColor: vec4.fromValues(255, 255, 255, 255), - }); - } else { - // Parse control points from the shader code and sort them - for (const controlPoint of parsedControlPoints) { - let normalizedPosition = computeInvlerp(range, controlPoint.inputValue); - normalizedPosition = Math.min(Math.max(0, normalizedPosition), 1); - const position = computeLerp( - [0, TRANSFER_FUNCTION_LENGTH - 1], - DataType.UINT16, - normalizedPosition, - ) as number; - controlPoints.push({ inputValue: position, outputColor: controlPoint.outputColor }); - } - const pointPositions = new Set(); - for (let i = 0; i < controlPoints.length; i++) { - const controlPoint = controlPoints[i]; - if (pointPositions.has(controlPoint.inputValue)) { - errors.push( - `Duplicate control point position: ${parsedControlPoints[i].inputValue}`, - ); - } - pointPositions.add(controlPoint.inputValue); - } - controlPoints.sort((a, b) => a.inputValue - b.inputValue); + if ( + sortedControlPoints.length === 0 && + !specifedPoints && + dataType !== undefined + ) { + const startPoint = computeLerp(range, dataType, 0.4) as number; + const endPoint = computeLerp(range, 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 }; } + // TODO (skm) - support parsing window and size return { control: { type: "transferFunction", dataType, - default: { controlPoints, channel, color, range }, + default: { + sortedControlPoints, + channel, + defaultColor, + range, + window: range, + size: TRANSFER_FUNCTION_LENGTH, + }, } as ShaderTransferFunctionControl, errors: undefined, }; @@ -1078,8 +1061,6 @@ class TrackablePropertyInvlerpParameters extends TrackableValue parseDataTypeInterval(x, dataType), defaultValue.range, ); - const controlPoints = verifyOptionalObjectProperty( + const sortedControlPoints = verifyOptionalObjectProperty( obj, "controlPoints", (x) => parseTransferFunctionControlPoints(x, range, dataType), - defaultValue.controlPoints.map((x) => ({ - position: x.inputValue, - color: x.outputColor, - })), + defaultValue.sortedControlPoints, ); + // TODO (skm) - support parsing window and size + const window = range; + const size = TRANSFER_FUNCTION_LENGTH; return { - controlPoints: controlPoints, + sortedControlPoints, channel: verifyOptionalObjectProperty( obj, "channel", (x) => parseInvlerpChannel(x, defaultValue.channel.length), defaultValue.channel, ), - color: verifyOptionalObjectProperty( + defaultColor: verifyOptionalObjectProperty( obj, "color", (x) => parseRGBColorSpecification(x), - defaultValue.color, + defaultValue.defaultColor, ), - range: range, + range, + window, + size, }; } +// TODO (skm) still need copy? function copyTransferFunctionParameters( defaultValue: TransferFunctionParameters, ) { return { - controlPoints: defaultValue.controlPoints.map((x) => ({ - position: x.inputValue, - color: x.outputColor, - })), + sortedControlPoints: new SortedControlPoints( + defaultValue.sortedControlPoints.controlPoints.map( + (x) => new ControlPoint(x.inputValue, x.outputColor), + ), + defaultValue.range, + ), channel: defaultValue.channel, - color: defaultValue.color, + defaultColor: defaultValue.defaultColor, range: defaultValue.range, + window: defaultValue.range, + size: defaultValue.size, }; } @@ -1284,32 +1255,22 @@ class TrackableTransferFunctionParameters extends TrackableValue ({ input: positionToJson(x.inputValue), color: serializeColor( - vec3.fromValues(x.outputColor[0] / 255, x.outputColor[1] / 255, x.outputColor[2] / 255), + vec3.fromValues( + x.outputColor[0] / 255, + x.outputColor[1] / 255, + x.outputColor[2] / 255, + ), ), opacity: x.outputColor[3] / 255, })); @@ -1317,42 +1278,60 @@ class TrackableTransferFunctionParameters extends TrackableValue arraysEqual(a.outputColor, b.outputColor) && a.inputValue === b.inputValue, + defaultValue.sortedControlPoints.controlPoints, + sortedControlPoints.controlPoints, + (a, b) => + arraysEqual(a.outputColor, b.outputColor) && + a.inputValue === b.inputValue, ) ? undefined - : this.controlPointsToJson(this.value.controlPoints, range, dataType); + : this.controlPointsToJson(sortedControlPoints.controlPoints, dataType); if ( rangeJson === undefined && channelJson === undefined && colorJson === undefined && - controlPointsJson === undefined + controlPointsJson === undefined && + windowJson === undefined && + sizeJson === undefined ) { return undefined; } return { range: rangeJson, channel: channelJson, - color: colorJson, - controlPoints: controlPointsJson, + defaultColor: colorJson, + sortedControlPoints: controlPointsJson, + window: windowJson, + size: sizeJson, }; } } @@ -1798,12 +1777,14 @@ function setControlInShader( // Value is hard-coded in shader. break; case "transferFunction": + // TODO (SKM) - support variable length enableTransferFunctionShader( shader, uName, control.dataType, - value.controlPoints, + value.sortedControlPoints, value.range, + TRANSFER_FUNCTION_LENGTH ); } } diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 6393d8969..82ff7fc18 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -84,6 +84,14 @@ const transferFunctionSamplerTextureUnit = Symbol( "transferFunctionSamplerTexture", ); +/** + * Convert a [0, 1] float to a uint8 value between 0 and 255 + * TODO (SKM) belong here? Maybe utils? + */ +export function floatToUint8(float: number) { + return Math.min(255, Math.max(Math.round(float * 255), 0)); +} + /** * Options to update a lookup table texture */ @@ -99,6 +107,7 @@ export interface LookupTableTextureOptions { inputRange: DataTypeInterval; } +// TODO (skm) - window currently doesn't work. Need to update round bound inputs export interface TransferFunctionParameters { sortedControlPoints: SortedControlPoints; range: DataTypeInterval; @@ -188,6 +197,9 @@ export class SortedControlPoints { this.range = range; this.sort(); } + get length() { + return this.controlPoints.length; + } addPoint(controlPoint: ControlPoint) { const { inputValue, outputColor } = controlPoint; const nearestPoint = this.findNearestControlPointIndex(inputValue); @@ -383,14 +395,6 @@ export class TransferFunction extends RefCounted { } } -/** - * Convert a [0, 1] float to a uint8 value between 0 and 255 - * TODO (SKM) belong here? Maybe utils? - */ -export function floatToUint8(float: number) { - return Math.min(255, Math.max(Math.round(float * 255), 0)); -} - /** * Represent the underlying transfer function lookup table as a texture * TODO(skm) consider if height can be used for more efficiency @@ -1275,7 +1279,7 @@ export function enableTransferFunctionShader( shader: ShaderProgram, name: string, dataType: DataType, - controlPoints: ControlPoint[], + controlPoints: SortedControlPoints, interval: DataTypeInterval, lookupTableSize: number, ) { @@ -1291,9 +1295,10 @@ export function enableTransferFunctionShader( new LookupTableTexture(gl), ); } + // TODO (SKM) probably need to handle the sorted nature shader.bindAndUpdateTransferFunctionTexture( `TransferFunction.${name}`, - controlPoints, + controlPoints.controlPoints, ); // Bind the length of the lookup table to the shader as a uniform From 710cd39a16109cdef538d3a11ebfe89c959d9a52 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 21 Mar 2024 17:08:59 +0100 Subject: [PATCH 26/60] refactor(progress): fix more type errors --- src/webgl/shader.ts | 11 +- src/webgl/shader_ui_controls.browser_test.ts | 4 +- src/widget/transfer_function.browser_test.ts | 141 +++++++++++++ src/widget/transfer_function.spec.ts | 84 +------- src/widget/transfer_function.ts | 203 ++++++++++--------- 5 files changed, 262 insertions(+), 181 deletions(-) create mode 100644 src/widget/transfer_function.browser_test.ts diff --git a/src/webgl/shader.ts b/src/webgl/shader.ts index 2a6d003b3..24f99d388 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -18,8 +18,9 @@ import { RefCounted } from "#src/util/disposable.js"; import type { GL } from "#src/webgl/context.js"; import type { ControlPoint, - TransferFunctionTexture, + ControlPointTexture, } from "#src/widget/transfer_function.js"; +import { DataTypeInterval } from "src/util/lerp"; const DEBUG_SHADER = false; @@ -168,9 +169,9 @@ export class ShaderProgram extends RefCounted { textureUnits: Map; vertexShaderInputBinders: { [name: string]: VertexShaderInputBinder } = {}; vertexDebugOutputs?: VertexDebugOutput[]; - transferFunctionTextures: Map = new Map< + transferFunctionTextures: Map = new Map< any, - TransferFunctionTexture + ControlPointTexture >(); constructor( @@ -257,6 +258,7 @@ export class ShaderProgram extends RefCounted { bindAndUpdateTransferFunctionTexture( symbol: symbol | string, controlPoints: ControlPoint[], + inputRange: DataTypeInterval, ) { const textureUnit = this.textureUnits.get(symbol); if (textureUnit === undefined) { @@ -268,7 +270,8 @@ export class ShaderProgram extends RefCounted { `Invalid transfer function texture symbol: ${symbol.toString()}`, ); } - texture.updateAndActivate({ textureUnit, controlPoints }); + // TODO (SKM) - how to correctly get the input range? + texture.updateAndActivate({ textureUnit, controlPoints, inputRange }); } unbindTransferFunctionTextures() { diff --git a/src/webgl/shader_ui_controls.browser_test.ts b/src/webgl/shader_ui_controls.browser_test.ts index b844d2420..1152fc37c 100644 --- a/src/webgl/shader_ui_controls.browser_test.ts +++ b/src/webgl/shader_ui_controls.browser_test.ts @@ -22,7 +22,9 @@ import { parseShaderUiControls, stripComments, } from "#src/webgl/shader_ui_controls.js"; -import { TRANSFER_FUNCTION_LENGTH } from "#src/widget/transfer_function.js"; + +// TODO (SKM) handle parsing +const TRANSFER_FUNCTION_LENGTH = 512; describe("stripComments", () => { it("handles code without comments", () => { diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts new file mode 100644 index 000000000..b74fbb025 --- /dev/null +++ b/src/widget/transfer_function.browser_test.ts @@ -0,0 +1,141 @@ +/** + * @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 { 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 { + SortedControlPoints, + ControlPoint, + LookupTable, + TransferFunction, + TransferFunctionParameters, + NUM_COLOR_CHANNELS, + defineTransferFunctionShader, + enableTransferFunctionShader, +} from "#src/widget/transfer_function.js"; +import { TrackableValue } from "#src/trackable_value.js"; + +const TRANSFER_FUNCTION_LENGTH = 512; + +function makeTransferFunction(controlPoints: ControlPoint[]) { + const range = defaultDataTypeRange[DataType.UINT8]; + const sortedControlPoints = new SortedControlPoints(controlPoints, range); + return new TransferFunction( + DataType.UINT8, + new TrackableValue( + { + sortedControlPoints, + range: range, + window: range, + defaultColor: vec3.fromValues(0, 0, 0), + channel: [], + size: TRANSFER_FUNCTION_LENGTH, + }, + (x) => x, + ), + ); +} + +describe("compute transfer function on GPU", () => { + const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; + const controlPoints = new SortedControlPoints( + [ + new ControlPoint(0, vec4.fromValues(0, 0, 0, 0)), + new ControlPoint( + maxTransferFunctionPoints, + vec4.fromValues(255, 255, 255, 255), + ), + ], + defaultDataTypeRange[DataType.UINT8], + ); + for (const dataType of Object.values(DataType)) { + if (typeof dataType === "string") continue; + it(`computes transfer function on GPU for ${DataType[dataType]}`, () => { + const shaderType = getShaderType(dataType); + fragmentShaderTest( + { inputValue: dataType }, + { val1: "float", val2: "float", val3: "float", val4: "float" }, + (tester) => { + const { builder } = tester; + builder.addFragmentCode(` +${shaderType} getInterpolatedDataValue() { + 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; +`); + const { shader } = tester; + const testShader = (point: any) => { + enableTransferFunctionShader( + shader, + "doTransferFunction", + dataType, + controlPoints, + defaultDataTypeRange[dataType], + TRANSFER_FUNCTION_LENGTH, + ); + tester.execute({ inputValue: point }); + const values = tester.values; + return vec4.fromValues( + values.val1, + values.val2, + values.val3, + values.val4, + ); + }; + const minValue = defaultDataTypeRange[dataType][0]; + const maxValue = defaultDataTypeRange[dataType][1]; + let color = testShader(minValue); + expect(color).toEqual(vec4.fromValues(0, 0, 0, 0)); + color = testShader(maxValue); + expect(color).toEqual(vec4.fromValues(1, 1, 1, 1)); + if (dataType !== DataType.UINT64) { + const minValueNumber = minValue as number; + const maxValueNumber = maxValue as number; + color = testShader((maxValueNumber + minValueNumber) / 2); + for (let i = 0; i < 3; i++) { + expect(color[i]).toBeCloseTo(0.5); + } + } else { + const value = (maxValue as Uint64).toNumber() / 2; + const position = Uint64.fromNumber(value); + color = testShader(position); + for (let i = 0; i < 3; i++) { + expect(color[i]).toBeCloseTo(0.5); + } + } + }, + ); + }); + } +}); diff --git a/src/widget/transfer_function.spec.ts b/src/widget/transfer_function.spec.ts index 0d5b48b2c..787e62b1f 100644 --- a/src/widget/transfer_function.spec.ts +++ b/src/widget/transfer_function.spec.ts @@ -164,86 +164,4 @@ describe("lerpBetweenControlPoints", () => { } } }); -}); - -// describe("compute transfer function on GPU", () => { -// const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; -// const controlPoints: ControlPoint[] = [ -// new ControlPoint(0, vec4.fromValues(0, 0, 0, 0)), -// new ControlPoint( -// maxTransferFunctionPoints, -// vec4.fromValues(255, 255, 255, 255), -// ), -// ]; -// for (const dataType of Object.values(DataType)) { -// if (typeof dataType === "string") continue; -// it(`computes transfer function on GPU for ${DataType[dataType]}`, () => { -// const shaderType = getShaderType(dataType); -// fragmentShaderTest( -// { inputValue: dataType }, -// { val1: "float", val2: "float", val3: "float", val4: "float" }, -// (tester) => { -// const { builder } = tester; -// builder.addFragmentCode(` -// ${shaderType} getInterpolatedDataValue() { -// 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; -// `); -// const { shader } = tester; -// const testShader = (point: any) => { -// enableTransferFunctionShader( -// shader, -// "doTransferFunction", -// dataType, -// controlPoints, -// defaultDataTypeRange[dataType], -// ); -// tester.execute({ inputValue: point }); -// const values = tester.values; -// return vec4.fromValues( -// values.val1, -// values.val2, -// values.val3, -// values.val4, -// ); -// }; -// const minValue = defaultDataTypeRange[dataType][0]; -// const maxValue = defaultDataTypeRange[dataType][1]; -// let color = testShader(minValue); -// expect(color).toEqual(vec4.fromValues(0, 0, 0, 0)); -// color = testShader(maxValue); -// expect(color).toEqual(vec4.fromValues(1, 1, 1, 1)); -// if (dataType !== DataType.UINT64) { -// const minValueNumber = minValue as number; -// const maxValueNumber = maxValue as number; -// color = testShader((maxValueNumber + minValueNumber) / 2); -// for (let i = 0; i < 3; i++) { -// expect(color[i]).toBeCloseTo(0.5); -// } -// } else { -// const value = (maxValue as Uint64).toNumber() / 2; -// const position = Uint64.fromNumber(value); -// color = testShader(position); -// for (let i = 0; i < 3; i++) { -// expect(color[i]).toBeCloseTo(0.5); -// } -// } -// }, -// ); -// }); -// } -// }); +}); \ No newline at end of file diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 82ff7fc18..26098a0d8 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -24,7 +24,11 @@ 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, findClosestMatchInSortedArray } from "#src/util/array.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 { @@ -101,10 +105,6 @@ export interface LookupTableTextureOptions { lookupTable: LookupTable; /** textureUnit to update with the new transfer function texture data */ textureUnit: number | undefined; - /** range of the input space I, where T: I -> O. Allows for more precision in the - * transfer function texture generation due to texture size limitations - */ - inputRange: DataTypeInterval; } // TODO (skm) - window currently doesn't work. Need to update round bound inputs @@ -119,15 +119,11 @@ export interface TransferFunctionParameters { /** * Options to update a transfer function texture - * TODO remove + * TODO should this be sorted control points? */ -export interface LookupTableTextureOptionsOld { - /** If lookupTable is defined, it will be used to update the texture directly. - * A lookup table is a series of color values (0 - 255) for each index in the transfer function texture - */ - lookupTable: LookupTable; - /** If lookupTable is undefined, controlPoints will be used to generate a lookup table as a first step */ - controlPoints?: ControlPoint[]; +export interface ControlPointTextureOptions { + /** controlPoints will be used to generate a lookup table as a first step */ + controlPoints: ControlPoint[]; /** textureUnit to update with the new transfer function texture data */ textureUnit: number | undefined; /** range of the input space I, where T: I -> O. Allows for more precision in the @@ -395,72 +391,31 @@ export class TransferFunction extends RefCounted { } } -/** - * Represent the underlying transfer function lookup table as a texture - * TODO(skm) consider if height can be used for more efficiency - */ -export class LookupTableTexture extends RefCounted { +abstract class BaseTexture extends RefCounted { texture: WebGLTexture | null = null; width: number; height = 1; - private priorOptions: LookupTableTextureOptions | undefined = undefined; - + 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 */ - optionsEqual( - existingOptions: LookupTableTextureOptions | undefined, - newOptions: LookupTableTextureOptions, - ) { - if (existingOptions === undefined) return false; - let lookupTableEqual = true; - if ( - existingOptions.lookupTable !== undefined && - newOptions.lookupTable !== undefined - ) { - lookupTableEqual = LookupTable.equal( - existingOptions.lookupTable, - newOptions.lookupTable, - ); - } - // let controlPointsEqual = true; - // if ( - // existingOptions.controlPoints !== undefined && - // newOptions.controlPoints !== undefined - // ) { - // controlPointsEqual = arraysEqualWithPredicate( - // existingOptions.controlPoints, - // newOptions.controlPoints, - // (a, b) => - // a.inputValue === b.inputValue && - // arraysEqual(a.outputColor, b.outputColor), - // ); - // } - const textureUnitEqual = - existingOptions.textureUnit === newOptions.textureUnit; - - return lookupTableEqual && textureUnitEqual; - } - - updateAndActivate(options: LookupTableTextureOptions) { + abstract optionsEqual( + newOptions: LookupTableTextureOptions | ControlPointTextureOptions, + ): boolean; + abstract createLookupTable( + options: LookupTableTextureOptions | ControlPointTextureOptions, + ): LookupTable; + updateAndActivate(options: LookupTableTextureOptions | ControlPointTextureOptions) { const { gl } = this; if (gl === null) return; let { texture } = this; - // Verify input - // if ( - // options.lookupTable === undefined && - // options.controlPoints === undefined - // ) { - // throw new Error( - // "Either lookupTable or controlPoints must be defined for transfer function texture", - // ); - // } - function activateAndBindTexture(gl: GL, textureUnit: number | undefined) { if (textureUnit === undefined) { throw new Error( @@ -472,7 +427,7 @@ export class LookupTableTexture extends RefCounted { } // If the texture is already up to date, just bind and activate it - if (texture !== null && this.optionsEqual(this.priorOptions, options)) { + if (texture !== null && this.optionsEqual(options)) { activateAndBindTexture(gl, options.textureUnit); return; } @@ -483,16 +438,8 @@ export class LookupTableTexture extends RefCounted { // Update the texture activateAndBindTexture(gl, options.textureUnit); setRawTextureParameters(gl); - let lookupTable = options.lookupTable; - // if (lookupTable === undefined) { - // lookupTable = new LookupTable(options.size); - // lerpBetweenControlPoints( - // lookupTable, - // options.controlPoints!, - // options.inputRange, - // this.width * this.height, - // ); - // } + let lookupTable = this.createLookupTable(options); + gl.texImage2D( WebGL2RenderingContext.TEXTURE_2D, 0, @@ -509,12 +456,6 @@ export class LookupTableTexture extends RefCounted { // Make a copy of the options for the purpose of comparison // TODO(skm) is this copy needed? this.priorOptions = { ...options }; - // textureUnit: options.textureUnit, - // lookupTable: options.lookupTable, - // // controlPoints: options.controlPoints?.map((point) => - // // ControlPoint.copyFrom(point), - // // ), - // inputRange: options.inputRange, } disposed() { @@ -525,12 +466,89 @@ export class LookupTableTexture extends RefCounted { } } +/** + * Represent the underlying transfer function lookup table as a texture + * TODO(skm) consider if height can be used for more efficiency + */ +class DirectLookupTableTexture extends BaseTexture { + texture: WebGLTexture | null = null; + width: number; + height = 1; + protected priorOptions: LookupTableTextureOptions | undefined = undefined; + + constructor(public gl: GL | null) { + super(gl); + } + optionsEqual(newOptions: LookupTableTextureOptions) { + const existingOptions = this.priorOptions; + if (existingOptions === undefined) return false; + let lookupTableEqual = true; + if ( + existingOptions.lookupTable !== undefined && + newOptions.lookupTable !== undefined + ) { + lookupTableEqual = LookupTable.equal( + existingOptions.lookupTable, + newOptions.lookupTable, + ); + } + const textureUnitEqual = + existingOptions.textureUnit === newOptions.textureUnit; + + return lookupTableEqual && textureUnitEqual; + } + createLookupTable(options: LookupTableTextureOptions): LookupTable { + return options.lookupTable; + } +} + +export class ControlPointTexture extends BaseTexture { + protected priorOptions: ControlPointTextureOptions | undefined; + constructor(public gl: GL | null) { + super(gl); + } + optionsEqual(newOptions: ControlPointTextureOptions): boolean { + const existingOptions = this.priorOptions; + if (existingOptions === undefined) return false; + let controlPointsEqual = true; + if ( + existingOptions.controlPoints !== undefined && + newOptions.controlPoints !== undefined + ) { + controlPointsEqual = arraysEqualWithPredicate( + existingOptions.controlPoints, + newOptions.controlPoints, + (a, b) => + a.inputValue === b.inputValue && + arraysEqual(a.outputColor, b.outputColor), + ); + } + const textureUnitEqual = + existingOptions.textureUnit === newOptions.textureUnit; + // TODO (skm) how to handle uint64? + // const inputRangeEqual = arraysEqual( + // existingOptions.inputRange, + // newOptions.inputRange, + // ); + + return controlPointsEqual && textureUnitEqual; + } + createLookupTable(options: ControlPointTextureOptions): LookupTable { + // TODO (SKM) - need variable size? + const lookupTable = new LookupTable(this.width * this.height); + lookupTable.updateFromControlPoints( + new SortedControlPoints(options.controlPoints, options.inputRange), + ); + return lookupTable; + } +} + /** * Display the UI canvas for the transfer function widget and * handle shader updates for elements of the canvas */ class TransferFunctionPanel extends IndirectRenderedPanel { - texture: LookupTableTexture; + texture: DirectLookupTableTexture; private textureVertexBuffer: Buffer; private textureVertexBufferArray: Float32Array; private controlPointsVertexBuffer: Buffer; @@ -563,7 +581,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { this.textureVertexBufferArray = createGriddedRectangleArray( TRANSFER_FUNCTION_PANEL_SIZE, ); - this.texture = this.registerDisposer(new LookupTableTexture(gl)); + this.texture = this.registerDisposer(new DirectLookupTableTexture(gl)); function createBuffer(dataArray: Float32Array) { return getMemoizedBuffer( @@ -807,7 +825,6 @@ out_color = tempColor * alpha; this.texture.updateAndActivate({ lookupTable: this.transferFunction.lookupTable, textureUnit, - inputRange: this.transferFunction.trackable.value.range, }); drawQuads(this.gl, TRANSFER_FUNCTION_PANEL_SIZE, 1); gl.disableVertexAttribArray(aVertexPosition); @@ -1043,7 +1060,8 @@ class TransferFunctionController extends RefCounted { ); return { ...this.getModel(), - sortedControlPoints: this.transferFunction.trackable.value.sortedControlPoints, + sortedControlPoints: + this.transferFunction.trackable.value.sortedControlPoints, }; } return undefined; @@ -1059,18 +1077,16 @@ class TransferFunctionController extends RefCounted { normalizedY > 1 ) return undefined; - + // Near the borders of the transfer function, clamp the control point to the border if (normalizedX < TRANSFER_FUNCTION_BORDER_WIDTH) { normalizedX = 0.0; - } - else if (normalizedX > 1 - TRANSFER_FUNCTION_BORDER_WIDTH) { + } else if (normalizedX > 1 - TRANSFER_FUNCTION_BORDER_WIDTH) { normalizedX = 1.0; } if (normalizedY < TRANSFER_FUNCTION_BORDER_WIDTH) { normalizedY = 0.0; - } - else if (normalizedY > 1 - TRANSFER_FUNCTION_BORDER_WIDTH) { + } else if (normalizedY > 1 - TRANSFER_FUNCTION_BORDER_WIDTH) { normalizedY = 1.0; } @@ -1292,13 +1308,14 @@ export function enableTransferFunctionShader( if (texture === undefined) { shader.transferFunctionTextures.set( `TransferFunction.${name}`, - new LookupTableTexture(gl), + new ControlPointTexture(gl), ); } // TODO (SKM) probably need to handle the sorted nature shader.bindAndUpdateTransferFunctionTexture( `TransferFunction.${name}`, controlPoints.controlPoints, + interval ); // Bind the length of the lookup table to the shader as a uniform From e661c3573292dd0039794f924745c6d88f684b3b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 21 Mar 2024 17:10:24 +0100 Subject: [PATCH 27/60] fix(tests): remove unused imports --- src/widget/transfer_function.browser_test.ts | 26 +------------------- src/widget/transfer_function.spec.ts | 7 +----- 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index b74fbb025..6a2853190 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -16,7 +16,7 @@ import { describe, it, expect } from "vitest"; import { DataType } from "#src/util/data_type.js"; -import { vec3, vec4 } from "#src/util/geom.js"; +import { 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"; @@ -24,36 +24,12 @@ import { fragmentShaderTest } from "#src/webgl/shader_testing.js"; import { SortedControlPoints, ControlPoint, - LookupTable, - TransferFunction, - TransferFunctionParameters, - NUM_COLOR_CHANNELS, defineTransferFunctionShader, enableTransferFunctionShader, } from "#src/widget/transfer_function.js"; -import { TrackableValue } from "#src/trackable_value.js"; const TRANSFER_FUNCTION_LENGTH = 512; -function makeTransferFunction(controlPoints: ControlPoint[]) { - const range = defaultDataTypeRange[DataType.UINT8]; - const sortedControlPoints = new SortedControlPoints(controlPoints, range); - return new TransferFunction( - DataType.UINT8, - new TrackableValue( - { - sortedControlPoints, - range: range, - window: range, - defaultColor: vec3.fromValues(0, 0, 0), - channel: [], - size: TRANSFER_FUNCTION_LENGTH, - }, - (x) => x, - ), - ); -} - describe("compute transfer function on GPU", () => { const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; const controlPoints = new SortedControlPoints( diff --git a/src/widget/transfer_function.spec.ts b/src/widget/transfer_function.spec.ts index 787e62b1f..bf414c4d7 100644 --- a/src/widget/transfer_function.spec.ts +++ b/src/widget/transfer_function.spec.ts @@ -18,9 +18,6 @@ import { describe, it, expect } from "vitest"; 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 { SortedControlPoints, ControlPoint, @@ -28,8 +25,6 @@ import { TransferFunction, TransferFunctionParameters, NUM_COLOR_CHANNELS, - defineTransferFunctionShader, - enableTransferFunctionShader, } from "#src/widget/transfer_function.js"; import { TrackableValue } from "#src/trackable_value.js"; @@ -164,4 +159,4 @@ describe("lerpBetweenControlPoints", () => { } } }); -}); \ No newline at end of file +}); From c40483ceb5cdcb7ecc1eeba09e2f3239735091ea Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 21 Mar 2024 17:13:50 +0100 Subject: [PATCH 28/60] tests(fix): browser test whole TF --- src/webgl/shader.ts | 2 +- src/webgl/shader_ui_controls.ts | 7 +- src/widget/transfer_function.browser_test.ts | 144 ++++++++++++++++++- src/widget/transfer_function.spec.ts | 5 +- src/widget/transfer_function.ts | 10 +- 5 files changed, 155 insertions(+), 13 deletions(-) diff --git a/src/webgl/shader.ts b/src/webgl/shader.ts index 24f99d388..5b20b4987 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -20,7 +20,7 @@ import type { ControlPoint, ControlPointTexture, } from "#src/widget/transfer_function.js"; -import { DataTypeInterval } from "src/util/lerp"; +import type { DataTypeInterval } from "src/util/lerp"; const DEBUG_SHADER = false; diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 7e58bbeb8..b929a46d2 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -34,7 +34,7 @@ import { } from "#src/util/color.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; -import { kOneVec, kZeroVec4, vec3, vec4 } from "#src/util/geom.js"; +import {kZeroVec4, vec3, vec4 } from "#src/util/geom.js"; import { parseArray, parseFixedLengthArray, @@ -67,10 +67,11 @@ 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, - TransferFunctionParameters, floatToUint8, SortedControlPoints, ControlPoint, @@ -1784,7 +1785,7 @@ function setControlInShader( control.dataType, value.sortedControlPoints, value.range, - TRANSFER_FUNCTION_LENGTH + TRANSFER_FUNCTION_LENGTH, ); } } diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index 6a2853190..55b85e315 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -15,21 +15,159 @@ */ import { describe, it, expect } from "vitest"; +import { TrackableValue } from "#src/trackable_value.js"; import { DataType } from "#src/util/data_type.js"; -import { vec4 } 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 { 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"; + enableTransferFunctionShader} from "#src/widget/transfer_function.js"; const TRANSFER_FUNCTION_LENGTH = 512; +function makeTransferFunction(controlPoints: ControlPoint[]) { + const range = defaultDataTypeRange[DataType.UINT8]; + const sortedControlPoints = new SortedControlPoints(controlPoints, range); + return new TransferFunction( + DataType.UINT8, + new TrackableValue( + { + sortedControlPoints, + range: range, + window: range, + defaultColor: vec3.fromValues(0, 0, 0), + channel: [], + size: TRANSFER_FUNCTION_LENGTH, + }, + (x) => x, + ), + ); +} + +describe("lerpBetweenControlPoints", () => { + const range = defaultDataTypeRange[DataType.UINT8]; + const output = new Uint8Array(NUM_COLOR_CHANNELS * TRANSFER_FUNCTION_LENGTH); + it("returns transparent black when given no control points for raw classes", () => { + const controlPoints: ControlPoint[] = []; + const sortedControlPoints = new SortedControlPoints(controlPoints, range); + const lookupTable = new LookupTable(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.toLookupTableIndex(0)!; + + 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", () => { + 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 = transferFunction.toLookupTableIndex(0)!; + const secondPointTransferIndex = transferFunction.toLookupTableIndex(1)!; + const thirdPointTransferIndex = transferFunction.toLookupTableIndex(2)!; + + expect( + output + .slice(0, NUM_COLOR_CHANNELS * firstPointTransferIndex) + .every((value) => value === 0), + ).toBeTruthy(); + expect( + output + .slice(NUM_COLOR_CHANNELS * thirdPointTransferIndex) + .every((value) => value === 255), + ).toBeTruthy(); + + const firstColor = controlPoints[0].outputColor; + const secondColor = controlPoints[1].outputColor; + for ( + let i = firstPointTransferIndex * NUM_COLOR_CHANNELS; + i < secondPointTransferIndex * NUM_COLOR_CHANNELS; + i++ + ) { + const difference = Math.floor((i - 120 * NUM_COLOR_CHANNELS) / 4); + const expectedValue = + firstColor[i % NUM_COLOR_CHANNELS] + + ((secondColor[i % NUM_COLOR_CHANNELS] - + firstColor[i % NUM_COLOR_CHANNELS]) * + difference) / + 20; + const decimalPart = expectedValue - Math.floor(expectedValue); + // If the decimal part is 0.5, it could be rounded up or down depending on precision. + 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 thirdColor = controlPoints[2].outputColor; + for ( + let i = secondPointTransferIndex * NUM_COLOR_CHANNELS; + i < thirdPointTransferIndex * NUM_COLOR_CHANNELS; + i++ + ) { + const difference = Math.floor((i - 140 * NUM_COLOR_CHANNELS) / 4); + const expectedValue = + secondColor[i % NUM_COLOR_CHANNELS] + + ((thirdColor[i % NUM_COLOR_CHANNELS] - + secondColor[i % NUM_COLOR_CHANNELS]) * + difference) / + 60; + const decimalPart = expectedValue - Math.floor(expectedValue); + // If the decimal part is 0.5, it could be rounded up or down depending on precision. + 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)); + } + } + }); +}); + describe("compute transfer function on GPU", () => { const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; const controlPoints = new SortedControlPoints( diff --git a/src/widget/transfer_function.spec.ts b/src/widget/transfer_function.spec.ts index bf414c4d7..aff016308 100644 --- a/src/widget/transfer_function.spec.ts +++ b/src/widget/transfer_function.spec.ts @@ -15,18 +15,19 @@ */ 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 type { + TransferFunctionParameters} from "#src/widget/transfer_function.js"; import { SortedControlPoints, ControlPoint, LookupTable, TransferFunction, - TransferFunctionParameters, NUM_COLOR_CHANNELS, } from "#src/widget/transfer_function.js"; -import { TrackableValue } from "#src/trackable_value.js"; const TRANSFER_FUNCTION_LENGTH = 512; diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 26098a0d8..790fe6334 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -44,7 +44,7 @@ import { } from "#src/util/lerp.js"; import { MouseEventBinder } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; -import { Uint64 } from "#src/util/uint64.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"; @@ -411,7 +411,9 @@ abstract class BaseTexture extends RefCounted { abstract createLookupTable( options: LookupTableTextureOptions | ControlPointTextureOptions, ): LookupTable; - updateAndActivate(options: LookupTableTextureOptions | ControlPointTextureOptions) { + updateAndActivate( + options: LookupTableTextureOptions | ControlPointTextureOptions, + ) { const { gl } = this; if (gl === null) return; let { texture } = this; @@ -438,7 +440,7 @@ abstract class BaseTexture extends RefCounted { // Update the texture activateAndBindTexture(gl, options.textureUnit); setRawTextureParameters(gl); - let lookupTable = this.createLookupTable(options); + const lookupTable = this.createLookupTable(options); gl.texImage2D( WebGL2RenderingContext.TEXTURE_2D, @@ -1315,7 +1317,7 @@ export function enableTransferFunctionShader( shader.bindAndUpdateTransferFunctionTexture( `TransferFunction.${name}`, controlPoints.controlPoints, - interval + interval, ); // Bind the length of the lookup table to the shader as a uniform From 5de6aa3c7192584073faae8e58c0ec2c5e9e6a18 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 9 Apr 2024 13:32:41 +0200 Subject: [PATCH 29/60] fix: transfer function correct interpolation --- src/widget/transfer_function.browser_test.ts | 62 ++++--- src/widget/transfer_function.spec.ts | 163 ------------------- src/widget/transfer_function.ts | 22 +-- 3 files changed, 49 insertions(+), 198 deletions(-) delete mode 100644 src/widget/transfer_function.spec.ts diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index 55b85e315..f157f5818 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -23,17 +23,16 @@ 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 type { TransferFunctionParameters } from "#src/widget/transfer_function.js"; import { SortedControlPoints, ControlPoint, LookupTable, TransferFunction, NUM_COLOR_CHANNELS, - defineTransferFunctionShader, - enableTransferFunctionShader} from "#src/widget/transfer_function.js"; + enableTransferFunctionShader, +} from "#src/widget/transfer_function.js"; const TRANSFER_FUNCTION_LENGTH = 512; @@ -57,10 +56,10 @@ function makeTransferFunction(controlPoints: ControlPoint[]) { } describe("lerpBetweenControlPoints", () => { - const range = defaultDataTypeRange[DataType.UINT8]; const output = new Uint8Array(NUM_COLOR_CHANNELS * TRANSFER_FUNCTION_LENGTH); - it("returns transparent black when given no control points for raw classes", () => { + it("returns transparent black when given no control points for base classes", () => { const controlPoints: ControlPoint[] = []; + const range = defaultDataTypeRange[DataType.UINT8]; const sortedControlPoints = new SortedControlPoints(controlPoints, range); const lookupTable = new LookupTable(TRANSFER_FUNCTION_LENGTH); lookupTable.updateFromControlPoints(sortedControlPoints); @@ -105,34 +104,44 @@ describe("lerpBetweenControlPoints", () => { const firstPointTransferIndex = transferFunction.toLookupTableIndex(0)!; const secondPointTransferIndex = transferFunction.toLookupTableIndex(1)!; const thirdPointTransferIndex = transferFunction.toLookupTableIndex(2)!; + expect(firstPointTransferIndex).toBe(Math.floor((120 / 255) * 511)); + expect(secondPointTransferIndex).toBe(Math.floor((140 / 255) * 511)); + expect(thirdPointTransferIndex).toBe(Math.floor((200 / 255) * 511)); + // 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(); - const firstColor = controlPoints[0].outputColor; - const secondColor = controlPoints[1].outputColor; + // 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 difference = Math.floor((i - 120 * NUM_COLOR_CHANNELS) / 4); - const expectedValue = - firstColor[i % NUM_COLOR_CHANNELS] + - ((secondColor[i % NUM_COLOR_CHANNELS] - - firstColor[i % NUM_COLOR_CHANNELS]) * - difference) / - 20; - const decimalPart = expectedValue - Math.floor(expectedValue); + 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], @@ -142,21 +151,26 @@ describe("lerpBetweenControlPoints", () => { } } - const thirdColor = controlPoints[2].outputColor; + // 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 difference = Math.floor((i - 140 * NUM_COLOR_CHANNELS) / 4); + 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] + - ((thirdColor[i % NUM_COLOR_CHANNELS] - - secondColor[i % NUM_COLOR_CHANNELS]) * - difference) / - 60; - const decimalPart = expectedValue - Math.floor(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], diff --git a/src/widget/transfer_function.spec.ts b/src/widget/transfer_function.spec.ts deleted file mode 100644 index aff016308..000000000 --- a/src/widget/transfer_function.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * @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 type { - TransferFunctionParameters} from "#src/widget/transfer_function.js"; -import { - SortedControlPoints, - ControlPoint, - LookupTable, - TransferFunction, - NUM_COLOR_CHANNELS, -} from "#src/widget/transfer_function.js"; - -const TRANSFER_FUNCTION_LENGTH = 512; - -function makeTransferFunction(controlPoints: ControlPoint[]) { - const range = defaultDataTypeRange[DataType.UINT8]; - const sortedControlPoints = new SortedControlPoints(controlPoints, range); - return new TransferFunction( - DataType.UINT8, - new TrackableValue( - { - sortedControlPoints, - range: range, - window: range, - defaultColor: vec3.fromValues(0, 0, 0), - channel: [], - size: TRANSFER_FUNCTION_LENGTH, - }, - (x) => x, - ), - ); -} - -describe("lerpBetweenControlPoints", () => { - const range = defaultDataTypeRange[DataType.UINT8]; - const output = new Uint8Array(NUM_COLOR_CHANNELS * TRANSFER_FUNCTION_LENGTH); - it("returns transparent black when given no control points for raw classes", () => { - const controlPoints: ControlPoint[] = []; - const sortedControlPoints = new SortedControlPoints(controlPoints, range); - const lookupTable = new LookupTable(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.toLookupTableIndex(0)!; - - 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", () => { - 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 = transferFunction.toLookupTableIndex(0)!; - const secondPointTransferIndex = transferFunction.toLookupTableIndex(1)!; - const thirdPointTransferIndex = transferFunction.toLookupTableIndex(2)!; - - expect( - output - .slice(0, NUM_COLOR_CHANNELS * firstPointTransferIndex) - .every((value) => value === 0), - ).toBeTruthy(); - expect( - output - .slice(NUM_COLOR_CHANNELS * thirdPointTransferIndex) - .every((value) => value === 255), - ).toBeTruthy(); - - const firstColor = controlPoints[0].outputColor; - const secondColor = controlPoints[1].outputColor; - for ( - let i = firstPointTransferIndex * NUM_COLOR_CHANNELS; - i < secondPointTransferIndex * NUM_COLOR_CHANNELS; - i++ - ) { - const difference = Math.floor((i - 120 * NUM_COLOR_CHANNELS) / 4); - const expectedValue = - firstColor[i % NUM_COLOR_CHANNELS] + - ((secondColor[i % NUM_COLOR_CHANNELS] - - firstColor[i % NUM_COLOR_CHANNELS]) * - difference) / - 20; - const decimalPart = expectedValue - Math.floor(expectedValue); - // If the decimal part is 0.5, it could be rounded up or down depending on precision. - 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 thirdColor = controlPoints[2].outputColor; - for ( - let i = secondPointTransferIndex * NUM_COLOR_CHANNELS; - i < thirdPointTransferIndex * NUM_COLOR_CHANNELS; - i++ - ) { - const difference = Math.floor((i - 140 * NUM_COLOR_CHANNELS) / 4); - const expectedValue = - secondColor[i % NUM_COLOR_CHANNELS] + - ((thirdColor[i % NUM_COLOR_CHANNELS] - - secondColor[i % NUM_COLOR_CHANNELS]) * - difference) / - 60; - const decimalPart = expectedValue - Math.floor(expectedValue); - // If the decimal part is 0.5, it could be rounded up or down depending on precision. - 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)); - } - } - }); -}); diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 790fe6334..7d54753a4 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -166,7 +166,7 @@ export class ControlPoint { ); } interpolateColor(other: ControlPoint, t: number): vec4 { - const outputColor = new vec4(); + const outputColor = vec4.create(); for (let i = 0; i < 4; ++i) { outputColor[i] = computeLerp( [this.outputColor[i], other.outputColor[i]], @@ -218,7 +218,7 @@ export class SortedControlPoints { return this.findNearestControlPointIndex(value); } updatePointColor(index: number, color: vec4 | vec3) { - let outputColor = new vec4(); + let outputColor = vec4.create(); if (outputColor.length === 3) { outputColor = vec4.fromValues( color[0], @@ -281,6 +281,9 @@ export class LookupTable { 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); } @@ -306,21 +309,18 @@ export class LookupTable { let controlPointIndex = 0; for (let i = firstInputValue; i < size; ++i) { const currentPoint = controlPoints[controlPointIndex]; - const nextPoint = - controlPoints[ - Math.min(controlPointIndex + 1, controlPoints.length - 1) - ]; const lookupIndex = i * NUM_COLOR_CHANNELS; - if (currentPoint === nextPoint) { + if (controlPointIndex === controlPoints.length - 1) { addLookupValue(lookupIndex, currentPoint.outputColor); } else { - const currentInputValue = toTransferFunctionSpace(currentPoint); - const nextInputValue = toTransferFunctionSpace(nextPoint); + const nextPoint = controlPoints[controlPointIndex + 1]; + const currentPointIndex = toTransferFunctionSpace(currentPoint); + const nextPointIndex = toTransferFunctionSpace(nextPoint); const t = - (i - currentInputValue) / (nextInputValue - currentInputValue); + (i - currentPointIndex) / (nextPointIndex - currentPointIndex); const lerpedColor = currentPoint.interpolateColor(nextPoint, t); addLookupValue(lookupIndex, lerpedColor); - if (i === nextPoint.inputValue) { + if (i >= nextPointIndex) { controlPointIndex++; } } From a716b929e19e08251147f15ed294969e8bb3769a Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 9 Apr 2024 16:25:15 +0200 Subject: [PATCH 30/60] fix: dynamic transfer function size and correct GPU control --- src/webgl/shader.ts | 15 ++- src/widget/transfer_function.browser_test.ts | 116 ++++++++++++------ src/widget/transfer_function.ts | 121 +++++++++++-------- 3 files changed, 157 insertions(+), 95 deletions(-) diff --git a/src/webgl/shader.ts b/src/webgl/shader.ts index 5b20b4987..1bb8b5c47 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -14,11 +14,12 @@ * limitations under the License. */ +import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; import type { GL } from "#src/webgl/context.js"; import type { - ControlPoint, ControlPointTexture, + SortedControlPoints, } from "#src/widget/transfer_function.js"; import type { DataTypeInterval } from "src/util/lerp"; @@ -257,8 +258,10 @@ export class ShaderProgram extends RefCounted { bindAndUpdateTransferFunctionTexture( symbol: symbol | string, - controlPoints: ControlPoint[], + sortedControlPoints: SortedControlPoints, inputRange: DataTypeInterval, + dataType: DataType, + lookupTableSize: number, ) { const textureUnit = this.textureUnits.get(symbol); if (textureUnit === undefined) { @@ -271,7 +274,13 @@ export class ShaderProgram extends RefCounted { ); } // TODO (SKM) - how to correctly get the input range? - texture.updateAndActivate({ textureUnit, controlPoints, inputRange }); + return texture.updateAndActivate({ + textureUnit, + sortedControlPoints, + inputRange, + dataType, + lookupTableSize, + }); } unbindTransferFunctionTextures() { diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index f157f5818..507b76dc0 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -34,7 +34,7 @@ import { enableTransferFunctionShader, } from "#src/widget/transfer_function.js"; -const TRANSFER_FUNCTION_LENGTH = 512; +const FIXED_TRANSFER_FUNCTION_LENGTH = 1024; function makeTransferFunction(controlPoints: ControlPoint[]) { const range = defaultDataTypeRange[DataType.UINT8]; @@ -48,7 +48,7 @@ function makeTransferFunction(controlPoints: ControlPoint[]) { window: range, defaultColor: vec3.fromValues(0, 0, 0), channel: [], - size: TRANSFER_FUNCTION_LENGTH, + size: FIXED_TRANSFER_FUNCTION_LENGTH, }, (x) => x, ), @@ -56,12 +56,14 @@ function makeTransferFunction(controlPoints: ControlPoint[]) { } describe("lerpBetweenControlPoints", () => { - const output = new Uint8Array(NUM_COLOR_CHANNELS * TRANSFER_FUNCTION_LENGTH); + 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 range = defaultDataTypeRange[DataType.UINT8]; const sortedControlPoints = new SortedControlPoints(controlPoints, range); - const lookupTable = new LookupTable(TRANSFER_FUNCTION_LENGTH); + const lookupTable = new LookupTable(FIXED_TRANSFER_FUNCTION_LENGTH); lookupTable.updateFromControlPoints(sortedControlPoints); expect(output.every((value) => value === 0)).toBeTruthy(); @@ -104,9 +106,15 @@ describe("lerpBetweenControlPoints", () => { const firstPointTransferIndex = transferFunction.toLookupTableIndex(0)!; const secondPointTransferIndex = transferFunction.toLookupTableIndex(1)!; const thirdPointTransferIndex = transferFunction.toLookupTableIndex(2)!; - expect(firstPointTransferIndex).toBe(Math.floor((120 / 255) * 511)); - expect(secondPointTransferIndex).toBe(Math.floor((140 / 255) * 511)); - expect(thirdPointTransferIndex).toBe(Math.floor((200 / 255) * 511)); + expect(firstPointTransferIndex).toBe( + Math.floor((120 / 255) * (FIXED_TRANSFER_FUNCTION_LENGTH - 1)), + ); + expect(secondPointTransferIndex).toBe( + Math.floor((140 / 255) * (FIXED_TRANSFER_FUNCTION_LENGTH - 1)), + ); + expect(thirdPointTransferIndex).toBe( + Math.floor((200 / 255) * (FIXED_TRANSFER_FUNCTION_LENGTH - 1)), + ); // Transparent black up to the first control point expect( @@ -182,25 +190,39 @@ describe("lerpBetweenControlPoints", () => { }); }); +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", () => { - const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; - const controlPoints = new SortedControlPoints( - [ - new ControlPoint(0, vec4.fromValues(0, 0, 0, 0)), - new ControlPoint( - maxTransferFunctionPoints, - vec4.fromValues(255, 255, 255, 255), - ), - ], - defaultDataTypeRange[DataType.UINT8], - ); for (const dataType of Object.values(DataType)) { if (typeof dataType === "string") continue; - it(`computes transfer function on GPU for ${DataType[dataType]}`, () => { + 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)), + ], + range, + ); + 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" }, + { + val1: "float", + val2: "float", + val3: "float", + val4: "float", + val5: "float", + }, (tester) => { const { builder } = tester; builder.addFragmentCode(` @@ -221,6 +243,7 @@ val1 = result.r; val2 = result.g; val3 = result.b; val4 = result.a; +val5 = uTransferFunctionEnd_doTransferFunction; `); const { shader } = tester; const testShader = (point: any) => { @@ -230,37 +253,50 @@ val4 = result.a; dataType, controlPoints, defaultDataTypeRange[dataType], - TRANSFER_FUNCTION_LENGTH, + textureSizes[dataType], ); tester.execute({ inputValue: point }); const values = tester.values; - return vec4.fromValues( - values.val1, - values.val2, - values.val3, - values.val4, - ); + 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]; - let color = testShader(minValue); - expect(color).toEqual(vec4.fromValues(0, 0, 0, 0)); - color = testShader(maxValue); - expect(color).toEqual(vec4.fromValues(1, 1, 1, 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; - color = testShader((maxValueNumber + minValueNumber) / 2); - for (let i = 0; i < 3; i++) { - expect(color[i]).toBeCloseTo(0.5); - } + position = (maxValueNumber + minValueNumber) / 2; } else { const value = (maxValue as Uint64).toNumber() / 2; - const position = Uint64.fromNumber(value); - color = testShader(position); - for (let i = 0; i < 3; i++) { - expect(color[i]).toBeCloseTo(0.5); - } + 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.ts b/src/widget/transfer_function.ts index 7d54753a4..458ab6187 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -40,6 +40,7 @@ import type { DataTypeInterval } from "#src/util/lerp.js"; import { computeInvlerp, computeLerp, + dataTypeIntervalEqual, parseDataTypeValue, } from "#src/util/lerp.js"; import { MouseEventBinder } from "#src/util/mouse_bindings.js"; @@ -107,29 +108,33 @@ export interface LookupTableTextureOptions { textureUnit: number | undefined; } -// TODO (skm) - window currently doesn't work. Need to update round bound inputs -export interface TransferFunctionParameters { - sortedControlPoints: SortedControlPoints; - range: DataTypeInterval; - window: DataTypeInterval; - size: number; - channel: number[]; - defaultColor: vec3; -} - /** * Options to update a transfer function texture * TODO should this be sorted control points? */ export interface ControlPointTextureOptions { /** controlPoints will be used to generate a lookup table as a first step */ - controlPoints: ControlPoint[]; + sortedControlPoints: SortedControlPoints; /** textureUnit to update with the new transfer function texture data */ textureUnit: number | undefined; /** range of the input space I, where T: I -> O. Allows for more precision in the * transfer function texture generation due to texture size limitations */ inputRange: DataTypeInterval; + /** Data type of the control points */ + dataType: DataType; + /** Lookup table number of elements*/ + lookupTableSize: number; +} + +// TODO (skm) - window currently doesn't work. Need to update round bound inputs +export interface TransferFunctionParameters { + sortedControlPoints: SortedControlPoints; + range: DataTypeInterval; + window: DataTypeInterval; + size: number; + channel: number[]; + defaultColor: vec3; } interface CanvasPosition { @@ -248,6 +253,10 @@ export class SortedControlPoints { (a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range), ); } + updateRange(newRange: DataTypeInterval) { + this.range = newRange; + this.sort(); + } } export class LookupTable { @@ -391,7 +400,7 @@ export class TransferFunction extends RefCounted { } } -abstract class BaseTexture extends RefCounted { +abstract class BaseLookupTexture extends RefCounted { texture: WebGLTexture | null = null; width: number; height = 1; @@ -431,7 +440,7 @@ abstract class BaseTexture extends RefCounted { // If the texture is already up to date, just bind and activate it if (texture !== null && this.optionsEqual(options)) { activateAndBindTexture(gl, options.textureUnit); - return; + return this.width * this.height; } // If the texture has not been created yet, create it if (texture === null) { @@ -447,7 +456,7 @@ abstract class BaseTexture extends RefCounted { 0, WebGL2RenderingContext.RGBA, this.width, - 1, + this.height, 0, WebGL2RenderingContext.RGBA, WebGL2RenderingContext.UNSIGNED_BYTE, @@ -458,6 +467,8 @@ abstract class BaseTexture extends RefCounted { // Make a copy of the options for the purpose of comparison // TODO(skm) is this copy needed? this.priorOptions = { ...options }; + + return this.width * this.height; } disposed() { @@ -472,10 +483,8 @@ abstract class BaseTexture extends RefCounted { * Represent the underlying transfer function lookup table as a texture * TODO(skm) consider if height can be used for more efficiency */ -class DirectLookupTableTexture extends BaseTexture { +class DirectLookupTableTexture extends BaseLookupTexture { texture: WebGLTexture | null = null; - width: number; - height = 1; protected priorOptions: LookupTableTextureOptions | undefined = undefined; constructor(public gl: GL | null) { @@ -504,7 +513,7 @@ class DirectLookupTableTexture extends BaseTexture { } } -export class ControlPointTexture extends BaseTexture { +export class ControlPointTexture extends BaseLookupTexture { protected priorOptions: ControlPointTextureOptions | undefined; constructor(public gl: GL | null) { super(gl); @@ -512,37 +521,45 @@ export class ControlPointTexture extends BaseTexture { optionsEqual(newOptions: ControlPointTextureOptions): boolean { const existingOptions = this.priorOptions; if (existingOptions === undefined) return false; - let controlPointsEqual = true; - if ( - existingOptions.controlPoints !== undefined && - newOptions.controlPoints !== undefined - ) { - controlPointsEqual = arraysEqualWithPredicate( - existingOptions.controlPoints, - newOptions.controlPoints, - (a, b) => - a.inputValue === b.inputValue && - arraysEqual(a.outputColor, b.outputColor), - ); - } + 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; - // TODO (skm) how to handle uint64? - // const inputRangeEqual = arraysEqual( - // existingOptions.inputRange, - // newOptions.inputRange, - // ); - - return controlPointsEqual && textureUnitEqual; + const dataTypeEqual = existingOptions.dataType === newOptions.dataType; + const inputRangeEqual = dataTypeIntervalEqual( + newOptions.dataType, + newOptions.inputRange, + existingOptions.inputRange, + ); + return ( + controlPointsEqual && textureUnitEqual && dataTypeEqual && inputRangeEqual + ); } createLookupTable(options: ControlPointTextureOptions): LookupTable { - // TODO (SKM) - need variable size? - const lookupTable = new LookupTable(this.width * this.height); - lookupTable.updateFromControlPoints( - new SortedControlPoints(options.controlPoints, options.inputRange), - ); + 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; + sortedControlPoints.updateRange(options.inputRange); + lookupTable.updateFromControlPoints(sortedControlPoints); return lookupTable; } + setTextureWidthAndHeightFromSize(size: number) { + this.width = size; + } + 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; + } } /** @@ -1297,12 +1314,11 @@ export function enableTransferFunctionShader( shader: ShaderProgram, name: string, dataType: DataType, - controlPoints: SortedControlPoints, + sortedControlPoints: SortedControlPoints, interval: DataTypeInterval, lookupTableSize: number, ) { const { gl } = shader; - const texture = shader.transferFunctionTextures.get( `TransferFunction.${name}`, ); @@ -1313,18 +1329,19 @@ export function enableTransferFunctionShader( new ControlPointTexture(gl), ); } - // TODO (SKM) probably need to handle the sorted nature - shader.bindAndUpdateTransferFunctionTexture( + const textureSize = shader.bindAndUpdateTransferFunctionTexture( `TransferFunction.${name}`, - controlPoints.controlPoints, + sortedControlPoints, interval, + 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}`), - lookupTableSize - 1, - ); + gl.uniform1f(shader.uniform(`uTransferFunctionEnd_${name}`), textureSize - 1); // Use the lerp shader function to grab an index into the lookup table enableLerpShaderFunction(shader, name, dataType, interval); From ab43cc6b19e12c1084aed75f36b2006ecd1e1810 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 10 Apr 2024 15:06:08 +0200 Subject: [PATCH 31/60] feat: remove range and size as TF params Instead they are computed based on input --- src/widget/transfer_function.browser_test.ts | 10 +-- src/widget/transfer_function.ts | 68 ++++++++++++++------ 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index 507b76dc0..dcfbe3a7b 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -44,11 +44,9 @@ function makeTransferFunction(controlPoints: ControlPoint[]) { new TrackableValue( { sortedControlPoints, - range: range, window: range, defaultColor: vec3.fromValues(0, 0, 0), channel: [], - size: FIXED_TRANSFER_FUNCTION_LENGTH, }, (x) => x, ), @@ -106,14 +104,16 @@ describe("lerpBetweenControlPoints", () => { const firstPointTransferIndex = transferFunction.toLookupTableIndex(0)!; const secondPointTransferIndex = transferFunction.toLookupTableIndex(1)!; const thirdPointTransferIndex = transferFunction.toLookupTableIndex(2)!; + const size = transferFunction.size; + const range = transferFunction.range as [number, number]; expect(firstPointTransferIndex).toBe( - Math.floor((120 / 255) * (FIXED_TRANSFER_FUNCTION_LENGTH - 1)), + Math.floor(((120 - range[0]) / (range[1] - range[0])) * (size - 1)), ); expect(secondPointTransferIndex).toBe( - Math.floor((140 / 255) * (FIXED_TRANSFER_FUNCTION_LENGTH - 1)), + Math.floor(((140 - range[0]) / (range[1] - range[0])) * (size - 1)), ); expect(thirdPointTransferIndex).toBe( - Math.floor((200 / 255) * (FIXED_TRANSFER_FUNCTION_LENGTH - 1)), + Math.floor(((200 - range[0]) / (range[1] - range[0])) * (size - 1)), ); // Transparent black up to the first control point diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 458ab6187..3210d6a3b 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -89,6 +89,18 @@ const transferFunctionSamplerTextureUnit = Symbol( "transferFunctionSamplerTexture", ); +// TODO consider increasing these if textures are packed +const defaultTransferFunctionSizes: Record = { + [DataType.UINT8]: 0xff, + [DataType.INT8]: 0xff, + [DataType.UINT16]: 8192, + [DataType.INT16]: 8192, + [DataType.UINT32]: 8192, + [DataType.INT32]: 8192, + [DataType.UINT64]: 8192, + [DataType.FLOAT32]: 8192, +}; + /** * Convert a [0, 1] float to a uint8 value between 0 and 255 * TODO (SKM) belong here? Maybe utils? @@ -128,11 +140,10 @@ export interface ControlPointTextureOptions { } // TODO (skm) - window currently doesn't work. Need to update round bound inputs +// TODO (skm) - these params seem a little odd, maybe the some can be computed FROM the trackable instead export interface TransferFunctionParameters { sortedControlPoints: SortedControlPoints; - range: DataTypeInterval; window: DataTypeInterval; - size: number; channel: number[]; defaultColor: vec3; } @@ -193,10 +204,11 @@ export class SortedControlPoints { constructor( public controlPoints: ControlPoint[] = [], public range: DataTypeInterval, + private autoComputeRange: boolean = true, ) { this.controlPoints = controlPoints; this.range = range; - this.sort(); + this.sortAndComputeRange(); } get length() { return this.controlPoints.length; @@ -211,7 +223,7 @@ export class SortedControlPoints { } const newPoint = new ControlPoint(inputValue, outputColor); this.controlPoints.push(newPoint); - this.sort(); + this.sortAndComputeRange(); } removePoint(index: number) { this.controlPoints.splice(index, 1); @@ -219,7 +231,7 @@ export class SortedControlPoints { updatePoint(index: number, controlPoint: ControlPoint): number { this.controlPoints[index] = controlPoint; const value = controlPoint.inputValue; - this.sort(); + this.sortAndComputeRange(); return this.findNearestControlPointIndex(value); } updatePointColor(index: number, color: vec4 | vec3) { @@ -248,14 +260,24 @@ export class SortedControlPoints { (a, b) => a - b, ); } - sort() { + sortAndComputeRange() { this.controlPoints.sort( (a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range), ); + // TODO (skm) - are the negatives ok here? + if (this.autoComputeRange) { + if (this.controlPoints.length < 2) { + return; + } + this.range = [ + this.controlPoints[0].inputValue, + this.controlPoints[this.controlPoints.length - 1].inputValue, + ] as DataTypeInterval; + } } updateRange(newRange: DataTypeInterval) { this.range = newRange; - this.sort(); + this.sortAndComputeRange(); } } @@ -351,7 +373,7 @@ export class TransferFunction extends RefCounted { public trackable: WatchableValueInterface, ) { super(); - this.lookupTable = new LookupTable(this.trackable.value.size); + this.lookupTable = new LookupTable(defaultTransferFunctionSizes[dataType]); this.sortedControlPoints = this.trackable.value.sortedControlPoints; this.updateLookupTable(); } @@ -365,13 +387,10 @@ export class TransferFunction extends RefCounted { index = this.sortedControlPoints.controlPoints.length + controlPointIndex; } return this.sortedControlPoints.controlPoints[index]?.transferFunctionIndex( - this.trackable.value.range, - this.trackable.value.size, + this.sortedControlPoints.range, + this.lookupTable.lookupTableSize, ); } - toNormalizedInput(controlPoint: ControlPoint) { - return controlPoint.normalizedInput(this.trackable.value.range); - } updateLookupTable() { this.lookupTable.updateFromControlPoints(this.sortedControlPoints); } @@ -398,6 +417,12 @@ export class TransferFunction extends RefCounted { ); return this.sortedControlPoints.findNearestControlPointIndex(absoluteValue); } + get range() { + return this.sortedControlPoints.range; + } + get size() { + return this.lookupTable.lookupTableSize; + } } abstract class BaseLookupTexture extends RefCounted { @@ -909,6 +934,7 @@ out_color = tempColor * alpha; /** * Create the bounds on the UI range inputs for the transfer function widget + * TODO this should now be window */ function createRangeBoundInputs( dataType: DataType, @@ -938,18 +964,18 @@ function createRangeBoundInputs( updateInputBoundWidth(input); }); input.addEventListener("change", () => { - const existingBounds = model.value.range; + const existingBounds = model.value.window; const intervals = { range: existingBounds, window: existingBounds }; try { const value = parseDataTypeValue(dataType, input.value); - const range = getUpdatedRangeAndWindowParameters( + const window = getUpdatedRangeAndWindowParameters( intervals, "window", endpointIndex, value, /*fitRangeInWindow=*/ true, ).window; - model.value = { ...model.value, range }; + model.value = { ...model.value, window }; } catch { updateInputBoundValue(input, existingBounds[endpointIndex]); } @@ -1113,7 +1139,8 @@ class TransferFunctionController extends RefCounted { } grabControlPointNearCursor(mouseXPosition: number, mouseYPosition: number) { const { transferFunction, dataType } = this; - const { range, window } = transferFunction.trackable.value; + const { window } = transferFunction.trackable.value; + const range = transferFunction.sortedControlPoints.range; const nearestControlPointIndex = transferFunction.findNearestControlPointIndex(mouseXPosition, window); if (nearestControlPointIndex === -1) { @@ -1192,6 +1219,7 @@ class TransferFunctionWidget extends Tab { new TransferFunctionPanel(this), ); + // TODO window range = createRangeBoundInputs(this.dataType, this.trackable); constructor( visibility: WatchableVisibilityPriority, @@ -1243,8 +1271,8 @@ class TransferFunctionWidget extends Tab { this.updateControlPointsAndDraw(); }), ); - updateInputBoundValue(this.range.inputs[0], this.trackable.value.range[0]); - updateInputBoundValue(this.range.inputs[1], this.trackable.value.range[1]); + updateInputBoundValue(this.range.inputs[0], this.trackable.value.window[0]); + updateInputBoundValue(this.range.inputs[1], this.trackable.value.window[1]); } updateView() { this.transferFunctionPanel.scheduleRedraw(); @@ -1285,7 +1313,7 @@ vec4 ${name}_(float inputValue) { } vec4 ${name}(${shaderType} inputValue) { float v = computeInvlerp(inputValue, uLerpParams_${name}); - return ${name}_(clamp(v, 0.0, 1.0)); + return v < 0.0 ? vec4(0.0, 0.0, 0.0, 0.0) : ${name}_(clamp(v, 0.0, 1.0)); } vec4 ${name}() { return ${name}(getInterpolatedDataValue(${channel.join(",")})); From 19361236378307a9a699b14f8da9989665a16cc6 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 10 Apr 2024 16:19:04 +0200 Subject: [PATCH 32/60] fix: parse shader directive for new TF --- src/webgl/shader_ui_controls.browser_test.ts | 177 ++++++++++++------- src/webgl/shader_ui_controls.ts | 81 +++++---- src/widget/transfer_function.ts | 27 ++- 3 files changed, 173 insertions(+), 112 deletions(-) diff --git a/src/webgl/shader_ui_controls.browser_test.ts b/src/webgl/shader_ui_controls.browser_test.ts index 1152fc37c..793b5ecc1 100644 --- a/src/webgl/shader_ui_controls.browser_test.ts +++ b/src/webgl/shader_ui_controls.browser_test.ts @@ -22,9 +22,11 @@ import { parseShaderUiControls, stripComments, } from "#src/webgl/shader_ui_controls.js"; - -// TODO (SKM) handle parsing -const TRANSFER_FUNCTION_LENGTH = 512; +import { + ControlPoint, + SortedControlPoints, +} from "#src/widget/transfer_function.js"; +import { Uint64 } from "#src/util/uint64.js"; describe("stripComments", () => { it("handles code without comments", () => { @@ -577,6 +579,8 @@ void main() { void main() { } `; + const range = defaultDataTypeRange[DataType.UINT8]; + const sortedControlPoints = new SortedControlPoints([], range); expect( parseShaderUiControls(code, { imageData: { dataType: DataType.UINT8, channelRank: 0 }, @@ -592,10 +596,10 @@ void main() { type: "transferFunction", dataType: DataType.UINT8, default: { - controlPoints: [], + sortedControlPoints: new SortedControlPoints([], range), channel: [], - color: vec3.fromValues(1, 1, 1), - range: [0, 255], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, }, }, ], @@ -613,9 +617,10 @@ void main() { void main() { } `; + const range = defaultDataTypeRange[DataType.UINT16]; expect( parseShaderUiControls(code, { - imageData: { dataType: DataType.UINT8, channelRank: 1 }, + imageData: { dataType: DataType.UINT16, channelRank: 1 }, }), ).toEqual({ source: code, @@ -626,12 +631,12 @@ void main() { "colormap", { type: "transferFunction", - dataType: DataType.UINT8, + dataType: DataType.UINT16, default: { - controlPoints: [], + sortedControlPoints: new SortedControlPoints([], range), channel: [0], - color: vec3.fromValues(1, 1, 1), - range: [0, 255], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, }, }, ], @@ -649,9 +654,10 @@ void main() { void main() { } `; + const range = defaultDataTypeRange[DataType.UINT64]; expect( parseShaderUiControls(code, { - imageData: { dataType: DataType.UINT8, channelRank: 0 }, + imageData: { dataType: DataType.UINT64, channelRank: 0 }, }), ).toEqual({ source: code, @@ -662,12 +668,12 @@ void main() { "colormap", { type: "transferFunction", - dataType: DataType.UINT8, + dataType: DataType.UINT64, default: { - controlPoints: [], + sortedControlPoints: new SortedControlPoints([], range), channel: [], - color: vec3.fromValues(1, 1, 1), - range: [0, 255], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, }, }, ], @@ -685,9 +691,10 @@ void main() { void main() { } `; + const range = defaultDataTypeRange[DataType.FLOAT32]; expect( parseShaderUiControls(code, { - imageData: { dataType: DataType.UINT8, channelRank: 1 }, + imageData: { dataType: DataType.FLOAT32, channelRank: 1 }, }), ).toEqual({ source: code, @@ -698,12 +705,12 @@ void main() { "colormap", { type: "transferFunction", - dataType: DataType.UINT8, + dataType: DataType.FLOAT32, default: { - controlPoints: [], + sortedControlPoints: new SortedControlPoints([], range), channel: [1], - color: vec3.fromValues(1, 1, 1), - range: [0, 255], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, }, }, ], @@ -721,9 +728,10 @@ void main() { void main() { } `; + const range = defaultDataTypeRange[DataType.FLOAT32]; expect( parseShaderUiControls(code, { - imageData: { dataType: DataType.UINT8, channelRank: 1 }, + imageData: { dataType: DataType.FLOAT32, channelRank: 1 }, }), ).toEqual({ source: code, @@ -734,12 +742,12 @@ void main() { "colormap", { type: "transferFunction", - dataType: DataType.UINT8, + dataType: DataType.FLOAT32, default: { - controlPoints: [], + sortedControlPoints: new SortedControlPoints([], range), channel: [1], - color: vec3.fromValues(1, 1, 1), - range: [0, 255], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, }, }, ], @@ -757,6 +765,7 @@ void main() { void main() { } `; + const range = defaultDataTypeRange[DataType.FLOAT32]; expect( parseShaderUiControls(code, { imageData: { dataType: DataType.FLOAT32, channelRank: 2 }, @@ -772,10 +781,10 @@ void main() { type: "transferFunction", dataType: DataType.FLOAT32, default: { - controlPoints: [], + sortedControlPoints: new SortedControlPoints([], range), channel: [1, 2], - color: vec3.fromValues(1, 1, 1), - range: [0, 1], + defaultColor: vec3.fromValues(1, 1, 1), + window: range, }, }, ], @@ -784,7 +793,7 @@ void main() { }); 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]], color="#0000ff", range=[0, 200], channel=[]) +#uicontrol transferFunction colormap(controlPoints=[[200, "#00ff00", 0.1], [100, "#ff0000", 0.5], [0, "#000000", 0.0]], defaultColor="#0000ff", window=[0, 1000], channel=[]) void main() { } `; @@ -793,7 +802,13 @@ void main() { void main() { } `; - const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; + 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 range = defaultDataTypeRange[DataType.UINT32]; + const sortedControlPoints = new SortedControlPoints(controlPoints, range); expect( parseShaderUiControls(code, { imageData: { dataType: DataType.UINT32, channelRank: 0 }, @@ -809,29 +824,20 @@ void main() { type: "transferFunction", dataType: DataType.UINT32, default: { - controlPoints: [ - { position: 0, color: vec4.fromValues(0, 0, 0, 0) }, - { - position: Math.ceil(maxTransferFunctionPoints / 2), - color: vec4.fromValues(255, 0, 0, 128), - }, - { - position: maxTransferFunctionPoints, - color: vec4.fromValues(0, 255, 0, 26), - }, - ], + sortedControlPoints, channel: [], - color: vec3.fromValues(0, 0, 1), - range: [0, 200], + 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]], color="#0000ff", channel=[]) +#uicontrol transferFunction colormap(controlPoints=[[18446744073709551615, "#00ff00", 0.1], [9223372111111111111, "#ff0000", 0.5], [0, "#000000", 0.0]], defaultColor="#0000ff", channel=[]) void main() { } `; @@ -840,7 +846,20 @@ void main() { void main() { } `; - const maxTransferFunctionPoints = TRANSFER_FUNCTION_LENGTH - 1; + const range = defaultDataTypeRange[DataType.UINT64]; + const controlPoints = [ + new ControlPoint( + Uint64.fromNumber(9223372111111111111), + vec4.fromValues(255, 0, 0, 128), + ), + new ControlPoint(Uint64.fromNumber(0), vec4.fromValues(0, 0, 0, 0)), + new ControlPoint( + Uint64.fromNumber(18446744073709551615), + vec4.fromValues(0, 255, 0, 26), + ), + ]; + const sortedControlPoints = new SortedControlPoints(controlPoints, range); + console.log(sortedControlPoints); expect( parseShaderUiControls(code, { imageData: { dataType: DataType.UINT64, channelRank: 0 }, @@ -856,20 +875,60 @@ void main() { type: "transferFunction", dataType: DataType.UINT64, default: { - controlPoints: [ - { position: 0, color: vec4.fromValues(0, 0, 0, 0) }, - { - position: Math.ceil(maxTransferFunctionPoints / 2), - color: vec4.fromValues(255, 0, 0, 128), - }, - { - position: maxTransferFunctionPoints, - color: vec4.fromValues(0, 255, 0, 26), - }, - ], + sortedControlPoints: sortedControlPoints, + channel: [], + defaultColor: vec3.fromValues(0, 0, 1), + window: sortedControlPoints.range, + }, + }, + ], + ]), + }); + }); + 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, + [0, 255], + ); + 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: [], - color: vec3.fromValues(0, 0, 1), - range: defaultDataTypeRange[DataType.UINT64], + defaultColor: vec3.fromValues(1, 1, 1), + window, }, }, ], diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index b929a46d2..fbba5e382 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -34,7 +34,7 @@ import { } from "#src/util/color.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; -import {kZeroVec4, vec3, vec4 } from "#src/util/geom.js"; +import { kZeroVec4, vec3, vec4 } from "#src/util/geom.js"; import { parseArray, parseFixedLengthArray, @@ -67,12 +67,10 @@ 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 type { TransferFunctionParameters } from "#src/widget/transfer_function.js"; import { defineTransferFunctionShader, enableTransferFunctionShader, - floatToUint8, SortedControlPoints, ControlPoint, } from "#src/widget/transfer_function.js"; @@ -602,10 +600,10 @@ function parseTransferFunctionDirective( const errors = []; let channel = new Array(channelRank).fill(0); let defaultColor = vec3.fromValues(1.0, 1.0, 1.0); - let range: DataTypeInterval | undefined; + let window: DataTypeInterval | undefined; const sortedControlPoints = new SortedControlPoints( [], - dataType ? defaultDataTypeRange[dataType] : [0, 1], + dataType !== undefined ? defaultDataTypeRange[dataType] : [0, 1], ); // TODO (skm) - support parsing window and size let specifedPoints = false; @@ -614,8 +612,6 @@ function parseTransferFunctionDirective( } if (dataType === undefined) { errors.push("image data must be provided to use a transfer function"); - } else { - range = defaultDataTypeRange[dataType]; } for (const [key, value] of parameters) { try { @@ -624,13 +620,13 @@ function parseTransferFunctionDirective( channel = parseInvlerpChannel(value, channel.length); break; } - case "color": { + case "defaultColor": { defaultColor = parseRGBColorSpecification(value); break; } - case "range": { + case "window": { if (dataType !== undefined) { - range = validateDataTypeInterval( + window = validateDataTypeInterval( parseDataTypeInterval(value, dataType), ); } @@ -639,13 +635,12 @@ function parseTransferFunctionDirective( case "controlPoints": { specifedPoints = true; if (dataType !== undefined) { - const convertedPoints = convertTransferFunctionControlPoints( - value, - dataType, - ); - for (const point of convertedPoints) { - sortedControlPoints.addPoint(point); - } + const controlPoints = + parseTransferFunctionControlPointsFromShaderDirective( + value, + dataType, + ); + sortedControlPoints.updateControlPoints(controlPoints); } break; } @@ -658,9 +653,8 @@ function parseTransferFunctionDirective( } } - if (range === undefined) { - if (dataType !== undefined) range = defaultDataTypeRange[dataType]; - else range = [0, 1] as [number, number]; + if (window === undefined) { + window = sortedControlPoints.range; } // Set a simple black to white transfer function if no control points are specified. if ( @@ -668,8 +662,8 @@ function parseTransferFunctionDirective( !specifedPoints && dataType !== undefined ) { - const startPoint = computeLerp(range, dataType, 0.4) as number; - const endPoint = computeLerp(range, dataType, 0.7) as number; + 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)), @@ -687,9 +681,7 @@ function parseTransferFunctionDirective( sortedControlPoints, channel, defaultColor, - range, - window: range, - size: TRANSFER_FUNCTION_LENGTH, + window, }, } as ShaderTransferFunctionControl, errors: undefined, @@ -1062,11 +1054,11 @@ class TrackablePropertyInvlerpParameters extends TrackableValue { + return parseArray(controlPointsDefinition, (x) => { // Validate input length and types if ( x.length !== 3 || @@ -1082,11 +1074,11 @@ function convertTransferFunctionControlPoints( } // Validate values - let position: number | Uint64; + let inputValue: number | Uint64; if (dataType !== DataType.UINT64) { const defaultRange = defaultDataTypeRange[dataType] as [number, number]; - position = verifyFiniteFloat(x[0]); - if (position < defaultRange[0] || position > defaultRange[1]) { + inputValue = verifyFiniteFloat(x[0]); + if (inputValue < defaultRange[0] || inputValue > defaultRange[1]) { throw new Error( `Expected x in range [${defaultRange[0]}, ${ defaultRange[1] @@ -1095,10 +1087,10 @@ function convertTransferFunctionControlPoints( } } else { const defaultRange = defaultDataTypeRange[dataType] as [Uint64, Uint64]; - position = Uint64.fromNumber(x[0]); + inputValue = Uint64.fromNumber(x[0]); if ( - Uint64.less(position, defaultRange[0]) || - Uint64.less(defaultRange[1], position) + Uint64.less(inputValue, defaultRange[0]) || + Uint64.less(defaultRange[1], inputValue) ) { throw new Error( `Expected x in range [${defaultRange[0]}, ${ @@ -1121,9 +1113,17 @@ function convertTransferFunctionControlPoints( ); } const color = parseRGBColorSpecification(x[1]); + function floatToUint8(float: number) { + return Math.min(255, Math.max(Math.round(float * 255), 0)); + } return new ControlPoint( - position, - vec4.fromValues(color[0], color[1], color[2], x[2]), + inputValue, + vec4.fromValues( + floatToUint8(color[0]), + floatToUint8(color[1]), + floatToUint8(color[2]), + floatToUint8(x[2]), + ), ); }); } @@ -1169,12 +1169,15 @@ function parseTransferFunctionControlPoints( `Expected opacity as number but received: ${JSON.stringify(x.opacity)}`, ); } - const opacity = floatToUint8(Math.max(0, Math.min(1, x.opacity))); + + function floatToUint8(float: number) { + return Math.min(255, Math.max(Math.round(float * 255), 0)); + } const rgbaColor = vec4.fromValues( floatToUint8(color[0]), floatToUint8(color[1]), floatToUint8(color[2]), - opacity, + floatToUint8(x.opacity), ); return new ControlPoint(parsePosition(x.input), rgbaColor); }); diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 3210d6a3b..0e048f880 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -101,14 +101,6 @@ const defaultTransferFunctionSizes: Record = { [DataType.FLOAT32]: 8192, }; -/** - * Convert a [0, 1] float to a uint8 value between 0 and 255 - * TODO (SKM) belong here? Maybe utils? - */ -export function floatToUint8(float: number) { - return Math.min(255, Math.max(Math.round(float * 255), 0)); -} - /** * Options to update a lookup table texture */ @@ -155,7 +147,7 @@ interface CanvasPosition { /** * Transfer functions are controlled via a set of control points - * with an input value and an output RGBA color. + * 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 @@ -213,13 +205,19 @@ export class SortedControlPoints { get length() { return this.controlPoints.length; } + updateControlPoints(newControlPoints: ControlPoint[]) { + this.controlPoints = newControlPoints.map((point) => + ControlPoint.copyFrom(point), + ); + this.sortAndComputeRange(); + } addPoint(controlPoint: ControlPoint) { const { inputValue, outputColor } = controlPoint; - const nearestPoint = this.findNearestControlPointIndex(inputValue); - if (nearestPoint !== -1) { - this.controlPoints[nearestPoint].inputValue = inputValue; - this.controlPoints[nearestPoint].outputColor = outputColor; - return; + 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); @@ -571,6 +569,7 @@ export class ControlPointTexture extends BaseLookupTexture { this.setTextureWidthAndHeightFromSize(lookupTableSize); const lookupTable = new LookupTable(lookupTableSize); const sortedControlPoints = options.sortedControlPoints; + // TODO this doesn't make sense as range is computed sortedControlPoints.updateRange(options.inputRange); lookupTable.updateFromControlPoints(sortedControlPoints); return lookupTable; From 16df32db66c88c7e5fbce60330504acf5561cca2 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 11 Apr 2024 12:43:05 +0200 Subject: [PATCH 33/60] fix: JSON state parsing and saving for new TF --- src/webgl/shader_ui_controls.browser_test.ts | 128 ++++++++++- src/webgl/shader_ui_controls.ts | 211 ++++++------------- src/widget/transfer_function.ts | 6 - 3 files changed, 193 insertions(+), 152 deletions(-) diff --git a/src/webgl/shader_ui_controls.browser_test.ts b/src/webgl/shader_ui_controls.browser_test.ts index 793b5ecc1..b7052fa13 100644 --- a/src/webgl/shader_ui_controls.browser_test.ts +++ b/src/webgl/shader_ui_controls.browser_test.ts @@ -19,14 +19,18 @@ import { DataType } from "#src/util/data_type.js"; import { vec3, vec4 } from "#src/util/geom.js"; import { defaultDataTypeRange } from "#src/util/lerp.js"; import { + TrackableTransferFunctionParameters, parseShaderUiControls, + parseTransferFunctionParameters, stripComments, } from "#src/webgl/shader_ui_controls.js"; import { ControlPoint, SortedControlPoints, + TransferFunctionParameters, } from "#src/widget/transfer_function.js"; import { Uint64 } from "#src/util/uint64.js"; +import exp from "node:constants"; describe("stripComments", () => { it("handles code without comments", () => { @@ -837,7 +841,7 @@ void main() { }); 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=[]) +#uicontrol transferFunction colormap(controlPoints=[["18446744073709551615", "#00ff00", 0.1], ["9223372111111111111", "#ff0000", 0.5], [0, "#000000", 0.0]], defaultColor="#0000ff", channel=[]) void main() { } `; @@ -849,17 +853,16 @@ void main() { const range = defaultDataTypeRange[DataType.UINT64]; const controlPoints = [ new ControlPoint( - Uint64.fromNumber(9223372111111111111), + Uint64.parseString("9223372111111111111"), vec4.fromValues(255, 0, 0, 128), ), new ControlPoint(Uint64.fromNumber(0), vec4.fromValues(0, 0, 0, 0)), new ControlPoint( - Uint64.fromNumber(18446744073709551615), + Uint64.parseString("18446744073709551615"), vec4.fromValues(0, 255, 0, 26), ), ]; const sortedControlPoints = new SortedControlPoints(controlPoints, range); - console.log(sortedControlPoints); expect( parseShaderUiControls(code, { imageData: { dataType: DataType.UINT64, channelRank: 0 }, @@ -936,3 +939,120 @@ void main() { }); }); }); + +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)), + ], + [0, 255], + ), + 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), + ), + ], + defaultDataTypeRange[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 fbba5e382..280a04dcf 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -53,6 +53,7 @@ import { defaultDataTypeRange, normalizeDataTypeInterval, parseDataTypeInterval, + parseDataTypeValue, parseUnknownDataTypeInterval, validateDataTypeInterval, } from "#src/util/lerp.js"; @@ -601,7 +602,7 @@ function parseTransferFunctionDirective( let channel = new Array(channelRank).fill(0); let defaultColor = vec3.fromValues(1.0, 1.0, 1.0); let window: DataTypeInterval | undefined; - const sortedControlPoints = new SortedControlPoints( + let sortedControlPoints = new SortedControlPoints( [], dataType !== undefined ? defaultDataTypeRange[dataType] : [0, 1], ); @@ -635,12 +636,10 @@ function parseTransferFunctionDirective( case "controlPoints": { specifedPoints = true; if (dataType !== undefined) { - const controlPoints = - parseTransferFunctionControlPointsFromShaderDirective( - value, - dataType, - ); - sortedControlPoints.updateControlPoints(controlPoints); + sortedControlPoints = parseTransferFunctionControlPoints( + value, + dataType, + ); } break; } @@ -1054,15 +1053,16 @@ class TrackablePropertyInvlerpParameters extends TrackableValue { + const parsedPoints = parseArray(controlPointsDefinition, (x) => { // 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 || - typeof x[0] !== "number" || + !allowedInput || typeof x[1] !== "string" || typeof x[2] !== "number" ) { @@ -1072,33 +1072,39 @@ function parseTransferFunctionControlPointsFromShaderDirective( )}`, ); } - - // Validate values - let inputValue: number | Uint64; - if (dataType !== DataType.UINT64) { - const defaultRange = defaultDataTypeRange[dataType] as [number, number]; - inputValue = verifyFiniteFloat(x[0]); - if (inputValue < defaultRange[0] || inputValue > defaultRange[1]) { - throw new Error( - `Expected x in range [${defaultRange[0]}, ${ - defaultRange[1] - }], but received: ${JSON.stringify(x[0])}`, - ); - } - } else { - const defaultRange = defaultDataTypeRange[dataType] as [Uint64, Uint64]; - inputValue = Uint64.fromNumber(x[0]); - if ( - Uint64.less(inputValue, defaultRange[0]) || - Uint64.less(defaultRange[1], inputValue) - ) { - throw new Error( - `Expected x in range [${defaultRange[0]}, ${ - defaultRange[1] - }], but received: ${JSON.stringify(x[0])}`, - ); - } - } + const inputValue = parseDataTypeValue(dataType, x[0]); + + // TODO skm think above function replaces the below + // // Validate values + // let inputValue: number | Uint64; + // if (dataType !== DataType.UINT64) { + // const defaultRange = defaultDataTypeRange[dataType] as [number, number]; + // inputValue = verifyFiniteFloat(x[0]); + // if (inputValue < defaultRange[0] || inputValue > defaultRange[1]) { + // throw new Error( + // `Expected x in range [${defaultRange[0]}, ${ + // defaultRange[1] + // }], but received: ${JSON.stringify(x[0])}`, + // ); + // } + // } else { + // const defaultRange = defaultDataTypeRange[dataType] as [Uint64, Uint64]; + // if (typeof x[0] === "string") { + // inputValue = Uint64.parseString(x[0]); + // } else { + // inputValue = Uint64.fromNumber(x[0]); + // } + // if ( + // Uint64.less(inputValue, defaultRange[0]) || + // Uint64.less(defaultRange[1], inputValue) + // ) { + // throw new Error( + // `Expected x in range [${defaultRange[0]}, ${ + // defaultRange[1] + // }], but received: ${JSON.stringify(x[0])}`, + // ); + // } + // } if (x[1].length !== 7 || x[1][0] !== "#") { throw new Error( @@ -1126,86 +1132,28 @@ function parseTransferFunctionControlPointsFromShaderDirective( ), ); }); + return new SortedControlPoints(parsedPoints, defaultDataTypeRange[dataType]); } -function parseTransferFunctionControlPoints( - value: unknown, - range: DataTypeInterval, - dataType: DataType, -) { - // TODO (skm) - this parsing is in the wrong space - function parsePosition(position: number): number { - const toConvert = - dataType === DataType.UINT64 ? Uint64.fromNumber(position) : position; - let normalizedPosition = computeInvlerp(range, toConvert); - normalizedPosition = Math.min(Math.max(0, normalizedPosition), 1); - const positionInTransferFunction = computeLerp( - [0, TRANSFER_FUNCTION_LENGTH - 1], - DataType.UINT16, - normalizedPosition, - ) as number; - return positionInTransferFunction; - } - const parsedPoints = parseArray(value, (x) => { - if ( - x.input === undefined || - x.color === undefined || - x.opacity === undefined - ) { - throw new Error( - `Expected object with position and color and opacity properties, but received: ${JSON.stringify( - x, - )}`, - ); - } - if (typeof x.input !== "number") { - throw new Error( - `Expected position as number but received: ${JSON.stringify(x.input)}`, - ); - } - const color = parseRGBColorSpecification(x.color); - if (typeof x.opacity !== "number") { - throw new Error( - `Expected opacity as number but received: ${JSON.stringify(x.opacity)}`, - ); - } - - function floatToUint8(float: number) { - return Math.min(255, Math.max(Math.round(float * 255), 0)); - } - const rgbaColor = vec4.fromValues( - floatToUint8(color[0]), - floatToUint8(color[1]), - floatToUint8(color[2]), - floatToUint8(x.opacity), - ); - return new ControlPoint(parsePosition(x.input), rgbaColor); - }); - return new SortedControlPoints(parsedPoints, range); -} - -function parseTransferFunctionParameters( +export function parseTransferFunctionParameters( obj: unknown, dataType: DataType, defaultValue: TransferFunctionParameters, ): TransferFunctionParameters { if (obj === undefined) return defaultValue; verifyObject(obj); - const range = verifyOptionalObjectProperty( - obj, - "range", - (x) => parseDataTypeInterval(x, dataType), - defaultValue.range, - ); const sortedControlPoints = verifyOptionalObjectProperty( obj, "controlPoints", - (x) => parseTransferFunctionControlPoints(x, range, dataType), + (x) => parseTransferFunctionControlPoints(x, dataType), defaultValue.sortedControlPoints, ); - // TODO (skm) - support parsing window and size - const window = range; - const size = TRANSFER_FUNCTION_LENGTH; + const window = verifyOptionalObjectProperty( + obj, + "window", + (x) => parseDataTypeInterval(x, dataType), + defaultValue.window, + ); return { sortedControlPoints, channel: verifyOptionalObjectProperty( @@ -1216,13 +1164,11 @@ function parseTransferFunctionParameters( ), defaultColor: verifyOptionalObjectProperty( obj, - "color", + "defaultColor", (x) => parseRGBColorSpecification(x), defaultValue.defaultColor, ), - range, window, - size, }; } @@ -1235,17 +1181,15 @@ function copyTransferFunctionParameters( defaultValue.sortedControlPoints.controlPoints.map( (x) => new ControlPoint(x.inputValue, x.outputColor), ), - defaultValue.range, + defaultValue.sortedControlPoints.range, ), channel: defaultValue.channel, defaultColor: defaultValue.defaultColor, - range: defaultValue.range, - window: defaultValue.range, - size: defaultValue.size, + window: defaultValue.window, }; } -class TrackableTransferFunctionParameters extends TrackableValue { +export class TrackableTransferFunctionParameters extends TrackableValue { constructor( public dataType: DataType, public defaultValue: TransferFunctionParameters, @@ -1253,57 +1197,44 @@ class TrackableTransferFunctionParameters extends TrackableValue which is necessary for the changed signal to be emitted. - const defaultValueCopy = copyTransferFunctionParameters(defaultValue); - super(defaultValueCopy, (obj) => + //const defaultValueCopy = copyTransferFunctionParameters(defaultValue); + super(defaultValue, (obj) => parseTransferFunctionParameters(obj, dataType, defaultValue), ); } controlPointsToJson(controlPoints: ControlPoint[], dataType: DataType) { - function positionToJson(position: number | Uint64) { + function inputToJson(inputValue: number | Uint64) { if (dataType === DataType.UINT64) { - return (position as Uint64).toNumber(); + return (inputValue as Uint64).toJSON(); } - return position; + return inputValue; } - return controlPoints.map((x) => ({ - input: positionToJson(x.inputValue), - color: serializeColor( + return controlPoints.map((x) => [ + inputToJson(x.inputValue), + serializeColor( vec3.fromValues( x.outputColor[0] / 255, x.outputColor[1] / 255, x.outputColor[2] / 255, ), ), - opacity: x.outputColor[3] / 255, - })); + x.outputColor[3] / 255, + ]); } toJSON() { const { - value: { - channel, - sortedControlPoints, - defaultColor, - window, - range, - size, - }, + value: { channel, sortedControlPoints, defaultColor, window }, dataType, defaultValue, } = this; - const rangeJson = dataTypeIntervalToJson( - range, - dataType, - defaultValue.range, - ); const windowJson = dataTypeIntervalToJson( window, dataType, defaultValue.window, ); - const sizeJson = size === defaultValue.size ? undefined : size; const channelJson = arraysEqual(defaultValue.channel, channel) ? undefined : channel; @@ -1320,22 +1251,18 @@ class TrackableTransferFunctionParameters extends TrackableValue - ControlPoint.copyFrom(point), - ); - this.sortAndComputeRange(); - } addPoint(controlPoint: ControlPoint) { const { inputValue, outputColor } = controlPoint; const exactMatch = this.controlPoints.findIndex( From 3cece0354af1a00b3b8ad9df0baa81d08cfba063 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 11 Apr 2024 13:05:55 +0200 Subject: [PATCH 34/60] fix: new TF runs, but UI control is broken --- src/webgl/shader.ts | 6 +---- src/webgl/shader_ui_controls.browser_test.ts | 10 +++----- src/webgl/shader_ui_controls.ts | 26 ++++---------------- src/widget/transfer_function.browser_test.ts | 1 - src/widget/transfer_function.ts | 18 +++----------- 5 files changed, 13 insertions(+), 48 deletions(-) diff --git a/src/webgl/shader.ts b/src/webgl/shader.ts index 1bb8b5c47..193acf233 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -14,14 +14,13 @@ * limitations under the License. */ -import { DataType } from "#src/util/data_type.js"; +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"; -import type { DataTypeInterval } from "src/util/lerp"; const DEBUG_SHADER = false; @@ -259,7 +258,6 @@ export class ShaderProgram extends RefCounted { bindAndUpdateTransferFunctionTexture( symbol: symbol | string, sortedControlPoints: SortedControlPoints, - inputRange: DataTypeInterval, dataType: DataType, lookupTableSize: number, ) { @@ -273,11 +271,9 @@ export class ShaderProgram extends RefCounted { `Invalid transfer function texture symbol: ${symbol.toString()}`, ); } - // TODO (SKM) - how to correctly get the input range? return texture.updateAndActivate({ textureUnit, sortedControlPoints, - inputRange, dataType, lookupTableSize, }); diff --git a/src/webgl/shader_ui_controls.browser_test.ts b/src/webgl/shader_ui_controls.browser_test.ts index b7052fa13..4c34f844b 100644 --- a/src/webgl/shader_ui_controls.browser_test.ts +++ b/src/webgl/shader_ui_controls.browser_test.ts @@ -18,19 +18,18 @@ import { expect, describe, it } from "vitest"; 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 { TrackableTransferFunctionParameters, parseShaderUiControls, parseTransferFunctionParameters, stripComments, } from "#src/webgl/shader_ui_controls.js"; +import type { TransferFunctionParameters } from "#src/widget/transfer_function.js"; import { ControlPoint, SortedControlPoints, - TransferFunctionParameters, } from "#src/widget/transfer_function.js"; -import { Uint64 } from "#src/util/uint64.js"; -import exp from "node:constants"; describe("stripComments", () => { it("handles code without comments", () => { @@ -584,7 +583,6 @@ void main() { } `; const range = defaultDataTypeRange[DataType.UINT8]; - const sortedControlPoints = new SortedControlPoints([], range); expect( parseShaderUiControls(code, { imageData: { dataType: DataType.UINT8, channelRank: 0 }, @@ -986,7 +984,8 @@ 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 default_val = parsed_val.controls.get("tf")! + .default as TransferFunctionParameters; const transferFunctionParameters = new TrackableTransferFunctionParameters( DataType.UINT64, default_val, @@ -1053,6 +1052,5 @@ void main() { window: undefined, controlPoints: undefined, }); - }); }); diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 280a04dcf..687eb1924 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -46,7 +46,6 @@ import { } from "#src/util/json.js"; import type { DataTypeInterval } from "#src/util/lerp.js"; import { - computeInvlerp, computeLerp, convertDataTypeInterval, dataTypeIntervalToJson, @@ -59,7 +58,7 @@ import { } from "#src/util/lerp.js"; import { NullarySignal } from "#src/util/signal.js"; import type { Trackable } from "#src/util/trackable.js"; -import { Uint64 } from "#src/util/uint64.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"; @@ -1059,7 +1058,10 @@ function parseTransferFunctionControlPoints( ) { const parsedPoints = parseArray(controlPointsDefinition, (x) => { // Validate input length and types - const allowedInput = dataType === DataType.UINT64 ? (typeof x[0] === "string" || typeof x[0] === "number") : typeof x[0] === "number"; + const allowedInput = + dataType === DataType.UINT64 + ? typeof x[0] === "string" || typeof x[0] === "number" + : typeof x[0] === "number"; if ( x.length !== 3 || !allowedInput || @@ -1172,23 +1174,6 @@ export function parseTransferFunctionParameters( }; } -// TODO (skm) still need copy? -function copyTransferFunctionParameters( - defaultValue: TransferFunctionParameters, -) { - return { - sortedControlPoints: new SortedControlPoints( - defaultValue.sortedControlPoints.controlPoints.map( - (x) => new ControlPoint(x.inputValue, x.outputColor), - ), - defaultValue.sortedControlPoints.range, - ), - channel: defaultValue.channel, - defaultColor: defaultValue.defaultColor, - window: defaultValue.window, - }; -} - export class TrackableTransferFunctionParameters extends TrackableValue { constructor( public dataType: DataType, @@ -1714,7 +1699,6 @@ function setControlInShader( uName, control.dataType, value.sortedControlPoints, - value.range, TRANSFER_FUNCTION_LENGTH, ); } diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index dcfbe3a7b..6dbe55ca5 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -252,7 +252,6 @@ val5 = uTransferFunctionEnd_doTransferFunction; "doTransferFunction", dataType, controlPoints, - defaultDataTypeRange[dataType], textureSizes[dataType], ); tester.execute({ inputValue: point }); diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 764e0c5c0..a1cbd3b14 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -40,7 +40,6 @@ import type { DataTypeInterval } from "#src/util/lerp.js"; import { computeInvlerp, computeLerp, - dataTypeIntervalEqual, parseDataTypeValue, } from "#src/util/lerp.js"; import { MouseEventBinder } from "#src/util/mouse_bindings.js"; @@ -121,10 +120,6 @@ export interface ControlPointTextureOptions { sortedControlPoints: SortedControlPoints; /** textureUnit to update with the new transfer function texture data */ textureUnit: number | undefined; - /** range of the input space I, where T: I -> O. Allows for more precision in the - * transfer function texture generation due to texture size limitations - */ - inputRange: DataTypeInterval; /** Data type of the control points */ dataType: DataType; /** Lookup table number of elements*/ @@ -161,6 +156,7 @@ export class ControlPoint { /** Convert the input value to a normalized value between 0 and 1 */ normalizedInput(range: DataTypeInterval): number { + console.log("normalizedInput", range, this.inputValue); return computeInvlerp(range, this.inputValue); } @@ -548,13 +544,8 @@ export class ControlPointTexture extends BaseLookupTexture { const textureUnitEqual = existingOptions.textureUnit === newOptions.textureUnit; const dataTypeEqual = existingOptions.dataType === newOptions.dataType; - const inputRangeEqual = dataTypeIntervalEqual( - newOptions.dataType, - newOptions.inputRange, - existingOptions.inputRange, - ); return ( - controlPointsEqual && textureUnitEqual && dataTypeEqual && inputRangeEqual + controlPointsEqual && textureUnitEqual && dataTypeEqual ); } createLookupTable(options: ControlPointTextureOptions): LookupTable { @@ -563,8 +554,6 @@ export class ControlPointTexture extends BaseLookupTexture { this.setTextureWidthAndHeightFromSize(lookupTableSize); const lookupTable = new LookupTable(lookupTableSize); const sortedControlPoints = options.sortedControlPoints; - // TODO this doesn't make sense as range is computed - sortedControlPoints.updateRange(options.inputRange); lookupTable.updateFromControlPoints(sortedControlPoints); return lookupTable; } @@ -1336,7 +1325,6 @@ export function enableTransferFunctionShader( name: string, dataType: DataType, sortedControlPoints: SortedControlPoints, - interval: DataTypeInterval, lookupTableSize: number, ) { const { gl } = shader; @@ -1353,7 +1341,6 @@ export function enableTransferFunctionShader( const textureSize = shader.bindAndUpdateTransferFunctionTexture( `TransferFunction.${name}`, sortedControlPoints, - interval, dataType, lookupTableSize, ); @@ -1365,6 +1352,7 @@ export function enableTransferFunctionShader( 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); } From be771682b82282010fb6f602fea50b9602d17747 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 11 Apr 2024 13:06:46 +0200 Subject: [PATCH 35/60] fix: remove accidental log --- src/widget/transfer_function.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index a1cbd3b14..a20c995c2 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -156,7 +156,6 @@ export class ControlPoint { /** Convert the input value to a normalized value between 0 and 1 */ normalizedInput(range: DataTypeInterval): number { - console.log("normalizedInput", range, this.inputValue); return computeInvlerp(range, this.inputValue); } From 7f47d8cbacba42b468450fb6623ac65727c7adca Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 11 Apr 2024 17:19:10 +0200 Subject: [PATCH 36/60] fix: control points display in correct position in UI --- src/widget/transfer_function.ts | 191 +++++++++++++++++++++++--------- 1 file changed, 139 insertions(+), 52 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index a20c995c2..53cfe0322 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -543,9 +543,7 @@ export class ControlPointTexture extends BaseLookupTexture { const textureUnitEqual = existingOptions.textureUnit === newOptions.textureUnit; const dataTypeEqual = existingOptions.dataType === newOptions.dataType; - return ( - controlPointsEqual && textureUnitEqual && dataTypeEqual - ); + return controlPointsEqual && textureUnitEqual && dataTypeEqual; } createLookupTable(options: ControlPointTextureOptions): LookupTable { const lookupTableSize = this.ensureTextureSize(options.lookupTableSize); @@ -631,8 +629,10 @@ class TransferFunctionPanel extends IndirectRenderedPanel { updateTransferFunctionPointsAndLines() { // Normalize position to [-1, 1] for shader (x axis) - function normalizePosition(position: number) { - return (position / (TRANSFER_FUNCTION_PANEL_SIZE - 1)) * 2 - 1; + 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) { @@ -648,10 +648,10 @@ class TransferFunctionPanel extends IndirectRenderedPanel { positions: vec4, ): number { for (let i = 0; i < VERTICES_PER_LINE; ++i) { - array[index++] = normalizePosition(positions[0]); - array[index++] = normalizeOpacity(positions[1]); - array[index++] = normalizePosition(positions[2]); - array[index++] = normalizeOpacity(positions[3]); + array[index++] = positions[0]; + array[index++] = positions[1]; + array[index++] = positions[2]; + array[index++] = positions[3]; } return index; } @@ -666,68 +666,152 @@ class TransferFunctionPanel extends IndirectRenderedPanel { let positionArrayIndex = 0; let lineFromLeftEdge = null; let lineToRightEdge = null; + 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) { - // If the start point is above 0, need to draw a line from the left edge - const firstInputValue = transferFunction.toLookupTableIndex(0)!; - if (firstInputValue > 0) { - numLines += 1; - lineFromLeftEdge = vec4.fromValues(0, 0, firstInputValue, 0); + // Map all control points to normalized values for the shader + // Try to find the first and last point in the window + let firstPointIndexInWindow = null; + let lastPointIndexInWindow = null; + for (let i = 0; i < controlPoints.length; ++i) { + const normalizedInput = normalizedControlPoints[i].input; + if (normalizedInput >= -1 && normalizedInput <= 1) { + firstPointIndexInWindow = firstPointIndexInWindow ?? i; + lastPointIndexInWindow = i; + } } - // If the end point is less than the transfer function length, need to draw a line to the right edge - const finalPoint = controlPoints[controlPoints.length - 1]; - const finalInputValue = transferFunction.toLookupTableIndex(-1)!; - if (finalInputValue < TRANSFER_FUNCTION_PANEL_SIZE - 1) { + // If there are no points in the window, 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[0].input > 1; + const indexOfReferencePoint = allPointsLeftOfWindow + ? controlPoints.length - 1 + : 0; numLines += 1; - lineToRightEdge = vec4.fromValues( - finalInputValue, - finalPoint.outputColor[3], - TRANSFER_FUNCTION_PANEL_SIZE - 1, - finalPoint.outputColor[3], + const referenceOpacity = + normalizedControlPoints[indexOfReferencePoint].output; + lineFromLeftEdge = vec4.fromValues( + -1, + referenceOpacity, + 1, + referenceOpacity, ); + } else { + const firstPointInWindow = + normalizedControlPoints[firstPointIndexInWindow]; + // Need to draw a line from the left edge to the first control point in the window + // Unless the first point is at the left edge + 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) { + 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, + ); + } + // If the first point in the window is the leftmost point, draw a 0 line up to the point + else { + lineFromLeftEdge = vec4.fromValues( + -1, + 0, + firstPointInWindow.input, + 0, + ); + } + 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) { + 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, + ); + } + // If the last point in the window is the rightmost point, draw a line from the point to 1 + else { + lineToRightEdge = vec4.fromValues( + lastPointInWindow.input, + lastPointInWindow.output, + 1, + lastPointInWindow.output, + ); + } + numLines += 1; + } } } - // Create line positions const linePositionArray = new Float32Array( - numLines * VERTICES_PER_LINE * POSITION_VALUES_PER_LINE, + numLines * POSITION_VALUES_PER_LINE * VERTICES_PER_LINE, ); - // Draw a vertical line up to the first control point + if (lineFromLeftEdge !== null) { - positionArrayIndex = addLine( - linePositionArray, - positionArrayIndex, - lineFromLeftEdge, - ); + addLine(linePositionArray, positionArrayIndex, lineFromLeftEdge); } // 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]; - const inputValue = transferFunction.toLookupTableIndex(i)!; + colorArray[colorIndex] = normalizeColor(outputColor[0]); colorArray[colorIndex + 1] = normalizeColor(outputColor[1]); colorArray[colorIndex + 2] = normalizeColor(outputColor[2]); - positionArray[positionIndex] = normalizePosition(inputValue); - positionArray[positionIndex + 1] = normalizeOpacity(outputColor[3]); + positionArray[positionIndex] = inputValue; + positionArray[positionIndex + 1] = outputValue; // Don't create a line for the last point if (i === controlPoints.length - 1) break; - const linePosition = vec4.fromValues( + const lineBetweenPoints = vec4.fromValues( inputValue, - outputColor[3], - transferFunction.toLookupTableIndex(i + 1)!, - controlPoints[i + 1].outputColor[3], + outputValue, + normalizedControlPoints[i + 1].input, + normalizedControlPoints[i + 1].output, ); positionArrayIndex = addLine( linePositionArray, positionArrayIndex, - linePosition, + lineBetweenPoints, ); } - // Draw a horizontal line out from the last point if (lineToRightEdge !== null) { addLine(linePositionArray, positionArrayIndex, lineToRightEdge); @@ -1007,7 +1091,7 @@ class TransferFunctionController extends RefCounted { "remove-point", (actionEvent) => { const mouseEvent = actionEvent.detail; - const nearestIndex = this.findNearestControlPointIndex(mouseEvent); + const nearestIndex = this.findControlPointIfNearCursor(mouseEvent); if (nearestIndex !== -1) { this.transferFunction.removePoint(nearestIndex); this.updateValue({ @@ -1023,7 +1107,7 @@ class TransferFunctionController extends RefCounted { "change-point-color", (actionEvent) => { const mouseEvent = actionEvent.detail; - const nearestIndex = this.findNearestControlPointIndex(mouseEvent); + const nearestIndex = this.findControlPointIfNearCursor(mouseEvent); if (nearestIndex !== -1) { const color = this.transferFunction.trackable.value.defaultColor; this.transferFunction.updatePointColor(nearestIndex, color); @@ -1040,15 +1124,9 @@ class TransferFunctionController extends RefCounted { if (value === undefined) return; this.setModel(value); } - findNearestControlPointIndex(event: MouseEvent) { - const { normalizedX, normalizedY } = this.getControlPointPosition( - event, - ) as CanvasPosition; - return this.grabControlPointNearCursor(normalizedX, normalizedY); - } addControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { const color = this.transferFunction.trackable.value.defaultColor; - const nearestIndex = this.findNearestControlPointIndex(event); + const nearestIndex = this.findControlPointIfNearCursor(event); if (nearestIndex !== -1) { this.currentGrabbedControlPointIndex = nearestIndex; return undefined; @@ -1063,7 +1141,7 @@ class TransferFunctionController extends RefCounted { ), ); this.currentGrabbedControlPointIndex = - this.findNearestControlPointIndex(event); + this.findControlPointIfNearCursor(event); return { ...this.getModel(), sortedControlPoints: @@ -1118,14 +1196,23 @@ class TransferFunctionController extends RefCounted { return { normalizedX, normalizedY }; } - grabControlPointNearCursor(mouseXPosition: number, mouseYPosition: number) { + /** + * 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 position = this.getControlPointPosition(event); + if (position === undefined) return -1; + const mouseXPosition = position.normalizedX; + const mouseYPosition = position.normalizedY; const { transferFunction, dataType } = this; const { window } = transferFunction.trackable.value; const range = transferFunction.sortedControlPoints.range; const nearestControlPointIndex = transferFunction.findNearestControlPointIndex(mouseXPosition, window); if (nearestControlPointIndex === -1) { - return nearestControlPointIndex; + return -1; } const lookupTableIndex = transferFunction.toLookupTableIndex( nearestControlPointIndex, From 63375938225cafafc041c1c07a2349be96beb9ba Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 11 Apr 2024 17:59:43 +0200 Subject: [PATCH 37/60] fix: find control point near cursor in new TF --- src/widget/transfer_function.ts | 72 +++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 53cfe0322..b9b935a56 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -81,7 +81,7 @@ import { Tab } from "#src/widget/tab_view.js"; const TRANSFER_FUNCTION_PANEL_SIZE = 1024; export const NUM_COLOR_CHANNELS = 4; const POSITION_VALUES_PER_LINE = 4; // x1, y1, x2, y2 -const CONTROL_POINT_X_GRAB_DISTANCE = TRANSFER_FUNCTION_PANEL_SIZE / 40; +const CONTROL_POINT_X_GRAB_DISTANCE = 0.05; const TRANSFER_FUNCTION_BORDER_WIDTH = 0.05; const transferFunctionSamplerTextureUnit = Symbol( @@ -1202,28 +1202,41 @@ class TransferFunctionController extends RefCounted { * 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 { transferFunction, dataType } = this; - const { window } = transferFunction.trackable.value; - const range = transferFunction.sortedControlPoints.range; const nearestControlPointIndex = transferFunction.findNearestControlPointIndex(mouseXPosition, window); if (nearestControlPointIndex === -1) { return -1; } - const lookupTableIndex = transferFunction.toLookupTableIndex( - nearestControlPointIndex, - )!; - const mousePositionTableIndex = Math.floor( - computeInvlerp(range, computeLerp(window, dataType, mouseXPosition)) * - TRANSFER_FUNCTION_PANEL_SIZE - - 1, - ); + const nearestControlPointPanelPosition = + convertControlPointInputToPanelSpace(nearestControlPointIndex)!; if ( - Math.abs(lookupTableIndex - mousePositionTableIndex) > + Math.abs(mouseXPosition - nearestControlPointPanelPosition) > CONTROL_POINT_X_GRAB_DISTANCE ) { return -1; @@ -1233,49 +1246,48 @@ class TransferFunctionController extends RefCounted { [ nearestControlPointIndex, Math.abs( - transferFunction.sortedControlPoints.controlPoints[ - nearestControlPointIndex - ].outputColor[3] - mouseYPosition, + convertControlPointOpacityToPanelSpace(nearestControlPointIndex)! - + mouseYPosition, ), ], ]; - const nextPosition = transferFunction.toLookupTableIndex( + const nextPosition = convertControlPointInputToPanelSpace( nearestControlPointIndex + 1, ); const nextDistance = - nextPosition !== undefined - ? Math.abs(nextPosition - mousePositionTableIndex) + nextPosition !== null + ? Math.abs(nextPosition - mouseXPosition) : Infinity; if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { possibleMatches.push([ nearestControlPointIndex + 1, Math.abs( - transferFunction.sortedControlPoints.controlPoints[ - nearestControlPointIndex + 1 - ].outputColor[3] - mouseYPosition, + convertControlPointOpacityToPanelSpace( + nearestControlPointIndex + 1, + )! - mouseYPosition, ), ]); } - const previousPosition = transferFunction.toLookupTableIndex( + const previousPosition = convertControlPointInputToPanelSpace( nearestControlPointIndex - 1, - false, ); const previousDistance = - previousPosition !== undefined - ? Math.abs(previousPosition - mousePositionTableIndex) + previousPosition !== null + ? Math.abs(previousPosition - mouseXPosition) : Infinity; if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { possibleMatches.push([ nearestControlPointIndex - 1, Math.abs( - transferFunction.sortedControlPoints.controlPoints[ - nearestControlPointIndex - 1 - ].outputColor[3] - mouseYPosition, + convertControlPointOpacityToPanelSpace( + nearestControlPointIndex - 1, + )! - mouseYPosition, ), ]); } - return possibleMatches.sort((a, b) => a[1] - b[1])[0][0]; + const bestMatch = possibleMatches.sort((a, b) => a[1] - b[1])[0][0]; + return bestMatch; } } From 4310bd947e4fdd00e160185d7e6cb53a83dd801d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 11 Apr 2024 18:45:10 +0200 Subject: [PATCH 38/60] fix: moving control points and setting color --- src/widget/transfer_function.ts | 74 +++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index b9b935a56..f6b1253d0 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -223,13 +223,9 @@ export class SortedControlPoints { } updatePointColor(index: number, color: vec4 | vec3) { let outputColor = vec4.create(); - if (outputColor.length === 3) { - outputColor = vec4.fromValues( - color[0], - color[1], - color[2], - this.controlPoints[index].outputColor[3], - ); + 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); } @@ -730,10 +726,10 @@ class TransferFunctionPanel extends IndirectRenderedPanel { // 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, - 0, firstPointInWindow.input, - 0, + firstPointInWindow.output, ); } numLines += 1; @@ -782,7 +778,11 @@ class TransferFunctionPanel extends IndirectRenderedPanel { ); if (lineFromLeftEdge !== null) { - addLine(linePositionArray, positionArrayIndex, lineFromLeftEdge); + positionArrayIndex = addLine( + linePositionArray, + positionArrayIndex, + lineFromLeftEdge, + ); } // Update points and draw lines between control points @@ -1110,7 +1110,12 @@ class TransferFunctionController extends RefCounted { const nearestIndex = this.findControlPointIfNearCursor(mouseEvent); if (nearestIndex !== -1) { const color = this.transferFunction.trackable.value.defaultColor; - this.transferFunction.updatePointColor(nearestIndex, color); + const colorInAbsoluteValue = + this.convertPanelSpaceColorToAbsoluteValue(color); + this.transferFunction.updatePointColor( + nearestIndex, + colorInAbsoluteValue, + ); this.updateValue({ ...this.getModel(), sortedControlPoints: @@ -1124,6 +1129,31 @@ class TransferFunctionController extends RefCounted { 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); @@ -1134,10 +1164,16 @@ class TransferFunctionController extends RefCounted { const { normalizedX, normalizedY } = this.getControlPointPosition( event, ) as CanvasPosition; + const outputColor = vec4.fromValues( + color[0], + color[1], + color[2], + normalizedY, + ); this.transferFunction.addPoint( new ControlPoint( - normalizedX, - vec4.fromValues(color[0], color[1], color[2], normalizedY), + this.convertPanelSpaceInputToAbsoluteValue(normalizedX), + this.convertPanelSpaceColorToAbsoluteValue(outputColor) as vec4, ), ); this.currentGrabbedControlPointIndex = @@ -1157,10 +1193,13 @@ class TransferFunctionController extends RefCounted { this.transferFunction.trackable.value.sortedControlPoints.controlPoints[ this.currentGrabbedControlPointIndex ].outputColor; - newColor[3] = normalizedY; + newColor[3] = Math.round(normalizedY * 255); this.currentGrabbedControlPointIndex = this.transferFunction.updatePoint( this.currentGrabbedControlPointIndex, - new ControlPoint(normalizedX, newColor), + new ControlPoint( + this.convertPanelSpaceInputToAbsoluteValue(normalizedX), + newColor, + ), ); return { ...this.getModel(), @@ -1204,7 +1243,8 @@ class TransferFunctionController extends RefCounted { findControlPointIfNearCursor(event: MouseEvent) { const { transferFunction } = this; const { window } = transferFunction.trackable.value; - const numControlPoints = transferFunction.sortedControlPoints.controlPoints.length; + const numControlPoints = + transferFunction.sortedControlPoints.controlPoints.length; function convertControlPointInputToPanelSpace(controlPointIndex: number) { if (controlPointIndex < 0 || controlPointIndex >= numControlPoints) { return null; @@ -1215,7 +1255,7 @@ class TransferFunctionController extends RefCounted { .inputValue, ); } - function convertControlPointOpacityToPanelSpace(controlPointIndex: number){ + function convertControlPointOpacityToPanelSpace(controlPointIndex: number) { if (controlPointIndex < 0 || controlPointIndex >= numControlPoints) { return null; } From 6790a6364abcc6c69ebaa24acebdc3adfbf70e30 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 10:37:52 +0200 Subject: [PATCH 39/60] fix: correct number of lines --- src/widget/transfer_function.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index f6b1253d0..d396d2f87 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -187,7 +187,6 @@ export class ControlPoint { } export class SortedControlPoints { - // TODO (skm) is the copy needed? Does the constucture make sense? constructor( public controlPoints: ControlPoint[] = [], public range: DataTypeInterval, @@ -247,7 +246,6 @@ export class SortedControlPoints { this.controlPoints.sort( (a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range), ); - // TODO (skm) - are the negatives ok here? if (this.autoComputeRange) { if (this.controlPoints.length < 2) { return; @@ -256,6 +254,7 @@ export class SortedControlPoints { this.controlPoints[0].inputValue, this.controlPoints[this.controlPoints.length - 1].inputValue, ] as DataTypeInterval; + console.log("range", this.range); } } updateRange(newRange: DataTypeInterval) { @@ -655,7 +654,7 @@ class TransferFunctionPanel extends IndirectRenderedPanel { const { transferFunction } = this; const { controlPoints } = transferFunction.trackable.value.sortedControlPoints; - let numLines = controlPoints.length; + 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); From 5d61c62cdb93bdea4f8ad5a1810e4ab1a6eef02f Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 11:07:28 +0200 Subject: [PATCH 40/60] fix: display UI panel texture for TF --- src/widget/transfer_function.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index d396d2f87..767097eb7 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -78,7 +78,7 @@ import type { import { PositionWidget } from "#src/widget/position_widget.js"; import { Tab } from "#src/widget/tab_view.js"; -const TRANSFER_FUNCTION_PANEL_SIZE = 1024; +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; @@ -254,7 +254,6 @@ export class SortedControlPoints { this.controlPoints[0].inputValue, this.controlPoints[this.controlPoints.length - 1].inputValue, ] as DataTypeInterval; - console.log("range", this.range); } } updateRange(newRange: DataTypeInterval) { @@ -353,9 +352,10 @@ export class TransferFunction extends RefCounted { constructor( public dataType: DataType, public trackable: WatchableValueInterface, + size: number = defaultTransferFunctionSizes[dataType], ) { super(); - this.lookupTable = new LookupTable(defaultTransferFunctionSizes[dataType]); + this.lookupTable = new LookupTable(size); this.sortedControlPoints = this.trackable.value.sortedControlPoints; this.updateLookupTable(); } @@ -409,8 +409,8 @@ export class TransferFunction extends RefCounted { abstract class BaseLookupTexture extends RefCounted { texture: WebGLTexture | null = null; - width: number; - height = 1; + protected width: number; + protected height = 1; protected priorOptions: | LookupTableTextureOptions | ControlPointTextureOptions @@ -443,7 +443,6 @@ abstract class BaseLookupTexture extends RefCounted { 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); @@ -453,6 +452,10 @@ abstract class BaseLookupTexture extends RefCounted { if (texture === null) { texture = this.texture = gl.createTexture(); } + // TODO (SKM) remove + // if (!this.optionsEqual(options)) { + // console.log("update texture"); + // } // Update the texture activateAndBindTexture(gl, options.textureUnit); setRawTextureParameters(gl); @@ -477,7 +480,9 @@ abstract class BaseLookupTexture extends RefCounted { return this.width * this.height; } - + setTextureWidthAndHeightFromSize(size: number) { + this.width = size; + } disposed() { this.gl?.deleteTexture(this.texture); this.texture = null; @@ -516,6 +521,7 @@ class DirectLookupTableTexture extends BaseLookupTexture { return lookupTableEqual && textureUnitEqual; } createLookupTable(options: LookupTableTextureOptions): LookupTable { + this.setTextureWidthAndHeightFromSize(options.lookupTable.lookupTableSize); return options.lookupTable; } } @@ -549,9 +555,6 @@ export class ControlPointTexture extends BaseLookupTexture { lookupTable.updateFromControlPoints(sortedControlPoints); return lookupTable; } - setTextureWidthAndHeightFromSize(size: number) { - this.width = size; - } ensureTextureSize(size: number) { const gl = this.gl; if (gl === null) return; @@ -579,7 +582,11 @@ class TransferFunctionPanel extends IndirectRenderedPanel { return 1; } transferFunction = this.registerDisposer( - new TransferFunction(this.parent.dataType, this.parent.trackable), + new TransferFunction( + this.parent.dataType, + this.parent.trackable, + TRANSFER_FUNCTION_PANEL_SIZE, + ), ); controller = this.registerDisposer( new TransferFunctionController( From f8e4f10a90bb1f1936f84200bd6615a610ee1f29 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 11:29:22 +0200 Subject: [PATCH 41/60] fix: render data from TF texture --- src/widget/transfer_function.ts | 52 ++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 767097eb7..9dbc961f4 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -126,8 +126,6 @@ export interface ControlPointTextureOptions { lookupTableSize: number; } -// TODO (skm) - window currently doesn't work. Need to update round bound inputs -// TODO (skm) - these params seem a little odd, maybe the some can be computed FROM the trackable instead export interface TransferFunctionParameters { sortedControlPoints: SortedControlPoints; window: DataTypeInterval; @@ -260,6 +258,12 @@ export class SortedControlPoints { this.range = newRange; this.sortAndComputeRange(); } + copy() { + const controlPoints = this.controlPoints.map((point) => + ControlPoint.copyFrom(point), + ); + return new SortedControlPoints(controlPoints, this.range); + } } export class LookupTable { @@ -341,6 +345,11 @@ export class LookupTable { 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; + } } /** @@ -427,6 +436,9 @@ abstract class BaseLookupTexture extends RefCounted { abstract createLookupTable( options: LookupTableTextureOptions | ControlPointTextureOptions, ): LookupTable; + abstract setOptions( + options: LookupTableTextureOptions | ControlPointTextureOptions, + ): void; updateAndActivate( options: LookupTableTextureOptions | ControlPointTextureOptions, ) { @@ -452,10 +464,6 @@ abstract class BaseLookupTexture extends RefCounted { if (texture === null) { texture = this.texture = gl.createTexture(); } - // TODO (SKM) remove - // if (!this.optionsEqual(options)) { - // console.log("update texture"); - // } // Update the texture activateAndBindTexture(gl, options.textureUnit); setRawTextureParameters(gl); @@ -474,10 +482,7 @@ abstract class BaseLookupTexture extends RefCounted { ); // Update the prior options to the current options for future comparisons - // Make a copy of the options for the purpose of comparison - // TODO(skm) is this copy needed? - this.priorOptions = { ...options }; - + this.setOptions(options); return this.width * this.height; } setTextureWidthAndHeightFromSize(size: number) { @@ -505,25 +510,24 @@ class DirectLookupTableTexture extends BaseLookupTexture { optionsEqual(newOptions: LookupTableTextureOptions) { const existingOptions = this.priorOptions; if (existingOptions === undefined) return false; - let lookupTableEqual = true; - if ( - existingOptions.lookupTable !== undefined && - newOptions.lookupTable !== undefined - ) { - lookupTableEqual = LookupTable.equal( - existingOptions.lookupTable, - newOptions.lookupTable, - ); - } + 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 { @@ -546,6 +550,12 @@ export class ControlPointTexture extends BaseLookupTexture { 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); From 6d0544949248aef21b8f6243dec0c2148eb16a70 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 11:33:38 +0200 Subject: [PATCH 42/60] fix: remove fixed size TF texture --- src/webgl/shader_ui_controls.ts | 4 ---- src/widget/transfer_function.browser_test.ts | 1 - src/widget/transfer_function.ts | 2 +- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 687eb1924..bd9c6744d 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -76,8 +76,6 @@ import { } from "#src/widget/transfer_function.js"; // TODO (SKM) - remove temp -const TRANSFER_FUNCTION_LENGTH = 512; - export interface ShaderSliderControl { type: "slider"; valueType: "int" | "uint" | "float"; @@ -1693,13 +1691,11 @@ function setControlInShader( // Value is hard-coded in shader. break; case "transferFunction": - // TODO (SKM) - support variable length enableTransferFunctionShader( shader, uName, control.dataType, value.sortedControlPoints, - TRANSFER_FUNCTION_LENGTH, ); } } diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index 6dbe55ca5..e2e5e0094 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -252,7 +252,6 @@ val5 = uTransferFunctionEnd_doTransferFunction; "doTransferFunction", dataType, controlPoints, - textureSizes[dataType], ); tester.execute({ inputValue: point }); const values = tester.values; diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 9dbc961f4..cbceaa952 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -1479,7 +1479,7 @@ export function enableTransferFunctionShader( name: string, dataType: DataType, sortedControlPoints: SortedControlPoints, - lookupTableSize: number, + lookupTableSize: number = defaultTransferFunctionSizes[dataType], ) { const { gl } = shader; const texture = shader.transferFunctionTextures.get( From 9c179ef2888dd4c3b87082d2c2afdac3dae18d9b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 13:24:37 +0200 Subject: [PATCH 43/60] fix: link UI to JSON for control points --- src/webgl/shader_ui_controls.ts | 17 ++++++++++++----- src/widget/transfer_function.ts | 18 ++++++++++-------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index bd9c6744d..101ae3876 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -1172,16 +1172,23 @@ export function parseTransferFunctionParameters( }; } +function copyTransferFunctionParameters( + defaultValue: TransferFunctionParameters, +) { + return { + ...defaultValue, + sortedControlPoints: defaultValue.sortedControlPoints.copy(), + }; +} + export class TrackableTransferFunctionParameters extends TrackableValue { constructor( public dataType: DataType, public defaultValue: TransferFunctionParameters, ) { - // Make a copy of the default value so that we can modify it without affecting the original. - // This is necessary because the default value is compared with the current value to determine - // whether the value has changed -> which is necessary for the changed signal to be emitted. - //const defaultValueCopy = copyTransferFunctionParameters(defaultValue); - super(defaultValue, (obj) => + // Create a copy of the default value to avoid modifying it. + const defaultValueCopy = copyTransferFunctionParameters(defaultValue); + super(defaultValueCopy, (obj) => parseTransferFunctionParameters(obj, dataType, defaultValue), ); } diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index cbceaa952..cebd7ba5b 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -241,13 +241,14 @@ export class SortedControlPoints { ); } sortAndComputeRange() { + if (this.controlPoints.length == 0) { + return; + } this.controlPoints.sort( (a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range), ); if (this.autoComputeRange) { - if (this.controlPoints.length < 2) { - return; - } + // TODO (SKM) fix panel repr for length = 1 this.range = [ this.controlPoints[0].inputValue, this.controlPoints[this.controlPoints.length - 1].inputValue, @@ -259,10 +260,11 @@ export class SortedControlPoints { this.sortAndComputeRange(); } copy() { - const controlPoints = this.controlPoints.map((point) => + const copy = new SortedControlPoints([], this.range, this.autoComputeRange); + copy.controlPoints = this.controlPoints.map((point) => ControlPoint.copyFrom(point), ); - return new SortedControlPoints(controlPoints, this.range); + return copy; } } @@ -1177,9 +1179,9 @@ class TransferFunctionController extends RefCounted { this.currentGrabbedControlPointIndex = nearestIndex; return undefined; } - const { normalizedX, normalizedY } = this.getControlPointPosition( - event, - ) as CanvasPosition; + const position = this.getControlPointPosition(event); + if (position === undefined) return undefined; + const { normalizedX, normalizedY } = position; const outputColor = vec4.fromValues( color[0], color[1], From 5a4e218b38a2732c518533d8ea1b846e2cf2c4c4 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 15:37:35 +0200 Subject: [PATCH 44/60] fix: remove temps and TODOs --- src/webgl/shader_ui_controls.ts | 35 --------------------------------- src/widget/transfer_function.ts | 29 +++++++++++---------------- 2 files changed, 12 insertions(+), 52 deletions(-) diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 101ae3876..846ad203e 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -75,7 +75,6 @@ import { ControlPoint, } from "#src/widget/transfer_function.js"; -// TODO (SKM) - remove temp export interface ShaderSliderControl { type: "slider"; valueType: "int" | "uint" | "float"; @@ -603,7 +602,6 @@ function parseTransferFunctionDirective( [], dataType !== undefined ? defaultDataTypeRange[dataType] : [0, 1], ); - // TODO (skm) - support parsing window and size let specifedPoints = false; if (valueType !== "transferFunction") { errors.push("type must be transferFunction"); @@ -668,7 +666,6 @@ function parseTransferFunctionDirective( if (errors.length > 0) { return { errors }; } - // TODO (skm) - support parsing window and size return { control: { type: "transferFunction", @@ -1074,38 +1071,6 @@ function parseTransferFunctionControlPoints( } const inputValue = parseDataTypeValue(dataType, x[0]); - // TODO skm think above function replaces the below - // // Validate values - // let inputValue: number | Uint64; - // if (dataType !== DataType.UINT64) { - // const defaultRange = defaultDataTypeRange[dataType] as [number, number]; - // inputValue = verifyFiniteFloat(x[0]); - // if (inputValue < defaultRange[0] || inputValue > defaultRange[1]) { - // throw new Error( - // `Expected x in range [${defaultRange[0]}, ${ - // defaultRange[1] - // }], but received: ${JSON.stringify(x[0])}`, - // ); - // } - // } else { - // const defaultRange = defaultDataTypeRange[dataType] as [Uint64, Uint64]; - // if (typeof x[0] === "string") { - // inputValue = Uint64.parseString(x[0]); - // } else { - // inputValue = Uint64.fromNumber(x[0]); - // } - // if ( - // Uint64.less(inputValue, defaultRange[0]) || - // Uint64.less(defaultRange[1], inputValue) - // ) { - // throw new Error( - // `Expected x in range [${defaultRange[0]}, ${ - // defaultRange[1] - // }], but received: ${JSON.stringify(x[0])}`, - // ); - // } - // } - if (x[1].length !== 7 || x[1][0] !== "#") { throw new Error( `Expected #RRGGBB, but received: ${JSON.stringify(x[1])}`, diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index cebd7ba5b..80dc7e619 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -88,7 +88,6 @@ const transferFunctionSamplerTextureUnit = Symbol( "transferFunctionSamplerTexture", ); -// TODO consider increasing these if textures are packed const defaultTransferFunctionSizes: Record = { [DataType.UINT8]: 0xff, [DataType.INT8]: 0xff, @@ -101,7 +100,7 @@ const defaultTransferFunctionSizes: Record = { }; /** - * Options to update a lookup table texture + * 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 @@ -112,8 +111,7 @@ export interface LookupTableTextureOptions { } /** - * Options to update a transfer function texture - * TODO should this be sorted control points? + * 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 */ @@ -500,7 +498,6 @@ abstract class BaseLookupTexture extends RefCounted { /** * Represent the underlying transfer function lookup table as a texture - * TODO(skm) consider if height can be used for more efficiency */ class DirectLookupTableTexture extends BaseLookupTexture { texture: WebGLTexture | null = null; @@ -1016,14 +1013,13 @@ out_color = tempColor * alpha; } /** - * Create the bounds on the UI range inputs for the transfer function widget - * TODO this should now be window + * Create the bounds on the UI window inputs for the transfer function widget */ -function createRangeBoundInputs( +function createWindowBoundInputs( dataType: DataType, model: WatchableValueInterface, ) { - function createRangeBoundInput(endpoint: number): HTMLInputElement { + function createWindowBoundInput(endpoint: number): HTMLInputElement { const e = document.createElement("input"); e.addEventListener("focus", () => { e.select(); @@ -1034,13 +1030,13 @@ function createRangeBoundInputs( e.autocomplete = "off"; e.title = `${ endpoint === 0 ? "Lower" : "Upper" - } bound for transfer function range`; + } window for transfer function`; return e; } const container = document.createElement("div"); container.classList.add("neuroglancer-transfer-function-range-bounds"); - const inputs = [createRangeBoundInput(0), createRangeBoundInput(1)]; + const inputs = [createWindowBoundInput(0), createWindowBoundInput(1)]; for (let endpointIndex = 0; endpointIndex < 2; ++endpointIndex) { const input = inputs[endpointIndex]; input.addEventListener("input", () => { @@ -1357,8 +1353,7 @@ class TransferFunctionWidget extends Tab { new TransferFunctionPanel(this), ); - // TODO window - range = createRangeBoundInputs(this.dataType, this.trackable); + window = createWindowBoundInputs(this.dataType, this.trackable); constructor( visibility: WatchableVisibilityPriority, public display: DisplayContext, @@ -1371,8 +1366,8 @@ class TransferFunctionWidget extends Tab { element.appendChild(this.transferFunctionPanel.element); // Range bounds element - element.appendChild(this.range.container); - this.range.container.dispatchEvent(new Event("change")); + element.appendChild(this.window.container); + this.window.container.dispatchEvent(new Event("change")); // Color picker element const colorPickerDiv = document.createElement("div"); @@ -1409,8 +1404,8 @@ class TransferFunctionWidget extends Tab { this.updateControlPointsAndDraw(); }), ); - updateInputBoundValue(this.range.inputs[0], this.trackable.value.window[0]); - updateInputBoundValue(this.range.inputs[1], this.trackable.value.window[1]); + updateInputBoundValue(this.window.inputs[0], this.trackable.value.window[0]); + updateInputBoundValue(this.window.inputs[1], this.trackable.value.window[1]); } updateView() { this.transferFunctionPanel.scheduleRedraw(); From 2cd6829bdb8a6cd78210fd8629309d4443487ec6 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 15:59:28 +0200 Subject: [PATCH 45/60] fix: handle control point range for 0 and 1 points --- src/webgl/shader_ui_controls.ts | 6 +- src/widget/transfer_function.browser_test.ts | 30 ++++++---- src/widget/transfer_function.ts | 59 +++++++++++--------- 3 files changed, 57 insertions(+), 38 deletions(-) diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 846ad203e..e1c88f02e 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -58,7 +58,7 @@ import { } 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 { 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"; @@ -600,7 +600,7 @@ function parseTransferFunctionDirective( let window: DataTypeInterval | undefined; let sortedControlPoints = new SortedControlPoints( [], - dataType !== undefined ? defaultDataTypeRange[dataType] : [0, 1], + dataType !== undefined ? dataType : DataType.FLOAT32, ); let specifedPoints = false; if (valueType !== "transferFunction") { @@ -1097,7 +1097,7 @@ function parseTransferFunctionControlPoints( ), ); }); - return new SortedControlPoints(parsedPoints, defaultDataTypeRange[dataType]); + return new SortedControlPoints(parsedPoints, dataType); } export function parseTransferFunctionParameters( diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index e2e5e0094..a0ed0c016 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -37,14 +37,13 @@ import { const FIXED_TRANSFER_FUNCTION_LENGTH = 1024; function makeTransferFunction(controlPoints: ControlPoint[]) { - const range = defaultDataTypeRange[DataType.UINT8]; - const sortedControlPoints = new SortedControlPoints(controlPoints, range); + const sortedControlPoints = new SortedControlPoints(controlPoints, DataType.UINT8); return new TransferFunction( DataType.UINT8, new TrackableValue( { sortedControlPoints, - window: range, + window: defaultDataTypeRange[DataType.UINT8], defaultColor: vec3.fromValues(0, 0, 0), channel: [], }, @@ -59,8 +58,7 @@ describe("lerpBetweenControlPoints", () => { ); it("returns transparent black when given no control points for base classes", () => { const controlPoints: ControlPoint[] = []; - const range = defaultDataTypeRange[DataType.UINT8]; - const sortedControlPoints = new SortedControlPoints(controlPoints, range); + const sortedControlPoints = new SortedControlPoints(controlPoints, DataType.UINT8); const lookupTable = new LookupTable(FIXED_TRANSFER_FUNCTION_LENGTH); lookupTable.updateFromControlPoints(sortedControlPoints); @@ -77,8 +75,13 @@ describe("lerpBetweenControlPoints", () => { new ControlPoint(120, vec4.fromValues(21, 22, 254, 210)), ]; const transferFunction = makeTransferFunction(controlPoints); + console.log(transferFunction); const output = transferFunction.lookupTable.outputValues; - const firstPointTransferIndex = transferFunction.toLookupTableIndex(0)!; + const firstPointTransferIndex = + transferFunction.sortedControlPoints.controlPoints[0].transferFunctionIndex( + transferFunction.sortedControlPoints.range, + transferFunction.size, + ); expect( output @@ -86,6 +89,7 @@ describe("lerpBetweenControlPoints", () => { .every((value) => value === 0), ).toBeTruthy(); const endPiece = output.slice(NUM_COLOR_CHANNELS * firstPointTransferIndex); + console.log(firstPointTransferIndex, endPiece); const color = controlPoints[0].outputColor; expect( endPiece.every( @@ -94,6 +98,12 @@ describe("lerpBetweenControlPoints", () => { ).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)), @@ -101,9 +111,9 @@ describe("lerpBetweenControlPoints", () => { ]; const transferFunction = makeTransferFunction(controlPoints); const output = transferFunction.lookupTable.outputValues; - const firstPointTransferIndex = transferFunction.toLookupTableIndex(0)!; - const secondPointTransferIndex = transferFunction.toLookupTableIndex(1)!; - const thirdPointTransferIndex = transferFunction.toLookupTableIndex(2)!; + 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( @@ -210,7 +220,7 @@ describe("compute transfer function on GPU", () => { new ControlPoint(range[0], vec4.fromValues(0, 0, 0, 0)), new ControlPoint(range[1], vec4.fromValues(255, 255, 255, 255)), ], - range, + dataType, ); it(`computes transfer function between transparent black and opaque white on GPU for ${DataType[dataType]}`, () => { const shaderType = getShaderType(dataType); diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 80dc7e619..780b55e76 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -40,6 +40,7 @@ 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"; @@ -160,6 +161,8 @@ export class ControlPoint { dataRange: DataTypeInterval, transferFunctionSize: number, ): number { + if (dataRange[0] === dataRange[1]) { + } return Math.floor( this.normalizedInput(dataRange) * (transferFunctionSize - 1), ); @@ -183,13 +186,14 @@ export class ControlPoint { } export class SortedControlPoints { + public range: DataTypeInterval; constructor( public controlPoints: ControlPoint[] = [], - public range: DataTypeInterval, + public dataType: DataType, private autoComputeRange: boolean = true, ) { this.controlPoints = controlPoints; - this.range = range; + this.range = defaultDataTypeRange[dataType]; this.sortAndComputeRange(); } get length() { @@ -240,17 +244,24 @@ export class SortedControlPoints { } sortAndComputeRange() { if (this.controlPoints.length == 0) { + this.range = defaultDataTypeRange[this.dataType]; return; } this.controlPoints.sort( (a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range), ); if (this.autoComputeRange) { - // TODO (SKM) fix panel repr for length = 1 - this.range = [ - this.controlPoints[0].inputValue, - this.controlPoints[this.controlPoints.length - 1].inputValue, - ] as DataTypeInterval; + 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; + } } } updateRange(newRange: DataTypeInterval) { @@ -258,7 +269,12 @@ export class SortedControlPoints { this.sortAndComputeRange(); } copy() { - const copy = new SortedControlPoints([], this.range, this.autoComputeRange); + const copy = new SortedControlPoints( + [], + this.dataType, + this.autoComputeRange, + ); + copy.range = this.range; copy.controlPoints = this.controlPoints.map((point) => ControlPoint.copyFrom(point), ); @@ -357,7 +373,6 @@ export class LookupTable { */ export class TransferFunction extends RefCounted { lookupTable: LookupTable; - sortedControlPoints: SortedControlPoints; constructor( public dataType: DataType, public trackable: WatchableValueInterface, @@ -365,22 +380,10 @@ export class TransferFunction extends RefCounted { ) { super(); this.lookupTable = new LookupTable(size); - this.sortedControlPoints = this.trackable.value.sortedControlPoints; this.updateLookupTable(); } - /** The index of the vec4 in the lookup table corresponding to the given control point. Supports negative indexing */ - toLookupTableIndex( - controlPointIndex: number, - rollIndex: boolean = true, - ): number | undefined { - let index = controlPointIndex; - if (rollIndex && index < 0) { - index = this.sortedControlPoints.controlPoints.length + controlPointIndex; - } - return this.sortedControlPoints.controlPoints[index]?.transferFunctionIndex( - this.sortedControlPoints.range, - this.lookupTable.lookupTableSize, - ); + get sortedControlPoints() { + return this.trackable.value.sortedControlPoints; } updateLookupTable() { this.lookupTable.updateFromControlPoints(this.sortedControlPoints); @@ -1404,8 +1407,14 @@ class TransferFunctionWidget extends Tab { this.updateControlPointsAndDraw(); }), ); - updateInputBoundValue(this.window.inputs[0], this.trackable.value.window[0]); - updateInputBoundValue(this.window.inputs[1], this.trackable.value.window[1]); + updateInputBoundValue( + this.window.inputs[0], + this.trackable.value.window[0], + ); + updateInputBoundValue( + this.window.inputs[1], + this.trackable.value.window[1], + ); } updateView() { this.transferFunctionPanel.scheduleRedraw(); From 2cfe965276d79890434907fdb0de33985864ba50 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 16:05:30 +0200 Subject: [PATCH 46/60] fix: test --- src/widget/transfer_function.browser_test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index a0ed0c016..bd12727d6 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -75,7 +75,6 @@ describe("lerpBetweenControlPoints", () => { new ControlPoint(120, vec4.fromValues(21, 22, 254, 210)), ]; const transferFunction = makeTransferFunction(controlPoints); - console.log(transferFunction); const output = transferFunction.lookupTable.outputValues; const firstPointTransferIndex = transferFunction.sortedControlPoints.controlPoints[0].transferFunctionIndex( @@ -89,7 +88,6 @@ describe("lerpBetweenControlPoints", () => { .every((value) => value === 0), ).toBeTruthy(); const endPiece = output.slice(NUM_COLOR_CHANNELS * firstPointTransferIndex); - console.log(firstPointTransferIndex, endPiece); const color = controlPoints[0].outputColor; expect( endPiece.every( @@ -262,6 +260,7 @@ val5 = uTransferFunctionEnd_doTransferFunction; "doTransferFunction", dataType, controlPoints, + textureSizes[dataType], ); tester.execute({ inputValue: point }); const values = tester.values; From 2ed5fdfd8b838bddc71e9d4e9a910ee3957dbd1c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 16:43:12 +0200 Subject: [PATCH 47/60] fix: unused code --- src/webgl/shader_ui_controls.browser_test.ts | 39 +++++++++++++------- src/webgl/shader_ui_controls.ts | 2 +- src/widget/transfer_function.ts | 2 - 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/webgl/shader_ui_controls.browser_test.ts b/src/webgl/shader_ui_controls.browser_test.ts index 4c34f844b..b55449f0a 100644 --- a/src/webgl/shader_ui_controls.browser_test.ts +++ b/src/webgl/shader_ui_controls.browser_test.ts @@ -598,7 +598,7 @@ void main() { type: "transferFunction", dataType: DataType.UINT8, default: { - sortedControlPoints: new SortedControlPoints([], range), + sortedControlPoints: new SortedControlPoints([], DataType.UINT8), channel: [], defaultColor: vec3.fromValues(1, 1, 1), window: range, @@ -635,7 +635,7 @@ void main() { type: "transferFunction", dataType: DataType.UINT16, default: { - sortedControlPoints: new SortedControlPoints([], range), + sortedControlPoints: new SortedControlPoints([], DataType.UINT16), channel: [0], defaultColor: vec3.fromValues(1, 1, 1), window: range, @@ -672,7 +672,7 @@ void main() { type: "transferFunction", dataType: DataType.UINT64, default: { - sortedControlPoints: new SortedControlPoints([], range), + sortedControlPoints: new SortedControlPoints([], DataType.UINT64), channel: [], defaultColor: vec3.fromValues(1, 1, 1), window: range, @@ -709,7 +709,10 @@ void main() { type: "transferFunction", dataType: DataType.FLOAT32, default: { - sortedControlPoints: new SortedControlPoints([], range), + sortedControlPoints: new SortedControlPoints( + [], + DataType.FLOAT32, + ), channel: [1], defaultColor: vec3.fromValues(1, 1, 1), window: range, @@ -746,7 +749,10 @@ void main() { type: "transferFunction", dataType: DataType.FLOAT32, default: { - sortedControlPoints: new SortedControlPoints([], range), + sortedControlPoints: new SortedControlPoints( + [], + DataType.FLOAT32, + ), channel: [1], defaultColor: vec3.fromValues(1, 1, 1), window: range, @@ -783,7 +789,10 @@ void main() { type: "transferFunction", dataType: DataType.FLOAT32, default: { - sortedControlPoints: new SortedControlPoints([], range), + sortedControlPoints: new SortedControlPoints( + [], + DataType.FLOAT32, + ), channel: [1, 2], defaultColor: vec3.fromValues(1, 1, 1), window: range, @@ -809,8 +818,10 @@ void main() { new ControlPoint(200, vec4.fromValues(0, 255, 0, 26)), new ControlPoint(100, vec4.fromValues(255, 0, 0, 128)), ]; - const range = defaultDataTypeRange[DataType.UINT32]; - const sortedControlPoints = new SortedControlPoints(controlPoints, range); + const sortedControlPoints = new SortedControlPoints( + controlPoints, + DataType.UINT32, + ); expect( parseShaderUiControls(code, { imageData: { dataType: DataType.UINT32, channelRank: 0 }, @@ -848,7 +859,6 @@ void main() { void main() { } `; - const range = defaultDataTypeRange[DataType.UINT64]; const controlPoints = [ new ControlPoint( Uint64.parseString("9223372111111111111"), @@ -860,7 +870,10 @@ void main() { vec4.fromValues(0, 255, 0, 26), ), ]; - const sortedControlPoints = new SortedControlPoints(controlPoints, range); + const sortedControlPoints = new SortedControlPoints( + controlPoints, + DataType.UINT64, + ); expect( parseShaderUiControls(code, { imageData: { dataType: DataType.UINT64, channelRank: 0 }, @@ -909,7 +922,7 @@ void main() { ]; const sortedControlPoints = new SortedControlPoints( controlPoints, - [0, 255], + DataType.UINT8, ); expect( parseShaderUiControls(code, { @@ -968,7 +981,7 @@ void main() { new ControlPoint(0, vec4.fromValues(0, 0, 0, 0)), new ControlPoint(150, vec4.fromValues(255, 255, 255, 255)), ], - [0, 255], + DataType.UINT8, ), channel: [], defaultColor: vec3.fromValues(1, 0, 0), @@ -1001,7 +1014,7 @@ void main() { vec4.fromValues(255, 255, 255, 255), ), ], - defaultDataTypeRange[DataType.UINT64], + DataType.UINT64, ); transferFunctionParameters.value = { ...default_val, diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index e1c88f02e..f7c3375d6 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -58,7 +58,7 @@ import { } from "#src/util/lerp.js"; import { NullarySignal } from "#src/util/signal.js"; import type { Trackable } from "#src/util/trackable.js"; -import { Uint64 } from "#src/util/uint64.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"; diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 780b55e76..ce9479dd3 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -161,8 +161,6 @@ export class ControlPoint { dataRange: DataTypeInterval, transferFunctionSize: number, ): number { - if (dataRange[0] === dataRange[1]) { - } return Math.floor( this.normalizedInput(dataRange) * (transferFunctionSize - 1), ); From ffdf603c79d0ffa68f348b1079507edb283fd51d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 17:04:20 +0200 Subject: [PATCH 48/60] fix: can no longer lose control of point that you were trying to move --- src/widget/transfer_function.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index ce9479dd3..de6cdcaf5 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -215,8 +215,19 @@ export class SortedControlPoints { updatePoint(index: number, controlPoint: ControlPoint): number { this.controlPoints[index] = controlPoint; const value = controlPoint.inputValue; + const outputValue = controlPoint.outputColor; this.sortAndComputeRange(); - return this.findNearestControlPointIndex(value); + // If two points end up with the same x value, 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(); @@ -261,6 +272,9 @@ export class SortedControlPoints { ] as DataTypeInterval; } } + if (this.range[0] === this.range[1]) { + this.range = defaultDataTypeRange[this.dataType]; + } } updateRange(newRange: DataTypeInterval) { this.range = newRange; From 7209fc0721a5263374fe69379fb68d50e66535c8 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 17:11:10 +0200 Subject: [PATCH 49/60] fix: compute range after removing a point --- src/widget/transfer_function.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index de6cdcaf5..28ab7b5d0 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -211,6 +211,7 @@ export class SortedControlPoints { } removePoint(index: number) { this.controlPoints.splice(index, 1); + this.computeRange(); } updatePoint(index: number, controlPoint: ControlPoint): number { this.controlPoints[index] = controlPoint; @@ -251,7 +252,7 @@ export class SortedControlPoints { (a, b) => a - b, ); } - sortAndComputeRange() { + private sortAndComputeRange() { if (this.controlPoints.length == 0) { this.range = defaultDataTypeRange[this.dataType]; return; @@ -259,6 +260,9 @@ export class SortedControlPoints { this.controlPoints.sort( (a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range), ); + this.computeRange(); + } + private computeRange() { if (this.autoComputeRange) { if (this.controlPoints.length === 1) { this.range = [ From 57450e79984d9a5b1c87eab09af5964897361e7b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Apr 2024 17:13:02 +0200 Subject: [PATCH 50/60] refactor: clearer range update --- src/widget/transfer_function.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 28ab7b5d0..2c6ae7b3c 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -253,10 +253,6 @@ export class SortedControlPoints { ); } private sortAndComputeRange() { - if (this.controlPoints.length == 0) { - this.range = defaultDataTypeRange[this.dataType]; - return; - } this.controlPoints.sort( (a, b) => a.normalizedInput(this.range) - b.normalizedInput(this.range), ); @@ -264,7 +260,10 @@ export class SortedControlPoints { } private computeRange() { if (this.autoComputeRange) { - if (this.controlPoints.length === 1) { + 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], From 0ee67463014b455470a9b657782a2626517885e1 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 18 Apr 2024 11:37:23 +0200 Subject: [PATCH 51/60] fix: tf UI has correct texture indicator --- src/widget/transfer_function.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 2c6ae7b3c..87e803899 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -262,8 +262,7 @@ export class SortedControlPoints { if (this.autoComputeRange) { if (this.controlPoints.length == 0) { this.range = defaultDataTypeRange[this.dataType]; - } - else if (this.controlPoints.length === 1) { + } else if (this.controlPoints.length === 1) { this.range = [ this.controlPoints[0].inputValue, defaultDataTypeRange[this.dataType][1], @@ -318,8 +317,12 @@ export class LookupTable { * @param controlPoints The control points to interpolate between * @param dataRange The range of the input data space */ - updateFromControlPoints(sortedControlPoints: SortedControlPoints) { - const { controlPoints, range } = sortedControlPoints; + 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) { @@ -400,8 +403,8 @@ export class TransferFunction extends RefCounted { get sortedControlPoints() { return this.trackable.value.sortedControlPoints; } - updateLookupTable() { - this.lookupTable.updateFromControlPoints(this.sortedControlPoints); + updateLookupTable(window: DataTypeInterval | undefined = undefined) { + this.lookupTable.updateFromControlPoints(this.sortedControlPoints, window); } addPoint(controlPoint: ControlPoint) { this.sortedControlPoints.addPoint(controlPoint); @@ -1022,7 +1025,7 @@ out_color = tempColor * alpha; gl.disable(WebGL2RenderingContext.BLEND); } update() { - this.transferFunction.updateLookupTable(); + this.transferFunction.updateLookupTable(this.parent.trackable.value.window); this.updateTransferFunctionPointsAndLines(); } isReady() { From dbed2d4c19a81ff0cd91af80fcd1653c05589fd8 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 18 Apr 2024 12:09:33 +0200 Subject: [PATCH 52/60] feat: default intensity for transfer functions --- src/widget/transfer_function.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 87e803899..c74ea2a37 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -1473,6 +1473,7 @@ vec4 ${name}_(float inputValue) { } 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}() { From 7065562930ac959fed8d302c66c830a139e877c6 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 18 Apr 2024 13:02:49 +0200 Subject: [PATCH 53/60] fix: don't crash on window[0] === window[1] in TF UI panel --- src/widget/transfer_function.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index c74ea2a37..a69f9ff80 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -1075,6 +1075,9 @@ function createWindowBoundInputs( 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]); From ee4f2df23fba38a8e77f063f4574de7a28097c43 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 18 Apr 2024 18:29:33 +0200 Subject: [PATCH 54/60] fix: userIntensity always overwrites default --- src/volume_rendering/volume_render_layer.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/volume_rendering/volume_render_layer.ts b/src/volume_rendering/volume_render_layer.ts index 307a8fc9c..7ad132b3a 100644 --- a/src/volume_rendering/volume_render_layer.ts +++ b/src/volume_rendering/volume_render_layer.ts @@ -254,16 +254,18 @@ void emitIntensity(float value) { float savedDepth = 0.0; float savedIntensity = 0.0; vec4 newColor = vec4(0.0); +float userEmittedIntensity = -100.0; `); glsl_emitIntensity = ` float convertIntensity(float value) { return clamp(${glsl_intensityConversion}, 0.0, 1.0); } void emitIntensity(float value) { - defaultMaxProjectionIntensity = value; + userEmittedIntensity = value; } float getIntensity() { - return convertIntensity(defaultMaxProjectionIntensity); + float intensity = userEmittedIntensity > -100.0 ? userEmittedIntensity : defaultMaxProjectionIntensity; + return convertIntensity(intensity); } `; glsl_rgbaEmit = ` @@ -283,6 +285,7 @@ void emitRGBA(vec4 rgba) { outputColor = intensityChanged ? newColor : outputColor; emit(outputColor, savedDepth, savedIntensity); defaultMaxProjectionIntensity = 0.0; + userEmittedIntensity = -100.0; `; } emitter(builder); From 1f81dcd04b38367a1a6aea74caa48fa49fd8e08c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Apr 2024 12:13:06 +0200 Subject: [PATCH 55/60] docs: update transfer function docs --- src/sliceview/image_layer_rendering.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/sliceview/image_layer_rendering.md b/src/sliceview/image_layer_rendering.md index 46844c6ab..412cd3362 100644 --- a/src/sliceview/image_layer_rendering.md +++ b/src/sliceview/image_layer_rendering.md @@ -158,30 +158,38 @@ the inverse linear interpolation of the data value for configured channel/proper ### `transferFunction` controls The `transferFunction` control type allows the user to specify a function which maps -each 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. Any data point in the range that lies before the first control point is completely transparent. Any data point in the range that lies after the last control point has the value of the last control point. Any data point outside the range is clamped to lie within the range. In between control points, the color and opacity is linearly interpolated. +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 (range=[lower, higher], controlPoints=[[input, hexColorString, opacity]], channel=[], color="#rrggbb") -// For example: -#uicontrol transferFunction colormap(range=[0, 100], controlPoints=[[0.0, "#000000", 0.0], [100.0, "#ffffff", 1.0]], channel=[], color="#rrggbb") +#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: -- `range`: Optional. The default input range to map to an output. Must be specified as an array. May be overridden using the UI control. If not specified, defaults to the full range of - the data type for integer data types, and `[0, 1]` for float32. It is valid to specify an - inverted interval like `[50, 20]`. +- `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 valid to specify an + inverted interval like `[50, 20]`, but not 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 knee from transparent black to fully opaque white. +- `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. -- `color`: 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`. +- `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 From b2f8cc189aeae24e632f918e16613f4e30963fa1 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Apr 2024 12:13:23 +0200 Subject: [PATCH 56/60] tests: fix a test for TFs with uint64 data --- src/webgl/shader_ui_controls.browser_test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webgl/shader_ui_controls.browser_test.ts b/src/webgl/shader_ui_controls.browser_test.ts index b55449f0a..2b2569778 100644 --- a/src/webgl/shader_ui_controls.browser_test.ts +++ b/src/webgl/shader_ui_controls.browser_test.ts @@ -850,7 +850,7 @@ void main() { }); 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=[]) +#uicontrol transferFunction colormap(controlPoints=[["18446744073709551615", "#00ff00", 0.1], ["9223372111111111111", "#ff0000", 0.5], [0, "#000000", 0.0]], defaultColor="#0000ff", channel=[], window=[0, 2000]) void main() { } `; @@ -892,7 +892,7 @@ void main() { sortedControlPoints: sortedControlPoints, channel: [], defaultColor: vec3.fromValues(0, 0, 1), - window: sortedControlPoints.range, + window: [0, 2000], }, }, ], From 7a50e556252808b03d05265d6abcb45bec2961db Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Apr 2024 13:20:03 +0200 Subject: [PATCH 57/60] feat: use non-interpolated value in TF for consistency and efficiency --- src/widget/transfer_function.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index a69f9ff80..a9e040002 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -1480,7 +1480,7 @@ vec4 ${name}(${shaderType} inputValue) { return v < 0.0 ? vec4(0.0, 0.0, 0.0, 0.0) : ${name}_(clamp(v, 0.0, 1.0)); } vec4 ${name}() { - return ${name}(getInterpolatedDataValue(${channel.join(",")})); + return ${name}(getDataValue(${channel.join(",")})); } `; if (dataType !== DataType.UINT64 && dataType !== DataType.FLOAT32) { From 8f485dbd10a03f7217e8fdba04334290e81f4444 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Apr 2024 17:42:21 +0200 Subject: [PATCH 58/60] fix: tests --- src/webgl/shader_ui_controls.browser_test.ts | 2 +- src/widget/transfer_function.browser_test.ts | 25 ++++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/webgl/shader_ui_controls.browser_test.ts b/src/webgl/shader_ui_controls.browser_test.ts index 2b2569778..a5cea04d8 100644 --- a/src/webgl/shader_ui_controls.browser_test.ts +++ b/src/webgl/shader_ui_controls.browser_test.ts @@ -892,7 +892,7 @@ void main() { sortedControlPoints: sortedControlPoints, channel: [], defaultColor: vec3.fromValues(0, 0, 1), - window: [0, 2000], + window: [Uint64.fromNumber(0), Uint64.fromNumber(2000)], }, }, ], diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index bd12727d6..f9a81233b 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -37,7 +37,10 @@ import { const FIXED_TRANSFER_FUNCTION_LENGTH = 1024; function makeTransferFunction(controlPoints: ControlPoint[]) { - const sortedControlPoints = new SortedControlPoints(controlPoints, DataType.UINT8); + const sortedControlPoints = new SortedControlPoints( + controlPoints, + DataType.UINT8, + ); return new TransferFunction( DataType.UINT8, new TrackableValue( @@ -58,7 +61,10 @@ describe("lerpBetweenControlPoints", () => { ); it("returns transparent black when given no control points for base classes", () => { const controlPoints: ControlPoint[] = []; - const sortedControlPoints = new SortedControlPoints(controlPoints, DataType.UINT8); + const sortedControlPoints = new SortedControlPoints( + controlPoints, + DataType.UINT8, + ); const lookupTable = new LookupTable(FIXED_TRANSFER_FUNCTION_LENGTH); lookupTable.updateFromControlPoints(sortedControlPoints); @@ -96,8 +102,13 @@ describe("lerpBetweenControlPoints", () => { ).toBeTruthy(); }); it("correctly interpolates between three control points", () => { - function toLookupTableIndex(transferFunction: TransferFunction, index: number) { - return transferFunction.sortedControlPoints.controlPoints[index].transferFunctionIndex( + function toLookupTableIndex( + transferFunction: TransferFunction, + index: number, + ) { + return transferFunction.sortedControlPoints.controlPoints[ + index + ].transferFunctionIndex( transferFunction.sortedControlPoints.range, transferFunction.size, ); @@ -236,7 +247,11 @@ describe("compute transfer function on GPU", () => { builder.addFragmentCode(` ${shaderType} getInterpolatedDataValue() { return inputValue; -}`); +} +${shaderType} getDataValue() { + return inputValue; +} +`); builder.addFragmentCode( defineTransferFunctionShader( builder, From 02b415319336467c5528c79617dde75440a3a29f Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Apr 2024 18:11:24 +0200 Subject: [PATCH 59/60] Python(fix): fix transfer function control input --- python/neuroglancer/viewer_state.py | 17 +++++--------- python/tests/shader_controls_test.py | 34 +++++++++++++++------------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index c0495d622..1d2f1d62e 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -518,21 +518,14 @@ class InvlerpParameters(JsonObjectWrapper): channel = wrapped_property("channel", optional(typed_list(int))) -@export -class ControlPointsSpec(JsonObjectWrapper): - input = wrapped_property("input", optional(numbers.Number)) - color = wrapped_property("color", optional(str)) - opacity = wrapped_property("opacity", optional(float)) - - @export class TransferFunctionParameters(JsonObjectWrapper): - range = wrapped_property("range", optional(array_wrapper(numbers.Number, 2))) + 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(ControlPointsSpec)) + "controlPoints", optional(typed_list(typed_list(number_or_string))) ) - color = wrapped_property("color", optional(str)) + defaultColor = wrapped_property("defaultColor", optional(str)) _UINT64_STR_PATTERN = re.compile("[0-9]+") @@ -1300,7 +1293,9 @@ def interpolate(a, b, t): if index == -1: continue other_layer = b[index] - if type(other_layer.layer) is not type(layer.layer): # pylint: disable=unidiomatic-typecheck # noqa: E721 + if type(other_layer.layer) is not type( + layer.layer + ): # pylint: disable=unidiomatic-typecheck # noqa: E721 continue layer.layer = type(layer.layer).interpolate( layer.layer, other_layer.layer, t diff --git a/python/tests/shader_controls_test.py b/python/tests/shader_controls_test.py index 7aa9809f5..0753ad987 100644 --- a/python/tests/shader_controls_test.py +++ b/python/tests/shader_controls_test.py @@ -72,13 +72,10 @@ def test_transfer_function(webdriver): """ shaderControls = { "colormap": { - "controlPoints": [ - {"input": 0, "color": "#000000", "opacity": 0.0}, - {"input": 84, "color": "#ffffff", "opacity": 1.0}, - ], - "range": [0, 100], + "controlPoints": [[0, "#000000", 0.0], [84, "#ffffff", 1.0]], + "window": [0, 50], "channel": [], - "color": "#ff00ff", + "defaultColor": "#ff00ff", } } with webdriver.viewer.txn() as s: @@ -91,7 +88,7 @@ def test_transfer_function(webdriver): layer=neuroglancer.ImageLayer( source=neuroglancer.LocalVolume( dimensions=s.dimensions, - data=np.full(shape=(1, 1), dtype=np.uint32, fill_value=42), + data=np.full(shape=(1, 1), dtype=np.uint64, fill_value=63), ), ), visible=True, @@ -105,7 +102,8 @@ def test_transfer_function(webdriver): s.show_axis_lines = False control = webdriver.viewer.state.layers["image"].shader_controls["colormap"] assert isinstance(control, neuroglancer.TransferFunctionParameters) - np.testing.assert_equal(control.range, [0, 100]) + np.testing.assert_equal(control.window, [0, 50]) + assert control.defaultColor == "#ff00ff" def expect_color(color): webdriver.sync() @@ -115,20 +113,24 @@ def expect_color(color): np.tile(np.array(color, dtype=np.uint8), (10, 10, 1)), ) - expect_color([64, 64, 64, 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=[ - {"input": 0, "color": "#000000", "opacity": 1.0}, - {"input": 84, "color": "#ffffff", "opacity": 1.0}, - ], - range=[50, 90], + controlPoints=[[0, "#000000", 0.0], [84, "#ffffff", 1.0]], + window=[500, 5000], channel=[], - color="#ff00ff", + defaultColor="#ff0000", ) } - expect_color([0, 0, 0, 255]) + 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): From c738b16c12f133792042c13860c789fef37493c3 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Apr 2024 18:13:22 +0200 Subject: [PATCH 60/60] docs: fix formatting --- src/sliceview/image_layer_rendering.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sliceview/image_layer_rendering.md b/src/sliceview/image_layer_rendering.md index 412cd3362..2802b2997 100644 --- a/src/sliceview/image_layer_rendering.md +++ b/src/sliceview/image_layer_rendering.md @@ -160,7 +160,7 @@ the inverse linear interpolation of the data value for configured channel/proper 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. +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.