From 084a10f7835acab6a851922850c474bc9c7b864b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20H=2E=20K=C3=B6hler?= Date: Tue, 11 Feb 2025 14:39:45 -0300 Subject: [PATCH] feat: add viewport overlays to microscopy mode (#4776) --- .../src/DicomMicroscopyViewport.tsx | 516 ++++++++---------- .../src/components/ViewportOverlay/index.tsx | 126 ++--- extensions/dicom-microscopy/src/index.tsx | 3 - .../src/contextProviders/SystemProvider.tsx | 2 +- 4 files changed, 289 insertions(+), 358 deletions(-) diff --git a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx index c0c72bb1f..8975a912d 100644 --- a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx +++ b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx @@ -1,318 +1,258 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { cleanDenaturalizedDataset } from '@ohif/extension-default'; import './DicomMicroscopyViewport.css'; import ViewportOverlay from './components/ViewportOverlay'; import getDicomWebClient from './utils/dicomWebClient'; import dcmjs from 'dcmjs'; -import MicroscopyService from './services/MicroscopyService'; -import { CustomizationService } from '@ohif/core'; +import { useSystem } from '@ohif/core'; -class DicomMicroscopyViewport extends Component { - state = { - error: null as any, - isLoaded: false, - }; +function DicomMicroscopyViewport({ + activeViewportId, + setViewportActive, + displaySets, + viewportId, + dataSource, + resizeRef, +}: { + activeViewportId: string; + setViewportActive: Function; + displaySets: any[]; + viewportId: string; + dataSource: any; + resizeRef: any; +}) { + const { servicesManager, extensionManager } = useSystem(); + const [isLoaded, setIsLoaded] = useState(false); + const [viewer, setViewer] = useState(null); + const [managedViewer, setManagedViewer] = useState(null); + const overlayElement = useRef(); + const container = useRef(); + const { microscopyService, customizationService } = servicesManager.services; - microscopyService: MicroscopyService; - customizationService: CustomizationService; - viewer: any = null; // dicom-microscopy-viewer instance - managedViewer: any = null; // managed wrapper of microscopy-dicom extension - - container = React.createRef(); - overlayElement = React.createRef(); - - constructor(props: any) { - super(props); - - const { microscopyService, customizationService } = this.props.servicesManager.services; - this.microscopyService = microscopyService; - this.customizationService = customizationService; - } - - static propTypes = { - viewportData: PropTypes.object, - activeViewportId: PropTypes.string, - setViewportActive: PropTypes.func, - - // props from OHIF Viewport Grid - displaySets: PropTypes.array, - viewportId: PropTypes.string, - viewportLabel: PropTypes.string, - dataSource: PropTypes.object, - viewportOptions: PropTypes.object, - displaySetOptions: PropTypes.array, - - // other props from wrapping component - servicesManager: PropTypes.object, - extensionManager: PropTypes.object, - commandsManager: PropTypes.object, - resizeRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.any })]), - }; - - /** - * Get the nearest ROI from the mouse click point - * - * @param event - * @param autoselect - * @returns - */ - getNearbyROI(event: Event, autoselect = true) { - const symbols = Object.getOwnPropertySymbols(this.viewer); - const _drawingSource = symbols.find(p => p.description === 'drawingSource'); - const _pyramid = symbols.find(p => p.description === 'pyramid'); - const _map = symbols.find(p => p.description === 'map'); - const _affine = symbols.find(p => p.description === 'affine'); - - const feature = this.viewer[_drawingSource].getClosestFeatureToCoordinate( - this.viewer[_map].getEventCoordinate(event) - ); - - if (!feature) { - return null; - } - - const roiAnnotation = this.viewer._getROIFromFeature( - feature, - this.viewer[_pyramid].metadata, - this.viewer[_affine] - ); - if (roiAnnotation && autoselect) { - this.microscopyService.selectAnnotation(roiAnnotation); - } - return roiAnnotation; - } + const overlayData = customizationService.getCustomization('microscopyViewport.overlay'); // install the microscopy renderer into the web page. // you should only do this once. - async installOpenLayersRenderer(container, displaySet) { - const loadViewer = async metadata => { - const dicomMicroscopyModule = await this.microscopyService.importDicomMicroscopyViewer(); - const { viewer: DicomMicroscopyViewer, metadata: metadataUtils } = dicomMicroscopyModule; + const installOpenLayersRenderer = useCallback( + async (container, displaySet) => { + const loadViewer = async metadata => { + const dicomMicroscopyModule = await microscopyService.importDicomMicroscopyViewer(); + const { viewer: DicomMicroscopyViewer, metadata: metadataUtils } = dicomMicroscopyModule; - const microscopyViewer = DicomMicroscopyViewer.VolumeImageViewer; + const microscopyViewer = DicomMicroscopyViewer.VolumeImageViewer; - const client = getDicomWebClient({ - extensionManager: this.props.extensionManager, - servicesManager: this.props.servicesManager, - }); - - // Parse, format, and filter metadata - const volumeImages: any[] = []; - - /** - * This block of code is the original way of loading DICOM into dicom-microscopy-viewer - * as in their documentation. - * But we have the metadata already loaded by our loaders. - * As the metadata for microscopy DIOM files tends to be big and we don't - * want to double load it, below we have the mechanism to reconstruct the - * DICOM JSON structure (denaturalized) from naturalized metadata. - * (NOTE: Our loaders cache only naturalized metadata, not the denaturalized.) - */ - // { - // const retrieveOptions = { - // studyInstanceUID: metadata[0].StudyInstanceUID, - // seriesInstanceUID: metadata[0].SeriesInstanceUID, - // }; - // metadata = await client.retrieveSeriesMetadata(retrieveOptions); - // // Parse, format, and filter metadata - // metadata.forEach(m => { - // if ( - // volumeImages.length > 0 && - // m['00200052'].Value[0] != volumeImages[0].FrameOfReferenceUID - // ) { - // console.warn( - // 'Expected FrameOfReferenceUID of difference instances within a series to be the same, found multiple different values', - // m['00200052'].Value[0] - // ); - // m['00200052'].Value[0] = volumeImages[0].FrameOfReferenceUID; - // } - // NOTE: depending on different data source, image.ImageType sometimes - // is a string, not a string array. - // m['00080008'] = transformImageTypeUnnaturalized(m['00080008']); - - // const image = new metadataUtils.VLWholeSlideMicroscopyImage({ - // metadata: m, - // }); - // const imageFlavor = image.ImageType[2]; - // if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') { - // volumeImages.push(image); - // } - // }); - // } - - metadata.forEach(m => { - // NOTE: depending on different data source, image.ImageType sometimes - // is a string, not a string array. - m.ImageType = typeof m.ImageType === 'string' ? m.ImageType.split('\\') : m.ImageType; - - const inst = cleanDenaturalizedDataset( - dcmjs.data.DicomMetaDictionary.denaturalizeDataset(m), - { - StudyInstanceUID: m.StudyInstanceUID, - SeriesInstanceUID: m.SeriesInstanceUID, - dataSourceConfig: this.props.dataSource.getConfig(), - } - ); - if (!inst['00480105']) { - // Optical Path Sequence, no OpticalPathIdentifier? - // NOTE: this is actually a not-well formatted DICOM VL Whole Slide Microscopy Image. - inst['00480105'] = { - vr: 'SQ', - Value: [ - { - '00480106': { - vr: 'SH', - Value: ['1'], - }, - }, - ], - }; - } - const image = new metadataUtils.VLWholeSlideMicroscopyImage({ - metadata: inst, + const client = getDicomWebClient({ + extensionManager, + servicesManager, }); - const imageFlavor = image.ImageType[2]; - if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') { - volumeImages.push(image); - } - }); + // Parse, format, and filter metadata + const volumeImages: any[] = []; - // format metadata for microscopy-viewer - const options = { - client, - metadata: volumeImages, - retrieveRendered: false, - controls: ['overview', 'position'], + /** + * This block of code is the original way of loading DICOM into dicom-microscopy-viewer + * as in their documentation. + * But we have the metadata already loaded by our loaders. + * As the metadata for microscopy DIOM files tends to be big and we don't + * want to double load it, below we have the mechanism to reconstruct the + * DICOM JSON structure (denaturalized) from naturalized metadata. + * (NOTE: Our loaders cache only naturalized metadata, not the denaturalized.) + */ + // { + // const retrieveOptions = { + // studyInstanceUID: metadata[0].StudyInstanceUID, + // seriesInstanceUID: metadata[0].SeriesInstanceUID, + // }; + // metadata = await client.retrieveSeriesMetadata(retrieveOptions); + // // Parse, format, and filter metadata + // metadata.forEach(m => { + // if ( + // volumeImages.length > 0 && + // m['00200052'].Value[0] != volumeImages[0].FrameOfReferenceUID + // ) { + // console.warn( + // 'Expected FrameOfReferenceUID of difference instances within a series to be the same, found multiple different values', + // m['00200052'].Value[0] + // ); + // m['00200052'].Value[0] = volumeImages[0].FrameOfReferenceUID; + // } + // NOTE: depending on different data source, image.ImageType sometimes + // is a string, not a string array. + // m['00080008'] = transformImageTypeUnnaturalized(m['00080008']); + + // const image = new metadataUtils.VLWholeSlideMicroscopyImage({ + // metadata: m, + // }); + // const imageFlavor = image.ImageType[2]; + // if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') { + // volumeImages.push(image); + // } + // }); + // } + + metadata.forEach(m => { + // NOTE: depending on different data source, image.ImageType sometimes + // is a string, not a string array. + m.ImageType = typeof m.ImageType === 'string' ? m.ImageType.split('\\') : m.ImageType; + + const inst = cleanDenaturalizedDataset( + dcmjs.data.DicomMetaDictionary.denaturalizeDataset(m), + { + StudyInstanceUID: m.StudyInstanceUID, + SeriesInstanceUID: m.SeriesInstanceUID, + dataSourceConfig: dataSource.getConfig(), + } + ); + if (!inst['00480105']) { + // Optical Path Sequence, no OpticalPathIdentifier? + // NOTE: this is actually a not-well formatted DICOM VL Whole Slide Microscopy Image. + inst['00480105'] = { + vr: 'SQ', + Value: [ + { + '00480106': { + vr: 'SH', + Value: ['1'], + }, + }, + ], + }; + } + const image = new metadataUtils.VLWholeSlideMicroscopyImage({ + metadata: inst, + }); + + const imageFlavor = image.ImageType[2]; + if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') { + volumeImages.push(image); + } + }); + + // format metadata for microscopy-viewer + const options = { + client, + metadata: volumeImages, + retrieveRendered: false, + controls: ['overview', 'position'], + }; + + const viewer = new microscopyViewer(options); + + if (overlayElement && overlayElement.current && viewer.addViewportOverlay) { + viewer.addViewportOverlay({ + element: overlayElement.current, + coordinates: [0, 0], // TODO: dicom-microscopy-viewer documentation says this can be false to be automatically, but it is not. + navigate: true, + className: 'OpenLayersOverlay', + }); + } + + viewer.render({ container }); + + const { StudyInstanceUID, SeriesInstanceUID } = displaySet; + + const managedViewer = microscopyService.addViewer( + viewer, + viewportId, + container, + StudyInstanceUID, + SeriesInstanceUID + ); + + managedViewer.addContextMenuCallback((event: Event) => { + // TODO: refactor this after Bill's changes on ContextMenu feature get merged + // const roiAnnotationNearBy = this.getNearbyROI(event); + }); + + setViewer(viewer); + setManagedViewer(managedViewer); }; - this.viewer = new microscopyViewer(options); + microscopyService.clearAnnotations(); - if (this.overlayElement && this.overlayElement.current && this.viewer.addViewportOverlay) { - this.viewer.addViewportOverlay({ - element: this.overlayElement.current, - coordinates: [0, 0], // TODO: dicom-microscopy-viewer documentation says this can be false to be automatically, but it is not. - navigate: true, - className: 'OpenLayersOverlay', - }); - } - - this.viewer.render({ container }); - - const { StudyInstanceUID, SeriesInstanceUID } = displaySet; - - this.managedViewer = this.microscopyService.addViewer( - this.viewer, - this.props.viewportId, - container, - StudyInstanceUID, - SeriesInstanceUID - ); - - this.managedViewer.addContextMenuCallback((event: Event) => { - // TODO: refactor this after Bill's changes on ContextMenu feature get merged - // const roiAnnotationNearBy = this.getNearbyROI(event); - }); - }; - - this.microscopyService.clearAnnotations(); - - let smDisplaySet = displaySet; - if (displaySet.Modality === 'SR') { - // for SR displaySet, let's load the actual image displaySet - smDisplaySet = displaySet.getSourceDisplaySet(); - } - console.log('Loading viewer metadata', smDisplaySet); - - await loadViewer(smDisplaySet.others); - - if (displaySet.Modality === 'SR') { - displaySet.load(smDisplaySet); - } - } - - componentDidMount() { - const { displaySets, viewportOptions } = this.props; - // Todo-rename: this is always getting the 0 - const displaySet = displaySets[0]; - this.installOpenLayersRenderer(this.container.current, displaySet).then(() => { - this.setState({ isLoaded: true }); - }); - } - - componentDidUpdate(prevProps: Readonly<{}>, prevState: Readonly<{}>, snapshot?: any): void { - if (this.managedViewer && prevProps.displaySets !== this.props.displaySets) { - const { displaySets } = this.props; - const displaySet = displaySets[0]; - - this.microscopyService.clearAnnotations(); - - // loading SR + let smDisplaySet = displaySet; if (displaySet.Modality === 'SR') { - const referencedDisplaySet = displaySet.getSourceDisplaySet(); - displaySet.load(referencedDisplaySet); + // for SR displaySet, let's load the actual image displaySet + smDisplaySet = displaySet.getSourceDisplaySet(); } + console.log('Loading viewer metadata', smDisplaySet); + + await loadViewer(smDisplaySet.others); + + if (displaySet.Modality === 'SR') { + displaySet.load(smDisplaySet); + } + }, + [dataSource, extensionManager, microscopyService, servicesManager, viewportId] + ); + + useEffect(() => { + const displaySet = displaySets[0]; + installOpenLayersRenderer(container.current, displaySet).then(() => { + setIsLoaded(true); + }); + + return () => { + if (viewer) { + microscopyService.removeViewer(viewer); + } + }; + }, []); + + useEffect(() => { + const displaySet = displaySets[0]; + + microscopyService.clearAnnotations(); + + // loading SR + if (displaySet.Modality === 'SR') { + const referencedDisplaySet = displaySet.getSourceDisplaySet(); + displaySet.load(referencedDisplaySet); } - } + }, [managedViewer, displaySets, microscopyService]); - componentWillUnmount() { - this.microscopyService.removeViewer(this.viewer); - } + const style = { width: '100%', height: '100%' }; + const displaySet = displaySets[0]; + const firstInstance = displaySet.firstInstance || displaySet.instance; + const LoadingIndicatorProgress = customizationService.getCustomization( + 'ui.loadingIndicatorProgress' + ); - setViewportActiveHandler = () => { - const { setViewportActive, viewportId, activeViewportId } = this.props; - - if (viewportId !== activeViewportId) { - setViewportActive(viewportId); - } - }; - - render() { - const style = { width: '100%', height: '100%' }; - const displaySet = this.props.displaySets[0]; - const firstInstance = displaySet.firstInstance || displaySet.instance; - const LoadingIndicatorProgress = this.customizationService.getCustomization( - 'ui.loadingIndicatorProgress' - ); - - return ( -
-
-
-
- {displaySet && firstInstance.imageId && ( - - )} -
+ return ( +
{ + if (viewportId !== activeViewportId) { + setViewportActive(viewportId); + } + }} + > +
+
+
+ {displaySet && firstInstance.imageId && ( + + )}
- {this.state.error ? ( -

{JSON.stringify(this.state.error)}

- ) : ( -
{ - this.container.current = ref; - this.props.resizeRef.current = ref; - }} - /> - )} - {this.state.isLoaded ? null : ( - - )}
- ); - } +
{ + container.current = ref; + resizeRef.current = ref; + }} + /> + {isLoaded ? null : } +
+ ); } export default DicomMicroscopyViewport; diff --git a/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx b/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx index 7cbc04a51..7b8175b09 100644 --- a/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx +++ b/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx @@ -24,72 +24,66 @@ interface OverlayItem { * @param {*} extensionManager is used to load the image data. * @returns */ -export const generateFromConfig = ({ - topLeft = [], - topRight = [], - bottomLeft = [], - bottomRight = [], - itemGenerator = () => {}, -}: { - topLeft?: OverlayItem[]; - topRight?: OverlayItem[]; - bottomLeft?: OverlayItem[]; - bottomRight?: OverlayItem[]; - itemGenerator?: (props: any) => any; -}) => { - return (props: any) => { - const topLeftClass = 'top-viewport left-viewport text-primary-light'; - const topRightClass = 'top-viewport right-viewport-scrollbar text-primary-light'; - const bottomRightClass = 'bottom-viewport right-viewport-scrollbar text-primary-light'; - const bottomLeftClass = 'bottom-viewport left-viewport text-primary-light'; - const overlay = 'absolute pointer-events-none microscopy-viewport-overlay'; +export const generateFromConfig = ({ config, overlayData, ...props }) => { + const { + topLeft = [], + topRight = [], + bottomLeft = [], + bottomRight = [], + }: { + topLeft?: OverlayItem[]; + topRight?: OverlayItem[]; + bottomLeft?: OverlayItem[]; + bottomRight?: OverlayItem[]; + } = overlayData ?? {}; + const topLeftClass = 'top-viewport left-viewport text-primary-light'; + const topRightClass = 'top-viewport right-viewport-scrollbar text-primary-light'; + const bottomRightClass = 'bottom-viewport right-viewport-scrollbar text-primary-light'; + const bottomLeftClass = 'bottom-viewport left-viewport text-primary-light'; + const overlay = 'absolute pointer-events-none microscopy-viewport-overlay'; - return ( - <> - {topLeft && topLeft.length > 0 && ( -
- {listComponentGenerator({ ...props, list: topLeft, itemGenerator })} -
- )} - {topRight && topRight.length > 0 && ( -
- {listComponentGenerator({ - ...props, - list: topRight, - itemGenerator, - })} -
- )} - {bottomRight && bottomRight.length > 0 && ( -
- {listComponentGenerator({ - ...props, - list: bottomRight, - itemGenerator, - })} -
- )} - {bottomLeft && bottomLeft.length > 0 && ( -
- {listComponentGenerator({ - ...props, - list: bottomLeft, - itemGenerator, - })} -
- )} - - ); - }; + return ( + <> + {topLeft && topLeft.length > 0 && ( +
+ {listComponentGenerator({ ...props, list: topLeft, itemGenerator })} +
+ )} + {topRight && topRight.length > 0 && ( +
+ {listComponentGenerator({ + ...props, + list: topRight, + itemGenerator, + })} +
+ )} + {bottomRight && bottomRight.length > 0 && ( +
+ {listComponentGenerator({ + ...props, + list: bottomRight, + itemGenerator, + })} +
+ )} + {bottomLeft && bottomLeft.length > 0 && ( +
+ {listComponentGenerator({ + ...props, + list: bottomLeft, + itemGenerator, + })} +
+ )} + + ); }; const itemGenerator = (props: any) => { @@ -123,4 +117,4 @@ const itemGenerator = (props: any) => { ); }; -export default generateFromConfig({}); +export default generateFromConfig; diff --git a/extensions/dicom-microscopy/src/index.tsx b/extensions/dicom-microscopy/src/index.tsx index 5ccae968d..4023222c3 100644 --- a/extensions/dicom-microscopy/src/index.tsx +++ b/extensions/dicom-microscopy/src/index.tsx @@ -83,9 +83,6 @@ const extension: Types.Extensions.Extension = { return ( { viewportGridService.setActiveViewportId(viewportId); diff --git a/platform/core/src/contextProviders/SystemProvider.tsx b/platform/core/src/contextProviders/SystemProvider.tsx index 6f1686080..3a522a5db 100644 --- a/platform/core/src/contextProviders/SystemProvider.tsx +++ b/platform/core/src/contextProviders/SystemProvider.tsx @@ -14,7 +14,7 @@ interface SystemContextProviderProps { const systemContext = createContext(null); const { Provider } = systemContext; -export const useSystem = () => useContext(systemContext); +export const useSystem = () => useContext(systemContext); export function SystemContextProvider({ children,