diff --git a/extensions/cornerstone-dicom-pmap/.webpack/webpack.dev.js b/extensions/cornerstone-dicom-pmap/.webpack/webpack.dev.js new file mode 100644 index 000000000..6aea859ca --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/.webpack/webpack.dev.js @@ -0,0 +1,12 @@ +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +const ENTRY = { + app: `${SRC_DIR}/index.tsx`, +}; + +module.exports = (env, argv) => { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY }); +}; diff --git a/extensions/cornerstone-dicom-pmap/.webpack/webpack.prod.js b/extensions/cornerstone-dicom-pmap/.webpack/webpack.prod.js new file mode 100644 index 000000000..0ae7d6c2d --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/.webpack/webpack.prod.js @@ -0,0 +1,54 @@ +const webpack = require('webpack'); +const { merge } = require('webpack-merge'); +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); + +const pkg = require('./../package.json'); + +const ROOT_DIR = path.join(__dirname, '../'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); +const ENTRY = { + app: `${SRC_DIR}/index.tsx`, +}; + +const outputName = `ohif-${pkg.name.split('/').pop()}`; + +module.exports = (env, argv) => { + const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY }); + + return merge(commonConfig, { + stats: { + colors: true, + hash: true, + timings: true, + assets: true, + chunks: false, + chunkModules: false, + modules: false, + children: false, + warnings: true, + }, + optimization: { + minimize: true, + sideEffects: true, + }, + output: { + path: ROOT_DIR, + library: 'ohif-extension-cornerstone-dicom-pmap', + libraryTarget: 'umd', + filename: pkg.main, + }, + externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], + plugins: [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, + }), + new MiniCssExtractPlugin({ + filename: `./dist/${outputName}.css`, + chunkFilename: `./dist/${outputName}.css`, + }), + ], + }); +}; diff --git a/extensions/cornerstone-dicom-pmap/CHANGELOG.md b/extensions/cornerstone-dicom-pmap/CHANGELOG.md new file mode 100644 index 000000000..e4d87c4d4 --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/extensions/cornerstone-dicom-pmap/LICENSE b/extensions/cornerstone-dicom-pmap/LICENSE new file mode 100644 index 000000000..983c5ef34 --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2023 Open Health Imaging Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/extensions/cornerstone-dicom-pmap/README.md b/extensions/cornerstone-dicom-pmap/README.md new file mode 100644 index 000000000..af903360c --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/README.md @@ -0,0 +1,12 @@ +# dicom-pmap +## Description + +DICOM PMAP read workflow. This extension will allow you to load a DICOM Parametric +Map image and display it on OHIF. + +## Author + +OHIF + +## License +MIT diff --git a/extensions/cornerstone-dicom-pmap/babel.config.js b/extensions/cornerstone-dicom-pmap/babel.config.js new file mode 100644 index 000000000..e51442551 --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/babel.config.js @@ -0,0 +1,44 @@ +module.exports = { + plugins: ['@babel/plugin-proposal-class-properties'], + env: { + test: { + presets: [ + [ + // TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2 + '@babel/preset-env', + { + modules: 'commonjs', + debug: false, + }, + '@babel/preset-typescript', + ], + '@babel/preset-react', + ], + plugins: [ + '@babel/plugin-proposal-object-rest-spread', + '@babel/plugin-syntax-dynamic-import', + '@babel/plugin-transform-regenerator', + '@babel/plugin-transform-runtime', + ], + }, + production: { + presets: [ + // WebPack handles ES6 --> Target Syntax + ['@babel/preset-env', { modules: false }], + '@babel/preset-react', + '@babel/preset-typescript', + ], + ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], + }, + development: { + presets: [ + // WebPack handles ES6 --> Target Syntax + ['@babel/preset-env', { modules: false }], + '@babel/preset-react', + '@babel/preset-typescript', + ], + plugins: ['react-hot-loader/babel'], + ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], + }, + }, +}; diff --git a/extensions/cornerstone-dicom-pmap/package.json b/extensions/cornerstone-dicom-pmap/package.json new file mode 100644 index 000000000..427cb7874 --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/package.json @@ -0,0 +1,54 @@ +{ + "name": "@ohif/extension-cornerstone-dicom-pmap", + "version": "3.9.0-beta.64", + "description": "DICOM Parametric Map read workflow", + "author": "OHIF", + "license": "MIT", + "main": "dist/ohif-extension-cornerstone-dicom-pmap.umd.js", + "module": "src/index.tsx", + "files": [ + "dist/**", + "public/**", + "README.md" + ], + "repository": "OHIF/Viewers", + "keywords": [ + "ohif-extension" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1.18.0" + }, + "scripts": { + "clean": "shx rm -rf dist", + "clean:deep": "yarn run clean && shx rm -rf node_modules", + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo", + "dev:dicom-pmap": "yarn run dev", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package-1": "yarn run build", + "start": "yarn run dev" + }, + "peerDependencies": { + "@ohif/core": "3.9.0-beta.64", + "@ohif/extension-cornerstone": "3.9.0-beta.64", + "@ohif/extension-default": "3.9.0-beta.64", + "@ohif/i18n": "3.9.0-beta.64", + "prop-types": "^15.6.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^12.2.2", + "react-router": "^6.8.1", + "react-router-dom": "^6.8.1" + }, + "dependencies": { + "@babel/runtime": "^7.20.13", + "@cornerstonejs/adapters": "^1.77.6", + "@cornerstonejs/core": "^1.77.6", + "@kitware/vtk.js": "30.4.1", + "react-color": "^2.19.3" + } +} diff --git a/extensions/cornerstone-dicom-pmap/src/getSopClassHandlerModule.ts b/extensions/cornerstone-dicom-pmap/src/getSopClassHandlerModule.ts new file mode 100644 index 000000000..6f446b0d0 --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/src/getSopClassHandlerModule.ts @@ -0,0 +1,217 @@ +import { utils } from '@ohif/core'; +import { metaData, cache, utilities as csUtils, volumeLoader } from '@cornerstonejs/core'; +import { adaptersPMAP } from '@cornerstonejs/adapters'; +import { SOPClassHandlerId } from './id'; +import { dicomLoaderService } from '@ohif/extension-cornerstone'; + +const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume'; +const sopClassUids = ['1.2.840.10008.5.1.4.1.1.30']; + +function _getDisplaySetsFromSeries( + instances, + servicesManager: AppTypes.ServicesManager, + extensionManager +) { + const instance = instances[0]; + + const { + StudyInstanceUID, + SeriesInstanceUID, + SOPInstanceUID, + SeriesDescription, + SeriesNumber, + SeriesDate, + SOPClassUID, + wadoRoot, + wadoUri, + wadoUriRoot, + } = instance; + + const displaySet = { + // Parametric map use to have the same modality as its referenced volume but + // "PMAP" is used in the viewer even though this is not a valid DICOM modality + Modality: 'PMAP', + isReconstructable: true, // by default for now + displaySetInstanceUID: `pmap.${utils.guid()}`, + SeriesDescription, + SeriesNumber, + SeriesDate, + SOPInstanceUID, + SeriesInstanceUID, + StudyInstanceUID, + SOPClassHandlerId, + SOPClassUID, + referencedImages: null, + referencedSeriesInstanceUID: null, + referencedDisplaySetInstanceUID: null, + referencedVolumeURI: null, + referencedVolumeId: null, + isDerivedDisplaySet: true, + loadStatus: { + loading: false, + loaded: false, + }, + sopClassUids, + instance, + instances: [instance], + wadoRoot, + wadoUriRoot, + wadoUri, + isOverlayDisplaySet: true, + }; + + const referencedSeriesSequence = instance.ReferencedSeriesSequence; + + if (!referencedSeriesSequence) { + console.error('ReferencedSeriesSequence is missing for the parametric map'); + return; + } + + const referencedSeries = referencedSeriesSequence[0] || referencedSeriesSequence; + + displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence; + displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID; + + // Does not get the referenced displaySet during parametric displaySet creation + // because it is still not available (getDisplaySetByUID returns `undefined`). + displaySet.getReferenceDisplaySet = () => { + const { displaySetService } = servicesManager.services; + + if (displaySet.referencedDisplaySetInstanceUID) { + return displaySetService.getDisplaySetByUID(displaySet.referencedDisplaySetInstanceUID); + } + + const referencedDisplaySets = displaySetService.getDisplaySetsForSeries( + displaySet.referencedSeriesInstanceUID + ); + + if (!referencedDisplaySets || referencedDisplaySets.length === 0) { + throw new Error('Referenced displaySet is missing for the parametric map'); + } + + const referencedDisplaySet = referencedDisplaySets[0]; + + displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID; + + return referencedDisplaySet; + }; + + // Does not get the referenced volumeId during parametric displaySet creation because the + // referenced displaySet is still not avaialble (getDisplaySetByUID returns `undefined`). + displaySet.getReferencedVolumeId = () => { + if (displaySet.referencedVolumeId) { + return displaySet.referencedVolumeId; + } + + const referencedDisplaySet = displaySet.getReferenceDisplaySet(); + const referencedVolumeURI = referencedDisplaySet.displaySetInstanceUID; + const referencedVolumeId = `${VOLUME_LOADER_SCHEME}:${referencedVolumeURI}`; + + displaySet.referencedVolumeURI = referencedVolumeURI; + displaySet.referencedVolumeId = referencedVolumeId; + + return referencedVolumeId; + }; + + displaySet.load = async ({ headers }) => + await _load(displaySet, servicesManager, extensionManager, headers); + + return [displaySet]; +} + +async function _load( + displaySet, + servicesManager: AppTypes.ServicesManager, + extensionManager, + headers +) { + const volumeId = `${VOLUME_LOADER_SCHEME}:${displaySet.displaySetInstanceUID}`; + const volumeLoadObject = cache.getVolumeLoadObject(volumeId); + + if (volumeLoadObject) { + return volumeLoadObject.promise; + } + + displaySet.loadStatus.loading = true; + + // We don't want to fire multiple loads, so we'll wait for the first to finish + // and also return the same promise to any other callers. + // loadPromises[SOPInstanceUID] = new Promise(async (resolve, reject) => { + const promise = _loadParametricMap({ + extensionManager, + displaySet, + headers, + }); + + cache.putVolumeLoadObject(volumeId, { promise }).catch(err => { + throw err; + }); + + promise + .then(() => { + displaySet.loadStatus.loading = false; + displaySet.loadStatus.loaded = true; + }) + .catch(err => { + displaySet.loadStatus.loading = false; + throw err; + }); + + return promise; +} + +async function _loadParametricMap({ extensionManager, displaySet, headers }: withAppTypes) { + const arrayBuffer = await dicomLoaderService.findDicomDataPromise(displaySet, null, headers); + const referencedVolumeId = displaySet.getReferencedVolumeId(); + const cachedReferencedVolume = cache.getVolume(referencedVolumeId); + + // Parametric map can be loaded only if its referenced volume exists otherwise it will fail + if (!cachedReferencedVolume) { + throw new Error( + 'Referenced Volume is missing for the PMAP, and stack viewport PMAP is not supported yet' + ); + } + + const { imageIds } = cachedReferencedVolume; + const results = await adaptersPMAP.Cornerstone3D.ParametricMap.generateToolState( + imageIds, + arrayBuffer, + metaData + ); + const { pixelData } = results; + const TypedArrayConstructor = pixelData.constructor; + const paramMapId = displaySet.displaySetInstanceUID; + + const derivedVolume = await volumeLoader.createAndCacheDerivedVolume(referencedVolumeId, { + volumeId: paramMapId, + targetBuffer: { + type: TypedArrayConstructor.name, + }, + }); + + derivedVolume.getScalarData().set(pixelData); + + const range = derivedVolume.imageData.getPointData().getScalars().getRange(); + const windowLevel = csUtils.windowLevel.toWindowLevel(range[0], range[1]); + + derivedVolume.metadata.voiLut = [windowLevel]; + derivedVolume.loadStatus = { loaded: true }; + + return derivedVolume; +} + +function getSopClassHandlerModule({ servicesManager, extensionManager }) { + const getDisplaySetsFromSeries = instances => { + return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager); + }; + + return [ + { + name: 'dicom-pmap', + sopClassUids, + getDisplaySetsFromSeries, + }, + ]; +} + +export default getSopClassHandlerModule; diff --git a/extensions/cornerstone-dicom-pmap/src/id.js b/extensions/cornerstone-dicom-pmap/src/id.js new file mode 100644 index 000000000..4ec5d5996 --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/src/id.js @@ -0,0 +1,7 @@ +import packageJson from '../package.json'; + +const id = packageJson.name; +const SOPClassHandlerName = 'dicom-pmap'; +const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`; + +export { id, SOPClassHandlerId, SOPClassHandlerName }; diff --git a/extensions/cornerstone-dicom-pmap/src/index.tsx b/extensions/cornerstone-dicom-pmap/src/index.tsx new file mode 100644 index 000000000..916f8a07f --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/src/index.tsx @@ -0,0 +1,39 @@ +import { id } from './id'; +import React from 'react'; +import getSopClassHandlerModule from './getSopClassHandlerModule'; + +const Component = React.lazy(() => { + return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstonePMAPViewport'); +}); + +const OHIFCornerstonePMAPViewport = props => { + return ( + Loading...}> + + + ); +}; + +/** + * You can remove any of the following modules if you don't need them. + */ +const extension = { + id, + getViewportModule({ servicesManager, extensionManager, commandsManager }) { + const ExtendedOHIFCornerstonePMAPViewport = props => { + return ( + + ); + }; + + return [{ name: 'dicom-pmap', component: ExtendedOHIFCornerstonePMAPViewport }]; + }, + getSopClassHandlerModule, +}; + +export default extension; diff --git a/extensions/cornerstone-dicom-pmap/src/utils/initPMAPToolGroup.ts b/extensions/cornerstone-dicom-pmap/src/utils/initPMAPToolGroup.ts new file mode 100644 index 000000000..6f0aa581e --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/src/utils/initPMAPToolGroup.ts @@ -0,0 +1,6 @@ +function createPMAPToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) { + const { tools } = customizationService.get('cornerstone.overlayViewportTools') ?? {}; + return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools); +} + +export default createPMAPToolGroupAndAddTools; diff --git a/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx b/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx new file mode 100644 index 000000000..9e0ee919f --- /dev/null +++ b/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx @@ -0,0 +1,163 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { useViewportGrid } from '@ohif/ui'; +import createPMAPToolGroupAndAddTools from '../utils/initPMAPToolGroup'; + +const PMAP_TOOLGROUP_BASE_NAME = 'PMAPToolGroup'; + +function OHIFCornerstonePMAPViewport(props: withAppTypes) { + const { displaySets, viewportOptions, displaySetOptions, servicesManager, extensionManager } = + props; + const viewportId = viewportOptions.viewportId; + const { displaySetService, toolGroupService, customizationService } = servicesManager.services; + const toolGroupId = `${PMAP_TOOLGROUP_BASE_NAME}-${viewportId}`; + + // PMAP viewport will always have a single display set + if (displaySets.length !== 1) { + throw new Error('PMAP viewport must have a single display set'); + } + + const pmapDisplaySet = displaySets[0]; + const [viewportGrid, viewportGridService] = useViewportGrid(); + const referencedDisplaySetRef = useRef(null); + const { viewports, activeViewportId } = viewportGrid; + const referencedDisplaySet = pmapDisplaySet.getReferenceDisplaySet(); + const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata( + referencedDisplaySet, + pmapDisplaySet + ); + + referencedDisplaySetRef.current = { + displaySet: referencedDisplaySet, + metadata: referencedDisplaySetMetadata, + }; + + const getCornerstoneViewport = useCallback(() => { + const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current; + const { component: Component } = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.viewportModule.cornerstone' + ); + + displaySetOptions.unshift({}); + const [pmapDisplaySetOptions] = displaySetOptions; + + // Make sure `options` exists + pmapDisplaySetOptions.options = pmapDisplaySetOptions.options ?? {}; + + Object.assign(pmapDisplaySetOptions.options, { + colormap: { + name: 'rainbow', + opacity: [ + { value: 0, opacity: 0.5 }, + { value: 1, opacity: 1 }, + ], + }, + }); + + return ( + + ); + }, [ + extensionManager, + displaySetOptions, + props, + pmapDisplaySet, + toolGroupId, + viewportOptions.orientation, + viewportOptions.viewportId, + ]); + + // Cleanup the PMAP viewport when the viewport is destroyed + useEffect(() => { + const onDisplaySetsRemovedSubscription = displaySetService.subscribe( + displaySetService.EVENTS.DISPLAY_SETS_REMOVED, + ({ displaySetInstanceUIDs }) => { + const activeViewport = viewports.get(activeViewportId); + if (displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)) { + viewportGridService.setDisplaySetsForViewport({ + viewportId: activeViewportId, + displaySetInstanceUIDs: [], + }); + } + } + ); + + return () => { + onDisplaySetsRemovedSubscription.unsubscribe(); + }; + }, [activeViewportId, displaySetService, viewportGridService, viewports]); + + useEffect(() => { + let toolGroup = toolGroupService.getToolGroup(toolGroupId); + + if (toolGroup) { + return; + } + + // This creates a custom tool group which has the lifetime of this view only + toolGroup = createPMAPToolGroupAndAddTools(toolGroupService, customizationService, toolGroupId); + + return () => toolGroupService.destroyToolGroup(toolGroupId); + }, [customizationService, toolGroupId, toolGroupService]); + + return ( + <> +
+ {getCornerstoneViewport()} +
+ + ); +} + +OHIFCornerstonePMAPViewport.propTypes = { + displaySets: PropTypes.arrayOf(PropTypes.object), + viewportId: PropTypes.string.isRequired, + dataSource: PropTypes.object, + children: PropTypes.node, +}; + +function _getReferencedDisplaySetMetadata(referencedDisplaySet, pmapDisplaySet) { + const { SharedFunctionalGroupsSequence } = pmapDisplaySet.instance; + + const SharedFunctionalGroup = Array.isArray(SharedFunctionalGroupsSequence) + ? SharedFunctionalGroupsSequence[0] + : SharedFunctionalGroupsSequence; + + const { PixelMeasuresSequence } = SharedFunctionalGroup; + + const PixelMeasures = Array.isArray(PixelMeasuresSequence) + ? PixelMeasuresSequence[0] + : PixelMeasuresSequence; + + const { SpacingBetweenSlices, SliceThickness } = PixelMeasures; + + const image0 = referencedDisplaySet.images[0]; + const referencedDisplaySetMetadata = { + PatientID: image0.PatientID, + PatientName: image0.PatientName, + PatientSex: image0.PatientSex, + PatientAge: image0.PatientAge, + SliceThickness: image0.SliceThickness || SliceThickness, + StudyDate: image0.StudyDate, + SeriesDescription: image0.SeriesDescription, + SeriesInstanceUID: image0.SeriesInstanceUID, + SeriesNumber: image0.SeriesNumber, + ManufacturerModelName: image0.ManufacturerModelName, + SpacingBetweenSlices: image0.SpacingBetweenSlices || SpacingBetweenSlices, + }; + + return referencedDisplaySetMetadata; +} + +export default OHIFCornerstonePMAPViewport; diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 12e2b5625..ba91619a0 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -131,6 +131,11 @@ const OHIFCornerstoneViewport = React.memo((props: withAppTypes) => { throw new Error('Viewport ID is required'); } + // Make sure displaySetOptions has one object per displaySet + while (displaySetOptions.length < displaySets.length) { + displaySetOptions.push({}); + } + // Since we only have support for dynamic data in volume viewports, we should // handle this case here and set the viewportType to volume if any of the // displaySets are dynamic volumes diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index cc46e9afb..934f3de52 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -231,5 +231,6 @@ export { getEnabledElement, ImageOverlayViewerTool, getSOPInstanceAttributes, + dicomLoaderService, }; export default cornerstoneExtension; diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts index 3c8b09c5d..09c0d8c2b 100644 --- a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts @@ -200,6 +200,9 @@ class CornerstoneCacheService { const volumeData = []; for (const displaySet of displaySets) { + const { Modality } = displaySet; + const isParametricMap = Modality === 'PMAP'; + // Don't create volumes for the displaySets that have custom load // function (e.g., SEG, RT, since they rely on the reference volumes // and they take care of their own loading after they are created in their @@ -210,24 +213,27 @@ class CornerstoneCacheService { const headers = userAuthenticationService.getAuthorizationHeader(); await displaySet.load({ headers }); - volumeData.push({ - studyInstanceUID: displaySet.StudyInstanceUID, - displaySetInstanceUID: displaySet.displaySetInstanceUID, - }); + // Parametric maps have a `load` method but it should not be loaded in the + // same way as SEG and RTSTRUCT but like a normal volume + if (!isParametricMap) { + volumeData.push({ + studyInstanceUID: displaySet.StudyInstanceUID, + displaySetInstanceUID: displaySet.displaySetInstanceUID, + }); - // Todo: do some cache check and empty the cache if needed - continue; + // Todo: do some cache check and empty the cache if needed + continue; + } } const volumeLoaderSchema = displaySet.volumeLoaderSchema ?? VOLUME_LOADER_SCHEME; - const volumeId = `${volumeLoaderSchema}:${displaySet.displaySetInstanceUID}`; - let volumeImageIds = this.volumeImageIds.get(displaySet.displaySetInstanceUID); - let volume = cs3DCache.getVolume(volumeId); - if (!volumeImageIds || !volume) { + // Parametric maps do not have image ids but they already have volume data + // therefore a new volume should not be created. + if (!isParametricMap && (!volumeImageIds || !volume)) { volumeImageIds = this._getCornerstoneVolumeImageIds(displaySet, dataSource); volume = await volumeLoader.createAndCacheVolume(volumeId, { diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 8ad62cb2f..6dd645bce 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -814,12 +814,16 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi // load any secondary displaySets const displaySetInstanceUIDs = this.viewportsDisplaySets.get(viewport.id); - // can be SEG or RTSTRUCT for now - const overlayDisplaySet = displaySetInstanceUIDs + // Can be SEG or RTSTRUCT for now but not PMAP + const segOrRTSOverlayDisplaySet = displaySetInstanceUIDs .map(displaySetService.getDisplaySetByUID) - .find(displaySet => displaySet?.isOverlayDisplaySet); - if (overlayDisplaySet) { - this.addOverlayRepresentationForDisplaySet(overlayDisplaySet, viewport); + .find( + displaySet => + displaySet?.isOverlayDisplaySet && ['SEG', 'RTSTRUCT'].includes(displaySet.Modality) + ); + + if (segOrRTSOverlayDisplaySet) { + this.addOverlayRepresentationForDisplaySet(segOrRTSOverlayDisplaySet, viewport); } else { // If the displaySet is not a SEG displaySet we assume it is a primary displaySet // and we can look into hydrated segmentations to check if any of them are diff --git a/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx b/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx index f23bfb641..ccd3870ea 100644 --- a/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx +++ b/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx @@ -27,7 +27,7 @@ function ToolbarSplitButtonWithServices({ }); }, })), - [] + [groupId, onInteraction] ); const PrimaryButtonComponent = diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index eaa10f40e..5920e7029 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -444,7 +444,7 @@ function PanelStudyBrowserTracking({ onClickUntrack={displaySetInstanceUID => { onClickUntrack(displaySetInstanceUID); }} - onClickThumbnail={() => { }} + onClickThumbnail={() => {}} onDoubleClickThumbnail={onDoubleClickThumbnailHandler} activeDisplaySetInstanceUIDs={activeViewportDisplaySetInstanceUIDs} /> @@ -624,7 +624,17 @@ function _mapDisplaySets( return [...thumbnailDisplaySets, ...thumbnailNoImageDisplaySets]; } -const thumbnailNoImageModalities = ['SR', 'SEG', 'SM', 'RTSTRUCT', 'RTPLAN', 'RTDOSE', 'DOC', 'OT']; +const thumbnailNoImageModalities = [ + 'SR', + 'SEG', + 'SM', + 'RTSTRUCT', + 'RTPLAN', + 'RTDOSE', + 'DOC', + 'OT', + 'PMAP', +]; function _getComponentType(ds) { if (thumbnailNoImageModalities.includes(ds.Modality) || ds?.unsupported) { diff --git a/modes/basic-test-mode/src/index.ts b/modes/basic-test-mode/src/index.ts index c303ee015..909898977 100644 --- a/modes/basic-test-mode/src/index.ts +++ b/modes/basic-test-mode/src/index.ts @@ -43,6 +43,11 @@ const dicomSeg = { panel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation', }; +const dicomPmap = { + sopClassHandler: '@ohif/extension-cornerstone-dicom-pmap.sopClassHandlerModule.dicom-pmap', + viewport: '@ohif/extension-cornerstone-dicom-pmap.viewportModule.dicom-pmap', +}; + const extensionDependencies = { // Can derive the versions at least process.env.from npm_package_version '@ohif/extension-default': '^3.0.0', @@ -50,6 +55,7 @@ const extensionDependencies = { '@ohif/extension-measurement-tracking': '^3.0.0', '@ohif/extension-cornerstone-dicom-sr': '^3.0.0', '@ohif/extension-cornerstone-dicom-seg': '^3.0.0', + '@ohif/extension-cornerstone-dicom-pmap': '^3.0.0', '@ohif/extension-dicom-pdf': '^3.0.1', '@ohif/extension-dicom-video': '^3.0.1', '@ohif/extension-test': '^0.0.1', @@ -162,6 +168,10 @@ function modeFactory() { namespace: dicomSeg.viewport, displaySetsToDisplay: [dicomSeg.sopClassHandler], }, + { + namespace: dicomPmap.viewport, + displaySetsToDisplay: [dicomPmap.sopClassHandler], + }, ], }, }; diff --git a/modes/longitudinal/src/index.ts b/modes/longitudinal/src/index.ts index 08362cafe..4ad67f50d 100644 --- a/modes/longitudinal/src/index.ts +++ b/modes/longitudinal/src/index.ts @@ -42,6 +42,11 @@ const dicomSeg = { panel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation', }; +const dicomPmap = { + sopClassHandler: '@ohif/extension-cornerstone-dicom-pmap.sopClassHandlerModule.dicom-pmap', + viewport: '@ohif/extension-cornerstone-dicom-pmap.viewportModule.dicom-pmap', +}; + const dicomRT = { viewport: '@ohif/extension-cornerstone-dicom-rt.viewportModule.dicom-rt', sopClassHandler: '@ohif/extension-cornerstone-dicom-rt.sopClassHandlerModule.dicom-rt', @@ -54,6 +59,7 @@ const extensionDependencies = { '@ohif/extension-measurement-tracking': '^3.0.0', '@ohif/extension-cornerstone-dicom-sr': '^3.0.0', '@ohif/extension-cornerstone-dicom-seg': '^3.0.0', + '@ohif/extension-cornerstone-dicom-pmap': '^3.0.0', '@ohif/extension-cornerstone-dicom-rt': '^3.0.0', '@ohif/extension-dicom-pdf': '^3.0.1', '@ohif/extension-dicom-video': '^3.0.1', @@ -206,6 +212,10 @@ function modeFactory({ modeConfiguration }) { namespace: dicomSeg.viewport, displaySetsToDisplay: [dicomSeg.sopClassHandler], }, + { + namespace: dicomPmap.viewport, + displaySetsToDisplay: [dicomPmap.sopClassHandler], + }, { namespace: dicomRT.viewport, displaySetsToDisplay: [dicomRT.sopClassHandler], @@ -226,6 +236,7 @@ function modeFactory({ modeConfiguration }) { sopClassHandlers: [ dicomvideo.sopClassHandler, dicomSeg.sopClassHandler, + dicomPmap.sopClassHandler, ohif.sopClassHandler, dicompdf.sopClassHandler, dicomsr.sopClassHandler, diff --git a/platform/app/pluginConfig.json b/platform/app/pluginConfig.json index fc5f4f90f..06ae62e55 100644 --- a/platform/app/pluginConfig.json +++ b/platform/app/pluginConfig.json @@ -22,6 +22,11 @@ "default": false, "version": "3.0.0" }, + { + "packageName": "@ohif/extension-cornerstone-dicom-pmap", + "default": false, + "version": "3.0.0" + }, { "packageName": "@ohif/extension-cornerstone-dynamic-volume", "default": false, diff --git a/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx b/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx index 9137aac8f..f9e0858e8 100644 --- a/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx +++ b/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx @@ -150,6 +150,7 @@ function _getModalityTooltip(modality) { const _modalityTooltips = { SR: 'Structured Report', SEG: 'Segmentation', + OT: 'Other', RTSTRUCT: 'RT Structure Set', };