feat(hp): enable OHIF to run with partial metadata for large studies at the cost of less effective hanging protocol (#3804)

Co-authored-by: rodrigobasilio2022 <rodrigo.basilio@radicalimaging.com>
Co-authored-by: rodrigobasilio2022 <114958722+rodrigobasilio2022@users.noreply.github.com>
This commit is contained in:
Igor Octaviano 2024-01-09 16:22:00 -03:00 committed by GitHub
parent b5cdf88a57
commit 0049f4c030
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 240 additions and 74 deletions

View File

@ -28,17 +28,35 @@ const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
const metadataProvider = classes.MetadataProvider; const metadataProvider = classes.MetadataProvider;
/** /**
* Creates a DICOM Web API based on the provided configuration.
* *
* @param {string} name - Data source name * @param {object} dicomWebConfig - Configuration for the DICOM Web API
* @param {string} wadoUriRoot - Legacy? (potentially unused/replaced) * @param {string} dicomWebConfig.name - Data source name
* @param {string} qidoRoot - Base URL to use for QIDO requests * @param {string} dicomWebConfig.wadoUriRoot - Legacy? (potentially unused/replaced)
* @param {string} wadoRoot - Base URL to use for WADO requests * @param {string} dicomWebConfig.qidoRoot - Base URL to use for QIDO requests
* @param {boolean} qidoSupportsIncludeField - Whether QIDO supports the "Include" option to request additional fields in response * @param {string} dicomWebConfig.wadoRoot - Base URL to use for WADO requests
* @param {string} imageRengering - wadors | ? (unsure of where/how this is used) * @param {string} dicomWebConfig.wadoUri - Base URL to use for WADO URI requests
* @param {string} thumbnailRendering - wadors | ? (unsure of where/how this is used) * @param {boolean} dicomWebConfig.qidoSupportsIncludeField - Whether QIDO supports the "Include" option to request additional fields in response
* @param {bool} supportsReject - Whether the server supports reject calls (i.e. DCM4CHEE) * @param {string} dicomWebConfig.imageRendering - wadors | ? (unsure of where/how this is used)
* @param {bool} lazyLoadStudy - "enableStudyLazyLoad"; Request series meta async instead of blocking * @param {string} dicomWebConfig.thumbnailRendering - wadors | ? (unsure of where/how this is used)
* @param {string|bool} singlepart - indicates of the retrieves can fetch singlepart. Options are bulkdata, video, image or boolean true * @param {boolean} dicomWebConfig.supportsReject - Whether the server supports reject calls (i.e. DCM4CHEE)
* @param {boolean} dicomWebConfig.lazyLoadStudy - "enableStudyLazyLoad"; Request series meta async instead of blocking
* @param {string|boolean} dicomWebConfig.singlepart - indicates if the retrieves can fetch singlepart. Options are bulkdata, video, image, or boolean true
* @param {string} dicomWebConfig.requestTransferSyntaxUID - Transfer syntax to request from the server
* @param {object} dicomWebConfig.acceptHeader - Accept header to use for requests
* @param {boolean} dicomWebConfig.omitQuotationForMultipartRequest - Whether to omit quotation marks for multipart requests
* @param {boolean} dicomWebConfig.supportsFuzzyMatching - Whether the server supports fuzzy matching
* @param {boolean} dicomWebConfig.supportsWildcard - Whether the server supports wildcard matching
* @param {boolean} dicomWebConfig.supportsNativeDICOMModel - Whether the server supports the native DICOM model
* @param {boolean} dicomWebConfig.enableStudyLazyLoad - Whether to enable study lazy loading
* @param {boolean} dicomWebConfig.enableRequestTag - Whether to enable request tag
* @param {boolean} dicomWebConfig.enableStudyLazyLoad - Whether to enable study lazy loading
* @param {boolean} dicomWebConfig.bulkDataURI - Whether to enable bulkDataURI
* @param {function} dicomWebConfig.onConfiguration - Function that is called after the configuration is initialized
* @param {boolean} dicomWebConfig.staticWado - Whether to use the static WADO client
* @param {object} userAuthenticationService - User authentication service
* @param {object} userAuthenticationService.getAuthorizationHeader - Function that returns the authorization header
* @returns {object} - DICOM Web API object
*/ */
function createDicomWebApi(dicomWebConfig, servicesManager) { function createDicomWebApi(dicomWebConfig, servicesManager) {
const { userAuthenticationService, customizationService } = servicesManager.services; const { userAuthenticationService, customizationService } = servicesManager.services;
@ -191,6 +209,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) {
sortCriteria, sortCriteria,
sortFunction, sortFunction,
madeInClient = false, madeInClient = false,
returnPromises = false,
} = {}) => { } = {}) => {
if (!StudyInstanceUID) { if (!StudyInstanceUID) {
throw new Error('Unable to query for SeriesMetadata without StudyInstanceUID'); throw new Error('Unable to query for SeriesMetadata without StudyInstanceUID');
@ -202,7 +221,8 @@ function createDicomWebApi(dicomWebConfig, servicesManager) {
filters, filters,
sortCriteria, sortCriteria,
sortFunction, sortFunction,
madeInClient madeInClient,
returnPromises
); );
} }
@ -336,7 +356,8 @@ function createDicomWebApi(dicomWebConfig, servicesManager) {
filters, filters,
sortCriteria, sortCriteria,
sortFunction, sortFunction,
madeInClient = false madeInClient = false,
returnPromises = false
) => { ) => {
const enableStudyLazyLoad = true; const enableStudyLazyLoad = true;
wadoDicomWebClient.headers = generateWadoHeader(); wadoDicomWebClient.headers = generateWadoHeader();
@ -416,7 +437,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) {
const naturalizedInstances = instances.map(addRetrieveBulkData); const naturalizedInstances = instances.map(addRetrieveBulkData);
// Adding instanceMetadata to OHIF MetadataProvider // Adding instanceMetadata to OHIF MetadataProvider
naturalizedInstances.forEach((instance, index) => { naturalizedInstances.forEach(instance => {
instance.wadoRoot = dicomWebConfig.wadoRoot; instance.wadoRoot = dicomWebConfig.wadoRoot;
instance.wadoUri = dicomWebConfig.wadoUri; instance.wadoUri = dicomWebConfig.wadoUri;
@ -443,7 +464,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) {
} }
function setSuccessFlag() { function setSuccessFlag() {
const study = DicomMetadataStore.getStudy(StudyInstanceUID, madeInClient); const study = DicomMetadataStore.getStudy(StudyInstanceUID);
if (!study) { if (!study) {
return; return;
} }
@ -458,13 +479,22 @@ function createDicomWebApi(dicomWebConfig, servicesManager) {
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient); DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
const seriesDeliveredPromises = seriesPromises.map(promise => const seriesDeliveredPromises = seriesPromises.map(promise => {
promise.then(instances => { if (!returnPromises) {
promise?.start();
}
return promise.then(instances => {
storeInstances(instances); storeInstances(instances);
}) });
); });
await Promise.all(seriesDeliveredPromises);
setSuccessFlag(); if (returnPromises) {
Promise.all(seriesDeliveredPromises).then(() => setSuccessFlag());
return seriesPromises;
} else {
await Promise.all(seriesDeliveredPromises);
setSuccessFlag();
}
return seriesSummaryMetadata; return seriesSummaryMetadata;
}, },
@ -496,7 +526,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) {
return imageIds; return imageIds;
}, },
getImageIdsForInstance({ instance, frame }) { getImageIdsForInstance({ instance, frame = undefined }) {
const imageIds = getImageId({ const imageIds = getImageId({
instance, instance,
frame, frame,

View File

@ -7,6 +7,7 @@ import { api } from 'dicomweb-client';
* performing searches doesn't work. This version fixes the query issue * performing searches doesn't work. This version fixes the query issue
* by manually implementing a query option. * by manually implementing a query option.
*/ */
export default class StaticWadoClient extends api.DICOMwebClient { export default class StaticWadoClient extends api.DICOMwebClient {
static studyFilterKeys = { static studyFilterKeys = {
studyinstanceuid: '0020000D', studyinstanceuid: '0020000D',

View File

@ -11,10 +11,17 @@ export default class RetrieveMetadataLoader {
* @param {Object} client The dicomweb-client. * @param {Object} client The dicomweb-client.
* @param {Array} studyInstanceUID Study instance ui to be retrieved * @param {Array} studyInstanceUID Study instance ui to be retrieved
* @param {Object} [filters] - Object containing filters to be applied on retrieve metadata process * @param {Object} [filters] - Object containing filters to be applied on retrieve metadata process
* @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against * @param {string} [filters.seriesInstanceUID] - series instance uid to filter results against
* @param {Function} [sortSeries] - Custom sort function for series * @param {Object} [sortCriteria] - Custom sort criteria used for series
* @param {Function} [sortFunction] - Custom sort function for series
*/ */
constructor(client, studyInstanceUID, filters = {}, sortCriteria, sortFunction) { constructor(
client,
studyInstanceUID,
filters = {},
sortCriteria = undefined,
sortFunction = undefined
) {
this.client = client; this.client = client;
this.studyInstanceUID = studyInstanceUID; this.studyInstanceUID = studyInstanceUID;
this.filters = filters; this.filters = filters;
@ -26,7 +33,6 @@ export default class RetrieveMetadataLoader {
const preLoadData = await this.preLoad(); const preLoadData = await this.preLoad();
const loadData = await this.load(preLoadData); const loadData = await this.load(preLoadData);
const postLoadData = await this.posLoad(loadData); const postLoadData = await this.posLoad(loadData);
return postLoadData; return postLoadData;
} }
@ -37,13 +43,9 @@ export default class RetrieveMetadataLoader {
async runLoaders(loaders) { async runLoaders(loaders) {
let result; let result;
for (const loader of loaders) { for (const loader of loaders) {
try { result = await loader();
result = await loader(); if (result && result.length) {
if (result && result.length) { break; // closes iterator in case data is retrieved successfully
break; // closes iterator in case data is retrieved successfully
}
} catch (e) {
throw e;
} }
} }

View File

@ -1,7 +1,58 @@
import dcmjs from 'dcmjs'; import dcmjs from 'dcmjs';
import { sortStudySeries, sortingCriteria } from '@ohif/core/src/utils/sortStudy'; import { sortStudySeries } from '@ohif/core/src/utils/sortStudy';
import RetrieveMetadataLoader from './retrieveMetadataLoader'; import RetrieveMetadataLoader from './retrieveMetadataLoader';
// Series Date, Series Time, Series Description and Series Number to be included
// in the series metadata query result
const includeField = ['00080021', '00080031', '0008103E', '00200011'].join(',');
export class DeferredPromise {
metadata = undefined;
processFunction = undefined;
internalPromise = undefined;
thenFunction = undefined;
rejectFunction = undefined;
setMetadata(metadata) {
this.metadata = metadata;
}
setProcessFunction(func) {
this.processFunction = func;
}
getPromise() {
return this.start();
}
start() {
if (this.internalPromise) {
return this.internalPromise;
}
this.internalPromise = this.processFunction();
// in case then and reject functions called before start
if (this.thenFunction) {
this.then(this.thenFunction);
this.thenFunction = undefined;
}
if (this.rejectFunction) {
this.reject(this.rejectFunction);
this.rejectFunction = undefined;
}
return this.internalPromise;
}
then(func) {
if (this.internalPromise) {
return this.internalPromise.then(func);
} else {
this.thenFunction = func;
}
}
reject(func) {
if (this.internalPromise) {
return this.internalPromise.reject(func);
} else {
this.rejectFunction = func;
}
}
}
/** /**
* Creates an immutable series loader object which loads each series sequentially using the iterator interface. * Creates an immutable series loader object which loads each series sequentially using the iterator interface.
* *
@ -16,12 +67,17 @@ function makeSeriesAsyncLoader(client, studyInstanceUID, seriesInstanceUIDList)
hasNext() { hasNext() {
return seriesInstanceUIDList.length > 0; return seriesInstanceUIDList.length > 0;
}, },
async next() { next() {
const seriesInstanceUID = seriesInstanceUIDList.shift(); const { seriesInstanceUID, metadata } = seriesInstanceUIDList.shift();
return client.retrieveSeriesMetadata({ const promise = new DeferredPromise();
studyInstanceUID, promise.setMetadata(metadata);
seriesInstanceUID, promise.setProcessFunction(() => {
return client.retrieveSeriesMetadata({
studyInstanceUID,
seriesInstanceUID,
});
}); });
return promise;
}, },
}); });
} }
@ -40,15 +96,22 @@ export default class RetrieveMetadataLoaderAsync extends RetrieveMetadataLoader
const preLoaders = []; const preLoaders = [];
const { studyInstanceUID, filters: { seriesInstanceUID } = {}, client } = this; const { studyInstanceUID, filters: { seriesInstanceUID } = {}, client } = this;
// asking to include Series Date, Series Time, Series Description
// and Series Number in the series metadata returned to better sort series
// in preLoad function
let options = {
studyInstanceUID,
queryParams: {
includefield: includeField,
},
};
if (seriesInstanceUID) { if (seriesInstanceUID) {
const options = { options.queryParams.SeriesInstanceUID = seriesInstanceUID;
studyInstanceUID,
queryParams: { SeriesInstanceUID: seriesInstanceUID },
};
preLoaders.push(client.searchForSeries.bind(client, options)); preLoaders.push(client.searchForSeries.bind(client, options));
} }
// Fallback preloader // Fallback preloader
preLoaders.push(client.searchForSeries.bind(client, { studyInstanceUID })); preLoaders.push(client.searchForSeries.bind(client, options));
yield* preLoaders; yield* preLoaders;
} }
@ -62,24 +125,23 @@ export default class RetrieveMetadataLoaderAsync extends RetrieveMetadataLoader
const { naturalizeDataset } = dcmjs.data.DicomMetaDictionary; const { naturalizeDataset } = dcmjs.data.DicomMetaDictionary;
const naturalized = result.map(naturalizeDataset); const naturalized = result.map(naturalizeDataset);
return sortStudySeries( return sortStudySeries(naturalized, sortCriteria, sortFunction);
naturalized,
sortCriteria || sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria,
sortFunction
);
} }
async load(preLoadData) { async load(preLoadData) {
const { client, studyInstanceUID } = this; const { client, studyInstanceUID } = this;
const seriesInstanceUIDs = preLoadData.map(s => s.SeriesInstanceUID); const seriesInstanceUIDs = preLoadData.map(seriesMetadata => {
return { seriesInstanceUID: seriesMetadata.SeriesInstanceUID, metadata: seriesMetadata };
});
const seriesAsyncLoader = makeSeriesAsyncLoader(client, studyInstanceUID, seriesInstanceUIDs); const seriesAsyncLoader = makeSeriesAsyncLoader(client, studyInstanceUID, seriesInstanceUIDs);
const promises = []; const promises = [];
while (seriesAsyncLoader.hasNext()) { while (seriesAsyncLoader.hasNext()) {
promises.push(seriesAsyncLoader.next()); const promise = seriesAsyncLoader.next();
promises.push(promise);
} }
return { return {

View File

@ -14,6 +14,7 @@ const defaultProtocol = {
editableBy: {}, editableBy: {},
protocolMatchingRules: [], protocolMatchingRules: [],
toolGroupIds: ['default'], toolGroupIds: ['default'],
hpInitiationCriteria: { minSeriesLoaded: 1 },
// -1 would be used to indicate active only, whereas other values are // -1 would be used to indicate active only, whereas other values are
// the number of required priors referenced - so 0 means active with // the number of required priors referenced - so 0 means active with
// 0 or more priors. // 0 or more priors.

View File

@ -635,4 +635,4 @@ function _findTabAndStudyOfDisplaySet(displaySetInstanceUID, tabs) {
} }
} }
} }
} }

View File

@ -13,7 +13,7 @@ import { history } from '../../utils/history';
import loadModules from '../../pluginImports'; import loadModules from '../../pluginImports';
import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed'; import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed';
const { getSplitParam } = utils; const { getSplitParam, sortingCriteria } = utils;
const { TimingEnum } = Types; const { TimingEnum } = Types;
/** /**
@ -26,11 +26,33 @@ const { TimingEnum } = Types;
* @returns array of subscriptions to cancel * @returns array of subscriptions to cancel
*/ */
function defaultRouteInit( function defaultRouteInit(
{ servicesManager, studyInstanceUIDs, dataSource, filters }, { servicesManager, studyInstanceUIDs, dataSource, filters, appConfig },
hangingProtocolId hangingProtocolId
) { ) {
const { displaySetService, hangingProtocolService, uiNotificationService } = const { displaySetService, hangingProtocolService, uiNotificationService, customizationService } =
servicesManager.services; servicesManager.services;
/**
* Function to apply the hanging protocol when the minimum number of display sets were
* received or all display sets retrieval were completed
* @returns
*/
function applyHangingProtocol() {
const displaySets = displaySetService.getActiveDisplaySets();
if (!displaySets || !displaySets.length) {
return;
}
// Gets the studies list to use
const studies = getStudies(studyInstanceUIDs, displaySets);
// study being displayed, and is thus the "active" study.
const activeStudy = studies[0];
// run the hanging protocol matching on the displaySets with the predefined
// hanging protocol in the mode configuration
hangingProtocolService.run({ studies, activeStudy, displaySets }, hangingProtocolId);
}
const unsubscriptions = []; const unsubscriptions = [];
const issuedWarningSeries = []; const issuedWarningSeries = [];
@ -55,6 +77,7 @@ function defaultRouteInit(
duration: 7000, duration: 7000,
}); });
} }
displaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient); displaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient);
} }
); );
@ -63,10 +86,15 @@ function defaultRouteInit(
log.time(TimingEnum.STUDY_TO_DISPLAY_SETS); log.time(TimingEnum.STUDY_TO_DISPLAY_SETS);
log.time(TimingEnum.STUDY_TO_FIRST_IMAGE); log.time(TimingEnum.STUDY_TO_FIRST_IMAGE);
const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID => const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID =>
dataSource.retrieve.series.metadata({ dataSource.retrieve.series.metadata({
StudyInstanceUID, StudyInstanceUID,
filters, filters,
returnPromises: true,
sortCriteria:
customizationService.get('sortingCriteria') ||
sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria,
}) })
); );
@ -77,31 +105,34 @@ function defaultRouteInit(
}); });
}); });
// The hanging protocol matching service is fairly expensive to run multiple Promise.allSettled(allRetrieves).then(promises => {
// times, and doesn't allow partial matches to be made (it will simply fail
// to display anything if a required match fails), so we wait here until all metadata
// is retrieved (which will synchronously trigger the display set creation)
// until we run the hanging protocol matching service.
Promise.allSettled(allRetrieves).then(() => {
log.timeEnd(TimingEnum.STUDY_TO_DISPLAY_SETS); log.timeEnd(TimingEnum.STUDY_TO_DISPLAY_SETS);
log.time(TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE); log.time(TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE);
log.time(TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES); log.time(TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES);
const displaySets = displaySetService.getActiveDisplaySets();
if (!displaySets || !displaySets.length) { const allPromises = [];
return; const remainingPromises = [];
function startRemainingPromises(remainingPromises) {
remainingPromises.forEach(p => p.forEach(p => p.start()));
} }
// Gets the studies list to use promises.forEach(promise => {
const studies = getStudies(studyInstanceUIDs, displaySets); const retrieveSeriesMetadataPromise = promise.value;
if (Array.isArray(retrieveSeriesMetadataPromise)) {
const { requiredSeries, remaining } = hangingProtocolService.filterSeriesRequiredForRun(
hangingProtocolId,
retrieveSeriesMetadataPromise
);
const requiredSeriesPromises = requiredSeries.map(promise => promise.start());
allPromises.push(Promise.allSettled(requiredSeriesPromises));
remainingPromises.push(remaining);
}
});
// study being displayed, and is thus the "active" study. Promise.allSettled(allPromises).then(applyHangingProtocol);
const activeStudy = studies[0]; startRemainingPromises(remainingPromises);
applyHangingProtocol();
// run the hanging protocol matching on the displaySets with the predefined
// hanging protocol in the mode configuration
hangingProtocolService.run({ studies, activeStudy, displaySets }, hangingProtocolId);
}); });
return unsubscriptions; return unsubscriptions;
@ -401,6 +432,7 @@ export default function ModeRoute({
studyInstanceUIDs, studyInstanceUIDs,
dataSource, dataSource,
filters, filters,
appConfig,
}, },
hangingProtocolIdToUse hangingProtocolIdToUse
); );
@ -482,4 +514,5 @@ ModeRoute.propTypes = {
extensionManager: PropTypes.object, extensionManager: PropTypes.object,
servicesManager: PropTypes.object, servicesManager: PropTypes.object,
hotkeysManager: PropTypes.object, hotkeysManager: PropTypes.object,
commandsManager: PropTypes.object,
}; };

