fix(microscopy): Prevent measurement clearing on viewport resize (#5796)

Wrap DicomMicroscopyViewport with React.memo and custom areEqual
to prevent unnecessary re-renders that trigger clearAnnotations().

---------

Co-authored-by: Joe Boccanfuso <joe.boccanfuso@radicalimaging.com>
This commit is contained in:
Ghadeer Albattarni 2026-02-11 10:00:26 -05:00 committed by GitHub
parent 7cdc54c86d
commit baef707e46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 299 additions and 222 deletions

View File

@ -7,252 +7,324 @@ import getDicomWebClient from './utils/dicomWebClient';
import dcmjs from 'dcmjs';
import { useSystem } from '@ohif/core';
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;
// This component is wrapped with React.memo and uses a custom areEqual comparison
// function to prevent unnecessary re-renders when props are semantically identical
// (e.g. `displaySets` reference changes, but the content is the same).
// Note: If this component starts depending on additional props,
// update `areEqual` accordingly.
const DicomMicroscopyViewport = React.memo(
({
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;
const overlayData = customizationService.getCustomization('microscopyViewport.overlay');
const overlayData = customizationService.getCustomization('microscopyViewport.overlay');
// install the microscopy renderer into the web page.
// you should only do this once.
const installOpenLayersRenderer = useCallback(
async (container, displaySet) => {
const loadViewer = async metadata => {
const dicomMicroscopyModule = await microscopyService.importDicomMicroscopyViewer();
const { viewer: DicomMicroscopyViewer, metadata: metadataUtils } = dicomMicroscopyModule;
// install the microscopy renderer into the web page.
// you should only do this once.
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,
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: 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);
};
const viewer = new microscopyViewer(options);
microscopyService.clearAnnotations();
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',
});
let smDisplaySet = displaySet;
if (displaySet.isOverlayDisplaySet) {
// for SR displaySet, let's load the actual image displaySet
smDisplaySet = displaySet.getSourceDisplaySet();
}
console.log('Loading viewer metadata', smDisplaySet);
viewer.render({ container });
await loadViewer(smDisplaySet.others);
const { StudyInstanceUID, SeriesInstanceUID } = displaySet;
if (displaySet.isOverlayDisplaySet && !displaySet.isLoaded && !displaySet.isLoading) {
const referencedDisplaySet = displaySet.getSourceDisplaySet();
displaySet.load(referencedDisplaySet);
}
},
[dataSource, extensionManager, microscopyService, servicesManager, viewportId]
);
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);
useEffect(() => {
if (!viewer) {
const displaySet = displaySets[0];
installOpenLayersRenderer(container.current, displaySet).then(() => {
setIsLoaded(true);
});
}
setViewer(viewer);
setManagedViewer(managedViewer);
return () => {
if (viewer) {
microscopyService.removeViewer(viewer);
}
};
}, [viewer]);
useEffect(() => {
const displaySet = displaySets[0];
microscopyService.clearAnnotations();
let smDisplaySet = displaySet;
if (displaySet.isOverlayDisplaySet) {
// for SR displaySet, let's load the actual image displaySet
smDisplaySet = displaySet.getSourceDisplaySet();
}
console.log('Loading viewer metadata', smDisplaySet);
await loadViewer(smDisplaySet.others);
// loading SR - only if not already loaded and not currently loading
if (displaySet.isOverlayDisplaySet && !displaySet.isLoaded && !displaySet.isLoading) {
displaySet.load(smDisplaySet);
const referencedDisplaySet = displaySet.getSourceDisplaySet();
displaySet.load(referencedDisplaySet);
}
},
[dataSource, extensionManager, microscopyService, servicesManager, viewportId]
);
}, [managedViewer, displaySets, microscopyService]);
useEffect(() => {
const style = { width: '100%', height: '100%' };
const displaySet = displaySets[0];
installOpenLayersRenderer(container.current, displaySet).then(() => {
setIsLoaded(true);
});
const firstInstance = displaySet.firstInstance || displaySet.instance;
const LoadingIndicatorProgress = customizationService.getCustomization(
'ui.loadingIndicatorProgress'
);
return () => {
if (viewer) {
microscopyService.removeViewer(viewer);
}
};
}, []);
useEffect(() => {
const displaySet = displaySets[0];
microscopyService.clearAnnotations();
// loading SR - only if not already loaded and not currently loading
if (displaySet.isOverlayDisplaySet && !displaySet.isLoaded && !displaySet.isLoading) {
const referencedDisplaySet = displaySet.getSourceDisplaySet();
displaySet.load(referencedDisplaySet);
}
}, [managedViewer, displaySets, microscopyService]);
const style = { width: '100%', height: '100%' };
const displaySet = displaySets[0];
const firstInstance = displaySet.firstInstance || displaySet.instance;
const LoadingIndicatorProgress = customizationService.getCustomization(
'ui.loadingIndicatorProgress'
);
return (
<div
className={'DicomMicroscopyViewer'}
style={style}
onClick={() => {
if (viewportId !== activeViewportId) {
setViewportActive(viewportId);
}
}}
>
<div style={{ ...style, display: 'none' }}>
<div style={{ ...style }} ref={overlayElement}>
<div style={{ position: 'relative', height: '100%', width: '100%' }}>
{displaySet && firstInstance.imageId && (
<ViewportOverlay
overlayData={overlayData}
displaySet={displaySet}
instance={displaySet.instance}
metadata={displaySet.metadata}
/>
)}
return (
<div
className={'DicomMicroscopyViewer'}
style={style}
onClick={() => {
if (viewportId !== activeViewportId) {
setViewportActive(viewportId);
}
}}
>
<div style={{ ...style, display: 'none' }}>
<div style={{ ...style }} ref={overlayElement}>
<div style={{ position: 'relative', height: '100%', width: '100%' }}>
{displaySet && firstInstance.imageId && (
<ViewportOverlay
overlayData={overlayData}
displaySet={displaySet}
instance={displaySet.instance}
metadata={displaySet.metadata}
/>
)}
</div>
</div>
</div>
<div
style={style}
ref={(ref: any) => {
container.current = ref;
resizeRef.current = ref;
}}
/>
{isLoaded ? null : <LoadingIndicatorProgress className={'h-full w-full bg-black'} />}
</div>
<div
style={style}
ref={(ref: any) => {
container.current = ref;
resizeRef.current = ref;
}}
/>
{isLoaded ? null : <LoadingIndicatorProgress className={'h-full w-full bg-black'} />}
</div>
);
);
},
areEqual
);
// Check if the props are the same
function areEqual(prevProps, nextProps) {
if (prevProps.setViewportActive !== nextProps.setViewportActive) {
return false;
}
if (prevProps.resizeRef !== nextProps.resizeRef) {
return false;
}
if (prevProps.viewportId !== nextProps.viewportId) {
return false;
}
if (prevProps.activeViewportId !== nextProps.activeViewportId) {
return false;
}
if (prevProps.dataSource !== nextProps.dataSource) {
return false;
}
const prevDisplaySets = prevProps.displaySets;
const nextDisplaySets = nextProps.displaySets;
if (prevDisplaySets.length !== nextDisplaySets.length) {
return false;
}
// Check if the displaySets are the same
for (let i = 0; i < prevDisplaySets.length; i++) {
const prevDisplaySet = prevDisplaySets[i];
const foundDisplaySet = nextDisplaySets.find(
nextDisplaySet =>
nextDisplaySet.displaySetInstanceUID === prevDisplaySet.displaySetInstanceUID
);
if (!foundDisplaySet) {
return false;
}
// Check if the displaySet's images are the same
if (foundDisplaySet.images?.length !== prevDisplaySet.images?.length) {
return false;
}
// Check if the displaySet's imageIds are the same
if (foundDisplaySet.images?.length) {
for (let j = 0; j < foundDisplaySet.images.length; j++) {
if (foundDisplaySet.images[j].imageId !== prevDisplaySet.images[j].imageId) {
return false;
}
}
}
}
return true;
}
DicomMicroscopyViewport.displayName = 'DicomMicroscopyViewport';
export default DicomMicroscopyViewport;

View File

@ -1,5 +1,5 @@
import { id } from './id';
import React, { Suspense, useMemo } from 'react';
import React, { Suspense, useCallback, useMemo } from 'react';
import getPanelModule from './getPanelModule';
import getCommandsModule from './getCommandsModule';
import getCustomizationModule from './getCustomizationModule';
@ -81,13 +81,18 @@ const extension: Types.Extensions.Extension = {
handleWidth: true,
});
const setViewportActive = useCallback(
(viewportId: string) => {
viewportGridService.setActiveViewportId(viewportId);
},
[viewportGridService]
);
return (
<MicroscopyViewport
key={displaySetsKey}
activeViewportId={activeViewportId}
setViewportActive={(viewportId: string) => {
viewportGridService.setActiveViewportId(viewportId);
}}
setViewportActive={setViewportActive}
viewportData={viewportOptions}
resizeRef={resizeRef}
{...props}