diff --git a/Sources/Common/Core/Math/index.d.ts b/Sources/Common/Core/Math/index.d.ts index 95e7dea8b8a..241e1991f59 100755 --- a/Sources/Common/Core/Math/index.d.ts +++ b/Sources/Common/Core/Math/index.d.ts @@ -471,9 +471,9 @@ export function multiply3x3_mat3(a_3x3: Matrix3x3, b_3x3: Matrix3x3, out_3x3: Ma * @param {Number} colA * @param {Number} rowB * @param {Number} colB - * @param {Matrix} out_rowXcol + * @param {Matrix} outRowAColB */ -export function multiplyMatrix(a: Matrix, b: Matrix, rowA: number, colA: number, rowB: number, colB: number, out_rowXcol: Matrix): void; +export function multiplyMatrix(a: Matrix, b: Matrix, rowA: number, colA: number, rowB: number, colB: number, outRowAColB: Matrix): void; /** * Transpose a 3x3 matrix. diff --git a/Sources/Common/Core/Math/index.js b/Sources/Common/Core/Math/index.js index 31055a0c2eb..f7f5997223a 100644 --- a/Sources/Common/Core/Math/index.js +++ b/Sources/Common/Core/Math/index.js @@ -609,7 +609,7 @@ export function multiply3x3_mat3(a_3x3, b_3x3, out_3x3) { } } -export function multiplyMatrix(a, b, rowA, colA, rowB, colB, out_rowXcol) { +export function multiplyMatrix(a, b, rowA, colA, rowB, colB, outRowAColB) { // we need colA == rowB if (colA !== rowB) { vtkErrorMacro('Number of columns of A must match number of rows of B.'); @@ -623,10 +623,10 @@ export function multiplyMatrix(a, b, rowA, colA, rowB, colB, out_rowXcol) { for (let i = 0; i < rowA; i++) { // output col for (let j = 0; j < colB; j++) { - out_rowXcol[i * colB + j] = 0; + outRowAColB[i * colB + j] = 0; // sum for this point for (let k = 0; k < colA; k++) { - out_rowXcol[i * colB + j] += copyA[i * colA + k] * copyB[j + colB * k]; + outRowAColB[i * colB + j] += copyA[i * colA + k] * copyB[j + colB * k]; } } } diff --git a/Sources/Filters/General/ScalarToRGBA/index.js b/Sources/Filters/General/ScalarToRGBA/index.js index cf61ab16f18..dba2c515718 100644 --- a/Sources/Filters/General/ScalarToRGBA/index.js +++ b/Sources/Filters/General/ScalarToRGBA/index.js @@ -40,7 +40,7 @@ function vtkScalarToRGBA(publicAPI, model) { const rgba = [0, 0, 0, 0]; const data = scalars.getData(); - const rgbaArray = new Uint8Array(data.length * 4); + const rgbaArray = new Uint8ClampedArray(data.length * 4); let offset = 0; for (let idx = 0; idx < data.length; idx++) { const x = data[idx]; diff --git a/Sources/Rendering/Core/AxesActor/index.js b/Sources/Rendering/Core/AxesActor/index.js index 13ed306eb30..2813d931c8c 100644 --- a/Sources/Rendering/Core/AxesActor/index.js +++ b/Sources/Rendering/Core/AxesActor/index.js @@ -35,7 +35,7 @@ function shiftDataset(ds, axis) { function addColor(ds, r, g, b) { const size = ds.getPoints().getData().length; - const rgbArray = new Uint8Array(size); + const rgbArray = new Uint8ClampedArray(size); let offset = 0; while (offset < size) { diff --git a/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts new file mode 100644 index 00000000000..32a4c0e557a --- /dev/null +++ b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts @@ -0,0 +1,109 @@ +/** + * A helper file to transform RGBA points using CSS filters equivalent + * The equivalents of CSS filters using SVG filters can be found here: + * https://www.w3.org/TR/filter-effects-1/#ShorthandEquivalents + * For each SVG filter, you can look for the maths behind it on the same page: + * https://www.w3.org/TR/filter-effects-1/#FilterPrimitivesOverview + * + * For example, the saturate filter equivalent is here: + * https://www.w3.org/TR/filter-effects-1/#saturateEquivalent + * And the maths behind the feColorMatrix of type saturate is here: + * https://www.w3.org/TR/filter-effects-1/#ref-for-attr-valuedef-type-saturate + * + * The transforms are done using matrices of size 5 by 5. They are row major + * as in vtkMath. The vectors representing the RGBA points uses + * [R, G, B, A, 1] vectors, with each channel between 0 and 1. + */ + +import { Matrix, Vector3 } from "../../../types"; + +export const luminanceWeights: Vector3; +export type FilterMatrix = Matrix; + +/** + * Create a new filter matrix + * This is a 5x5 row major array + * Use applyFilter() function to use it + * It is NOT the identity + */ +export function createCSSFilter(): FilterMatrix; + +/** + * Convert a filter to an identity matrix or create a new identity filter + * @param outFilter If specified, the outFilter is converted to identity filter + */ +export function createIdentityFilter(outFilter?: FilterMatrix): FilterMatrix; + +/** + * Combine two filters into a single filter + * Warning: it is NOT an operation inspired by CSS filters + * For this, apply filters one by one using applyFilter + * The clamping step is not applied between each filter when the filters are combined + * The order of the filters matters + * @param baseFilter The first filter that will be applied + * @param newFilter The second filter that will be applied + * @param outFilter An optional filter that will contain the combined filter + */ +export function combineFilters(baseFilter: FilterMatrix, newFilter: FilterMatrix, outFilter?: FilterMatrix): FilterMatrix; + +/** + * Apply a filter to a rgb(a) point + * It is a multiplication by the matrix and a clamping + * @param filter The filter + * @param r The red channel (between 0 and 1) + * @param g The green channel (between 0 and 1) + * @param b The blue channel (between 0 and 1) + * @param a The optional alpha channel (between 0 and 1), defaults to 1 + * @returns A vector of size 4 [r, g, b, a] + */ +export function applyFilter(filter: FilterMatrix, r: number, g: number, b: number, a?: number): [number, number, number, number]; + +/** + * A generic linear filter + * See svg equivalent for parameters and a specification + * https://www.w3.org/TR/filter-effects-1/#attr-valuedef-type-linear + * @param slope + * @param intercept + * @param outFilter Optional output, a new filter is created if not specified + */ +export function createLinearFilter(slope: number, intercept: number, outFilter?: FilterMatrix): FilterMatrix; + +/** + * A contrast filter + * See css/svg equivalent for parameters and a specification + * https://www.w3.org/TR/filter-effects-1/#contrastEquivalent + * https://www.w3.org/TR/filter-effects-1/#attr-valuedef-type-linear + * @param contrast + * @param outFilter Optional output, a new filter is created if not specified + */ +export function createContrastFilter(contrast: number, outFilter?: FilterMatrix): FilterMatrix; + +/** + * A saturate filter + * See css/svg equivalent for parameters and a specification + * https://www.w3.org/TR/filter-effects-1/#saturateEquivalent + * https://www.w3.org/TR/filter-effects-1/#ref-for-attr-valuedef-type-saturate + * @param saturate + * @param outFilter Optional output, a new filter is created if not specified + */ +export function createSaturateFilter(saturate: number, outFilter?: FilterMatrix): FilterMatrix; + +/** + * A brightness filter + * See css/svg equivalent for parameters and a specification + * https://www.w3.org/TR/filter-effects-1/#brightnessEquivalent + * https://www.w3.org/TR/filter-effects-1/#attr-valuedef-type-linear + * @param brightness + * @param outFilter Optional output, a new filter is created if not specified + */ +export function createBrightnessFilter(brightness: number, outFilter?: FilterMatrix): FilterMatrix; + +/** + * An invert filter + * See css/svg equivalent for parameters and a specification + * https://www.w3.org/TR/filter-effects-1/#invertEquivalent + * https://www.w3.org/TR/filter-effects-1/#attr-valuedef-type-table + * @param invert + * @param outFilter Optional output, a new filter is created if not specified + */ +export function createInvertFilter(invert: number, outFilter?: FilterMatrix): FilterMatrix; diff --git a/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js new file mode 100644 index 00000000000..2b39761c8c7 --- /dev/null +++ b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js @@ -0,0 +1,110 @@ +/** + * A helper file to transform RGBA points using CSS filters equivalent + * The equivalents of CSS filters using SVG filters can be found here: + * https://www.w3.org/TR/filter-effects-1/#ShorthandEquivalents + * For each SVG filter, you can look for the maths behind it on the same page: + * https://www.w3.org/TR/filter-effects-1/#FilterPrimitivesOverview + * + * For example, the saturate filter equivalent is here: + * https://www.w3.org/TR/filter-effects-1/#saturateEquivalent + * And the maths behind the feColorMatrix of type saturate is here: + * https://www.w3.org/TR/filter-effects-1/#ref-for-attr-valuedef-type-saturate + * + * The transforms are done using matrices of size 5 by 5. They are row major + * as in vtkMath. The vectors representing the RGBA points uses + * [R, G, B, A, 1] vectors, with each channel between 0 and 1. + */ + +import { identity, multiplyMatrix } from 'vtk.js/Sources/Common/Core/Math'; + +export const luminanceWeights = [0.213, 0.715, 0.072]; + +export function createCSSFilter() { + return new Array(25); +} + +export function createIdentityFilter(outFilter = createCSSFilter()) { + return identity(5, outFilter); +} + +export function combineFilters( + baseFilter, + newFilter, + outFilter = createCSSFilter() +) { + multiplyMatrix(newFilter, baseFilter, 5, 5, 5, 5, outFilter); + return outFilter; +} + +export function applyFilter(filter, r, g, b, a = 1) { + const vec = [r, g, b, a, 1]; + multiplyMatrix(filter, vec, 5, 5, 5, 1, vec); + // Clamp R, G, B, A + const output = new Array(4); + for (let i = 0; i < 4; ++i) { + const value = vec[i]; + if (value < 0) { + output[i] = 0; + } else if (value > 1) { + output[i] = 1; + } else { + output[i] = value; + } + } + return output; +} + +export function createLinearFilter( + slope, + intercept, + outFilter = createCSSFilter() +) { + createIdentityFilter(outFilter); + for (let row = 0; row < 3; ++row) { + outFilter[row * 5 + row] = slope; + outFilter[row * 5 + 4] = intercept; + } + return outFilter; +} + +// https://www.w3.org/TR/filter-effects-1/#contrastEquivalent +// https://www.w3.org/TR/filter-effects-1/#attr-valuedef-type-linear +export function createContrastFilter(contrast, outFilter = createCSSFilter()) { + const slope = contrast; + const intercept = -(0.5 * contrast) + 0.5; + return createLinearFilter(slope, intercept, outFilter); +} + +// https://www.w3.org/TR/filter-effects-1/#saturateEquivalent +// https://www.w3.org/TR/filter-effects-1/#ref-for-attr-valuedef-type-saturate +export function createSaturateFilter(saturate, outFilter = createCSSFilter()) { + createIdentityFilter(outFilter); + for (let col = 0; col < 3; ++col) { + const columnLuminance = luminanceWeights[col]; + const diagonalValue = columnLuminance + (1 - columnLuminance) * saturate; + const nonDiagonalValue = columnLuminance - columnLuminance * saturate; + for (let row = 0; row < 3; ++row) { + outFilter[row * 5 + col] = row === col ? diagonalValue : nonDiagonalValue; + } + } + return outFilter; +} + +// https://www.w3.org/TR/filter-effects-1/#brightnessEquivalent +// https://www.w3.org/TR/filter-effects-1/#attr-valuedef-type-linear +export function createBrightnessFilter( + brightness, + outFilter = createCSSFilter() +) { + const slope = brightness; + const intercept = 0; + return createLinearFilter(slope, intercept, outFilter); +} + +// https://www.w3.org/TR/filter-effects-1/#invertEquivalent +// https://www.w3.org/TR/filter-effects-1/#attr-valuedef-type-table +export function createInvertFilter(invert, outFilter = createCSSFilter()) { + const slope = 1 - 2 * invert; + const intercept = invert; + return createLinearFilter(slope, intercept, outFilter); +} diff --git a/Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js b/Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js new file mode 100644 index 00000000000..fa54e93cb5c --- /dev/null +++ b/Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js @@ -0,0 +1,69 @@ +import test from 'tape'; + +import * as CssFilters from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction/CssFilters'; + +test('Test CssFilters identity', (t) => { + const color = [Math.random(), Math.random(), -1, 2]; + const identity = CssFilters.createIdentityFilter(); + const output = CssFilters.applyFilter(identity, ...color); + const expectedOutput = [color[0], color[1], 0, 1]; + t.deepEqual(output, expectedOutput, 'Apply identity filter'); + t.end(); +}); + +test('Test CssFilters brightness', (t) => { + const color = [0.1, 0.5, 0.9, 0.2]; + const halfBright = CssFilters.createBrightnessFilter(0.5); + const output = CssFilters.applyFilter(halfBright, ...color); + t.deepEqual([0.05, 0.25, 0.45, 0.2], output, 'Apply brightness filter'); + t.end(); +}); + +test('Fuzzy test all CssFilters', (t) => { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + + const randomChannel = () => Math.floor(Math.random() * 256); + + const numberOfRepetitions = 1000; + for (let repetition = 0; repetition < numberOfRepetitions; repetition++) { + const contrast = 2 * Math.random(); + const saturate = 2 * Math.random(); + const brightness = 2 * Math.random(); + const invert = Math.random(); + + const color = [randomChannel(), randomChannel(), randomChannel(), 255]; + + // Apply filters one by one (don't combine as it is not equivalent) + let filtersOutput = color.map((channel) => channel / 255); + const contrastFilter = CssFilters.createContrastFilter(contrast); + filtersOutput = CssFilters.applyFilter(contrastFilter, ...filtersOutput); + const saturateFilter = CssFilters.createSaturateFilter(saturate); + filtersOutput = CssFilters.applyFilter(saturateFilter, ...filtersOutput); + const brightnessFilter = CssFilters.createBrightnessFilter(brightness); + filtersOutput = CssFilters.applyFilter(brightnessFilter, ...filtersOutput); + const invertFilter = CssFilters.createInvertFilter(invert); + filtersOutput = CssFilters.applyFilter(invertFilter, ...filtersOutput); + + // Reference: canvas 2D context filters + ctx.fillStyle = `rgb(${color[0]} ${color[1]} ${color[2]})`; + ctx.filter = `contrast(${contrast}) saturate(${saturate}) brightness(${brightness}) invert(${invert})`; + ctx.beginPath(); + ctx.fillRect(0, 0, canvas.width, canvas.height); + const canvasPixel = ctx.getImageData(0, 0, 1, 1).data; + const referenceOutput = [...canvasPixel].map((x) => x / 255); + + for (let channel = 0; channel < 4; ++channel) { + const error = referenceOutput[channel] - filtersOutput[channel]; + // The error depends on the values of each filter + // For example, using a color of [127.5, 127.5, 128.49] with a saturate of 1000 creates big errors + // This is because the CSS filters use uint8 instead of floating point numbers + if (Math.abs(error) > 7 / 255) { + t.fail('Error is too big'); + } + } + } + t.end(); +}); diff --git a/Sources/Rendering/OpenGL/ImageCPRMapper/index.js b/Sources/Rendering/OpenGL/ImageCPRMapper/index.js index d262553d45b..a6fa1d10cf1 100644 --- a/Sources/Rendering/OpenGL/ImageCPRMapper/index.js +++ b/Sources/Rendering/OpenGL/ImageCPRMapper/index.js @@ -228,7 +228,7 @@ function vtkOpenGLImageCPRMapper(publicAPI, model) { if (model.colorTextureString !== cfunToString) { const cWidth = 1024; const cSize = cWidth * textureHeight * 3; - const cTable = new Uint8Array(cSize); + const cTable = new Uint8ClampedArray(cSize); let cfun = ppty.getRGBTransferFunction(); if (cfun) { const tmpTable = new Float32Array(cWidth * 3); @@ -287,7 +287,7 @@ function vtkOpenGLImageCPRMapper(publicAPI, model) { if (model.pwfTextureString !== pwfunToString) { const pwfWidth = 1024; const pwfSize = pwfWidth * textureHeight; - const pwfTable = new Uint8Array(pwfSize); + const pwfTable = new Uint8ClampedArray(pwfSize); let pwfun = ppty.getPiecewiseFunction(); // support case where pwfun is added/removed model.pwfTexture.releaseGraphicsResources(model._openGLRenderWindow); diff --git a/Sources/Rendering/OpenGL/ImageMapper/index.js b/Sources/Rendering/OpenGL/ImageMapper/index.js index 4469ec7d704..aad0e725fcd 100644 --- a/Sources/Rendering/OpenGL/ImageMapper/index.js +++ b/Sources/Rendering/OpenGL/ImageMapper/index.js @@ -937,7 +937,7 @@ function vtkOpenGLImageMapper(publicAPI, model) { if (reBuildC) { const cWidth = 1024; const cSize = cWidth * textureHeight * 3; - const cTable = new Uint8Array(cSize); + const cTable = new Uint8ClampedArray(cSize); if (!model.colorTexture) { model.colorTexture = vtkOpenGLTexture.newInstance({ resizable: true, @@ -1023,7 +1023,7 @@ function vtkOpenGLImageMapper(publicAPI, model) { if (reBuildPwf) { const pwfWidth = 1024; const pwfSize = pwfWidth * textureHeight; - const pwfTable = new Uint8Array(pwfSize); + const pwfTable = new Uint8ClampedArray(pwfSize); if (!model.pwfTexture) { model.pwfTexture = vtkOpenGLTexture.newInstance({ resizable: true, diff --git a/Sources/Rendering/OpenGL/ImageResliceMapper/index.js b/Sources/Rendering/OpenGL/ImageResliceMapper/index.js index 64ce3396e1e..3b996d337ad 100644 --- a/Sources/Rendering/OpenGL/ImageResliceMapper/index.js +++ b/Sources/Rendering/OpenGL/ImageResliceMapper/index.js @@ -266,7 +266,7 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { if (reBuildC) { const cWidth = 1024; const cSize = cWidth * textureHeight * 3; - const cTable = new Uint8Array(cSize); + const cTable = new Uint8ClampedArray(cSize); if (!model.colorTexture) { model.colorTexture = vtkOpenGLTexture.newInstance(); model.colorTexture.setOpenGLRenderWindow(model._openGLRenderWindow); @@ -343,7 +343,7 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { if (reBuildPwf) { const pwfWidth = 1024; const pwfSize = pwfWidth * textureHeight; - const pwfTable = new Uint8Array(pwfSize); + const pwfTable = new Uint8ClampedArray(pwfSize); if (!model.pwfTexture) { model.pwfTexture = vtkOpenGLTexture.newInstance(); model.pwfTexture.setOpenGLRenderWindow(model._openGLRenderWindow); diff --git a/Sources/Rendering/OpenGL/VolumeMapper/index.js b/Sources/Rendering/OpenGL/VolumeMapper/index.js index 5b66eaa5e5d..1485acf56ee 100644 --- a/Sources/Rendering/OpenGL/VolumeMapper/index.js +++ b/Sources/Rendering/OpenGL/VolumeMapper/index.js @@ -1512,7 +1512,7 @@ function vtkOpenGLVolumeMapper(publicAPI, model) { ofTable ); } else { - const oTable = new Uint8Array(oSize); + const oTable = new Uint8ClampedArray(oSize); for (let i = 0; i < oSize; ++i) { oTable[i] = 255.0 * ofTable[i]; } @@ -1549,7 +1549,7 @@ function vtkOpenGLVolumeMapper(publicAPI, model) { if (reBuildC) { const cWidth = 1024; const cSize = cWidth * 2 * numIComps * 3; - const cTable = new Uint8Array(cSize); + const cTable = new Uint8ClampedArray(cSize); const tmpTable = new Float32Array(cWidth * 3); for (let c = 0; c < numIComps; ++c) { diff --git a/Sources/Rendering/WebGPU/ImageMapper/index.js b/Sources/Rendering/WebGPU/ImageMapper/index.js index 49ab1df4b17..155173d49dd 100644 --- a/Sources/Rendering/WebGPU/ImageMapper/index.js +++ b/Sources/Rendering/WebGPU/ImageMapper/index.js @@ -271,7 +271,7 @@ function vtkWebGPUImageMapper(publicAPI, model) { if (model.colorTextureString !== cfunToString) { model.numRows = numIComps; - const colorArray = new Uint8Array( + const colorArray = new Uint8ClampedArray( model.numRows * 2 * model.rowLength * 4 ); diff --git a/Sources/Rendering/WebGPU/SphereMapper/index.js b/Sources/Rendering/WebGPU/SphereMapper/index.js index b8b5d5f5e8a..6ca68624034 100644 --- a/Sources/Rendering/WebGPU/SphereMapper/index.js +++ b/Sources/Rendering/WebGPU/SphereMapper/index.js @@ -269,7 +269,7 @@ function vtkWebGPUSphereMapper(publicAPI, model) { if (colorComponents !== 4) { vtkErrorMacro('this should be 4'); } - const tmpVBO = new Uint8Array(3 * numPoints * 4); + const tmpVBO = new Uint8ClampedArray(3 * numPoints * 4); let vboIdx = 0; const colorData = c.getData(); for (let id = 0; id < numPoints; ++id) { diff --git a/Sources/Rendering/WebGPU/StickMapper/index.js b/Sources/Rendering/WebGPU/StickMapper/index.js index 4d30b18d91c..8ee43a7d5fb 100644 --- a/Sources/Rendering/WebGPU/StickMapper/index.js +++ b/Sources/Rendering/WebGPU/StickMapper/index.js @@ -400,7 +400,7 @@ function vtkWebGPUStickMapper(publicAPI, model) { if (colorComponents !== 4) { vtkErrorMacro('this should be 4'); } - const tmpVBO = new Uint8Array(numPoints * 4); + const tmpVBO = new Uint8ClampedArray(numPoints * 4); let vboIdx = 0; const colorData = c.getData(); for (let id = 0; id < numPoints; ++id) { diff --git a/Sources/Rendering/WebGPU/VolumePassFSQ/index.js b/Sources/Rendering/WebGPU/VolumePassFSQ/index.js index 7773b36af0e..6138e3ae9a6 100644 --- a/Sources/Rendering/WebGPU/VolumePassFSQ/index.js +++ b/Sources/Rendering/WebGPU/VolumePassFSQ/index.js @@ -536,7 +536,9 @@ function vtkWebGPUVolumePassFSQ(publicAPI, model) { } // allocate the image array - const colorArray = new Uint8Array(model.numRows * 2 * model.rowLength * 4); + const colorArray = new Uint8ClampedArray( + model.numRows * 2 * model.rowLength * 4 + ); const opacityArray = new Float32Array(model.numRows * 2 * model.rowLength); let imgRow = 0;