Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/core/src/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ import getVolumeViewReferenceId from './getVolumeViewReferenceId';
import calculateSpacingBetweenImageIds from './calculateSpacingBetweenImageIds';
export * as logger from './logger';
import { calculateNeighborhoodStats } from './calculateNeighborhoodStats';
import {
mapScalarToViewportVoiIntensity,
mapViewportVoiIntensityToScalar,
mapMappedBandToRawRange,
} from './viewportVoiIntensityMapping';
export type { ViewportVoiMappingProps } from './viewportVoiIntensityMapping';
export * from './getPixelSpacingInformation';
export * from './getPlaneCubeIntersectionDimensions';
export * from './rotateToViewCoordinates';
Expand Down Expand Up @@ -253,6 +259,9 @@ export {
getImageDataMetadata,
buildMetadata,
calculateNeighborhoodStats,
mapScalarToViewportVoiIntensity,
mapViewportVoiIntensityToScalar,
mapMappedBandToRawRange,
asArray,
viewportSupportsImageSlices,
viewportSupportsStackCalibration,
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utilities/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,10 @@ export const {
imageConsistencyLog,
} = logging;
export type Logger = logging.Logger;

/**
* One-click flood fill segmentation and island-removal diagnostics. Level is
* left to the consumer (like the other cs3d loggers); call
* `growCutLog.setLevel('info')` in the host app to see the diagnostics.
*/
export const growCutLog = toolsLog.getLogger('growCut');
104 changes: 104 additions & 0 deletions packages/core/src/utilities/viewportVoiIntensityMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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));
}
Loading
Loading