diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index dde1b7e2232..c9b3128052f 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -168,6 +168,7 @@ export default async function init({ interaction: appConfig?.maxNumRequests?.interaction || 100, thumbnail: appConfig?.maxNumRequests?.thumbnail || 75, prefetch: appConfig?.maxNumRequests?.prefetch || 10, + precache: appConfig?.maxNumRequests?.precache || 10, }; initWADOImageLoader(userAuthenticationService, appConfig, extensionManager); @@ -253,10 +254,10 @@ export default async function init({ /** * Runs error handler for failed requests. - * @param event + * @param event */ const imageLoadFailedHandler = ({ detail }) => { - const handler = errorHandler.getHTTPErrorHandler() + const handler = errorHandler.getHTTPErrorHandler(); handler(detail.error); }; @@ -290,7 +291,7 @@ export default async function init({ }); eventTarget.addEventListener(EVENTS.IMAGE_LOAD_FAILED, imageLoadFailedHandler); eventTarget.addEventListener(EVENTS.IMAGE_LOAD_ERROR, imageLoadFailedHandler); - + function elementEnabledHandler(evt) { const { element } = evt.detail; diff --git a/extensions/default/package.json b/extensions/default/package.json index acc85fe739c..a94d165427f 100644 --- a/extensions/default/package.json +++ b/extensions/default/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/calculate-suv": "^1.1.0" + "@cornerstonejs/calculate-suv": "^1.1.0", + "cod-dicomweb-server": "^1.3.4" } } diff --git a/extensions/default/src/DicomWebDataSource/codDicomWebServerWrapper.js b/extensions/default/src/DicomWebDataSource/codDicomWebServerWrapper.js new file mode 100644 index 00000000000..8a6ad6bfe3b --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/codDicomWebServerWrapper.js @@ -0,0 +1,235 @@ +import { CodDicomWebServer } from 'cod-dicomweb-server'; + +const Properties = { + StudyUID: '0020000D', + SeriesUID: '0020000E', +}; + +class CodDicomWebServerClient { + /** + * @param {Object} config + */ + constructor(config) { + this.baseURL = config.url; + this.qidoURL = this.baseURL; + this.wadoURL = this.baseURL; + this.config = config; + this.headers = config.headers; + this.errorInterceptor = config.errorInterceptor; + + this._codServer = new CodDicomWebServer({ domain: parseDomainFromBaseURL(this.baseURL) }); + this.deidStudyInstanceUIDMap = new Map(); // Map of study instance UIDs to deid study instance UIDs + } + + /** + * @param {URLSearchParams} queryParams + */ + async fetchStudiesMetadata(queryParams) { + this._studiesMetadata = await this.filesFromStudyInstanceUID({ + wadoURL: this.wadoURL, + bucketName: queryParams.get('bucket'), + prefix: queryParams.get('bucket-prefix'), + studyuids: queryParams.getAll('StudyInstanceUIDs'), + headers: this.headers, + }) + .then(studies => { + return studies.filter(study => { + study.series = study.series.filter(aSeries => { + if (aSeries.instances.length) { + return true; + } + + console.warn('No instance found in series ' + aSeries.deidSeriesInstanceUID); + return false; + }); + + if (study.series.length) { + const studyUID = study.series[0].instances[0]['0020000D'].Value[0]; + this.deidStudyInstanceUIDMap.set(study.deidStudyInstanceUID, studyUID); + return true; + } + + return false; + }); + }) + .catch(error => { + this.errorInterceptor(error); + return []; + }); + } + + /** + * @param {string} deidStudyInstanceUID + */ + getStudyUIDForDeidStudyUID(deidStudyInstanceUID) { + const studyWithDeidStudyUID = this._studiesMetadata.find( + study => (study.deidStudyInstanceUID = deidStudyInstanceUID) + ); + + return this._getProperty(studyWithDeidStudyUID, Properties.StudyUID); + } + + /** + * @param {Object} data + * @param {string} property + */ + _getProperty(data, property) { + if (!data) { + return; + } + + return ( + data[property]?.Value[0] || + data.instances?.[0][property].Value[0] || + data.series?.[0].instances[0][property].Value[0] + ); + } + + /** + * @param {Object[]} studies + * @param {string} studyInstanceUID + */ + _findStudy(studies = [], studyInstanceUID) { + return studies.find( + study => this._getProperty(study, Properties.StudyUID) === studyInstanceUID + ); + } + + /** + * @param {Object[]} series + * @param {string} seriesInstanceUID + */ + _findSeries(series = [], seriesInstanceUID) { + return series.find( + series => this._getProperty(series, Properties.SeriesUID) === seriesInstanceUID + ); + } + + /** + * @param {Object} options + * @param {string} options.studyInstanceUID + * @param {string} options.seriesInstanceUID + */ + retrieveSeriesMetadata({ studyInstanceUID, seriesInstanceUID }) { + const studyFound = this._findStudy(this._studiesMetadata, studyInstanceUID); + const seriesFound = this._findSeries(studyFound?.series, seriesInstanceUID); + + return new Promise((resolve, reject) => { + if (seriesFound) { + resolve(seriesFound.instances); + } else { + reject(); + } + }); + } + + /** + * @param {Object} options + * @param {string} options.studyInstanceUID + */ + retrieveStudyMetadata({ studyInstanceUID }) { + const studyFound = this._findStudy(this._studiesMetadata, studyInstanceUID); + + return new Promise((resolve, reject) => { + if (studyFound) { + resolve(studyFound.series.flatMap(aSeries => aSeries.instances)); + } else { + reject(); + } + }); + } + + /** + * @param {Object} options + * @param {string} options.studyInstanceUID + */ + searchForSeries({ studyInstanceUID }) { + const studyFound = this._findStudy(this._studiesMetadata, studyInstanceUID); + + return new Promise(resolve => { + if (studyFound) { + resolve(studyFound.series.flatMap(aSeries => aSeries.instances)); + } else { + resolve([]); + } + }); + } + + /** + * @param {Object} options + * @param {Object} options.queryParams + * @param {string} options.queryParams.StudyInstanceUID + */ + searchForStudies({ queryParams }) { + const studyFound = this._studiesMetadata.find(study => { + if (this._getProperty(study, Properties.StudyUID) === queryParams.StudyInstanceUID) { + return true; + } + + const tagFound = Object.entries(queryParams).find( + ([tag, value]) => this._getProperty(study, tag) === value + ); + if (tagFound) { + return true; + } + + return false; + }); + + return new Promise(resolve => { + if (studyFound) { + resolve([studyFound.series[0].instances[0]]); + } else { + resolve([]); + } + }); + } + + /** + * @param {Object} params + * @param {string} params.wadoURL + * @param {string} params.bucketName + * @param {string[]} params.studyuids + * @param {string} params.prefix + * @param {Object} params.headers + */ + async filesFromStudyInstanceUID({ wadoURL, bucketName, prefix, studyuids, headers }) { + const delimiter = '/'; + const domain = parseDomainFromBaseURL(wadoURL); + const bucketComponents = wadoURL.split(domain + delimiter)[1].split(delimiter); + const bucket = bucketName || bucketComponents[0]; + const bucketPrefix = prefix || bucketComponents.slice(1).join(delimiter) || 'dicomweb'; + const urlRoot = `${domain}/${bucket}/${bucketPrefix}`; + + const studyMetadata = studyuids.map(async (/** @type {string} */ deidStudyInstanceuid) => { + const folderPath = `${bucketPrefix}/studies/${deidStudyInstanceuid}/series/`; + const apiUrl = `${domain}/storage/v1/b/${bucket}/o?prefix=${folderPath}&delimiter=${delimiter}`; + const response = await fetch(apiUrl, { headers }); + const res = await response.json(); + const folders = res.prefixes || []; + const series = folders.map(async (/** @type {string} */ folderPath) => { + const deidSeriesInstanceUID = folderPath.split('/series/')[1].split(delimiter)[0]; + const wadoUrl = `${urlRoot}/studies/${deidStudyInstanceuid}/series/${deidSeriesInstanceUID}/metadata`; + return { + deidSeriesInstanceUID, + instances: await this._codServer.fetchCod(wadoUrl, headers), + }; + }); + return Promise.all(series).then(result => ({ + deidStudyInstanceUID: deidStudyInstanceuid, + series: result, + })); + }); + return await Promise.all(studyMetadata); + } +} + +/** + * @param {string} baseRoot + */ +function parseDomainFromBaseURL(baseRoot) { + const [firstPart, secondPart] = baseRoot.split('://'); + return `${firstPart}://${secondPart.split('/')[0]}`; +} + +export default CodDicomWebServerClient; diff --git a/extensions/default/src/DicomWebDataSource/getCodImageId.js b/extensions/default/src/DicomWebDataSource/getCodImageId.js new file mode 100644 index 00000000000..5cf1eaf8c00 --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/getCodImageId.js @@ -0,0 +1,25 @@ +import getWADORSImageId from './utils/getWADORSImageId'; + +/** + * @param {Object} params + * @param {Object} params.instance + * @param {number} [params.frame] + * @param {Object} params.config + */ +export default function getCodImageId({ instance, frame, config }) { + if (!instance) { + return; + } + + let wadoRsImageId; + + if (instance.imageId && frame === undefined) { + wadoRsImageId = instance.imageId; + } else if (instance.url) { + wadoRsImageId = instance.url; + } else { + wadoRsImageId = getWADORSImageId(instance, config, frame); + } + + return wadoRsImageId.replace('wadors:', 'cod:'); +} diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index 2ae44dc7e69..310fe891d23 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -16,6 +16,8 @@ import { retrieveStudyMetadata, deleteStudyMetadataPromise } from './retrieveStu import StaticWadoClient from './utils/StaticWadoClient'; import getDirectURL from '../utils/getDirectURL'; import { fixBulkDataURI } from './utils/fixBulkDataURI'; +import CodDicomWebServerClient from './codDicomWebServerWrapper'; +import getCodImageId from './getCodImageId'; const { DicomMetaDictionary, DicomDict } = dcmjs.data; @@ -33,6 +35,7 @@ const metadataProvider = classes.MetadataProvider; * @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} useCod - Indicates the viewer should use the cod dicomweb server proxy client * @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) @@ -51,7 +54,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { generateWadoHeader; const implementation = { - initialize: ({ params, query }) => { + initialize: async ({ params, query }) => { if (dicomWebConfig.onConfiguration && typeof dicomWebConfig.onConfiguration === 'function') { dicomWebConfig = dicomWebConfig.onConfiguration(dicomWebConfig, { params, @@ -103,13 +106,22 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { // TODO -> Two clients sucks, but its better than 1000. // TODO -> We'll need to merge auth later. - qidoDicomWebClient = dicomWebConfig.staticWado - ? new StaticWadoClient(qidoConfig) - : new api.DICOMwebClient(qidoConfig); - - wadoDicomWebClient = dicomWebConfig.staticWado - ? new StaticWadoClient(wadoConfig) - : new api.DICOMwebClient(wadoConfig); + qidoDicomWebClient = dicomWebConfig.useCod + ? new CodDicomWebServerClient(qidoConfig) + : dicomWebConfig.staticWado + ? new StaticWadoClient(qidoConfig) + : new api.DICOMwebClient(qidoConfig); + + wadoDicomWebClient = dicomWebConfig.useCod + ? new CodDicomWebServerClient(wadoConfig) + : dicomWebConfig.staticWado + ? new StaticWadoClient(wadoConfig) + : new api.DICOMwebClient(wadoConfig); + + if (dicomWebConfig.useCod) { + await qidoDicomWebClient.fetchStudiesMetadata(query); + await wadoDicomWebClient.fetchStudiesMetadata(query); + } }, query: { studies: { @@ -411,27 +423,36 @@ 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; - const imageId = implementation.getImageIdsForInstance({ - instance, - }); + const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance; + + const numberOfFrames = instance.NumberOfFrames || 1; + // Process all frames consistently, whether single or multiframe + for (let i = 0; i < numberOfFrames; i++) { + const frameNumber = i + 1; + const frameImageId = implementation.getImageIdsForInstance({ + instance, + frame: frameNumber, + }); + // Add imageId specific mapping to this data as the URL isn't necessarily WADO-URI. + metadataProvider.addImageIdToUIDs(frameImageId, { + StudyInstanceUID, + SeriesInstanceUID, + SOPInstanceUID, + frameNumber: numberOfFrames > 1 ? frameNumber : undefined, + }); + } // Adding imageId to each instance // Todo: This is not the best way I can think of to let external // metadata handlers know about the imageId that is stored in the store - instance.imageId = imageId; - - // Adding UIDs to metadataProvider - // Note: storing imageURI in metadataProvider since stack viewports - // will use the same imageURI - metadataProvider.addImageIdToUIDs(imageId, { - StudyInstanceUID, - SeriesInstanceUID: instance.SeriesInstanceUID, - SOPInstanceUID: instance.SOPInstanceUID, + const imageId = implementation.getImageIdsForInstance({ + instance, }); + instance.imageId = imageId; }); DicomMetadataStore.addInstances(naturalizedInstances, madeInClient); @@ -492,7 +513,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { return imageIds; }, getImageIdsForInstance({ instance, frame }) { - const imageIds = getImageId({ + const imageIds = (dicomWebConfig.useCod ? getCodImageId : getImageId)({ instance, frame, config: dicomWebConfig, @@ -513,7 +534,12 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { ? StudyInstanceUIDs : [StudyInstanceUIDs]; - return StudyInstanceUIDsAsArray; + const studyUIDs = wadoDicomWebClient.getStudyUIDForDeidStudyUID + ? StudyInstanceUIDsAsArray.map(studyUID => + wadoDicomWebClient.getStudyUIDForDeidStudyUID(studyUID) + ) + : StudyInstanceUIDsAsArray; + return studyUIDs; }, }; diff --git a/platform/app/public/config/gradient.js b/platform/app/public/config/gradient.js index a4881fc5923..dfcc5df60a0 100644 --- a/platform/app/public/config/gradient.js +++ b/platform/app/public/config/gradient.js @@ -24,6 +24,7 @@ window.config = { // Prefetch number is dependent on the http protocol. For http 2 or // above, the number of requests can be go a lot higher. prefetch: 25, + precache: 25, }, oidc: [ { @@ -92,6 +93,152 @@ window.config = { requestTransferSyntaxUID: '*' } }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'cod-dicomweb', + configuration: { + friendlyName: 'Cloud Optimized wado proxy server', + name: 'cod', + qidoRoot: + 'https://storage.googleapis.com/gradienthealth_cod_dicomweb_public_benchmark/v1/dicomweb', + wadoRoot: + 'https://storage.googleapis.com/gradienthealth_cod_dicomweb_public_benchmark/v1/dicomweb', + useCod: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + staticWado: false, + bulkDataURI: { + enabled: false, + }, + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'thryothor-495511-pacs-deid/v1.0', + configuration: { + friendlyName: 'Cloud Optimized wado proxy server for Thryothor', + name: 'cod-thryothor', + qidoRoot: 'https://storage.googleapis.com/thryothor-495511-pacs-deid/v1.0/dicomweb', + wadoRoot: 'https://storage.googleapis.com/thryothor-495511-pacs-deid/v1.0/dicomweb', + useCod: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + staticWado: false, + bulkDataURI: { + enabled: false, + }, + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'deid-cod-peregrine', + configuration: { + friendlyName: 'Cloud Optimized wado proxy server for Peregrine', + name: 'cod-peregrine', + qidoRoot: 'https://storage.googleapis.com/peregrine-78707-pacs-deid/v1.0/dicomweb', + wadoRoot: 'https://storage.googleapis.com/peregrine-78707-pacs-deid/v1.0/dicomweb', + useCod: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + staticWado: false, + bulkDataURI: { + enabled: false, + }, + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'deid-cod-quelea', + configuration: { + friendlyName: 'Cloud Optimized wado proxy server for Quelea', + name: 'cod-quelea', + qidoRoot: 'https://storage.googleapis.com/quelea-19938-pacs-deid/v1.0/dicomweb', + wadoRoot: 'https://storage.googleapis.com/quelea-19938-pacs-deid/v1.0/dicomweb', + useCod: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + staticWado: false, + bulkDataURI: { + enabled: false, + }, + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'deid-cod-tachyeres', + configuration: { + friendlyName: 'Cloud Optimized wado proxy server for Tachyeres', + name: 'cod-tachyeres', + qidoRoot: 'https://storage.googleapis.com/tachyeres-387144-pacs-deid/v1.0/dicomweb', + wadoRoot: 'https://storage.googleapis.com/tachyeres-387144-pacs-deid/v1.0/dicomweb', + useCod: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + staticWado: false, + bulkDataURI: { + enabled: false, + }, + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'deid-cod-auritus', + configuration: { + friendlyName: 'Cloud Optimized wado proxy server for Auritus', + name: 'cod-auritus', + qidoRoot: 'https://storage.googleapis.com/auritus-681591-pacs-deid/v1.0/dicomweb', + wadoRoot: 'https://storage.googleapis.com/auritus-681591-pacs-deid/v1.0/dicomweb', + useCod: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + staticWado: false, + bulkDataURI: { + enabled: false, + }, + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'deid-cod-xenops', + configuration: { + friendlyName: 'Cloud Optimized wado proxy server for Xenops', + name: 'cod-xenops', + qidoRoot: 'https://storage.googleapis.com/xenops-995729-pacs-deid/v1.0/dicomweb', + wadoRoot: 'https://storage.googleapis.com/xenops-995729-pacs-deid/v1.0/dicomweb', + useCod: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + staticWado: false, + bulkDataURI: { + enabled: false, + }, + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'deid-cod-flava', + configuration: { + friendlyName: 'Cloud Optimized wado proxy server for Flava', + name: 'cod-flava', + qidoRoot: 'https://storage.googleapis.com/flava-141889-pacs-deid/v1.0/dicomweb', + wadoRoot: 'https://storage.googleapis.com/flava-141889-pacs-deid/v1.0/dicomweb', + useCod: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + staticWado: false, + bulkDataURI: { + enabled: false, + }, + }, + }, { friendlyName: 'dicom json', namespace: