-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #134 from jadh4v/pet-ct-generalize-overlap
Upgrade PT/CT overlay example to generalize detection and handling of PT-CT grid overlap
- Loading branch information
Showing
1 changed file
with
149 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ import vtkLiteHttpDataAccessHelper from '@kitware/vtk.js/IO/Core/DataAccessHelpe | |
import vtkResourceLoader from '@kitware/vtk.js/IO/Core/ResourceLoader'; | ||
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps.js'; | ||
import { BlendMode } from '@kitware/vtk.js/Rendering/Core/VolumeMapper/Constants.js'; | ||
import vtkMath from '@kitware/vtk.js/Common/Core/Math'; | ||
import { unzipSync } from 'fflate'; | ||
import { useContext, useEffect, useState } from 'react'; | ||
import './PET_CT_Overlay.css'; | ||
|
@@ -18,15 +19,15 @@ import { | |
View, | ||
VolumeRepresentation, | ||
} from 'react-vtk-js'; | ||
import vtkImageReslice from '@kitware/vtk.js/Imaging/Core/ImageReslice'; | ||
import { InterpolationMode } from '@kitware/vtk.js/Imaging/Core/AbstractImageInterpolator/Constants'; | ||
|
||
function Slider(props) { | ||
const view = useContext(Contexts.ViewContext); | ||
const onChange = (e) => { | ||
const value = Number(e.currentTarget.value); | ||
props.setValue(value); | ||
if (props.setCTValue) { | ||
props.setCTValue(value * 4); | ||
} | ||
props.setPTValue(value); | ||
setTimeout(view?.renderView, 0); | ||
}; | ||
return ( | ||
|
@@ -96,23 +97,134 @@ function DropDown(props) { | |
); | ||
} | ||
|
||
function haveOverlappingGrids(img1, img2, tolerance = vtkMath.EPSILON) { | ||
if (!img1 || !img2) { | ||
return false; | ||
} | ||
const sameVec3 = (p1, p2, tolerance) => vtkMath.distance2BetweenPoints(p1, p2) < tolerance*tolerance; | ||
if (!sameVec3(img1.getOrigin(), img2.getOrigin())) { | ||
return false; | ||
} | ||
if (!sameVec3(img1.getSpacing(), img2.getSpacing())) { | ||
return false; | ||
} | ||
if (!sameVec3(img1.getDimensions(), img2.getDimensions())) { | ||
return false; | ||
} | ||
const dir1 = img1.getDirection(); | ||
const dir2 = img2.getDirection(); | ||
const dirDelta = dir1.reduce((accumulator, currentValue, currentIndex) => accumulator + Math.abs(currentValue - dir2[currentIndex]), 0); | ||
if (dirDelta > tolerance) { | ||
return false; | ||
} | ||
return true; | ||
} | ||
|
||
function resliceAndSetup(ctImageData, ptImageData) { | ||
loader.hidden = 'hidden'; | ||
fileInput.hidden = 'hidden'; | ||
exampleInput.hidden = 'hidden'; | ||
const overlappingGrids = haveOverlappingGrids(ctImageData, ptImageData, 1e-3); | ||
if (!overlappingGrids) { | ||
// Resample the image with background series grid: | ||
const reslicer = vtkImageReslice.newInstance(); | ||
reslicer.setInputData(ptImageData); | ||
reslicer.setOutputDimensionality(3); | ||
reslicer.setOutputExtent(ctImageData.getExtent()); | ||
reslicer.setOutputSpacing(ctImageData.getSpacing()); | ||
reslicer.setOutputDirection(ctImageData.getDirection()); | ||
reslicer.setOutputOrigin(ctImageData.getOrigin()); | ||
reslicer.setTransformInputSampling(false); | ||
reslicer.setInterpolationMode(InterpolationMode.LINEAR) | ||
ptImageData = reslicer.getOutputData(); | ||
window.setResliced(true); | ||
} | ||
window.ptData = ptImageData; | ||
window.ctData = ctImageData; | ||
window.setMaxKSlice(ctImageData.getDimensions()[2] - 1); | ||
window.setMaxJSlice(ctImageData.getDimensions()[1] - 1); | ||
const range = ptImageData?.getPointData()?.getScalars()?.getRange(); | ||
window.setPTColorWindow(range[1] - range[0]); | ||
window.setPTColorLevel((range[1] + range[0]) * 0.5); | ||
window.setStatusText(''); | ||
return [ctImageData, ptImageData]; | ||
} | ||
|
||
/** | ||
* Loads data from local storage. Function expects a zip file with two subfolders: CT, PT | ||
*/ | ||
const loadLocalData = async function (event) { | ||
event.preventDefault(); | ||
console.log('Loading itk module...'); | ||
window.setStatusText('Loading itk module...'); | ||
if (!window.itk) { | ||
await vtkResourceLoader.loadScript( | ||
'https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/itk-wasm.js' | ||
); | ||
} | ||
const files = event.target.files; | ||
if (files.length === 1) { | ||
const fileReader = new FileReader(); | ||
fileReader.onload = async function onLoad(e) { | ||
const zipFileDataArray = new Uint8Array(fileReader.result); | ||
const decompressedFiles = unzipSync(zipFileDataArray); | ||
const ctDCMFiles = []; | ||
const ptDCMFiles = []; | ||
const PTRe = /PT/; | ||
const CTRe = /CT/; | ||
Object.keys(decompressedFiles).forEach((relativePath) => { | ||
if (relativePath.endsWith('.dcm')) { | ||
if (PTRe.test(relativePath)) { | ||
ptDCMFiles.push(decompressedFiles[relativePath].buffer); | ||
} else if (CTRe.test(relativePath)) { | ||
ctDCMFiles.push(decompressedFiles[relativePath].buffer); | ||
} | ||
} | ||
}); | ||
|
||
if (ptDCMFiles.length === 0 || ctDCMFiles.length === 0) { | ||
const msg = 'Expected two directories in the zip file: "PT" and "CT"'; | ||
console.error(msg); | ||
window.alert(msg); | ||
return; | ||
} | ||
|
||
let ctImageData = null; | ||
let ptImageData = null; | ||
if (window.itk) { | ||
const { image: ctitkImage, webWorkerPool: ctWebWorkers } = | ||
await window.itk.readImageDICOMArrayBufferSeries(ctDCMFiles); | ||
ctWebWorkers.terminateWorkers(); | ||
ctImageData = vtkITKHelper.convertItkToVtkImage(ctitkImage); | ||
const { image: ptitkImage, webWorkerPool: ptWebWorkers } = | ||
await window.itk.readImageDICOMArrayBufferSeries(ptDCMFiles); | ||
ptWebWorkers.terminateWorkers(); | ||
ptImageData = vtkITKHelper.convertItkToVtkImage(ptitkImage); | ||
} | ||
return resliceAndSetup(ctImageData, ptImageData); | ||
}; | ||
|
||
fileReader.readAsArrayBuffer(files[0]); | ||
} | ||
}; | ||
|
||
const loadData = async () => { | ||
console.log('Loading itk module...'); | ||
loadData.setStatusText('Loading itk module...'); | ||
window.setStatusText('Loading itk module...'); | ||
if (!window.itk) { | ||
await vtkResourceLoader.loadScript( | ||
'https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/itk-wasm.js' | ||
); | ||
} | ||
|
||
console.log('Fetching/downloading the input file, please wait...'); | ||
loadData.setStatusText('Loading data, please wait...'); | ||
window.setStatusText('Loading data, please wait...'); | ||
const zipFileData = await vtkLiteHttpDataAccessHelper.fetchBinary( | ||
'https://data.kitware.com/api/v1/folder/661ad10a5165b19d36c87220/download' | ||
); | ||
|
||
console.log('Fetching/downloading input file done!'); | ||
loadData.setStatusText('Download complete!'); | ||
window.setStatusText('Download complete!'); | ||
|
||
const zipFileDataArray = new Uint8Array(zipFileData); | ||
const decompressedFiles = unzipSync(zipFileDataArray); | ||
|
@@ -142,20 +254,13 @@ const loadData = async () => { | |
ptWebWorkers.terminateWorkers(); | ||
ptImageData = vtkITKHelper.convertItkToVtkImage(ptitkImage); | ||
} | ||
loadData.setMaxKSlice(ctImageData.getDimensions()[2] - 1); | ||
loadData.setMaxJSlice(ptImageData.getDimensions()[1] - 1); | ||
const range = ptImageData?.getPointData()?.getScalars()?.getRange(); | ||
loadData.setPTColorWindow(range[1] - range[0]); | ||
loadData.setPTColorLevel((range[1] + range[0]) * 0.5); | ||
loadData.setStatusText(''); | ||
loader.hidden = 'hidden'; | ||
return [ctImageData, ptImageData]; | ||
return resliceAndSetup(ctImageData, ptImageData); | ||
}; | ||
|
||
function Example(props) { | ||
const [statusText, setStatusText] = useState('Loading data, please wait ...'); | ||
const [kSlice, setKSlice] = useState(0); | ||
const [jSlice, setJSlice] = useState(0); | ||
const [ptjSlice, setJSlice] = useState(0); | ||
const [ctjSlice, setCTJSlice] = useState(0); | ||
const [colorWindow, setColorWindow] = useState(2048); | ||
const [colorLevel, setColorLevel] = useState(0); | ||
|
@@ -165,24 +270,28 @@ function Example(props) { | |
const [opacity, setOpacity] = useState(0.4); | ||
const [maxKSlice, setMaxKSlice] = useState(310); | ||
const [maxJSlice, setMaxJSlice] = useState(110); | ||
loadData.setMaxKSlice = setMaxKSlice; | ||
loadData.setMaxJSlice = setMaxJSlice; | ||
loadData.setStatusText = setStatusText; | ||
loadData.setPTColorWindow = setPTColorWindow; | ||
loadData.setPTColorLevel = setPTColorLevel; | ||
const [resliced, setResliced] = useState(false); | ||
window.setMaxKSlice = setMaxKSlice; | ||
window.setMaxJSlice = setMaxJSlice; | ||
window.setStatusText = setStatusText; | ||
window.setPTColorWindow = setPTColorWindow; | ||
window.setPTColorLevel = setPTColorLevel; | ||
window.setResliced = setResliced; | ||
|
||
useEffect(() => { | ||
loadData().then(([ctData, ptData]) => { | ||
window.ctData = ctData; | ||
window.ptData = ptData; | ||
setKSlice(155); | ||
setJSlice(64); | ||
setCTJSlice(256); | ||
}); | ||
}, []); | ||
if (window.ctData && window.ptData) { | ||
const ptDim = window.ptData.getDimensions(); | ||
setKSlice(Math.floor(ptDim[2]/2)); | ||
setJSlice(Math.floor(ptDim[1]/2)); | ||
const ctDim = window.ctData.getDimensions(); | ||
setCTJSlice(Math.floor(ctDim[1]/2)); | ||
} | ||
}, [window.ctData, window.ptData]); | ||
|
||
return ( | ||
<MultiViewRoot> | ||
<input id='fileInput' type='file' className='file' accept='.zip' onChange={loadLocalData}/> | ||
<input id='exampleInput' type='button' value='Download Example Data' accept='.zip' onClick={loadData}/> | ||
<ShareDataSetRoot> | ||
<RegisterDataSet id='ctData'> | ||
<Dataset dataset={window.ctData} /> | ||
|
@@ -215,7 +324,7 @@ function Example(props) { | |
max={4095} | ||
value={colorLevel} | ||
setValue={setColorLevel} | ||
style={{ top: '60px', left: '205px' }} | ||
style={{ top: '60px', left: '5px' }} | ||
/> | ||
<Slider | ||
label='Color Window' | ||
|
@@ -238,7 +347,7 @@ function Example(props) { | |
options={vtkColorMaps.rgbPresetNames} | ||
value={colorPreset} | ||
setValue={setColorPreset} | ||
style={{ top: '30px', left: '305px' }} | ||
style={{ top: '10px', left: '405px' }} | ||
/> | ||
<div className='loader' id='loader' /> | ||
<div | ||
|
@@ -250,10 +359,10 @@ function Example(props) { | |
}} | ||
> | ||
<View | ||
id='0' | ||
id='axial' | ||
camera={{ | ||
position: [0, 0, 0], | ||
focalPoint: [0, 0, -1], | ||
focalPoint: [0, 0, 1], | ||
viewUp: [0, -1, 0], | ||
parallelProjection: true, | ||
}} | ||
|
@@ -306,10 +415,10 @@ function Example(props) { | |
}} | ||
> | ||
<View | ||
id='0' | ||
id='coronal' | ||
camera={{ | ||
position: [0, 0, 0], | ||
focalPoint: [0, -1, 0], | ||
focalPoint: [0, 1, 0], | ||
viewUp: [0, 0, 1], | ||
parallelProjection: true, | ||
}} | ||
|
@@ -318,15 +427,15 @@ function Example(props) { | |
<Slider | ||
label='Slice' | ||
max={maxJSlice} | ||
value={jSlice} | ||
setValue={setJSlice} | ||
setCTValue={setCTJSlice} | ||
value={ctjSlice} | ||
setValue={setCTJSlice} | ||
setPTValue={setJSlice} | ||
resliced={resliced} | ||
orient='vertical' | ||
style={{ top: '50%', left: '5%' }} | ||
/> | ||
<SliceRepresentation | ||
id='pt' | ||
jSlice={jSlice} | ||
jSlice={ptjSlice} | ||
mapper={{ | ||
resolveCoincidentTopology: 'Polygon', | ||
resolveCoincidentTopologyPolygonOffsetParameters: { | ||
|
@@ -364,7 +473,7 @@ function Example(props) { | |
}} | ||
> | ||
<View | ||
id='0' | ||
id='mip3D' | ||
camera={{ | ||
position: [0, 0, 0], | ||
focalPoint: [0, 1, 0], | ||
|