From 4d8f00900d36b03b640cb3310927d26c5d81d2fd Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Fri, 3 Jan 2025 17:07:13 -0800 Subject: [PATCH] improve volume coloring - Add `volume-data` theme that colors positions by volume data - Add support for color themes to `slice` representation - Improve/fix palette support in volume color themes --- CHANGELOG.md | 3 + src/mol-geo/geometry/color-data.ts | 9 +- src/mol-gl/renderable/schema.ts | 3 +- src/mol-gl/shader/direct-volume.frag.ts | 6 +- src/mol-gl/shader/image.frag.ts | 26 +++--- src/mol-model/volume/grid.ts | 56 +++++++++++- src/mol-plugin-ui/controls/parameters.tsx | 38 +++++--- src/mol-repr/volume/slice.ts | 59 ++++++++---- src/mol-theme/color.ts | 59 +++++++++++- src/mol-theme/color/external-volume.ts | 91 +++++++------------ src/mol-theme/color/volume-data.ts | 104 ++++++++++++++++++++++ src/mol-theme/color/volume-value.ts | 39 +++++--- 12 files changed, 379 insertions(+), 114 deletions(-) create mode 100644 src/mol-theme/color/volume-data.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fee4c9314a..e77b6a7696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Note that since we don't clearly distinguish between a public and private interf - Add `external-structure` theme that colors any geometry by structure properties - Support float and half-float data type for direct-volume rendering and GPU isosurface extraction - Minor documentation updates +- Add `volume-data` theme that colors positions by volume data +- Add support for color themes to `slice` representation +- Improve/fix palette support in volume color themes ## [v4.10.0] - 2024-12-15 diff --git a/src/mol-geo/geometry/color-data.ts b/src/mol-geo/geometry/color-data.ts index 1d79036b50..ddb610524e 100644 --- a/src/mol-geo/geometry/color-data.ts +++ b/src/mol-geo/geometry/color-data.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose * @author David Sehnal @@ -24,6 +24,7 @@ export type ColorData = { uColor: ValueCell, tColor: ValueCell>, tColorGrid: ValueCell, + uPaletteDomain: ValueCell, tPalette: ValueCell>, uColorTexDim: ValueCell, uColorGridDim: ValueCell, @@ -36,6 +37,8 @@ export function createColors(locationIt: LocationIterator, positionIt: LocationI const data = _createColors(locationIt, positionIt, colorTheme, colorData); if (colorTheme.palette) { ValueCell.updateIfChanged(data.dUsePalette, true); + const [min, max] = colorTheme.palette.domain || [0, 1]; + ValueCell.update(data.uPaletteDomain, Vec2.set(data.uPaletteDomain.ref.value, min, max)); updatePaletteTexture(colorTheme.palette, data.tPalette); } else { ValueCell.updateIfChanged(data.dUsePalette, false); @@ -103,6 +106,7 @@ export function createValueColor(value: Color, colorData?: ColorData): ColorData uColor: ValueCell.create(Color.toVec3Normalized(Vec3(), value)), tColor: ValueCell.create({ array: new Uint8Array(3), width: 1, height: 1 }), tColorGrid: ValueCell.create(createNullTexture()), + uPaletteDomain: ValueCell.create(Vec2.create(0, 1)), tPalette: ValueCell.create({ array: new Uint8Array(3), width: 1, height: 1 }), uColorTexDim: ValueCell.create(Vec2.create(1, 1)), uColorGridDim: ValueCell.create(Vec3.create(1, 1, 1)), @@ -131,6 +135,7 @@ export function createTextureColor(colors: TextureImage, type: Color uColor: ValueCell.create(Vec3()), tColor: ValueCell.create(colors), tColorGrid: ValueCell.create(createNullTexture()), + uPaletteDomain: ValueCell.create(Vec2.create(0, 1)), tPalette: ValueCell.create({ array: new Uint8Array(3), width: 1, height: 1 }), uColorTexDim: ValueCell.create(Vec2.create(colors.width, colors.height)), uColorGridDim: ValueCell.create(Vec3.create(1, 1, 1)), @@ -233,6 +238,7 @@ export function createGridColor(grid: ColorVolume, type: ColorType, colorData?: uColor: ValueCell.create(Vec3()), tColor: ValueCell.create({ array: new Uint8Array(3), width: 1, height: 1 }), tColorGrid: ValueCell.create(colors), + uPaletteDomain: ValueCell.create(Vec2.create(0, 1)), tPalette: ValueCell.create({ array: new Uint8Array(3), width: 1, height: 1 }), uColorTexDim: ValueCell.create(Vec2.create(width, height)), uColorGridDim: ValueCell.create(Vec3.clone(dimension)), @@ -255,6 +261,7 @@ function createDirectColor(colorData?: ColorData): ColorData { uColor: ValueCell.create(Vec3()), tColor: ValueCell.create({ array: new Uint8Array(3), width: 1, height: 1 }), tColorGrid: ValueCell.create(createNullTexture()), + uPaletteDomain: ValueCell.create(Vec2.create(0, 1)), tPalette: ValueCell.create({ array: new Uint8Array(3), width: 1, height: 1 }), uColorTexDim: ValueCell.create(Vec2.create(1, 1)), uColorGridDim: ValueCell.create(Vec3.create(1, 1, 1)), diff --git a/src/mol-gl/renderable/schema.ts b/src/mol-gl/renderable/schema.ts index 51763a3c18..6167b10178 100644 --- a/src/mol-gl/renderable/schema.ts +++ b/src/mol-gl/renderable/schema.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2024 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose * @author Gianluca Tomasello @@ -203,6 +203,7 @@ export const ColorSchema = { uColorTexDim: UniformSpec('v2'), uColorGridDim: UniformSpec('v3'), uColorGridTransform: UniformSpec('v4'), + uPaletteDomain: UniformSpec('v2'), tColor: TextureSpec('image-uint8', 'rgb', 'ubyte', 'nearest'), tPalette: TextureSpec('image-uint8', 'rgb', 'ubyte', 'nearest'), tColorGrid: TextureSpec('texture', 'rgb', 'ubyte', 'linear'), diff --git a/src/mol-gl/shader/direct-volume.frag.ts b/src/mol-gl/shader/direct-volume.frag.ts index 68292260b3..b84c6ff7ca 100644 --- a/src/mol-gl/shader/direct-volume.frag.ts +++ b/src/mol-gl/shader/direct-volume.frag.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2017-2024 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose * @author Michael Krone @@ -122,6 +122,7 @@ uniform mat4 uCartnToUnit; #endif #ifdef dUsePalette + uniform vec2 uPaletteDomain; uniform sampler2D tPalette; #endif @@ -271,7 +272,8 @@ vec4 raymarch(vec3 startLoc, vec3 step, vec3 rayDir) { #endif #if defined(dColorType_direct) && defined(dUsePalette) - material.rgb = texture2D(tPalette, vec2(value, 0.0)).rgb; + float paletteValue = (value - uPaletteDomain[0]) / (uPaletteDomain[1] - uPaletteDomain[0]); + material.rgb = texture2D(tPalette, vec2(clamp(paletteValue, 0.0, 1.0), 0.0)).rgb; #elif defined(dColorType_uniform) material.rgb = uColor; #elif defined(dColorType_instance) diff --git a/src/mol-gl/shader/image.frag.ts b/src/mol-gl/shader/image.frag.ts index bc69dd32c3..90b30aa86a 100644 --- a/src/mol-gl/shader/image.frag.ts +++ b/src/mol-gl/shader/image.frag.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2020-2024 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2020-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose */ @@ -25,6 +25,10 @@ uniform sampler2D tMarker; varying vec2 vUv; varying float vInstance; +#ifdef dUsePalette + uniform sampler2D tPalette; +#endif + #if defined(dInterpolation_catmulrom) || defined(dInterpolation_mitchell) || defined(dInterpolation_bspline) #define dInterpolation_cubic #endif @@ -99,12 +103,9 @@ void main() { #else vec4 imageData = texture2D(tImageTex, vUv); #endif - imageData.a = clamp(imageData.a, 0.0, 1.0); - if (imageData.a > 0.9) imageData.a = 1.0; + if (imageData.a < 0.5) discard; - imageData.a *= uAlpha; - if (imageData.a < 0.05) - discard; + imageData.a = uAlpha; float fragmentDepth = gl_FragCoord.z; @@ -115,8 +116,7 @@ void main() { } #if defined(dRenderVariant_pick) - if (imageData.a < 0.3) - discard; + #include check_picking_alpha #ifdef requiredDrawBuffers gl_FragColor = vec4(packIntToRGB(float(uObjectId)), 1.0); gl_FragData[1] = vec4(packIntToRGB(vInstance), 1.0); @@ -133,8 +133,6 @@ void main() { } #endif #elif defined(dRenderVariant_depth) - if (imageData.a < 0.05) - discard; if (uRenderMask == MaskOpaque) { gl_FragColor = packDepthToRGBA(fragmentDepth); } else if (uRenderMask == MaskTransparent) { @@ -148,11 +146,11 @@ void main() { marker = floor(marker * 255.0 + 0.5); // rounding required to work on some cards on win } if (uMarkingType == 1) { - if (marker > 0.0 || imageData.a < 0.05) + if (marker > 0.0) discard; gl_FragColor = packDepthToRGBA(fragmentDepth); } else { - if (marker == 0.0 || imageData.a < 0.05) + if (marker == 0.0) discard; float depthTest = 1.0; if (uMarkingDepthTest) { @@ -164,6 +162,10 @@ void main() { #elif defined(dRenderVariant_emissive) gl_FragColor = vec4(0.0); #elif defined(dRenderVariant_color) || defined(dRenderVariant_tracing) + #ifdef dUsePalette + float v = ((imageData.r * 256.0 * 256.0 * 255.0 + imageData.g * 256.0 * 255.0 + imageData.b * 255.0) - 1.0) / 16777215.0; + imageData.rgb = texture2D(tPalette, vec2(v, 0.0)).rgb; + #endif gl_FragColor = imageData; float marker = uMarker; diff --git a/src/mol-model/volume/grid.ts b/src/mol-model/volume/grid.ts index b5a1a610bd..ffcfc60516 100644 --- a/src/mol-model/volume/grid.ts +++ b/src/mol-model/volume/grid.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal * @author Alexander Rose @@ -8,6 +8,7 @@ import { SpacegroupCell, Box3D, Sphere3D } from '../../mol-math/geometry'; import { Tensor, Mat4, Vec3 } from '../../mol-math/linear-algebra'; import { Histogram, calculateHistogram } from '../../mol-math/histogram'; +import { lerp } from '../../mol-math/interpolate'; /** The basic unit cell that contains the grid data. */ interface Grid { @@ -76,6 +77,59 @@ namespace Grid { } return histograms[binCount]; } + + export function makeGetTrilinearlyInterpolated(grid: Grid, transform: 'none' | 'relative') { + const cartnToGrid = Grid.getGridToCartesianTransform(grid); + Mat4.invert(cartnToGrid, cartnToGrid); + const gridCoords = Vec3(); + + const { stats } = grid; + const { dimensions, get } = grid.cells.space; + const data = grid.cells.data; + + const [mi, mj, mk] = dimensions; + + return function getTrilinearlyInterpolated(position: Vec3): number { + Vec3.copy(gridCoords, position); + Vec3.transformMat4(gridCoords, gridCoords, cartnToGrid); + + const i = Math.trunc(gridCoords[0]); + const j = Math.trunc(gridCoords[1]); + const k = Math.trunc(gridCoords[2]); + + if (i < 0 || i >= mi || j < 0 || j >= mj || k < 0 || k >= mk) { + return NaN; + } + + const u = gridCoords[0] - i; + const v = gridCoords[1] - j; + const w = gridCoords[2] - k; + + // Tri-linear interpolation for the value + const ii = Math.min(i + 1, mi - 1); + const jj = Math.min(j + 1, mj - 1); + const kk = Math.min(k + 1, mk - 1); + + let a = get(data, i, j, k); + let b = get(data, ii, j, k); + let c = get(data, i, jj, k); + let d = get(data, ii, jj, k); + const x = lerp(lerp(a, b, u), lerp(c, d, u), v); + + a = get(data, i, j, kk); + b = get(data, ii, j, kk); + c = get(data, i, jj, kk); + d = get(data, ii, jj, kk); + const y = lerp(lerp(a, b, u), lerp(c, d, u), v); + + const value = lerp(x, y, w); + if (transform === 'relative') { + return (value - stats.mean) / stats.sigma; + } else { + return value; + } + }; + } } export { Grid }; \ No newline at end of file diff --git a/src/mol-plugin-ui/controls/parameters.tsx b/src/mol-plugin-ui/controls/parameters.tsx index b09c72b7a2..cb48ef236c 100644 --- a/src/mol-plugin-ui/controls/parameters.tsx +++ b/src/mol-plugin-ui/controls/parameters.tsx @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2024 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal * @author Alexander Rose @@ -695,15 +695,33 @@ const colorGradientInterpolated = memoize1((colors: ColorListEntry[]) => { const colorGradientBanded = memoize1((colors: ColorListEntry[]) => { const n = colors.length; - const styles: string[] = [`${colorEntryToStyle(colors[0])} ${100 * (1 / n)}%`]; - // TODO: does this need to support offsets? - for (let i = 1, il = n - 1; i < il; ++i) { - styles.push( - `${colorEntryToStyle(colors[i])} ${100 * (i / n)}%`, - `${colorEntryToStyle(colors[i])} ${100 * ((i + 1) / n)}%` - ); - } - styles.push(`${colorEntryToStyle(colors[n - 1])} ${100 * ((n - 1) / n)}%`); + const styles: string[] = []; + + const hasOffsets = colors.every(c => Array.isArray(c)); + if (hasOffsets) { + const off = colors as [Color, number][]; + styles.push(`${Color.toStyle(off[0][0])} ${(100 * off[0][1]).toFixed(2)}%`); + for (let i = 0, il = off.length - 1; i < il; ++i) { + const [c0, o0] = off[i]; + const [c1, o1] = off[i + 1]; + const o = o0 + (o1 - o0) / 2; + styles.push( + `${Color.toStyle(c0)} ${(100 * o).toFixed(2)}%`, + `${Color.toStyle(c1)} ${(100 * o).toFixed(2)}%` + ); + } + styles.push(`${Color.toStyle(off[off.length - 1][0])} ${(100 * off[off.length - 1][1]).toFixed(2)}%`); + } else { + const styles: string[] = [`${colorEntryToStyle(colors[0])} ${100 * (1 / n)}%`]; + for (let i = 1, il = n - 1; i < il; ++i) { + styles.push( + `${colorEntryToStyle(colors[i])} ${100 * (i / n)}%`, + `${colorEntryToStyle(colors[i])} ${100 * ((i + 1) / n)}%` + ); + } + styles.push(`${colorEntryToStyle(colors[n - 1])} ${100 * ((n - 1) / n)}%`); + } + return `linear-gradient(to right, ${styles.join(', ')})`; }); diff --git a/src/mol-repr/volume/slice.ts b/src/mol-repr/volume/slice.ts index ce4137a599..be1297789a 100644 --- a/src/mol-repr/volume/slice.ts +++ b/src/mol-repr/volume/slice.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2020-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose */ @@ -9,7 +9,7 @@ import { Image } from '../../mol-geo/geometry/image/image'; import { ThemeRegistryContext, Theme } from '../../mol-theme/theme'; import { Grid, Volume } from '../../mol-model/volume'; import { VolumeVisual, VolumeRepresentation, VolumeRepresentationProvider } from './representation'; -import { LocationIterator } from '../../mol-geo/util/location-iterator'; +import { LocationIterator, PositionLocation } from '../../mol-geo/util/location-iterator'; import { VisualUpdateState } from '../util'; import { NullLocation } from '../../mol-model/location'; import { RepresentationContext, RepresentationParamsGetter } from '../representation'; @@ -23,17 +23,18 @@ import { Color } from '../../mol-util/color'; import { ColorTheme } from '../../mol-theme/color'; import { packIntToRGBArray } from '../../mol-util/number-packing'; import { eachVolumeLoci } from './util'; +import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3'; +import { equalEps } from '../../mol-math/linear-algebra/3d/common'; export async function createImage(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: PD.Values, image?: Image) { const { dimension: { name: dim }, isoValue } = props; - const { space, data } = volume.grid.cells; - const { min, max } = volume.grid.stats; - const isoVal = Volume.IsoValue.toAbsolute(isoValue, volume.grid.stats).absoluteValue; + const { cells: { space, data }, stats } = volume.grid; + const isoVal = Volume.IsoValue.toAbsolute(isoValue, stats).absoluteValue; - // TODO more color themes - const color = 'color' in theme.color ? theme.color.color(NullLocation, false) : Color(0xffffff); - const [r, g, b] = Color.toRgbNormalized(color); + const color = 'color' in theme.color && theme.color.color + ? theme.color.color + : () => Color(0xffffff); const { width, height, @@ -51,18 +52,44 @@ export async function createImage(ctx: VisualContext, volume: Volume, key: numbe const imageArray = new Uint8Array(width * height * 4); const groupArray = getPackedGroupArray(volume.grid, props); + const gridToCartn = Grid.getGridToCartesianTransform(volume.grid); + const l = PositionLocation(Vec3(), Vec3()); + + let v: (ix: number, iy: number, iz: number) => number; + if (equalEps(isoVal, stats.min, 0.001)) { + v = () => 255; + } else if (equalEps(isoVal, stats.max, 0.001)) { + v = () => 0; + } else { + v = (ix, iy, iz) => { + return ( + (space.get(data, ix, iy, iz) >= isoVal ? 1 : 0) * 2 + + (space.get(data, ix + 1, iy, iz) >= isoVal ? 1 : 0) + + (space.get(data, ix - 1, iy, iz) >= isoVal ? 1 : 0) + + (space.get(data, ix, iy + 1, iz) >= isoVal ? 1 : 0) + + (space.get(data, ix, iy - 1, iz) >= isoVal ? 1 : 0) + + (space.get(data, ix, iy, iz + 1) >= isoVal ? 1 : 0) + + (space.get(data, ix, iy, iz - 1) >= isoVal ? 1 : 0) + + (space.get(data, ix + 1, iy + 1, iz + 1) >= isoVal ? 1 : 0) + + (space.get(data, ix - 1, iy + 1, iz + 1) >= isoVal ? 1 : 0) + + (space.get(data, ix + 1, iy - 1, iz + 1) >= isoVal ? 1 : 0) + + (space.get(data, ix + 1, iy + 1, iz - 1) >= isoVal ? 1 : 0) + + (space.get(data, ix - 1, iy - 1, iz + 1) >= isoVal ? 1 : 0) + + (space.get(data, ix - 1, iy + 1, iz - 1) >= isoVal ? 1 : 0) + + (space.get(data, ix + 1, iy - 1, iz - 1) >= isoVal ? 1 : 0) + + (space.get(data, ix - 1, iy - 1, iz - 1) >= isoVal ? 1 : 0) + ) / 16 * 255; + }; + } + let i = 0; for (let iy = y0; iy < ny; ++iy) { for (let ix = x0; ix < nx; ++ix) { for (let iz = z0; iz < nz; ++iz) { - const val = space.get(data, ix, iy, iz); - const normVal = (val - min) / (max - min); - - imageArray[i] = r * normVal * 2 * 255; - imageArray[i + 1] = g * normVal * 2 * 255; - imageArray[i + 2] = b * normVal * 2 * 255; - imageArray[i + 3] = val >= isoVal ? 255 : 0; - + Vec3.set(l.position, ix, iy, iz); + Vec3.transformMat4(l.position, l.position, gridToCartn); + Color.toArray(color(l, false), imageArray, i); + imageArray[i + 3] = v(ix, iy, iz); i += 4; } } diff --git a/src/mol-theme/color.ts b/src/mol-theme/color.ts index 5169fb9bbb..94a00dbf58 100644 --- a/src/mol-theme/color.ts +++ b/src/mol-theme/color.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2024 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose */ @@ -35,7 +35,7 @@ import { OperatorHklColorThemeProvider } from './color/operator-hkl'; import { PartialChargeColorThemeProvider } from './color/partial-charge'; import { AtomIdColorThemeProvider } from './color/atom-id'; import { EntityIdColorThemeProvider } from './color/entity-id'; -import { Texture, TextureFilter } from '../mol-gl/webgl/texture'; +import type { Texture, TextureFilter } from '../mol-gl/webgl/texture'; import { VolumeValueColorThemeProvider } from './color/volume-value'; import { Vec3, Vec4 } from '../mol-math/linear-algebra'; import { ModelIndexColorThemeProvider } from './color/model-index'; @@ -46,6 +46,11 @@ import { ColorThemeCategory } from './color/categories'; import { CartoonColorThemeProvider } from './color/cartoon'; import { FormalChargeColorThemeProvider } from './color/formal-charge'; import { ExternalStructureColorThemeProvider } from './color/external-structure'; +import { VolumeDataColorThemeProvider } from './color/volume-data'; +import { ColorListEntry } from '../mol-util/color/color'; +import { getPrecision } from '../mol-util/number'; +import { SortedArray } from '../mol-data/int/sorted-array'; +import { normalize } from '../mol-math/interpolate'; export type LocationColor = (location: Location, isSecondary: boolean) => Color @@ -94,8 +99,55 @@ namespace ColorTheme { export const Category = ColorThemeCategory; export interface Palette { - filter?: TextureFilter, colors: Color[], + filter?: TextureFilter, + domain?: [number, number], + } + + export function Palette(list: ColorListEntry[], kind: 'set' | 'interpolate', domain?: [number, number]): Palette { + const colors: Color[] = []; + + const hasOffsets = list.every(c => Array.isArray(c)); + if (hasOffsets) { + let maxPrecision = 0; + for (const e of list) { + if (Array.isArray(e)) { + const p = getPrecision(e[1]); + if (p > maxPrecision) maxPrecision = p; + } + } + const count = Math.pow(10, maxPrecision); + + const sorted = [...list] as [Color, number][]; + sorted.sort((a, b) => a[1] - b[1]); + + const src = sorted.map(c => c[0]); + const values = SortedArray.ofSortedArray(sorted.map(c => c[1])); + + const _off: number[] = []; + for (let i = 0, il = values.length - 1; i < il; ++i) { + _off.push(values[i] + (values[i + 1] - values[i]) / 2); + } + _off.push(values[values.length - 1]); + const off = SortedArray.ofSortedArray(_off); + + for (let i = 0, il = Math.max(count, list.length); i < il; ++i) { + const t = normalize(i, 0, count - 1); + const j = SortedArray.findPredecessorIndex(off, t); + colors[i] = src[j]; + } + } else { + for (const e of list) { + if (Array.isArray(e)) colors.push(e[0]); + else colors.push(e); + } + } + + return { + colors, + filter: kind === 'set' ? 'nearest' : 'linear', + domain, + }; } export const PaletteScale = (1 << 24) - 1; @@ -154,6 +206,7 @@ namespace ColorTheme { 'uncertainty': UncertaintyColorThemeProvider, 'unit-index': UnitIndexColorThemeProvider, 'uniform': UniformColorThemeProvider, + 'volume-data': VolumeDataColorThemeProvider, 'volume-segment': VolumeSegmentColorThemeProvider, 'volume-value': VolumeValueColorThemeProvider, }; diff --git a/src/mol-theme/color/external-volume.ts b/src/mol-theme/color/external-volume.ts index 75c37ff0de..71e069f514 100644 --- a/src/mol-theme/color/external-volume.ts +++ b/src/mol-theme/color/external-volume.ts @@ -1,20 +1,21 @@ /** - * Copyright (c) 2024 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2024-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal * @author Cai Huiyu + * @author Alexander Rose */ import { Color, ColorScale } from '../../mol-util/color'; import { Location } from '../../mol-model/location'; -import type { ColorTheme } from '../color'; +import { ColorTheme, LocationColor } from '../color'; import { ParamDefinition as PD } from '../../mol-util/param-definition'; import { ThemeDataContext } from '../theme'; import { Grid, Volume } from '../../mol-model/volume'; import { type PluginContext } from '../../mol-plugin/context'; import { isPositionLocation } from '../../mol-geo/util/location-iterator'; -import { Mat4, Vec3 } from '../../mol-math/linear-algebra'; -import { lerp } from '../../mol-math/interpolate'; +import { Vec3 } from '../../mol-math/linear-algebra'; +import { clamp } from '../../mol-math/interpolate'; import { ColorThemeCategory } from './categories'; const Description = `Assigns a color based on volume value at a given vertex.`; @@ -30,7 +31,7 @@ export const ExternalVolumeColorThemeParams = { coloring: PD.MappedStatic('absolute-value', { 'absolute-value': PD.Group({ domain: PD.MappedStatic('auto', { - custom: PD.Interval([-1, 1]), + custom: PD.Interval([-1, 1], { step: 0.001 }), auto: PD.Group({ symmetric: PD.Boolean(false, { description: 'If true the automatic range is determined as [-|max|, |max|].' }) }) @@ -39,7 +40,7 @@ export const ExternalVolumeColorThemeParams = { }), 'relative-value': PD.Group({ domain: PD.MappedStatic('auto', { - custom: PD.Interval([-1, 1]), + custom: PD.Interval([-1, 1], { step: 0.001 }), auto: PD.Group({ symmetric: PD.Boolean(false, { description: 'If true the automatic range is determined as [-|max|, |max|].' }) }) @@ -49,6 +50,7 @@ export const ExternalVolumeColorThemeParams = { }), defaultColor: PD.Color(Color(0xcccccc)), normalOffset: PD.Numeric(0., { min: 0, max: 20, step: 0.1 }, { description: 'Offset vertex position along its normal by given amount.' }), + usePalette: PD.Boolean(false, { description: 'Use a palette to color at the pixel level.' }), }; export type ExternalVolumeColorThemeParams = typeof ExternalVolumeColorThemeParams @@ -63,7 +65,11 @@ export function ExternalVolumeColorTheme(ctx: ThemeDataContext, props: PD.Values // NOTE: this will currently be slow for with GPU/texture meshes due to slow iteration // TODO: create texture to be able to do the sampling on the GPU - let color; + let color: LocationColor; + let palette: ColorTheme.Palette | undefined; + + const { normalOffset, defaultColor, usePalette } = props; + if (volume) { const coloring = props.coloring.params; const { stats } = volume.grid; @@ -75,7 +81,7 @@ export function ExternalVolumeColorTheme(ctx: ThemeDataContext, props: PD.Values domain[1] = (domain[1] - stats.mean) / stats.sigma; } - if (props.coloring.params.domain.name === 'auto' && props.coloring.params.domain.params.symmetric) { + if (coloring.domain.name === 'auto' && coloring.domain.params.symmetric) { const max = Math.max(Math.abs(domain[0]), Math.abs(domain[1])); domain[0] = -max; domain[1] = max; @@ -83,67 +89,37 @@ export function ExternalVolumeColorTheme(ctx: ThemeDataContext, props: PD.Values const scale = ColorScale.create({ domain, listOrName: coloring.list.colors }); - const cartnToGrid = Grid.getGridToCartesianTransform(volume.grid); - Mat4.invert(cartnToGrid, cartnToGrid); - const gridCoords = Vec3(); - - const { dimensions, get } = volume.grid.cells.space; - const data = volume.grid.cells.data; - - const [mi, mj, mk] = dimensions; + const position = Vec3(); + const getTrilinearlyInterpolated = Grid.makeGetTrilinearlyInterpolated(volume.grid, isRelative ? 'relative' : 'none'); color = (location: Location): Color => { if (!isPositionLocation(location)) { - return props.defaultColor; + return defaultColor; } // Offset the vertex position along its normal - Vec3.copy(gridCoords, location.position); - - if (props.normalOffset > 0) { - Vec3.scaleAndAdd(gridCoords, gridCoords, location.normal, props.normalOffset); + if (normalOffset > 0) { + Vec3.scaleAndAdd(position, location.position, location.normal, normalOffset); + } else { + Vec3.copy(position, location.position); } - Vec3.transformMat4(gridCoords, gridCoords, cartnToGrid); + const value = getTrilinearlyInterpolated(position); + if (isNaN(value)) return defaultColor; - const i = Math.floor(gridCoords[0]); - const j = Math.floor(gridCoords[1]); - const k = Math.floor(gridCoords[2]); - - if (i < 0 || i >= mi || j < 0 || j >= mj || k < 0 || k >= mk) { - return props.defaultColor; - } - - const u = gridCoords[0] - i; - const v = gridCoords[1] - j; - const w = gridCoords[2] - k; - - // Tri-linear interpolation for the value - const ii = Math.min(i + 1, mi - 1); - const jj = Math.min(j + 1, mj - 1); - const kk = Math.min(k + 1, mk - 1); - - let a = get(data, i, j, k); - let b = get(data, ii, j, k); - let c = get(data, i, jj, k); - let d = get(data, ii, jj, k); - const x = lerp(lerp(a, b, u), lerp(c, d, u), v); - - a = get(data, i, j, kk); - b = get(data, ii, j, kk); - c = get(data, i, jj, kk); - d = get(data, ii, jj, kk); - const y = lerp(lerp(a, b, u), lerp(c, d, u), v); - - let value = lerp(x, y, w); - if (isRelative) { - value = (value - stats.mean) / stats.sigma; + if (usePalette) { + return (clamp((value - domain[0]) / (domain[1] - domain[0]), 0, 1) * ColorTheme.PaletteScale) as Color; + } else { + return scale.color(value); } - - return scale.color(value); }; + + palette = usePalette ? { + colors: coloring.list.colors.map(e => Array.isArray(e) ? e[0] : e), + filter: (coloring.list.kind === 'set' ? 'nearest' : 'linear') as 'nearest' | 'linear' + } : undefined; } else { - color = () => props.defaultColor; + color = () => defaultColor; } return { @@ -151,6 +127,7 @@ export function ExternalVolumeColorTheme(ctx: ThemeDataContext, props: PD.Values granularity: 'vertex', preferSmoothing: true, color, + palette, props, description: Description, // TODO: figure out how to do legend for this diff --git a/src/mol-theme/color/volume-data.ts b/src/mol-theme/color/volume-data.ts new file mode 100644 index 0000000000..2499b72278 --- /dev/null +++ b/src/mol-theme/color/volume-data.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Alexander Rose + * @author David Sehnal + */ + +import { Color } from '../../mol-util/color'; +import { Location } from '../../mol-model/location'; +import { ColorTheme, LocationColor } from '../color'; +import { ParamDefinition as PD } from '../../mol-util/param-definition'; +import { ThemeDataContext } from '../theme'; +import { Grid, Volume } from '../../mol-model/volume'; +import { isPositionLocation } from '../../mol-geo/util/location-iterator'; +import { clamp } from '../../mol-math/interpolate'; +import { ColorThemeCategory } from './categories'; + +const Description = `Assigns a color based on volume value at a given vertex.`; + +export const VolumeDataColorThemeParams = { + coloring: PD.MappedStatic('absolute-value', { + 'absolute-value': PD.Group({ + domain: PD.MappedStatic('auto', { + custom: PD.Interval([-1, 1], { step: 0.001 }), + auto: PD.Group({ + symmetric: PD.Boolean(false, { description: 'If true the automatic range is determined as [-|max|, |max|].' }) + }) + }), + list: PD.ColorList('red-white-blue', { presetKind: 'scale' }) + }), + 'relative-value': PD.Group({ + domain: PD.MappedStatic('auto', { + custom: PD.Interval([-1, 1], { step: 0.001 }), + auto: PD.Group({ + symmetric: PD.Boolean(false, { description: 'If true the automatic range is determined as [-|max|, |max|].' }) + }) + }), + list: PD.ColorList('red-white-blue', { presetKind: 'scale' }) + }) + }), + defaultColor: PD.Color(Color(0xcccccc)), +}; +export type VolumeDataColorThemeParams = typeof VolumeDataColorThemeParams + +export function VolumeDataColorTheme(ctx: ThemeDataContext, props: PD.Values): ColorTheme { + let color: LocationColor; + let palette: ColorTheme.Palette | undefined; + + if (ctx.volume) { + const coloring = props.coloring.params; + const { stats } = ctx.volume.grid; + const domain: [number, number] = coloring.domain.name === 'custom' ? coloring.domain.params : [stats.min, stats.max]; + + const isRelative = props.coloring.name === 'relative-value'; + if (coloring.domain.name === 'auto' && isRelative) { + domain[0] = (domain[0] - stats.mean) / stats.sigma; + domain[1] = (domain[1] - stats.mean) / stats.sigma; + } + + if (coloring.domain.name === 'auto' && coloring.domain.params.symmetric) { + const max = Math.max(Math.abs(domain[0]), Math.abs(domain[1])); + domain[0] = -max; + domain[1] = max; + } + + const getTrilinearlyInterpolated = Grid.makeGetTrilinearlyInterpolated(ctx.volume.grid, isRelative ? 'relative' : 'none'); + + color = (location: Location): Color => { + if (!isPositionLocation(location)) { + return props.defaultColor; + } + + const value = getTrilinearlyInterpolated(location.position); + if (isNaN(value)) return props.defaultColor; + + return (clamp((value - domain[0]) / (domain[1] - domain[0]), 0, 1) * ColorTheme.PaletteScale) as Color; + }; + + palette = ColorTheme.Palette(coloring.list.colors, coloring.list.kind); + } else { + color = () => props.defaultColor; + } + + return { + factory: VolumeDataColorTheme, + granularity: 'vertex', + preferSmoothing: true, + color, + palette, + props, + description: Description, + // TODO: figure out how to do legend for this + }; +} + +export const VolumeDataColorThemeProvider: ColorTheme.Provider = { + name: 'volume-data', + label: 'Volume Data', + category: ColorThemeCategory.Misc, + factory: VolumeDataColorTheme, + getParams: () => VolumeDataColorThemeParams, + defaultValues: PD.getDefaultValues(VolumeDataColorThemeParams), + isApplicable: (ctx: ThemeDataContext) => !!ctx.volume && !Volume.Segmentation.get(ctx.volume), +}; \ No newline at end of file diff --git a/src/mol-theme/color/volume-value.ts b/src/mol-theme/color/volume-value.ts index aac13ae7b8..968fc92fc0 100644 --- a/src/mol-theme/color/volume-value.ts +++ b/src/mol-theme/color/volume-value.ts @@ -1,17 +1,17 @@ /** - * Copyright (c) 2021-2022 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2021-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose */ -import type { ColorTheme } from '../color'; -import { Color, ColorScale } from '../../mol-util/color'; +import { ColorTheme } from '../color'; import { ParamDefinition as PD } from '../../mol-util/param-definition'; import { ThemeDataContext } from '../theme'; import { ColorNames } from '../../mol-util/color/names'; import { ColorTypeDirect } from '../../mol-geo/geometry/color-data'; import { Volume } from '../../mol-model/volume/volume'; import { ColorThemeCategory } from './categories'; +import { normalize } from '../../mol-math/interpolate'; const Description = 'Assign color based on the given value of a volume cell.'; @@ -26,6 +26,12 @@ export const VolumeValueColorThemeParams = { [ColorNames.white, 1] ] }, { offsets: true, isEssential: true }), + domain: PD.MappedStatic('auto', { + custom: PD.Interval([-1, 1], { step: 0.001 }), + auto: PD.Group({ + symmetric: PD.Boolean(false, { description: 'If true the automatic range is determined as [-|max|, |max|].' }) + }) + }), }; export type VolumeValueColorThemeParams = typeof VolumeValueColorThemeParams export function getVolumeValueColorThemeParams(ctx: ThemeDataContext) { @@ -33,21 +39,32 @@ export function getVolumeValueColorThemeParams(ctx: ThemeDataContext) { } export function VolumeValueColorTheme(ctx: ThemeDataContext, props: PD.Values): ColorTheme { - const scale = ColorScale.create({ domain: [0, 1], listOrName: props.colorList.colors }); + let palette: ColorTheme.Palette | undefined; - const colors: Color[] = []; - for (let i = 0; i < 256; ++i) { - colors[i] = scale.color(i / 255); - } + if (ctx.volume) { + const { min, max } = ctx.volume.grid.stats; + const domain: [number, number] = props.domain.name === 'custom' ? props.domain.params : [min, max]; + const { colorList } = props; + + if (props.domain.name === 'auto' && props.domain.params.symmetric) { + const max = Math.max(Math.abs(domain[0]), Math.abs(domain[1])); + domain[0] = -max; + domain[1] = max; + } - const palette: ColorTheme.Palette = { colors, filter: 'linear' }; + const normalizedDomain = [ + normalize(domain[0], min, max), + normalize(domain[1], min, max) + ] as [number, number]; + + palette = ColorTheme.Palette(colorList.colors, colorList.kind, normalizedDomain); + } return { factory: VolumeValueColorTheme, granularity: 'direct', - props: props, + props, description: Description, - legend: scale.legend, palette, }; }