@niivue/niivue
Version:
minimal webgl2 nifti image viewer
290 lines (256 loc) • 9.28 kB
text/typescript
import * as cmaps from '@/cmaps'
import { log } from '@/logger'
export enum COLORMAP_TYPE {
MIN_TO_MAX = 0,
ZERO_TO_MAX_TRANSPARENT_BELOW_MIN = 1,
ZERO_TO_MAX_TRANSLUCENT_BELOW_MIN = 2
}
export type ColorMap = {
R: number[]
G: number[]
B: number[]
A: number[]
I: number[]
min?: number
max?: number
labels?: string[]
}
export type LUT = {
lut: Uint8ClampedArray
min?: number
max?: number
labels?: string[]
}
export class ColorTables {
gamma = 1.0
version = 0.1
cluts: Record<string, ColorMap> = {}
/**
* Sets cluts to alphabetically sorted cmaps
*/
constructor() {
const cmapKeys = Object.keys(cmaps) as Array<keyof typeof cmaps>
const cmapsSorted = cmapKeys
.filter((k) => !k.startsWith('$')) // ignore drawing maps
.sort(new Intl.Collator('en').compare) // case insensitive, e.g. "ROI_i256" > "actc"
for (const key of cmapsSorted) {
this.cluts[key] = cmaps[key]
}
}
addColormap(key: string, cmap: ColorMap): void {
this.cluts[key] = cmap
}
colormaps(): Array<keyof typeof this.cluts> {
return Object.keys(this.cluts) as Array<keyof typeof this.cluts>
}
// for backward compatibility: prior to v0.34 "colormaps" used to be "colorMaps"
colorMaps(): Array<keyof typeof this.cluts> {
return this.colormaps()
}
// returns key name if it exists, otherwise returns default "gray"
colormapFromKey(name: string): ColorMap {
let cmap = this.cluts[name]
if (cmap !== undefined) {
return cmap
}
cmap = this.cluts[name.toLowerCase()]
if (cmap !== undefined) {
return cmap
}
if (name.length > 0) {
log.warn('No color map named ' + name)
}
return {
min: 0,
max: 0,
R: [0, 255],
G: [0, 255],
B: [0, 255],
A: [0, 255],
I: [0, 255]
}
}
// not included in public docs
colormap(key = '', isInvert = false): Uint8ClampedArray {
const cmap = this.colormapFromKey(key)
return this.makeLut(cmap.R, cmap.G, cmap.B, cmap.A, cmap.I, isInvert)
}
makeLabelLut(cm: ColorMap, alphaFill = 255, maxIdx = Infinity): LUT {
if (cm.R === undefined || cm.G === undefined || cm.B === undefined) {
throw new Error(`Invalid colormap table: ${cm}`)
}
const nLabels = cm.R.length
// if indices are not provided, indices default to 0..(nLabels-1)
const idxs = cm.I ?? [...Array(nLabels).keys()]
// --- Check for out-of-range values: issue 1441
let hasInvalid = false
for (let i = 0; i < idxs.length; i++) {
if (idxs[i] > maxIdx) {
hasInvalid = true
idxs[i] = maxIdx // clamp
}
}
if (hasInvalid) {
log.warn(`Some colormap indices clamped to match label range.`)
}
if (nLabels !== cm.G.length || nLabels !== cm.B.length || nLabels !== idxs.length) {
throw new Error(`colormap does not make sense: ${cm} Rs ${cm.R.length} Gs ${cm.G.length} Bs ${cm.B.length} Is ${idxs.length}`)
}
let As = new Uint8ClampedArray(nLabels).fill(alphaFill)
const zeroPos = idxs.indexOf(0)
if (zeroPos >= 0) {
As[zeroPos] = 0
}
if (cm.A !== undefined) {
As = Uint8ClampedArray.from(cm.A)
}
const mnIdx = Math.min(...idxs)
const mxIdx = Math.max(...idxs)
// n.b. number of input labels can be sparse: I:[0,3,4] output is dense [0,1,2,3,4]
const nLabelsDense = mxIdx - mnIdx + 1
const lut = new Uint8ClampedArray(nLabelsDense * 4).fill(0)
for (let i = 0; i < nLabels; i++) {
let k = (idxs[i] - mnIdx) * 4
lut[k++] = cm.R[i] // Red
lut[k++] = cm.G[i] // Green
lut[k++] = cm.B[i] // Blue
lut[k++] = As[i] // Alpha
}
const cmap: LUT = {
lut,
min: mnIdx,
max: mxIdx
}
// labels are optional
if (cm.labels) {
const nL = cm.labels.length
if (nL === nLabelsDense) {
cmap.labels = cm.labels
} else if (nL === nLabels) {
cmap.labels = Array(nLabelsDense).fill('?')
for (let i = 0; i < nLabels; i++) {
cmap.labels[idxs[i] - mnIdx] = cm.labels[i]
}
}
}
return cmap
}
async makeLabelLutFromUrl(name: string, alphaFill = 255, maxIdx = Infinity): Promise<LUT> {
const response = await fetch(name)
const cm = await response.json()
return this.makeLabelLut(cm, alphaFill, maxIdx)
}
// not included in public docs
// The drawing colormap is a variant of the label colormap with precisely 256 colors
makeDrawLut(name: string | ColorMap): LUT {
let cmap: ColorMap = typeof name === 'object' ? name : cmaps[name as keyof typeof cmaps]
if (cmap === undefined) {
log.warn('colormap undefined ', name)
cmap = this.colormapFromKey('')
}
const cm = this.makeLabelLut(cmap, 255)
if (cm.labels === undefined) {
cm.labels = []
}
if (cm.labels.length < 256) {
const j = cm.labels.length
for (let i = j; i < 256; i++) {
// make all unused slots opaque red
cm.labels.push(i.toString())
}
}
const lut = new Uint8ClampedArray(256 * 4)
let k = 0
for (let i = 0; i < 256; i++) {
lut[k++] = 255 // Red
lut[k++] = 0 // Green
lut[k++] = 0 // Blue
lut[k++] = 255 // Alpha
}
lut[3] = 0 // make first alpha transparent: not part of drawing
// drawings can have no more than 256 colors
const explicitLUTbytes = Math.min(cm.lut.length, 256 * 4)
if (explicitLUTbytes > 0) {
for (let i = 0; i < explicitLUTbytes; i++) {
lut[i] = cm.lut[i]
}
}
return {
lut,
labels: cm.labels
}
}
// not included in public docs
makeLut(Rsi: number[], Gsi: number[], Bsi: number[], Asi: number[], Isi: number[], isInvert: boolean): Uint8ClampedArray {
// create color lookup table provided arrays of reds, greens, blues, alphas and intensity indices
// intensity indices should be in increasing order with the first value 0 and the last 255.
// this.makeLut([0, 255], [0, 0], [0,0], [0,128],[0,255]); //red gradient
const nIdx = Rsi.length
const Rs = [...Rsi]
const Gs = [...Gsi]
const Bs = [...Bsi]
if (!Isi) {
Isi = new Array(nIdx)
for (let i = 0; i < nIdx; i++) {
Isi[i] = (i / (nIdx - 1)) * 255
}
}
if (!Asi) {
Asi = new Array(nIdx).fill(64)
Asi[0] = 0
}
let As = Uint8ClampedArray.from(Asi)
let Is = Uint8ClampedArray.from(Isi)
if (isInvert) {
for (let i = 0; i < nIdx; i++) {
Rs[i] = Rsi[nIdx - 1 - i]
Gs[i] = Gsi[nIdx - 1 - i]
Bs[i] = Bsi[nIdx - 1 - i]
As[i] = 255 - Asi[nIdx - 1 - i]
Is[i] = 255 - Isi[nIdx - 1 - i]
}
}
const lut = new Uint8ClampedArray(256 * 4)
if (typeof Is === 'undefined') {
Is = new Uint8ClampedArray(nIdx).fill(0)
for (let i = 0; i < nIdx; i++) {
Is[i] = Math.round((i * 255.0) / (nIdx - 1))
}
}
if (typeof As === 'undefined') {
As = new Uint8ClampedArray(nIdx).fill(64)
As[0] = 0
}
for (let i = 0; i < nIdx - 1; i++) {
const idxLo = Is[i]
let idxHi = Is[i + 1]
if (i === 0 && idxLo !== 0) {
log.warn('colormap issue: indices expected to start with 0 not ', idxLo)
}
if (i === Is.length - 2 && idxHi !== 255) {
log.warn('padding colormap: indices expected end with 255 not ', idxHi)
idxHi = 255
}
const idxRng = idxHi - idxLo
let k = idxLo * 4
for (let j = idxLo; j <= idxHi; j++) {
const f = (j - idxLo) / idxRng
lut[k++] = Rs[i] + f * (Rs[i + 1] - Rs[i]) // Red
lut[k++] = Gs[i] + f * (Gs[i + 1] - Gs[i]) // Green
lut[k++] = Bs[i] + f * (Bs[i + 1] - Bs[i]) // Blue
lut[k++] = As[i] + f * (As[i + 1] - As[i]) // Alpha
}
}
if (this.gamma === 1.0) {
return lut
}
for (let i = 0; i < 255 * 4; i++) {
if (i % 4 === 3) {
continue
} // gamma changes RGB, not Alpha
lut[i] = Math.pow(lut[i] / 255, 1 / this.gamma) * 255
}
return lut
}
}
export const cmapper = new ColorTables()