View File

@ -214,6 +214,32 @@ export default class HangingProtocolService extends PubSubService {
}; };
} }
/**
* Filters the series required for running a hanging protocol.
*
* This can be extended in the future with more complex selection rules e.g.
* N series of a given type, and M of a different type, such as all CT series,
* and all SR, and then everything else.
*
* @param protocolId - The ID of the hanging protocol.
* @param seriesPromises - An array of promises representing the series.
* @returns An object containing the required series and the remaining series.
*/
public filterSeriesRequiredForRun(protocolId, seriesPromises) {
if (Array.isArray(protocolId)) {
protocolId = protocolId[0];
}
const minSeriesLoadedToRunHP =
this.getProtocolById(protocolId)?.hpInitiationCriteria?.minSeriesLoaded ||
seriesPromises.length;
const requiredSeries = seriesPromises.slice(0, minSeriesLoadedToRunHP);
const remaining = seriesPromises.slice(minSeriesLoadedToRunHP);
return {
requiredSeries,
remaining,
};
}
/** Gets the protocol with id 'default' */ /** Gets the protocol with id 'default' */
public getDefaultProtocol(): HangingProtocol.Protocol { public getDefaultProtocol(): HangingProtocol.Protocol {
return this.getProtocolById('default'); return this.getProtocolById('default');
@ -543,8 +569,8 @@ export default class HangingProtocolService extends PubSubService {
viewportId: existingViewportId viewportId: existingViewportId
? existingViewportId ? existingViewportId
: index === 0 : index === 0
? 'default' ? 'default'
: uuidv4(), : uuidv4(),
}, },
displaySets: viewport.displaySets || [], displaySets: viewport.displaySets || [],
}; };

View File

@ -293,6 +293,17 @@ export type Protocol = {
*/ */
numberOfPriorsReferenced?: number; numberOfPriorsReferenced?: number;
syncDataForViewports?: boolean; syncDataForViewports?: boolean;
/**
* Set of minimal conditions necessary to run the hanging protocol.
*/
hpInitiationCriteria?: {
/* If configured, sets the minimum number of series needed to run the hanging
* protocol and start displaying images. Used when OHIF needs to handle studies
* with several series and it is required that the first image should be loaded
* faster.
*/
minSeriesLoaded: number;
};
}; };
/** Used to dynamically generate protocols. /** Used to dynamically generate protocols.