From 59de2aa9c1274653d239a6f439cc772f30db81b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Bruy=C3=A8re?= Date: Thu, 1 Feb 2024 18:55:25 +0100 Subject: [PATCH 1/5] fix(colors): Use Uint8ClampedArrays where needed When creating buffers for color or opacity textures, prefer clamped arrays as they avoid ugly overflows --- Sources/Filters/General/ScalarToRGBA/index.js | 2 +- Sources/Rendering/Core/AxesActor/index.js | 2 +- Sources/Rendering/OpenGL/ImageCPRMapper/index.js | 4 ++-- Sources/Rendering/OpenGL/ImageMapper/index.js | 4 ++-- Sources/Rendering/OpenGL/ImageResliceMapper/index.js | 4 ++-- Sources/Rendering/OpenGL/VolumeMapper/index.js | 4 ++-- Sources/Rendering/WebGPU/ImageMapper/index.js | 2 +- Sources/Rendering/WebGPU/SphereMapper/index.js | 2 +- Sources/Rendering/WebGPU/StickMapper/index.js | 2 +- Sources/Rendering/WebGPU/VolumePassFSQ/index.js | 4 +++- 10 files changed, 16 insertions(+), 14 deletions(-) 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/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; From 18063bb4f7c96ac3ab4428cf94e64fcd1bd56bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Bruy=C3=A8re?= Date: Thu, 1 Feb 2024 18:55:36 +0100 Subject: [PATCH 2/5] feat(CSSHelper): Add CSS filter utilities --- Sources/Common/Core/Math/index.d.ts | 4 +- Sources/Common/Core/Math/index.js | 6 +- .../ColorTransferFunction/CssFilters.d.ts | 105 ++++++++++++++++++ .../Core/ColorTransferFunction/CssFilters.js | 91 +++++++++++++++ 4 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts create mode 100644 Sources/Rendering/Core/ColorTransferFunction/CssFilters.js 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/Rendering/Core/ColorTransferFunction/CssFilters.d.ts b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts new file mode 100644 index 00000000000..540c8a89a20 --- /dev/null +++ b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts @@ -0,0 +1,105 @@ +/** + * 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 getNewFilter(): 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 getIdentityFilter(outFilter?: FilterMatrix): FilterMatrix; + +/** + * Combine two filters into a single filter + * The order 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 + * @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 getLinearFilter(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 getContrastFilter(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 getSaturateFilter(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 getBrightnessFilter(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 getInvertFilter(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..5d3fbe1e83b --- /dev/null +++ b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js @@ -0,0 +1,91 @@ +/** + * 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 getNewFilter() { + return new Array(25); +} + +export function getIdentityFilter(outFilter = getNewFilter()) { + return identity(5, outFilter); +} + +export function combineFilters( + baseFilter, + newFilter, + outFilter = getNewFilter() +) { + 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); + return vec.slice(0, 4); +} + +export function getLinearFilter(slope, intercept, outFilter = getNewFilter()) { + getIdentityFilter(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 getContrastFilter(contrast, outFilter = getNewFilter()) { + const slope = contrast; + const intercept = -(0.5 * contrast) + 0.5; + return getLinearFilter(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 getSaturateFilter(saturate, outFilter = getNewFilter()) { + getIdentityFilter(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 getBrightnessFilter(brightness, outFilter = getNewFilter()) { + const slope = brightness; + const intercept = 0; + return getLinearFilter(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 getInvertFilter(invert, outFilter = getNewFilter()) { + const slope = 1 - 2 * invert; + const intercept = invert; + return getLinearFilter(slope, intercept, outFilter); +} From c4f856c11e6984fda2bec22fb66296d1388ae512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Bruy=C3=A8re?= Date: Fri, 2 Feb 2024 10:35:59 +0100 Subject: [PATCH 3/5] fix(CssFilters): Rename filters, use "create" instead of "get" --- .../ColorTransferFunction/CssFilters.d.ts | 14 ++++---- .../Core/ColorTransferFunction/CssFilters.js | 33 +++++++++++-------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts index 540c8a89a20..3a6d959db06 100644 --- a/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts +++ b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts @@ -26,13 +26,13 @@ export type FilterMatrix = Matrix; * Use applyFilter() function to use it * It is NOT the identity */ -export function getNewFilter(): FilterMatrix; +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 getIdentityFilter(outFilter?: FilterMatrix): FilterMatrix; +export function createIdentityFilter(outFilter?: FilterMatrix): FilterMatrix; /** * Combine two filters into a single filter @@ -62,7 +62,7 @@ export function applyFilter(filter: FilterMatrix, r: number, g: number, b: numbe * @param intercept * @param outFilter Optional output, a new filter is created if not specified */ -export function getLinearFilter(slope: number, intercept: number, outFilter?: FilterMatrix): FilterMatrix; +export function createLinearFilter(slope: number, intercept: number, outFilter?: FilterMatrix): FilterMatrix; /** * A contrast filter @@ -72,7 +72,7 @@ export function getLinearFilter(slope: number, intercept: number, outFilter?: Fi * @param contrast * @param outFilter Optional output, a new filter is created if not specified */ -export function getContrastFilter(contrast: number, outFilter?: FilterMatrix): FilterMatrix; +export function createContrastFilter(contrast: number, outFilter?: FilterMatrix): FilterMatrix; /** * A saturate filter @@ -82,7 +82,7 @@ export function getContrastFilter(contrast: number, outFilter?: FilterMatrix): F * @param saturate * @param outFilter Optional output, a new filter is created if not specified */ -export function getSaturateFilter(saturate: number, outFilter?: FilterMatrix): FilterMatrix; +export function createSaturateFilter(saturate: number, outFilter?: FilterMatrix): FilterMatrix; /** * A brightness filter @@ -92,7 +92,7 @@ export function getSaturateFilter(saturate: number, outFilter?: FilterMatrix): F * @param brightness * @param outFilter Optional output, a new filter is created if not specified */ -export function getBrightnessFilter(brightness: number, outFilter?: FilterMatrix): FilterMatrix; +export function createBrightnessFilter(brightness: number, outFilter?: FilterMatrix): FilterMatrix; /** * An invert filter @@ -102,4 +102,4 @@ export function getBrightnessFilter(brightness: number, outFilter?: FilterMatrix * @param invert * @param outFilter Optional output, a new filter is created if not specified */ -export function getInvertFilter(invert: number, outFilter?: FilterMatrix): FilterMatrix; +export function createInvertFilter(invert: number, outFilter?: FilterMatrix): FilterMatrix; diff --git a/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js index 5d3fbe1e83b..37c9821f238 100644 --- a/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js +++ b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js @@ -19,18 +19,18 @@ import { identity, multiplyMatrix } from 'vtk.js/Sources/Common/Core/Math'; export const luminanceWeights = [0.213, 0.715, 0.072]; -export function getNewFilter() { +export function createCSSFilter() { return new Array(25); } -export function getIdentityFilter(outFilter = getNewFilter()) { +export function createIdentityFilter(outFilter = createCSSFilter()) { return identity(5, outFilter); } export function combineFilters( baseFilter, newFilter, - outFilter = getNewFilter() + outFilter = createCSSFilter() ) { multiplyMatrix(newFilter, baseFilter, 5, 5, 5, 5, outFilter); return outFilter; @@ -42,8 +42,12 @@ export function applyFilter(filter, r, g, b, a = 1) { return vec.slice(0, 4); } -export function getLinearFilter(slope, intercept, outFilter = getNewFilter()) { - getIdentityFilter(outFilter); +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; @@ -53,16 +57,16 @@ export function getLinearFilter(slope, intercept, outFilter = getNewFilter()) { // https://www.w3.org/TR/filter-effects-1/#contrastEquivalent // https://www.w3.org/TR/filter-effects-1/#attr-valuedef-type-linear -export function getContrastFilter(contrast, outFilter = getNewFilter()) { +export function createContrastFilter(contrast, outFilter = createCSSFilter()) { const slope = contrast; const intercept = -(0.5 * contrast) + 0.5; - return getLinearFilter(slope, intercept, outFilter); + 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 getSaturateFilter(saturate, outFilter = getNewFilter()) { - getIdentityFilter(outFilter); +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; @@ -76,16 +80,19 @@ export function getSaturateFilter(saturate, outFilter = getNewFilter()) { // https://www.w3.org/TR/filter-effects-1/#brightnessEquivalent // https://www.w3.org/TR/filter-effects-1/#attr-valuedef-type-linear -export function getBrightnessFilter(brightness, outFilter = getNewFilter()) { +export function createBrightnessFilter( + brightness, + outFilter = createCSSFilter() +) { const slope = brightness; const intercept = 0; - return getLinearFilter(slope, intercept, outFilter); + 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 getInvertFilter(invert, outFilter = getNewFilter()) { +export function createInvertFilter(invert, outFilter = createCSSFilter()) { const slope = 1 - 2 * invert; const intercept = invert; - return getLinearFilter(slope, intercept, outFilter); + return createLinearFilter(slope, intercept, outFilter); } From 717bf4f66609840383c3b13d35f8a848d156356a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Bruy=C3=A8re?= Date: Thu, 8 Feb 2024 15:55:15 +0100 Subject: [PATCH 4/5] test(CssFilters): Add tests and fix clamping --- .../ColorTransferFunction/CssFilters.d.ts | 6 +- .../Core/ColorTransferFunction/CssFilters.js | 14 +++- .../test/testCssFilters.js | 66 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js diff --git a/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts index 3a6d959db06..32a4c0e557a 100644 --- a/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts +++ b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.d.ts @@ -36,7 +36,10 @@ export function createIdentityFilter(outFilter?: FilterMatrix): FilterMatrix; /** * Combine two filters into a single filter - * The order matters + * 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 @@ -45,6 +48,7 @@ export function combineFilters(baseFilter: FilterMatrix, newFilter: 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) diff --git a/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js index 37c9821f238..2b39761c8c7 100644 --- a/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js +++ b/Sources/Rendering/Core/ColorTransferFunction/CssFilters.js @@ -39,7 +39,19 @@ export function combineFilters( export function applyFilter(filter, r, g, b, a = 1) { const vec = [r, g, b, a, 1]; multiplyMatrix(filter, vec, 5, 5, 5, 1, vec); - return vec.slice(0, 4); + // 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( diff --git a/Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js b/Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js new file mode 100644 index 00000000000..f9e74e5ef26 --- /dev/null +++ b/Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js @@ -0,0 +1,66 @@ +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 + t.assert(Math.abs(error) <= 7 / 255); + } + } + t.end(); +}); From d09af675624d44a621c56c35bef1f4aa05980fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Bruy=C3=A8re?= Date: Thu, 8 Feb 2024 16:54:33 +0100 Subject: [PATCH 5/5] test(CssFilters): Avoid asserting 4000 times --- .../Core/ColorTransferFunction/test/testCssFilters.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js b/Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js index f9e74e5ef26..fa54e93cb5c 100644 --- a/Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js +++ b/Sources/Rendering/Core/ColorTransferFunction/test/testCssFilters.js @@ -59,7 +59,10 @@ test('Fuzzy test all CssFilters', (t) => { 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 - t.assert(Math.abs(error) <= 7 / 255); + // 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();