feat(4D): Add 4D dynamic volume rendering and new pre-clinical 4d pt/ct mode (#3664)

Co-authored-by: Alireza <ar.sedghi@gmail.com>
Co-authored-by: Neil <neil.a.macphee@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-04-09 23:57:34 -04:00
1 parent dc0b183dd4
commit d57e8bc157
200 files changed
+9589 -2178

No files matched your search

@@ -0,0 +1,189 @@
import React, { useEffect, useCallback, useState, useMemo, ReactElement } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import WindowLevelHistogram from './WindowLevelHistogram';
import InputRange from '../InputRange';
import InputDoubleRange from '../InputDoubleRange';
import {
VOI,
Colormap,
Histogram,
voiPropType,
histogramPropType,
colormapPropType,
} from './types';
import PanelSection from '../PanelSection';
const convertVOItoVOIRange = voi => {
return {
min: voi.windowCenter - voi.windowWidth / 2,
max: voi.windowCenter + voi.windowWidth / 2,
};
};
const WindowLevel = ({
title,
step,
histogram,
voi: voiProp,
opacity: opacityProp,
showOpacitySlider,
colormap,
style,
fillColor,
lineColor,
containerClassName,
onVOIChange,
onOpacityChange,
}: {
title: string;
step: number;
histogram: Histogram;
voi: VOI;
opacity: number;
showOpacitySlider: boolean;
colormap: Colormap;
style: string;
fillColor: string;
lineColor: string;
containerClassName: string;
onVOIChange: (voi: VOI) => void;
onOpacityChange: (opacity: number) => void;
}): ReactElement => {
const [opacity, setOpacity] = useState(opacityProp);
const range = useMemo(
() => ({
min: histogram.range.min,
max: histogram.range.max,
}),
[histogram]
);
const [voiRange, setVOIRange] = useState(
voiProp
? convertVOItoVOIRange(voiProp)
: {
min: range.min,
max: range.max,
}
);
const handleVOIRangeChange = useCallback(
newRange => {
if (newRange[0] === voiRange.min && newRange[1] === voiRange.max) {
return;
}
const windowWidth = newRange[1] - newRange[0];
const windowCenter = newRange[0] + windowWidth / 2;
setVOIRange({
min: newRange[0],
max: newRange[1],
});
if (onVOIChange) {
onVOIChange({ windowWidth, windowCenter });
}
},
[onVOIChange, voiRange]
);
const handleOpacityChange = useCallback(
value => {
if (onOpacityChange) {
onOpacityChange(value);
}
},
[onOpacityChange]
);
useEffect(() => setVOIRange(convertVOItoVOIRange(voiProp)), [voiProp]);
useEffect(() => setOpacity(opacityProp), [opacityProp]);
return (
<div className={classnames('maxValue-w-sm p-0.5 text-[0px] text-white', containerClassName)}>
<div className="px-2 pt-0 pb-[0.5]">
{/* <legend className="pb-1 text-base text-white"> {title}</legend> */}
<div className="flex h-4 text-xs">
<div className="relative h-fit grow">
<span className="absolute left-0 bottom-px leading-3">{range.min}</span>
</div>
<div className="relative h-fit grow text-right">
<span className="absolute right-0 bottom-px leading-3">{range.max}</span>
</div>
</div>
<div className="p-1">
<div className="pb-2.5">
<div className="h-11 bg-white">
<WindowLevelHistogram
range={range}
voiRange={voiRange}
histogram={histogram}
colormap={colormap}
style={style}
fillColor={fillColor}
lineColor={lineColor}
/>
</div>
<div>
<InputDoubleRange
values={[voiRange.min, voiRange.max]}
onChange={handleVOIRangeChange}
minValue={range.min}
maxValue={range.max}
allowNumberEdit={true}
showLabel={true}
step={step}
allowOutOfRange={true}
/>
</div>
</div>
{showOpacitySlider && (
<div className="flex items-center justify-between text-base">
<div className="text-xs text-white">Opacity</div>
<InputRange
inputClassName="grow"
maxValue={1}
minValue={0}
step={0.1}
value={opacity}
showLabel={true}
allowNumberEdit={true}
onChange={handleOpacityChange}
/>
</div>
)}
</div>
</div>
</div>
);
};
WindowLevel.defaultProps = {
step: 1,
opacity: 1,
showOpacitySlider: false,
style: 'polygon',
fillColor: '#3f3f3f',
lineColor: '#707070',
};
WindowLevel.propTypes = {
title: PropTypes.string,
step: PropTypes.number,
histogram: histogramPropType.isRequired,
voi: voiPropType,
opacity: PropTypes.number,
showOpacitySlider: PropTypes.bool,
colormap: colormapPropType,
style: PropTypes.oneOf(['polygon', 'bars']),
fillColor: PropTypes.string,
lineColor: PropTypes.string,
containerClassName: PropTypes.string,
onVOIChange: PropTypes.func,
onOpacityChange: PropTypes.func,
};
export default WindowLevel;
@@ -0,0 +1,236 @@
import React, { useLayoutEffect, useEffect, useRef, ReactElement } from 'react';
import PropTypes from 'prop-types';
import {
Range,
VOIRange,
Histogram,
Colormap,
voiRangePropType,
rangePropType,
histogramPropType,
colormapPropType,
} from './types';
const DEFAULT_COLORMAP = {
Name: 'Grayscale',
RGBPoints: [0, 0, 0, 0, 1, 1, 1, 1],
};
const clamp = (value, min, max) => Math.min(Math.max(min, value), max);
const interpolateVec3 = (a, b, t) => {
return [a[0] * (1 - t) + b[0] * t, a[1] * (1 - t) + b[1] * t, a[2] * (1 - t) + b[2] * t];
};
const drawBackground = (canvas, range, voiRange, colormap) => {
const context = canvas.getContext('2d');
const { width, height } = canvas;
const windowWidth = voiRange.max - voiRange.min;
const rgbPoints = colormap.RGBPoints;
const colorsCount = rgbPoints.length / 4;
const getColorPoint = index => {
const offset = 4 * index;
return index < colorsCount
? {
index,
position: rgbPoints[offset],
color: [rgbPoints[offset + 1], rgbPoints[offset + 2], rgbPoints[offset + 3]],
}
: undefined;
};
let leftColorPoint = undefined;
let rightColorPoint = getColorPoint(0);
const incRawPixelValue = (range.max - range.min) / (width - 1);
let rawPixelValue = range.min;
for (let x = 0; x < width; x++) {
const tVoiRange = (rawPixelValue - voiRange.min) / windowWidth;
// Find the color in a linear way (O(n) complexity)
if (rightColorPoint) {
for (let i = rightColorPoint.index; i < colorsCount; i++) {
if (tVoiRange <= rightColorPoint.position) {
break;
}
leftColorPoint = rightColorPoint;
rightColorPoint = getColorPoint(i + 1);
}
}
let normColor;
if (!leftColorPoint) {
normColor = [...rightColorPoint.color];
} else if (!rightColorPoint) {
normColor = [...leftColorPoint.color];
} else {
const tColorRange =
(tVoiRange - leftColorPoint.position) /
(rightColorPoint.position - leftColorPoint.position);
normColor = interpolateVec3(leftColorPoint.color, rightColorPoint.color, tColorRange);
}
const color = normColor.map(color => clamp(Math.round(color * 255), 0, 255));
context.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
context.fillRect(x, 0, 1, height);
rawPixelValue += incRawPixelValue;
}
};
/* plots the histogram bins as a polygon that traces the centers of each bin */
const drawPolygonHistogram = (canvas, context, histogram, options) => {
const xScale = canvas.width / histogram.numBins;
const { scale } = options;
const maxVal = scale(histogram.maxBinValue);
context.beginPath();
context.moveTo(0, canvas.height);
context.lineTo(0, canvas.height - (canvas.height * scale(histogram.bins[0])) / maxVal);
let x = xScale / 2;
for (let bin = 0; bin < histogram.numBins; bin++) {
context.lineTo(x, canvas.height - (canvas.height * scale(histogram.bins[bin])) / maxVal);
x += xScale;
}
context.lineTo(
canvas.width,
canvas.height - (canvas.height * scale(histogram.bins[histogram.numBins - 1])) / maxVal
);
context.lineTo(canvas.width, canvas.height);
context.lineTo(0, canvas.height);
context.closePath();
context.fill();
context.stroke();
};
/* plots the histogram bins as a series of n vertical bars (n = number of bins) */
const drawBarHistogram = (canvas, context, histogram, options) => {
const xScale = canvas.width / histogram.numBins;
const { scale } = options;
const maxVal = scale(histogram.maxBinValue);
context.beginPath();
for (let bin = 0; bin < histogram.numBins; bin++) {
context.moveTo(xScale * bin, canvas.height);
context.lineTo(
xScale * bin,
canvas.height - (canvas.height * scale(histogram.bins[bin])) / maxVal
);
}
context.closePath();
context.strokeStyle = options.fillColor;
context.lineWidth = xScale;
context.stroke();
};
const drawHistogram = (canvas, histogram, options) => {
const context = canvas.getContext('2d');
// context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = options.fillColor;
context.strokeStyle = options.lineColor;
if (options.style === 'polygon') {
drawPolygonHistogram(canvas, context, histogram, options);
} else if (options.style === 'bars') {
drawBarHistogram(canvas, context, histogram, options);
} else {
throw new Error(`Invalid style (${options.style})`);
}
};
const WindowLevelHistogram = ({
range,
voiRange,
histogram,
colormap,
style,
fillColor,
lineColor,
}: {
range: Range;
voiRange: VOIRange;
histogram: Histogram;
colormap: Colormap;
style: string;
fillColor: string;
lineColor: string;
}): ReactElement => {
const containerRef = useRef(null);
const canvasRef = useRef(null);
const minVOIPercent = ((voiRange.min - range.min) / (range.max - range.min)) * 100;
const maxVoiPercent = (1 - (range.max - voiRange.max) / (range.max - range.min)) * 100;
const background =
colormap.Name !== 'Grayscale'
? undefined
: `linear-gradient(to right, #000 0%, #000 ${minVOIPercent}%, #fff ${maxVoiPercent}%, #fff 100%)`;
useLayoutEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
canvas.width = container.offsetWidth;
canvas.height = container.offsetHeight;
}, [containerRef, canvasRef]);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
const fnIdentity = x => x;
const options = {
style,
fillColor,
lineColor,
scale: fnIdentity,
};
context.clearRect(0, 0, canvas.width, canvas.height);
drawBackground(canvas, range, voiRange, colormap);
drawHistogram(canvas, histogram, options);
}, [range, voiRange, histogram, colormap, style, fillColor, lineColor, canvasRef]);
return (
<div
ref={containerRef}
className="h-full"
style={{ background }}
>
<canvas
ref={canvasRef}
width="1"
height="1"
></canvas>
</div>
);
};
WindowLevelHistogram.defaultProps = {
colormap: DEFAULT_COLORMAP,
style: 'polygon',
fillColor: '#3f3f3f',
lineColor: '#707070',
};
WindowLevelHistogram.propTypes = {
range: rangePropType.isRequired,
voiRange: voiRangePropType.isRequired,
histogram: histogramPropType.isRequired,
colormap: colormapPropType,
style: PropTypes.oneOf(['polygon', 'bars']),
fillColor: PropTypes.string,
lineColor: PropTypes.string,
};
export default WindowLevelHistogram;
@@ -0,0 +1,103 @@
import WindowLevel from '../WindowLevel';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
import '../../InputRange/InputRange.css';
export const argTypes = {
component: WindowLevel,
title: 'Components/WindowLevel',
};
<Meta
title="Components/WindowLevel"
component={WindowLevel}
/>
export const WindowLevelTemplate = args => (
<div className="w-96">
<WindowLevel {...args} />
</div>
);
<Heading
title="WindowLevel"
componentRelativePath="WindowLevel/WindowLevel.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
WindowLevel is a component that...
<Canvas>
<Story
name="Overview"
args={{
title: 'Target',
voi: {
windowWidth: 800,
windowCenter: 0,
},
showOpacitySlider: true,
histogram: {
numBins: 256,
range: {
min: -1000,
max: 1000,
},
bins: [
469817, 485077, 499920, 513954, 526106, 472824, 551087, 560798, 570524, 576182, 509119,
585371, 585123, 586201, 581405, 504649, 569829, 560213, 550658, 535250, 455914, 504388,
487305, 467924, 446629, 427088, 355151, 384904, 362374, 342472, 321159, 262968, 282026,
261558, 243751, 225659, 184247, 195394, 180728, 166477, 153932, 124837, 132557, 123424,
114390, 106514, 98492, 80936, 87261, 81444, 76932, 72578, 60694, 66376, 62465, 59951,
57424, 48593, 53942, 52361, 50857, 49578, 42447, 47633, 46587, 45540, 45061, 38899, 44420,
43817, 43381, 43384, 42993, 37937, 43329, 43692, 43943, 44504, 39516, 46166, 46882, 48458,
49524, 44720, 53620, 55901, 58585, 61007, 56985, 68541, 72795, 78542, 84522, 91109, 86305,
106424, 115156, 125756, 136606, 130742, 160773, 176254, 192943, 209036, 197566, 242622,
261520, 282054, 301671, 280379, 340427, 361651, 381541, 400844, 366045, 431948, 448730,
461965, 474742, 484742, 429398, 495580, 498192, 500972, 495907, 430082, 487111, 478174,
466274, 454838, 387260, 429238, 412109, 394391, 377166, 314069, 342320, 323342, 303672,
287065, 269839, 222018, 237587, 220910, 207113, 192675, 157585, 168534, 158188, 147129,
137956, 112961, 122187, 114196, 108509, 103007, 85884, 94253, 90044, 87221, 84668, 71879,
80385, 78623, 77783, 77091, 76156, 66260, 76005, 75463, 74981, 75396, 65342, 75501, 76266,
76450, 76053, 67137, 76115, 76326, 76434, 77201, 67050, 76313, 76293, 75723, 75894, 75980,
65911, 74524, 74224, 73147, 72685, 62974, 70932, 70591, 69998, 68042, 58874, 66580, 65624,
64716, 63501, 54665, 61603, 60323, 59414, 57771, 50209, 56266, 55508, 54012, 53424, 52116,
44747, 50440, 49397, 47822, 47640, 40761, 45368, 44812, 44429, 42998, 37046, 41961, 41013,
39896, 39609, 34086, 38540, 37511, 36643, 35892, 35723, 30200, 34272, 33283, 32656, 32171,
27407, 30907, 30045, 29634, 28707, 24846, 27648, 27349, 26831, 25707, 22627, 25339, 24986,
24310, 23723, 23324,
],
maxBin: 13,
maxBinValue: 586201,
},
colormap: {
Name: '2hot',
RGBPoints: [
-1, 0.0416667, 0, 0, -0.873016, 0.208333, 0, 0, -0.746032, 0.375, 0, 0, -0.619048,
0.541667, 0, 0, -0.492063, 0.708333, 0, 0, -0.365079, 0.854137, 0, 0, -0.238095, 0.937488,
0.039062, 0, -0.111111, 1, 0.208333, 0, 0.015873, 1, 0.375, 0, 0.142857, 1, 0.541667, 0,
0.269841, 1, 0.708333, 0, 0.396825, 1, 0.858805, 0.03125, 0.52381, 1, 0.947392, 0.15625,
0.650794, 1, 1, 0.3125, 0.777778, 1, 1, 0.5625, 0.904762, 1, 1, 0.8125, 1, 1, 1, 1,
],
},
}}
>
{WindowLevelTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={WindowLevel} />
## Usage
## Contribute
<Footer componentRelativePath="WindowLevel/__stories__/windowLevel.stories.mdx" />
@@ -0,0 +1 @@
export { default } from './WindowLevel';
@@ -0,0 +1,64 @@
import PropTypes from 'prop-types';
export type Range = {
min: number;
max: number;
};
export type VOI = {
windowWidth: number;
windowCenter: number;
};
export type VOIRange = {
min: number;
max: number;
};
export type Histogram = {
bins: number[];
numBins: number;
maxBin: number;
maxBinValue: number;
range: {
min: number;
max: number;
};
};
export type Colormap = {
Name: string;
RGBPoints: number[];
};
export const rangePropType = PropTypes.shape({
min: PropTypes.number.isRequired,
max: PropTypes.number.isRequired,
});
export const voiRangePropType = PropTypes.shape({
min: PropTypes.number.isRequired,
max: PropTypes.number.isRequired,
});
export const voiPropType = PropTypes.shape({
windowCenter: PropTypes.number.isRequired,
windowWidth: PropTypes.number.isRequired,
});
export const histogramPropType = PropTypes.shape({
bins: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.instanceOf(Int32Array)])
.isRequired,
numBins: PropTypes.number.isRequired,
maxBin: PropTypes.number.isRequired,
maxBinValue: PropTypes.number.isRequired,
range: PropTypes.shape({
min: PropTypes.number.isRequired,
max: PropTypes.number.isRequired,
}),
});
export const colormapPropType = PropTypes.shape({
Name: PropTypes.string,
RGBPoints: PropTypes.arrayOf(PropTypes.number),
});