diff --git a/docs/loading_data.md b/docs/loading_data.md index dce2ce0c8..88f422af2 100644 --- a/docs/loading_data.md +++ b/docs/loading_data.md @@ -64,7 +64,7 @@ To layer images: 1. Load the base image. 1. On the Data tab, click the Add Layer button on the upper layer dataset. -1. Under the Rendering tab, an opacity slider changes the transparency of the upper layer. +1. Under the Rendering tab, open Layers to adjust the overlay color map, display range, opacity, and transparent background. ![Add Layer](./assets/add-layer.jpg) diff --git a/docs/rendering.md b/docs/rendering.md index 11edadb5a..86007f241 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -1,17 +1,36 @@ -# Cinematic Volume Rendering +# Rendering -VolView reads the DICOM tags of your data to determine appropriate preset parameter values for cinematic volume rendering for your data, but often you will want to tweak those presets to emphasize specific details. Rendering controls apply to the currently selected/active view. We recommend the following sequence of tweaks to improve your visualizations. +## Image layers in 2D views + +Open **Layers** in the Rendering tab to change scalar image color maps, +display ranges, visibility, and overlay opacity. The base image has its own +color map and range controls in ordinary 2D and oblique views. Overlay layers +render in ordinary 2D views. + +RGB and RGBA images use their original colors in slice views. Scalar color maps +and window/level do not recolor them, including when opening saved sessions. +RGBA overlays multiply their embedded alpha by the layer opacity. Integer color +channels use their type's zero-based range; floating-point channels use 0 to 1. + +The color bar always spans the image's full intensity range, even when the +selected display range extends beyond it. -1. Cinematic volume rendering involves two "transfer functions": one maps the recorded intensity values (e.g., CT hounsfield units) to opacity and the other maps recorded intensity values to color. Both can be adjusted using the controls at the top of the Rendering tab. The graph at the top shows a histogram of the recorded intensity values in light gray and the current opacity transfer function as an overlaid curve in black. In that graph, beneath the black curve, is a depiction of the color transfer function. ![Cinematic](./assets/16-volview-rendering.jpg) +**Transparent background** makes overlay values at or below Min transparent, +then fades linearly to the selected opacity over the first 10% of the display +range above Min. +## Cinematic rendering controls + +VolView reads the DICOM tags of your data to determine appropriate preset parameter values for cinematic volume rendering for your data, but often you will want to tweak those presets to emphasize specific details. Rendering controls apply to the currently selected/active view. We recommend the following sequence of tweaks to improve your visualizations. + +1. Cinematic volume rendering involves two "transfer functions": one maps the recorded intensity values (e.g., CT hounsfield units) to opacity and the other maps recorded intensity values to color. Both can be adjusted by expanding **3D Rendering** in the Rendering tab. The graph at the top shows a histogram of the recorded intensity values in light gray and the current opacity transfer function as an overlaid curve in black. In that graph, beneath the black curve, is a depiction of the color transfer function. ![Cinematic](./assets/16-volview-rendering.jpg) 1. Begin by adjusting the opacity transfer function by a press-and-drag right/left on the top graph. ![Opacity](./assets/17-volview-opacity-notes.jpg) 2. Then adjust the color transfer function by moving the blue dots on the colorbar beneath the graph. ![Color](./assets/17-volview-colormap-notes.jpg) -2. You may decide that the colormaps and/or transfer function aren't suitable for your data. Click on the "Presets" bar to expand and show the available presets that offer alternative opacity and color transfer functions. ![Presets](./assets/18-volview-presets.jpg) +2. You may decide that the colormaps and/or transfer function aren't suitable for your data. Expand **Color Presets** within **3D Rendering** to show the available presets that offer alternative opacity and color transfer functions. ![Presets](./assets/18-volview-presets.jpg) [**_Watch the video!_**](https://youtu.be/eyrGd-meg6I) 3. Next, the lighting can be controlled. - 1. Ambient lighting is the general brightness of the scene. 2. Diffuse lighting controls how the light reflects off the data. It is influenced by the position of the light relative to the orientation of the data's local surface. 3. Light-follows-camera can be enabled / disabled to create shadows that highlights details within the data. When enabled, the light will be positioned in-line with the camera. When disabled, the last position of the light stays fixed relative to the data, even if the camera is moved. With light-follows-camera enabled, here is a view when the light is following the camera: ![Default Lighting](./assets/20-volview-lightfollowcamera1.jpg). By moving the camera (and thereby the light) to the front of the data and then disabling light-follows-camera, the view should look light this: ![Light Setup](./assets/20-volview-lightfollowcamera2.jpg). Then moving back to the side view will show shadows that emphasize depth and details: ![Shadows](./assets/20-volview-lightfollowcamera3.jpg). diff --git a/docs/welcome_screen.md b/docs/welcome_screen.md index 92df151c9..2e8ae4e39 100644 --- a/docs/welcome_screen.md +++ b/docs/welcome_screen.md @@ -8,7 +8,7 @@ Information on the Patient data and non-DICOM data that have been loaded. The currently display image data is highlighted in blue. -**Sample Data** presents a variety of DICOM data that can be used to quickly explore the capabilities of VolView. When you select a sample dataset, that data is downloaded from [http://data.kitware.com/](https://data.kitware.com/#collection/586fef9f8d777f05f44a5c86/folder/634713cf11dab81428208e1e). +**Sample Data** presents a variety of DICOM data that can be used to quickly explore the capabilities of VolView. When you select a sample dataset, that data is downloaded from [http://data.kitware.com/](https://data.kitware.com/#collection/586fef9f8d777f05f44a5c86/folder/634713cf11dab81428208e1e). The PET/CT Chest sample opens a PET overlay on CT for trying the [layer display controls](./rendering.md). ### Annotations @@ -16,7 +16,7 @@ Lists the ruler and other measures that have been made on the currently loaded d ### Rendering -Controls for the 3D cinematic volume rendering. +Controls for 3D cinematic rendering and 2D image layers. See [Rendering](./rendering.md) for details. ## Load / Save State diff --git a/package-lock.json b/package-lock.json index 4b9e40a87..c7190f46a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@types/express": "^5.0.5", "@types/file-saver": "^2.0.7", "@types/mocha": "^10.0.10", + "@types/pngjs": "^6.0.5", "@velipso/polybool": "^2.0.11", "@vitejs/plugin-vue": "^6.0.2", "@vue/test-utils": "^2.4.6", @@ -63,6 +64,7 @@ "nanoid": "^5.1.6", "patch-package": "^8.0.1", "pinia": "^3.0.4", + "pngjs": "^7.0.0", "prettier": "3.7.2", "rollup-plugin-visualizer": "^6.0.5", "sass": "^1.94.2", @@ -5345,6 +5347,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", diff --git a/package.json b/package.json index 825153889..2ad40f8b3 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@types/express": "^5.0.5", "@types/file-saver": "^2.0.7", "@types/mocha": "^10.0.10", + "@types/pngjs": "^6.0.5", "@velipso/polybool": "^2.0.11", "@vitejs/plugin-vue": "^6.0.2", "@vue/test-utils": "^2.4.6", @@ -89,6 +90,7 @@ "nanoid": "^5.1.6", "patch-package": "^8.0.1", "pinia": "^3.0.4", + "pngjs": "^7.0.0", "prettier": "3.7.2", "rollup-plugin-visualizer": "^6.0.5", "sass": "^1.94.2", diff --git a/patches/@kitware+vtk.js+37.0.4.patch b/patches/@kitware+vtk.js+37.0.4.patch index 09a4dffed..36097ba87 100644 --- a/patches/@kitware+vtk.js+37.0.4.patch +++ b/patches/@kitware+vtk.js+37.0.4.patch @@ -45,6 +45,18 @@ index 7defbee..70178df 100644 let pwfWidth = model.renderable.getOpacityTextureWidth(); if (pwfWidth <= 0) pwfWidth = model.context.getParameter(model.context.MAX_TEXTURE_SIZE); const pwfSize = pwfWidth * textureHeight; +diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js b/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js +--- a/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js ++++ b/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js +@@ -95,7 +95,7 @@ function vtkOpenGLRenderer(publicAPI, model) { + publicAPI.clear = () => { + let clearMask = 0; + const gl = model.context; +- if (!model.renderable.getTransparent()) { ++ if (!model.renderable.getTransparent() || model.selector) { + const background = model.renderable.getBackgroundByReference(); + gl.clearColor(background[0], background[1], background[2], background[3]); + clearMask |= gl.COLOR_BUFFER_BIT; diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/Texture.js b/node_modules/@kitware/vtk.js/Rendering/OpenGL/Texture.js index 3563142..111214c 100644 --- a/node_modules/@kitware/vtk.js/Rendering/OpenGL/Texture.js diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index 5fa988c9e..91d2e5ef2 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -270,6 +270,26 @@ export type DataSourceImporter = ( sources: DataSource[] ) => Promise; +export function showImportedData(results: ImportResult[]) { + const loadDataStore = useLoadDataStore(); + + const primaryDataSource = findBaseDataSource( + results, + loadDataStore.segmentationExtension, + loadDataStore.layerExtension + ); + if (!primaryDataSource || !isVolumeResult(primaryDataSource)) return; + + useViewStore().setDataForAllViews(toDataSelection(primaryDataSource)); + autoLayerDicoms(primaryDataSource, results); + autoLayerByName(primaryDataSource, results, loadDataStore.layerExtension); + loadSegmentations( + primaryDataSource, + results, + loadDataStore.segmentationExtension + ); +} + type LoadDataSourcesOutcome = { datasetIds: string[]; hadErrors: boolean; @@ -305,27 +325,7 @@ function loadDataSourcesWithOutcome( .every((view) => !view.dataID); if (succeeded.length && shouldShowData) { - const primaryDataSource = findBaseDataSource( - succeeded, - loadDataStore.segmentationExtension, - loadDataStore.layerExtension - ); - - if (isVolumeResult(primaryDataSource)) { - const selection = toDataSelection(primaryDataSource); - viewStore.setDataForAllViews(selection); - autoLayerDicoms(primaryDataSource, succeeded); - autoLayerByName( - primaryDataSource, - succeeded, - loadDataStore.layerExtension - ); - loadSegmentations( - primaryDataSource, - succeeded, - loadDataStore.segmentationExtension - ); - } // else must be primaryDataSource.type === 'model', which are not dealt with here yet + showImportedData(succeeded); } // Every error importDataSources returns is unreported by contract @@ -410,7 +410,10 @@ export async function loadUserPromptedFiles() { return loadFiles(files); } -function urlsToDataSources(urls: string[], names: string[] = []): DataSource[] { +export function urlsToDataSources( + urls: string[], + names: string[] = [] +): DataSource[] { return urls.map((url, idx) => { const defaultName = basename(parseUrl(url, window.location.href).pathname) || url; diff --git a/src/assets/samples/PET-CT-Chest.png b/src/assets/samples/PET-CT-Chest.png new file mode 100644 index 000000000..2f3f54f71 Binary files /dev/null and b/src/assets/samples/PET-CT-Chest.png differ diff --git a/src/components/AboutBox.vue b/src/components/AboutBox.vue index 2ff62f19b..151799db5 100644 --- a/src/components/AboutBox.vue +++ b/src/components/AboutBox.vue @@ -197,6 +197,24 @@ >tomovision.com +
  • + PET/CT Chest: Li, P., Wang, S., Li, T., Lu, J., + HuangFu, Y., and Wang, D. (2020). "A Large-Scale CT and PET/CT Dataset + for Lung Cancer Diagnosis (Lung-PET-CT-Dx)." The Cancer Imaging + Archive. + DOI: 10.7937/TCIA.2020.NNC2-0461. + CC BY 4.0. Ten original DICOM instances selected from each series. +
  • diff --git a/src/components/BaseImageProperties.vue b/src/components/BaseImageProperties.vue new file mode 100644 index 000000000..dc4471295 --- /dev/null +++ b/src/components/BaseImageProperties.vue @@ -0,0 +1,154 @@ + + + diff --git a/src/components/ColorMapRangeControl.vue b/src/components/ColorMapRangeControl.vue new file mode 100644 index 000000000..e41691e83 --- /dev/null +++ b/src/components/ColorMapRangeControl.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/src/components/ColorMapSelect.vue b/src/components/ColorMapSelect.vue new file mode 100644 index 000000000..dd83dc8a9 --- /dev/null +++ b/src/components/ColorMapSelect.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/src/components/ColorMapSwatch.vue b/src/components/ColorMapSwatch.vue new file mode 100644 index 000000000..a04201471 --- /dev/null +++ b/src/components/ColorMapSwatch.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/src/components/ControlsStripTools.vue b/src/components/ControlsStripTools.vue index 79caf4fb5..7513ff647 100644 --- a/src/components/ControlsStripTools.vue +++ b/src/components/ControlsStripTools.vue @@ -9,15 +9,29 @@ v-slot:default="{ active, toggle }" :value="Tools.WindowLevel" > - - - + + !currentImageID.value); const currentTool = computed(() => toolStore.currentTool); + const windowingUnavailableReason = computed(() => { + if (!currentImageID.value) return 'Load an image to adjust window/level.'; + const scalars = currentImageData.value?.getPointData().getScalars(); + if (!scalars) return 'The image must finish loading first.'; + if (isCineImage(currentImageID.value)) + return 'Cine images already contain display-encoded pixels.'; + const components = scalars.getNumberOfComponents(); + if (components === 3 || components === 4) + return 'RGB and RGBA images use their original colors.'; + return ''; + }); + const activeViewRef = toRef(viewStore, 'activeView'); const activeEffective = useEffectiveView( computed(() => activeViewRef.value ?? '') ); + // The rendered viewer is decided by effective kind, not stored slot type: // a cine clip dropped into an Oblique slot still renders as cine, so the // toolbar should treat it as cine, not Oblique. const isObliqueLayout = computed( () => activeEffective.value?.kind === 'oblique' ); + const isCineActive = computed(() => activeEffective.value?.kind === 'cine'); const isDisallowedOnCine = (tool: Tools) => isCineActive.value && !isToolAllowedFor(tool, activeEffective.value); + const paintUnavailableReason = computed( () => toolStore.paintUnavailableReason ); const enableTempCrosshairs = useActionHeld('temporaryCrosshairs'); + watch(enableTempCrosshairs, (enable) => { if (enable) toolStore.activateTemporaryCrosshairs(); else toolStore.deactivateTemporaryCrosshairs(); @@ -205,6 +236,7 @@ export default defineComponent({ currentTool, setCurrentTool: toolStore.setCurrentTool, noCurrentImage, + windowingUnavailableReason, isObliqueLayout, isDisallowedOnCine, paintUnavailableReason, diff --git a/src/components/DisplayRangeControl.vue b/src/components/DisplayRangeControl.vue new file mode 100644 index 000000000..f2b761c00 --- /dev/null +++ b/src/components/DisplayRangeControl.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/components/DisplayRangeInput.vue b/src/components/DisplayRangeInput.vue new file mode 100644 index 000000000..a164d4607 --- /dev/null +++ b/src/components/DisplayRangeInput.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/src/components/ImageDisplayControls.vue b/src/components/ImageDisplayControls.vue new file mode 100644 index 000000000..4c6ee6417 --- /dev/null +++ b/src/components/ImageDisplayControls.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/src/components/LayerContrastPresets.vue b/src/components/LayerContrastPresets.vue new file mode 100644 index 000000000..52e221e74 --- /dev/null +++ b/src/components/LayerContrastPresets.vue @@ -0,0 +1,76 @@ + + + diff --git a/src/components/LayerList.vue b/src/components/LayerList.vue index b15861756..d1d9cd2b3 100644 --- a/src/components/LayerList.vue +++ b/src/components/LayerList.vue @@ -1,32 +1,25 @@ - diff --git a/src/components/LayerProperties.vue b/src/components/LayerProperties.vue index 37d0b1a9f..5ae4cbbf6 100644 --- a/src/components/LayerProperties.vue +++ b/src/components/LayerProperties.vue @@ -1,88 +1,163 @@ - diff --git a/src/components/ObliqueViewer.vue b/src/components/ObliqueViewer.vue index 8ec44e2a7..44f972c8f 100644 --- a/src/components/ObliqueViewer.vue +++ b/src/components/ObliqueViewer.vue @@ -56,6 +56,8 @@ diff --git a/src/components/vtk/VtkSliceView.vue b/src/components/vtk/VtkSliceView.vue index 8b65a7ca8..2ad7b548f 100644 --- a/src/components/vtk/VtkSliceView.vue +++ b/src/components/vtk/VtkSliceView.vue @@ -1,5 +1,13 @@