diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index a0748d86471..23c5329c40f 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -28,17 +28,35 @@ const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1'; const metadataProvider = classes.MetadataProvider; /** + * Creates a DICOM Web API based on the provided configuration. * - * @param {string} name - Data source name - * @param {string} wadoUriRoot - Legacy? (potentially unused/replaced) - * @param {string} qidoRoot - Base URL to use for QIDO requests - * @param {string} wadoRoot - Base URL to use for WADO requests - * @param {boolean} qidoSupportsIncludeField - Whether QIDO supports the "Include" option to request additional fields in response - * @param {string} imageRengering - wadors | ? (unsure of where/how this is used) - * @param {string} thumbnailRendering - wadors | ? (unsure of where/how this is used) - * @param {bool} supportsReject - Whether the server supports reject calls (i.e. DCM4CHEE) - * @param {bool} lazyLoadStudy - "enableStudyLazyLoad"; Request series meta async instead of blocking - * @param {string|bool} singlepart - indicates of the retrieves can fetch singlepart. Options are bulkdata, video, image or boolean true + * @param {object} dicomWebConfig - Configuration for the DICOM Web API + * @param {string} dicomWebConfig.name - Data source name + * @param {string} dicomWebConfig.wadoUriRoot - Legacy? (potentially unused/replaced) + * @param {string} dicomWebConfig.qidoRoot - Base URL to use for QIDO requests + * @param {string} dicomWebConfig.wadoRoot - Base URL to use for WADO requests + * @param {string} dicomWebConfig.wadoUri - Base URL to use for WADO URI requests + * @param {boolean} dicomWebConfig.qidoSupportsIncludeField - Whether QIDO supports the "Include" option to request additional fields in response + * @param {string} dicomWebConfig.imageRendering - wadors | ? (unsure of where/how this is used) + * @param {string} dicomWebConfig.thumbnailRendering - wadors | ? (unsure of where/how this is used) + * @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) { const { userAuthenticationService, customizationService } = servicesManager.services; @@ -191,6 +209,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { sortCriteria, sortFunction, madeInClient = false, + returnPromises = false, } = {}) => { if (!StudyInstanceUID) { throw new Error('Unable to query for SeriesMetadata without StudyInstanceUID'); @@ -202,7 +221,8 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { filters, sortCriteria, sortFunction, - madeInClient + madeInClient, + returnPromises ); } @@ -336,7 +356,8 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { filters, sortCriteria, sortFunction, - madeInClient = false + madeInClient = false, + returnPromises = false ) => { const enableStudyLazyLoad = true; wadoDicomWebClient.headers = generateWadoHeader(); @@ -416,7 +437,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { const naturalizedInstances = instances.map(addRetrieveBulkData); // Adding instanceMetadata to OHIF MetadataProvider - naturalizedInstances.forEach((instance, index) => { + naturalizedInstances.forEach(instance => { instance.wadoRoot = dicomWebConfig.wadoRoot; instance.wadoUri = dicomWebConfig.wadoUri; @@ -443,7 +464,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { } function setSuccessFlag() { - const study = DicomMetadataStore.getStudy(StudyInstanceUID, madeInClient); + const study = DicomMetadataStore.getStudy(StudyInstanceUID); if (!study) { return; } @@ -458,13 +479,22 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient); - const seriesDeliveredPromises = seriesPromises.map(promise => - promise.then(instances => { + const seriesDeliveredPromises = seriesPromises.map(promise => { + if (!returnPromises) { + promise?.start(); + } + return promise.then(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; }, @@ -496,7 +526,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { return imageIds; }, - getImageIdsForInstance({ instance, frame }) { + getImageIdsForInstance({ instance, frame = undefined }) { const imageIds = getImageId({ instance, frame, diff --git a/extensions/default/src/DicomWebDataSource/utils/StaticWadoClient.ts b/extensions/default/src/DicomWebDataSource/utils/StaticWadoClient.ts index 1ba2aad9069..ed2815c6046 100644 --- a/extensions/default/src/DicomWebDataSource/utils/StaticWadoClient.ts +++ b/extensions/default/src/DicomWebDataSource/utils/StaticWadoClient.ts @@ -7,6 +7,7 @@ import { api } from 'dicomweb-client'; * performing searches doesn't work. This version fixes the query issue * by manually implementing a query option. */ + export default class StaticWadoClient extends api.DICOMwebClient { static studyFilterKeys = { studyinstanceuid: '0020000D', diff --git a/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoader.js b/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoader.js index da747c88bbe..e0143000cd0 100644 --- a/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoader.js +++ b/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoader.js @@ -11,10 +11,17 @@ export default class RetrieveMetadataLoader { * @param {Object} client The dicomweb-client. * @param {Array} studyInstanceUID Study instance ui to be retrieved * @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 {Function} [sortSeries] - Custom sort function for series + * @param {string} [filters.seriesInstanceUID] - series instance uid to filter results against + * @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.studyInstanceUID = studyInstanceUID; this.filters = filters; @@ -26,7 +33,6 @@ export default class RetrieveMetadataLoader { const preLoadData = await this.preLoad(); const loadData = await this.load(preLoadData); const postLoadData = await this.posLoad(loadData); - return postLoadData; } @@ -37,13 +43,9 @@ export default class RetrieveMetadataLoader { async runLoaders(loaders) { let result; for (const loader of loaders) { - try { - result = await loader(); - if (result && result.length) { - break; // closes iterator in case data is retrieved successfully - } - } catch (e) { - throw e; + result = await loader(); + if (result && result.length) { + break; // closes iterator in case data is retrieved successfully } } diff --git a/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoaderAsync.js b/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoaderAsync.js index ffaa83418f9..0c9a656039f 100644 --- a/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoaderAsync.js +++ b/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoaderAsync.js @@ -1,7 +1,58 @@ 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'; +// 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. * @@ -16,12 +67,17 @@ function makeSeriesAsyncLoader(client, studyInstanceUID, seriesInstanceUIDList) hasNext() { return seriesInstanceUIDList.length > 0; }, - async next() { - const seriesInstanceUID = seriesInstanceUIDList.shift(); - return client.retrieveSeriesMetadata({ - studyInstanceUID, - seriesInstanceUID, + next() { + const { seriesInstanceUID, metadata } = seriesInstanceUIDList.shift(); + const promise = new DeferredPromise(); + promise.setMetadata(metadata); + promise.setProcessFunction(() => { + return client.retrieveSeriesMetadata({ + studyInstanceUID, + seriesInstanceUID, + }); }); + return promise; }, }); } @@ -40,15 +96,22 @@ export default class RetrieveMetadataLoaderAsync extends RetrieveMetadataLoader const preLoaders = []; 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) { - const options = { - studyInstanceUID, - queryParams: { SeriesInstanceUID: seriesInstanceUID }, - }; + options.queryParams.SeriesInstanceUID = seriesInstanceUID; preLoaders.push(client.searchForSeries.bind(client, options)); } // Fallback preloader - preLoaders.push(client.searchForSeries.bind(client, { studyInstanceUID })); + preLoaders.push(client.searchForSeries.bind(client, options)); yield* preLoaders; } @@ -62,24 +125,23 @@ export default class RetrieveMetadataLoaderAsync extends RetrieveMetadataLoader const { naturalizeDataset } = dcmjs.data.DicomMetaDictionary; const naturalized = result.map(naturalizeDataset); - return sortStudySeries( - naturalized, - sortCriteria || sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria, - sortFunction - ); + return sortStudySeries(naturalized, sortCriteria, sortFunction); } async load(preLoadData) { 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 promises = []; while (seriesAsyncLoader.hasNext()) { - promises.push(seriesAsyncLoader.next()); + const promise = seriesAsyncLoader.next(); + promises.push(promise); } return { diff --git a/extensions/default/src/getHangingProtocolModule.js b/extensions/default/src/getHangingProtocolModule.js index 596e3031d07..f9ad9bbbb31 100644 --- a/extensions/default/src/getHangingProtocolModule.js +++ b/extensions/default/src/getHangingProtocolModule.js @@ -14,6 +14,7 @@ const defaultProtocol = { editableBy: {}, protocolMatchingRules: [], toolGroupIds: ['default'], + hpInitiationCriteria: { minSeriesLoaded: 1 }, // -1 would be used to indicate active only, whereas other values are // the number of required priors referenced - so 0 means active with // 0 or more priors. diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index 98bc050c36b..f9715783e66 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -635,4 +635,4 @@ function _findTabAndStudyOfDisplaySet(displaySetInstanceUID, tabs) { } } } -} +} \ No newline at end of file diff --git a/platform/app/src/routes/Mode/Mode.tsx b/platform/app/src/routes/Mode/Mode.tsx index cb53db00c34..22840324639 100644 --- a/platform/app/src/routes/Mode/Mode.tsx +++ b/platform/app/src/routes/Mode/Mode.tsx @@ -13,7 +13,7 @@ import { history } from '../../utils/history'; import loadModules from '../../pluginImports'; import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed'; -const { getSplitParam } = utils; +const { getSplitParam, sortingCriteria } = utils; const { TimingEnum } = Types; /** @@ -26,11 +26,33 @@ const { TimingEnum } = Types; * @returns array of subscriptions to cancel */ function defaultRouteInit( - { servicesManager, studyInstanceUIDs, dataSource, filters }, + { servicesManager, studyInstanceUIDs, dataSource, filters, appConfig }, hangingProtocolId ) { - const { displaySetService, hangingProtocolService, uiNotificationService } = + const { displaySetService, hangingProtocolService, uiNotificationService, customizationService } = 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 issuedWarningSeries = []; @@ -55,6 +77,7 @@ function defaultRouteInit( duration: 7000, }); } + displaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient); } ); @@ -63,10 +86,15 @@ function defaultRouteInit( log.time(TimingEnum.STUDY_TO_DISPLAY_SETS); log.time(TimingEnum.STUDY_TO_FIRST_IMAGE); + const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID => dataSource.retrieve.series.metadata({ StudyInstanceUID, 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 - // 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(() => { + Promise.allSettled(allRetrieves).then(promises => { log.timeEnd(TimingEnum.STUDY_TO_DISPLAY_SETS); log.time(TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE); log.time(TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES); - const displaySets = displaySetService.getActiveDisplaySets(); - if (!displaySets || !displaySets.length) { - return; - } + const allPromises = []; + const remainingPromises = []; - // Gets the studies list to use - const studies = getStudies(studyInstanceUIDs, displaySets); + function startRemainingPromises(remainingPromises) { + remainingPromises.forEach(p => p.forEach(p => p.start())); + } - // study being displayed, and is thus the "active" study. - const activeStudy = studies[0]; + promises.forEach(promise => { + 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); + } + }); - // run the hanging protocol matching on the displaySets with the predefined - // hanging protocol in the mode configuration - hangingProtocolService.run({ studies, activeStudy, displaySets }, hangingProtocolId); + Promise.allSettled(allPromises).then(applyHangingProtocol); + startRemainingPromises(remainingPromises); + applyHangingProtocol(); }); return unsubscriptions; @@ -401,6 +432,7 @@ export default function ModeRoute({ studyInstanceUIDs, dataSource, filters, + appConfig, }, hangingProtocolIdToUse ); @@ -482,4 +514,5 @@ ModeRoute.propTypes = { extensionManager: PropTypes.object, servicesManager: PropTypes.object, hotkeysManager: PropTypes.object, + commandsManager: PropTypes.object, }; diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index c2c987c31ea..7c20251a348 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -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' */ public getDefaultProtocol(): HangingProtocol.Protocol { return this.getProtocolById('default'); @@ -543,8 +569,8 @@ export default class HangingProtocolService extends PubSubService { viewportId: existingViewportId ? existingViewportId : index === 0 - ? 'default' - : uuidv4(), + ? 'default' + : uuidv4(), }, displaySets: viewport.displaySets || [], }; diff --git a/platform/core/src/types/HangingProtocol.ts b/platform/core/src/types/HangingProtocol.ts index 8059b5675c8..0e23b1d2e4a 100644 --- a/platform/core/src/types/HangingProtocol.ts +++ b/platform/core/src/types/HangingProtocol.ts @@ -293,6 +293,17 @@ export type Protocol = { */ numberOfPriorsReferenced?: number; 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.