feat(thumbnails): Enhance DICOM Thumbnail Rendering with different strategies (#5004)

Co-authored-by: Martin Bellehumeur, M. Eng. <23396581+mbellehumeur@users.noreply.github.com>
This commit is contained in:
Bill WallaceandMartin Bellehumeur, M. Eng. authored and GitHub committed 2025-05-08 12:44:56 -04:00
1 parent dee6909a99
commit 578c3939f0
80 files changed
+371 -284

No files matched your search

@@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.12.1",
"@cornerstonejs/core": "^3.12.1",
"@cornerstonejs/adapters": "^3.12.2",
"@cornerstonejs/core": "^3.12.2",
"@kitware/vtk.js": "32.12.0",
"react-color": "^2.19.3"
}
@@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.12.1",
"@cornerstonejs/core": "^3.12.1",
"@cornerstonejs/adapters": "^3.12.2",
"@cornerstonejs/core": "^3.12.2",
"@kitware/vtk.js": "32.12.0",
"react-color": "^2.19.3"
}
+3 -3
View File
@@ -46,9 +46,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.12.1",
"@cornerstonejs/core": "^3.12.1",
"@cornerstonejs/tools": "^3.12.1",
"@cornerstonejs/adapters": "^3.12.2",
"@cornerstonejs/core": "^3.12.2",
"@cornerstonejs/tools": "^3.12.2",
"classnames": "^2.3.2"
}
}
@@ -42,8 +42,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/core": "^3.12.1",
"@cornerstonejs/tools": "^3.12.1",
"@cornerstonejs/core": "^3.12.2",
"@cornerstonejs/tools": "^3.12.2",
"classnames": "^2.3.2"
}
}
+7 -7
View File
@@ -38,7 +38,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.4",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/dicom-image-loader": "^3.12.1",
"@cornerstonejs/dicom-image-loader": "^3.12.2",
"@ohif/core": "3.11.0-beta.27",
"@ohif/ui": "3.11.0-beta.27",
"dcmjs": "*",
@@ -51,12 +51,12 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.12.1",
"@cornerstonejs/ai": "^3.12.1",
"@cornerstonejs/core": "^3.12.1",
"@cornerstonejs/labelmap-interpolation": "^3.12.1",
"@cornerstonejs/polymorphic-segmentation": "^3.12.1",
"@cornerstonejs/tools": "^3.12.1",
"@cornerstonejs/adapters": "^3.12.2",
"@cornerstonejs/ai": "^3.12.2",
"@cornerstonejs/core": "^3.12.2",
"@cornerstonejs/labelmap-interpolation": "^3.12.2",
"@cornerstonejs/polymorphic-segmentation": "^3.12.2",
"@cornerstonejs/tools": "^3.12.2",
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
"@kitware/vtk.js": "32.12.0",
"html2canvas": "^1.4.1",
@@ -17,6 +17,7 @@ const SOPClassHandlerId =
'@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler';
function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) {
const dataSource = extensionManager.getActiveDataSource()[0];
// If the series has no instances, stop here
if (!instances || !instances.length) {
throw new Error('No instances were provided');
@@ -33,22 +34,6 @@ function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager)
currentFrames = framesI;
}
}
let imageIdForThumbnail = null;
const dataSource = extensionManager.getActiveDataSource()[0];
if (singleFrameInstance) {
if (currentFrames == 1) {
// Not all DICOM server implementations support thumbnail service,
// So if we have a single-frame image, we will prefer it.
imageIdForThumbnail = singleFrameInstance.imageId;
}
if (!imageIdForThumbnail) {
// use the thumbnail service provided by DICOM server
imageIdForThumbnail = dataSource.getImageIdsForInstance({
instance: singleFrameInstance,
thumbnail: true,
});
}
}
const {
FrameOfReferenceUID,
@@ -108,11 +93,14 @@ function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager)
instance,
numImageFrames: 0,
numInstances: 1,
imageIdForThumbnail, // thumbnail image
others: instances, // all other level instances in the image Pyramid
instances,
othersFrameOfReferenceUID,
imageIds: instances.map(instance => instance.imageId),
getThumbnailSrc: dataSource.retrieve.getGetThumbnailSrc?.(
singleFrameInstance,
singleFrameInstance.imageId
),
label: SeriesDescription || `${i18n.t('Series')} ${SeriesNumber} - ${i18n.t('SM')}`,
};
// The microscopy viewer directly accesses the metadata already loaded, and
@@ -37,7 +37,15 @@ export type DicomWebConfig = {
wadoUri?: string; // - Base URL to use for WADO URI requests
qidoSupportsIncludeField?: boolean; // - Whether QIDO supports the "Include" option to request additional fields in response
imageRendering?: string; // - wadors | ? (unsure of where/how this is used)
thumbnailRendering?: string; // - wadors | ? (unsure of where/how this is used)
thumbnailRendering?: string;
/**
wadors - render using the wadors fetch. The full image is retrieved and rendered in cornerstone to thumbnail size png and returned as binary data to the src attribute of the image tag.
for example, <img src=data:image/png;base64,sdlfk;adkfadfk....asldfjkl;asdkf>
thumbnailDirect - get the direct url endpoint for the thumbnail as the image src (eg not authentication required).
for example, <img src=http://server:port/wadors/studies/1.2.3/thumbnail?accept=image/jpeg>
thumbnail - render using the thumbnail endpoint on wadors using bulkDataURI, passing authentication params to the url.
rendered - should use the rendered endpoint instead of the thumbnail endpoint
*/
/** Whether the server supports reject calls (i.e. DCM4CHEE) */
supportsReject?: boolean;
/** Request series meta async instead of blocking */
@@ -225,6 +233,71 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
* @returns an absolute URL to the resource, if the absolute URL can be retrieved as singlepart,
* or is already retrieved, or a promise to a URL for such use if a BulkDataURI
*/
getGetThumbnailSrc: function (instance, imageId) {
if (dicomWebConfig.thumbnailRendering === 'wadors') {
return function getThumbnailSrc(options) {
if (!imageId) {
return null;
}
if (!options?.getImageSrc) {
return null;
}
return options.getImageSrc(imageId);
};
}
if (dicomWebConfig.thumbnailRendering === 'thumbnailDirect') {
return function getThumbnailSrc() {
return this.directURL({
instance: instance,
defaultPath: '/thumbnail',
defaultType: 'image/jpeg',
singlepart: true,
tag: 'Absent',
});
}.bind(this);
}
if (dicomWebConfig.thumbnailRendering === 'thumbnail') {
return async function getThumbnailSrc() {
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
const bulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/thumbnail?accept=image/jpeg`;
return URL.createObjectURL(
new Blob(
[
await this.bulkDataURI({
BulkDataURI: bulkDataURI.replace('wadors:', ''),
defaultType: 'image/jpeg',
mediaTypes: ['image/jpeg'],
thumbnail: true,
}),
],
{ type: 'image/jpeg' }
)
);
}.bind(this);
}
if (dicomWebConfig.thumbnailRendering === 'rendered') {
return async function getThumbnailSrc() {
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
const bulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/rendered?accept=image/jpeg`;
return URL.createObjectURL(
new Blob(
[
await this.bulkDataURI({
BulkDataURI: bulkDataURI.replace('wadors:', ''),
defaultType: 'image/jpeg',
mediaTypes: ['image/jpeg'],
thumbnail: true,
}),
],
{ type: 'image/jpeg' }
)
);
}.bind(this);
}
},
directURL: params => {
return getDirectURL(
{
@@ -10,17 +10,7 @@ import { CallbackCustomization } from 'platform/core/src/types';
const { sortStudyInstances, formatDate, createStudyBrowserTabs } = utils;
const thumbnailNoImageModalities = [
'SR',
'SEG',
'SM',
'RTSTRUCT',
'RTPLAN',
'RTDOSE',
'DOC',
'OT',
'PMAP',
];
const thumbnailNoImageModalities = ['SR', 'SEG', 'RTSTRUCT', 'RTPLAN', 'RTDOSE', 'DOC', 'PMAP'];
/**
* Study Browser component that displays and manages studies and their display sets
@@ -178,7 +168,7 @@ function PanelStudyBrowser({
let currentDisplaySets = displaySetService.activeDisplaySets;
// filter non based on the list of modalities that are supported by cornerstone
currentDisplaySets = currentDisplaySets.filter(
ds => !thumbnailNoImageModalities.includes(ds.Modality)
ds => !thumbnailNoImageModalities.includes(ds.Modality) || ds.thumbnailSrc === null
);
if (!currentDisplaySets.length) {
@@ -188,20 +178,20 @@ function PanelStudyBrowser({
currentDisplaySets.forEach(async dSet => {
const newImageSrcEntry = {};
const displaySet = displaySetService.getDisplaySetByUID(dSet.displaySetInstanceUID);
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
const imageIds = dataSource.getImageIdsForDisplaySet(dSet);
const imageId = getImageIdForThumbnail(displaySet, imageIds);
// TODO: Is it okay that imageIds are not returned here for SR displaySets?
if (!imageId || displaySet?.unsupported) {
if (displaySet?.unsupported) {
return;
}
// When the image arrives, render it and store the result in the thumbnailImgSrcMap
let { thumbnailSrc } = displaySet;
if (!thumbnailSrc && displaySet.getThumbnailSrc) {
thumbnailSrc = await displaySet.getThumbnailSrc();
thumbnailSrc = await displaySet.getThumbnailSrc({ getImageSrc });
}
if (!thumbnailSrc) {
if (!thumbnailSrc && imageId) {
const thumbnailSrc = await getImageSrc(imageId);
displaySet.thumbnailSrc = thumbnailSrc;
}
@@ -253,13 +243,11 @@ function PanelStudyBrowser({
const { displaySetsAdded, options } = data;
displaySetsAdded.forEach(async dSet => {
const displaySetInstanceUID = dSet.displaySetInstanceUID;
const newImageSrcEntry = {};
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (displaySet?.unsupported) {
return;
}
if (options?.madeInClient) {
setJumpToDisplaySet(displaySetInstanceUID);
}
@@ -275,7 +263,7 @@ function PanelStudyBrowser({
// When the image arrives, render it and store the result in the thumbnailImgSrcMap
let { thumbnailSrc } = displaySet;
if (!thumbnailSrc && displaySet.getThumbnailSrc) {
thumbnailSrc = await displaySet.getThumbnailSrc();
thumbnailSrc = await displaySet.getThumbnailSrc({ getImageSrc });
}
if (!thumbnailSrc) {
thumbnailSrc = await getImageSrc(imageId);
@@ -512,7 +500,11 @@ function _mapDisplaySets(displaySets, displaySetLoadingState, thumbnailImageSrcM
}
function _getComponentType(ds) {
if (thumbnailNoImageModalities.includes(ds.Modality) || ds?.unsupported) {
if (
thumbnailNoImageModalities.includes(ds.Modality) ||
ds?.unsupported ||
ds.thumbnailSrc === null
) {
return 'thumbnailNoImage';
}
@@ -5,7 +5,13 @@ import getDisplaySetMessages from './getDisplaySetMessages';
import getDisplaySetsFromUnsupportedSeries from './getDisplaySetsFromUnsupportedSeries';
import { chartHandler } from './SOPClassHandlers/chartSOPClassHandler';
const { isImage, sopClassDictionary, isDisplaySetReconstructable } = utils;
const {
isImage,
sortStudyInstances,
instancesSortCriteria,
sopClassDictionary,
isDisplaySetReconstructable,
} = utils;
const { ImageSet } = classes;
const DEFAULT_VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume';
@@ -62,9 +68,12 @@ function getDisplaySetInfo(instances) {
}
const makeDisplaySet = instances => {
// Need to sort the instances in order to get a consistent instance/thumbnail
sortStudyInstances(instances);
const instance = instances[0];
const imageSet = new ImageSet(instances);
const { extensionManager } = appContext;
const dataSource = extensionManager.getActiveDataSource()[0];
const {
isDynamicVolume,
value: isReconstructable,
@@ -79,6 +88,16 @@ const makeDisplaySet = instances => {
// set appropriate attributes to image set...
const messages = getDisplaySetMessages(instances, isReconstructable, isDynamicVolume);
const imageIds = dataSource.getImageIdsForDisplaySet(imageSet);
let imageId = imageIds[Math.floor(imageIds.length / 2)];
let thumbnailInstance = instances[Math.floor(instances.length / 2)];
if (isDynamicVolume) {
const timePoints = dynamicVolumeInfo.timePoints;
const middleIndex = Math.floor(timePoints.length / 2);
const middleTimePointImageIds = timePoints[middleIndex];
imageId = middleTimePointImageIds[Math.floor(middleTimePointImageIds.length / 2)];
}
imageSet.setAttributes({
volumeLoaderSchema,
displaySetInstanceUID: imageSet.uid, // create a local alias for the imageSet UID
@@ -100,6 +119,7 @@ const makeDisplaySet = instances => {
averageSpacingBetweenFrames: averageSpacingBetweenFrames || null,
isDynamicVolume,
dynamicVolumeInfo,
getThumbnailSrc: dataSource.retrieve.getGetThumbnailSrc?.(thumbnailInstance, imageId),
supportsWindowLevel: true,
label:
instance.SeriesDescription ||
@@ -107,14 +127,7 @@ const makeDisplaySet = instances => {
FrameOfReferenceUID: instance.FrameOfReferenceUID,
});
// Sort the images in this series if needed
const shallSort = true; //!OHIF.utils.ObjectPath.get(Meteor, 'settings.public.ui.sortSeriesByIncomingOrder');
if (shallSort) {
imageSet.sortBy((a, b) => {
// Sort by InstanceNumber (0020,0013)
return (parseInt(a.InstanceNumber) || 0) - (parseInt(b.InstanceNumber) || 0);
});
}
imageSet.sortBy(instancesSortCriteria.default);
// Include the first image instance number (after sorted)
/*imageSet.setAttribute(
@@ -178,10 +191,8 @@ function getDisplaySetsFromSeries(instances) {
}
let displaySet;
if (isMultiFrame(instance)) {
displaySet = makeDisplaySet([instance]);
displaySet.setAttributes({
sopClassUids,
numImageFrames: instance.NumberOfFrames,
@@ -14,7 +14,7 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager)
const { Modality, SOPInstanceUID } = instance;
const { SeriesDescription = 'PDF', MIMETypeOfEncapsulatedDocument } = instance;
const { SeriesNumber, SeriesDate, SeriesInstanceUID, StudyInstanceUID, SOPClassUID } = instance;
const pdfUrl = dataSource.retrieve.directURL({
const renderedUrl = dataSource.retrieve.directURL({
instance,
tag: 'EncapsulatedDocument',
defaultType: MIMETypeOfEncapsulatedDocument || 'application/pdf',
@@ -35,14 +35,9 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager)
SOPClassUID,
referencedImages: null,
measurements: null,
pdfUrl,
renderedUrl: renderedUrl,
instances: [instance],
thumbnailSrc: dataSource.retrieve.directURL({
instance,
defaultPath: '/thumbnail',
defaultType: 'image/jpeg',
tag: 'Absent',
}),
thumbnailSrc: null,
isDerivedDisplaySet: true,
isLoaded: false,
sopClassUids,
@@ -28,15 +28,15 @@ function OHIFCornerstonePdfViewport({ displaySets }) {
);
}
const { pdfUrl } = displaySets[0];
const { renderedUrl } = displaySets[0];
useEffect(() => {
const load = async () => {
setUrl(await pdfUrl);
setUrl(await renderedUrl);
};
load();
}, [pdfUrl]);
}, [renderedUrl]);
return (
<div
@@ -32,6 +32,8 @@ const supportedTransferSyntaxUIDs = Object.values(SupportedTransferSyntaxes);
const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) => {
const dataSource = extensionManager.getActiveDataSource()[0];
const thumbnailSrc = null;
console.warn('dataSource=', dataSource);
return instances
.filter(metadata => {
const tsuid =
@@ -75,16 +77,9 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager)
referencedImages: null,
measurements: null,
viewportType: csEnums.ViewportType.VIDEO,
// The videoUrl is deprecated, the preferred URL is renderedUrl
videoUrl,
renderedUrl: videoUrl,
instances: [instance],
thumbnailSrc: dataSource.retrieve.directURL({
instance,
defaultPath: '/thumbnail',
defaultType: 'image/jpeg',
tag: 'Absent',
}),
getThumbnailSrc: dataSource.retrieve.getGetThumbnailSrc?.(instance),
thumbnailSrc,
imageIds: [imageId],
isDerivedDisplaySet: true,
isLoaded: false,
+2 -2
View File
@@ -32,8 +32,8 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@cornerstonejs/core": "^3.12.1",
"@cornerstonejs/tools": "^3.12.1",
"@cornerstonejs/core": "^3.12.2",
"@cornerstonejs/tools": "^3.12.2",
"@ohif/core": "3.11.0-beta.27",
"@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.27",
"@ohif/extension-default": "3.11.0-beta.27",
@@ -5,17 +5,7 @@ import PanelStudyBrowser from '@ohif/extension-default/src/Panels/StudyBrowser/P
import { UntrackSeriesModal } from './untrackSeriesModal';
import { useTrackedMeasurements } from '../../getContextModule';
const thumbnailNoImageModalities = [
'SR',
'SEG',
'SM',
'RTSTRUCT',
'RTPLAN',
'RTDOSE',
'DOC',
'OT',
'PMAP',
];
const thumbnailNoImageModalities = ['SR', 'SEG', 'RTSTRUCT', 'RTPLAN', 'RTDOSE', 'PMAP'];
/**
* Panel component for the Study Browser with tracking capabilities
@@ -126,7 +116,11 @@ export default function PanelStudyBrowserTracking({
// Override component type to use tracking specific components
const getComponentType = ds => {
if (thumbnailNoImageModalities.includes(ds.Modality) || ds?.unsupported) {
if (
thumbnailNoImageModalities.includes(ds.Modality) ||
ds.unsupported ||
ds.thumbnailSrc === null
) {
return 'thumbnailNoImage';
}
return 'thumbnailTracked';