@niivue/niivue
Version:
minimal webgl2 nifti image viewer
281 lines (274 loc) • 9.68 kB
text/typescript
/**
* IntensityCalibration module
*
* Handles intensity calibration, windowing, and histogram analysis.
* This module contains functions for:
* - Calculating robust intensity range (2%..98% percentile)
* - Converting between raw and calibrated intensity values
*/
import type { NVImage } from './index'
import { log } from '@/logger'
import { cmapper } from '@/colortables'
import { NiiIntentCode } from '@/nvimage/utils'
/**
* Convert voxel intensity from stored value to scaled intensity.
*
* @param nvImage - The NVImage instance
* @param raw - Raw intensity value
* @returns Scaled intensity value
*/
export function intensityRaw2Scaled(nvImage: NVImage, raw: number): number {
if (!nvImage.hdr) {
throw new Error('hdr undefined')
}
if (nvImage.hdr.scl_slope === 0) {
nvImage.hdr.scl_slope = 1.0
}
return raw * nvImage.hdr.scl_slope + nvImage.hdr.scl_inter
}
/**
* Convert voxel intensity from scaled intensity to stored value.
*
* @param nvImage - The NVImage instance
* @param scaled - Scaled intensity value
* @returns Raw intensity value
*/
export function intensityScaled2Raw(nvImage: NVImage, scaled: number): number {
if (!nvImage.hdr) {
throw new Error('hdr undefined')
}
if (nvImage.hdr.scl_slope === 0) {
nvImage.hdr.scl_slope = 1.0
}
return (scaled - nvImage.hdr.scl_inter) / nvImage.hdr.scl_slope
}
/**
* Set contrast/brightness to robust range (2%..98%).
* Calculate robust intensity range using histogram analysis.
*
* @param nvImage - The NVImage instance
* @param vol - Volume for estimate (use -1 to use estimate on all loaded volumes; use INFINITY for current volume)
* @param isBorder - If true (default) only center of volume used for estimate
* @returns Volume brightness and returns array [pct2, pct98, mnScale, mxScale]
*/
export function calMinMax(nvImage: NVImage, vol: number = Number.POSITIVE_INFINITY, isBorder: boolean = true): number[] {
if (!nvImage.hdr) {
throw new Error('hdr undefined')
}
if (!nvImage.img) {
throw new Error('img undefined')
}
// determine full range: min..max
let mn = Number.POSITIVE_INFINITY // not this.img[0] in case ignoreZeroVoxels
let mx = Number.NEGATIVE_INFINITY // this.img[0] in case ignoreZeroVoxels
let nZero = 0
let nNan = 0
let nVox3D = nvImage.hdr.dims[1] * nvImage.hdr.dims[2] * nvImage.hdr.dims[3]
// n.b. due to limitFrames4D nVol may not equal dims[4]
const nVol = Math.floor(nvImage.img.length / nVox3D)
if (vol >= nVol) {
vol = nvImage.frame4D
}
vol = Math.min(vol, nVol - 1)
const skipVox = vol * nVox3D
let img = []
if (!isBorder) {
img = new (nvImage.img.constructor as new (length: number) => any)(nVox3D)
for (let i = 0; i < nVox3D; i++) {
img[i] = nvImage.img[i + skipVox]
}
} else {
const borderFrac = 0.25
const borders = [Math.floor(borderFrac * nvImage.hdr.dims[1]), Math.floor(borderFrac * nvImage.hdr.dims[2]), Math.floor(borderFrac * nvImage.hdr.dims[3])]
const dims = [nvImage.hdr.dims[1] - 2 * borders[0], nvImage.hdr.dims[2] - 2 * borders[1], nvImage.hdr.dims[3] - 2 * borders[2]]
const bordersHi = [dims[0] + borders[0], dims[1] + borders[1], dims[2] + borders[2]]
nVox3D = dims[0] * dims[1] * dims[2]
img = new (nvImage.img.constructor as new (length: number) => any)(nVox3D)
let j = -1
let i = 0
for (let z = 0; z < nvImage.hdr.dims[3]; z++) {
for (let y = 0; y < nvImage.hdr.dims[2]; y++) {
for (let x = 0; x < nvImage.hdr.dims[1]; x++) {
j++
if (x < borders[0] || y < borders[1] || z < borders[2]) {
continue
}
if (x >= bordersHi[0] || y >= bordersHi[1] || z >= bordersHi[2]) {
continue
}
img[i] = nvImage.img[j + skipVox]
i++
}
}
}
}
/* for (let i = 0; i < nVox3D; i++) {
img[i] = nvImage.img[i + skipVox]
} */
// we can accelerate loops for integer data (which can not store NaN)
// n.b. do to stack size, we can not use Math.max.apply()
const isFastCalc = img.constructor !== Float64Array && img.constructor !== Float32Array && nvImage.ignoreZeroVoxels
if (isFastCalc) {
for (let i = 0; i < nVox3D; i++) {
mn = Math.min(img[i], mn)
mx = Math.max(img[i], mx)
if (img[i] === 0) {
nZero++
}
}
} else {
for (let i = 0; i < nVox3D; i++) {
if (isNaN(img[i])) {
nNan++
continue
}
if (img[i] === 0) {
nZero++
if (nvImage.ignoreZeroVoxels) {
continue
}
}
mn = Math.min(img[i], mn)
mx = Math.max(img[i], mx)
}
}
if (nvImage.ignoreZeroVoxels && mn === mx && nZero > 0) {
mn = 0
}
const mnScale = intensityRaw2Scaled(nvImage, mn)
const mxScale = intensityRaw2Scaled(nvImage, mx)
const cmap = cmapper.colormapFromKey(nvImage._colormap)
let cmMin = 0
let cmMax = 0
if (cmap.min !== undefined) {
cmMin = cmap.min
}
if (cmap.max !== undefined) {
cmMax = cmap.max
}
if (cmMin === cmMax && nvImage.trustCalMinMax && isFinite(nvImage.hdr.cal_min) && isFinite(nvImage.hdr.cal_max) && nvImage.hdr.cal_max > nvImage.hdr.cal_min) {
nvImage.cal_min = nvImage.hdr.cal_min
nvImage.cal_max = nvImage.hdr.cal_max
nvImage.robust_min = nvImage.cal_min
nvImage.robust_max = nvImage.cal_max
nvImage.global_min = mnScale
nvImage.global_max = mxScale
return [nvImage.hdr.cal_min, nvImage.hdr.cal_max, nvImage.hdr.cal_min, nvImage.hdr.cal_max]
}
// if color map specifies non zero values for min and max then use them
if (cmMin !== cmMax) {
nvImage.cal_min = cmMin
nvImage.cal_max = cmMax
nvImage.robust_min = nvImage.cal_min
nvImage.robust_max = nvImage.cal_max
return [cmMin, cmMax, cmMin, cmMax]
}
const percentZero = (100 * nZero) / (nVox3D - 0)
let isOverrideIgnoreZeroVoxels = false
if (percentZero > 60 && !nvImage.ignoreZeroVoxels) {
log.warn(`${Math.round(percentZero)}% of voxels are zero: ignoring zeros for cal_max`)
isOverrideIgnoreZeroVoxels = true
nvImage.ignoreZeroVoxels = true
}
if (!nvImage.ignoreZeroVoxels) {
nZero = 0
}
nZero += nNan
const n2pct = Math.round((nVox3D - 0 - nZero) * nvImage.percentileFrac)
if (n2pct < 1 || mn === mx) {
if (isBorder) {
// central region has no variability: explore entire image
return calMinMax(nvImage, vol, false)
}
log.debug('no variability in image intensity?')
nvImage.cal_min = mnScale
nvImage.cal_max = mxScale
nvImage.robust_min = nvImage.cal_min
nvImage.robust_max = nvImage.cal_max
nvImage.global_min = mnScale
nvImage.global_max = mxScale
return [mnScale, mxScale, mnScale, mxScale]
}
const nBins = 1001
const scl = (nBins - 1) / (mx - mn)
const hist = new Array(nBins)
for (let i = 0; i < nBins; i++) {
hist[i] = 0
}
if (isFastCalc) {
for (let i = 0; i < nVox3D; i++) {
hist[Math.round((img[i] - mn) * scl)]++
}
} else if (nvImage.ignoreZeroVoxels) {
for (let i = 0; i < nVox3D; i++) {
if (img[i] === 0) {
continue
}
if (isNaN(img[i])) {
continue
}
hist[Math.round((img[i] - mn) * scl)]++
}
} else {
for (let i = 0; i < nVox3D; i++) {
if (isNaN(img[i])) {
continue
}
hist[Math.round((img[i] - mn) * scl)]++
}
}
let n = 0
let lo = 0
while (n < n2pct) {
n += hist[lo]
lo++
}
lo-- // remove final increment
n = 0
let hi = nBins
while (n < n2pct) {
hi--
n += hist[hi]
}
if (lo === hi) {
// MAJORITY are not black or white
let ok = -1
while (ok !== 0) {
if (lo > 0) {
lo--
if (hist[lo] > 0) {
ok = 0
}
}
if (ok !== 0 && hi < nBins - 1) {
hi++
if (hist[hi] > 0) {
ok = 0
}
}
if (lo === 0 && hi === nBins - 1) {
ok = 0
}
} // while not ok
} // if lo === hi
let pct2 = intensityRaw2Scaled(nvImage, lo / scl + mn)
let pct98 = intensityRaw2Scaled(nvImage, hi / scl + mn)
if (nvImage.hdr.cal_min < nvImage.hdr.cal_max && nvImage.hdr.cal_min >= mnScale && nvImage.hdr.cal_max <= mxScale) {
pct2 = nvImage.hdr.cal_min
pct98 = nvImage.hdr.cal_max
}
if (isOverrideIgnoreZeroVoxels) {
pct2 = Math.min(pct2, 0)
}
nvImage.cal_min = pct2
nvImage.cal_max = pct98
if (nvImage.hdr.intent_code === NiiIntentCode.NIFTI_INTENT_LABEL) {
nvImage.cal_min = mnScale
nvImage.cal_max = mxScale
}
nvImage.robust_min = nvImage.cal_min
nvImage.robust_max = nvImage.cal_max
nvImage.global_min = mnScale
nvImage.global_max = mxScale
return [pct2, pct98, mnScale, mxScale]
}