All files / core/src/utilities viewportVoiIntensityMapping.ts

3.03% Statements 1/33
0% Branches 0/22
0% Functions 0/6
3.12% Lines 1/32

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105        128x                                                                                                                                                                                                        
import VOILUTFunctionType from '../enums/VOILUTFunctionType';
import { logit } from './logit';
import * as windowLevelUtil from './windowLevel';
 
const Y_EPS = 1e-6;
 
export type ViewportVoiMappingProps = {
  voiRange: { lower: number; upper: number };
  VOILUTFunction?: string | VOILUTFunctionType;
  /**
   * When true the viewport renders the VOI inverted (e.g. PET AC), so the
   * displayed intensity is `1 − mapped`. Both the forward and inverse maps must
   * honor this or sampled display luma will inverse-map to the wrong raw end.
   */
  invert?: boolean;
};
 
/**
 * Maps a stored scalar to a normalized display intensity in [0, 1] using the same
 * convention as VTK RGB transfer functions (linear) or DICOM sigmoid (sampled).
 */
export function mapScalarToViewportVoiIntensity(
  value: number,
  props: ViewportVoiMappingProps
): number {
  const { lower, upper } = props.voiRange;
  const span = upper - lower;
  const fn = props.VOILUTFunction as string | undefined;
  const applyInvert = (y: number) => (props.invert === true ? 1 - y : y);
 
  if (fn === VOILUTFunctionType.SAMPLED_SIGMOID || fn === 'SIGMOID') {
    const { windowCenter, windowWidth } = windowLevelUtil.toWindowLevel(
      lower,
      upper
    );
    const w = Math.max(Math.abs(windowWidth), 1e-12);
    return applyInvert(1 / (1 + Math.exp((-4 * (value - windowCenter)) / w)));
  }
 
  if (span === 0 || !Number.isFinite(span)) {
    return applyInvert(0);
  }
  return applyInvert(clamp01((value - lower) / span));
}
 
/**
 * Inverse map: normalized intensity Y in (0, 1) back to stored scalar.
 * Endpoints Y=0 and Y=1 map to lower and upper for linear modes.
 */
export function mapViewportVoiIntensityToScalar(
  mapped01: number,
  props: ViewportVoiMappingProps
): number {
  const { lower, upper } = props.voiRange;
  const fn = props.VOILUTFunction as string | undefined;
  // Undo display inversion before mapping back to a stored scalar so the
  // round-trip with mapScalarToViewportVoiIntensity is exact.
  const y =
    props.invert === true ? clamp01(1 - clamp01(mapped01)) : clamp01(mapped01);
 
  if (fn === VOILUTFunctionType.SAMPLED_SIGMOID || fn === 'SIGMOID') {
    const { windowCenter, windowWidth } = windowLevelUtil.toWindowLevel(
      lower,
      upper
    );
    const yy = clamp(y, Y_EPS, 1 - Y_EPS);
    return logit(yy, windowCenter, windowWidth);
  }
 
  const span = upper - lower;
  if (span === 0 || !Number.isFinite(span)) {
    return lower;
  }
  return lower + y * span;
}
 
/**
 * Converts a tolerance band in mapped [0,1] space to raw [rawMin, rawMax] (ordered).
 */
export function mapMappedBandToRawRange(
  mappedMin: number,
  mappedMax: number,
  props: ViewportVoiMappingProps
): { rawMin: number; rawMax: number } {
  const a = Math.min(mappedMin, mappedMax);
  const b = Math.max(mappedMin, mappedMax);
  const rawAtA = mapViewportVoiIntensityToScalar(a, props);
  const rawAtB = mapViewportVoiIntensityToScalar(b, props);
  return {
    rawMin: Math.min(rawAtA, rawAtB),
    rawMax: Math.max(rawAtA, rawAtB),
  };
}
 
function clamp01(x: number): number {
  if (!Number.isFinite(x)) {
    return 0;
  }
  return clamp(x, 0, 1);
}
 
function clamp(x: number, lo: number, hi: number): number {
  return Math.min(hi, Math.max(lo, x));
}