From ee69a5a40b26ce9176f0b4429bfd2fe93a2cf394 Mon Sep 17 00:00:00 2001 From: Rodrigo Antinarelli Date: Thu, 7 May 2020 10:56:26 -0300 Subject: [PATCH] create dicom web api --- .../default/src/DicomWebDataSource/index.js | 46 +++++ .../default/src/DicomWebDataSource/qido.js | 180 ++++++++++++++++++ .../default/src/getDataSourcesModule.js | 27 +-- .../core/src/DataSources/IWebApiDataSource.js | 42 ++++ 4 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 extensions/default/src/DicomWebDataSource/index.js create mode 100644 extensions/default/src/DicomWebDataSource/qido.js create mode 100644 platform/core/src/DataSources/IWebApiDataSource.js diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js new file mode 100644 index 000000000..1d0621cc5 --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -0,0 +1,46 @@ +import { api } from 'dicomweb-client'; +import { mapParams, search, processResults } from './qido.js'; +import IWebApi from '@ohif/core'; + +/** + * + * @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} lazyLoadStudy - "enableStudyLazyLoad"; Request series meta async instead of blocking + */ +function createDicomWebApi(dicomWebConfig) { + const { qidoRoot } = dicomWebConfig; + const config = { + url: qidoRoot, + // headers: DICOMWeb.getAuthorizationHeader(server), + }; + + const dicomWebClient = new api.DICOMwebClient(config); + + return IWebApi({ + query: { + studies: { + mapParams: mapParams.bind(), + search: search.bind(undefined, dicomWebClient, null, null), + processResults: processResults.bind(), + }, + instances: { + search: (studyInstanceUid, queryParamaters) => + search.call( + undefined, + dicomWebClient, + studyInstanceUid, + null, + queryParamaters + ), + }, + }, + }); +} + +export { createDicomWebApi }; diff --git a/extensions/default/src/DicomWebDataSource/qido.js b/extensions/default/src/DicomWebDataSource/qido.js new file mode 100644 index 000000000..8de52caca --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/qido.js @@ -0,0 +1,180 @@ +/** + * QIDO - Query based on ID for DICOM Objects + * search for studies, series and instances by patient ID, and receive their + * unique identifiers for further usage. + * + * Quick: https://www.dicomstandard.org/dicomweb/query-qido-rs/ + * Standard: http://dicom.nema.org/medical/dicom/current/output/html/part18.html#sect_10.6 + * + * Routes: + * ========== + * /studies? + * /studies/{studyInstanceUid}/series? + * /studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances? + * + * Query Parameters: + * ================ + * | KEY | VALUE | + * |------------------|--------------------| + * | {attributeId} | {value} | + * | includeField | {attribute} or all | + * | fuzzymatching | true OR false | + * | limit | {number} | + * | offset | {number} | + */ +import DICOMWeb from '@ohif/core'; + +const { getString, getName, getModalities } = DICOMWeb; + +/** + * Parses resulting data from a QIDO call into a set of Study MetaData + * + * @param {Array} qidoStudies - An array of study objects. Each object contains a keys for DICOM tags. + * @param {object} qidoStudies[0].qidoStudy - An object where each key is the DICOM Tag group+element + * @param {object} qidoStudies[0].qidoStudy[dicomTag] - Optional object that represents DICOM Tag + * @param {string} qidoStudies[0].qidoStudy[dicomTag].vr - Value Representation + * @param {string[]} qidoStudies[0].qidoStudy[dicomTag].Value - Optional string array representation of the DICOM Tag's value + * @returns {Array} An array of Study MetaData objects + */ +function processResults(qidoStudies) { + if (!qidoStudies || !qidoStudies.length) { + return []; + } + + const studies = []; + + qidoStudies.forEach(qidoStudy => + studies.push({ + studyInstanceUid: getString(qidoStudy['0020000D']), + studyDate: getString(qidoStudy['00080020']), + studyTime: getString(qidoStudy['00080030']), + accessionNumber: getString(qidoStudy['00080050']), + referringPhysicianName: getString(qidoStudy['00080090']), + patientName: getName(qidoStudy['00100010']), + patientId: getString(qidoStudy['00100020']), + patientBirthdate: getString(qidoStudy['00100030']), + patientSex: getString(qidoStudy['00100040']), + studyId: getString(qidoStudy['00200010']), + numberOfStudyRelatedSeries: getString(qidoStudy['00201206']), + numberOfStudyRelatedInstances: getString(qidoStudy['00201208']), + studyDescription: getString(qidoStudy['00081030']), + modalities: getString( + getModalities(qidoStudy['00080060'], qidoStudy['00080061']) + ), + }) + ); + + return studies; +} + +/** + * + * @param {object} dicomWebClient - Client similar to what's provided by `dicomweb-client` library + * @param {function} dicomWebClient.searchForStudies - + * @param {string} [studyInstanceUid] + * @param {string} [seriesInstanceUid] + * @param {string} [queryParamaters] + * @returns {Promise} - Promise that resolves results + */ +function search( + dicomWebClient, + studyInstanceUid, + seriesInstanceUid, + queryParamaters +) { + const requestFn = studyInstanceUid + ? dicomWebClient.searchForInstances + : dicomWebClient.searchForStudies; + + // TODO: Current version does not apply query Params for `searchForInstances` call? + // Just sets `studyInstanceUid` in options... + return requestFn({ + studyInstanceUid, + queryParams: queryParamaters, + }); +} + +export default function searchStudies(server, filter) { + const queryParams = getQIDOQueryParams( + filter, + server.qidoSupportsIncludeField + ); + const options = { + queryParams, + }; + + return dicomWeb.searchForStudies(options).then(resultDataToStudies); +} + +/** + * Produces a QIDO URL given server details and a set of specified search filter + * items + * + * @param filter + * @param serverSupportsQIDOIncludeField + * @returns {string} The URL with encoded filter query data + */ +function mapParams(params) { + const commaSeparatedFields = [ + '00081030', // Study Description + '00080060', // Modality + // Add more fields here if you want them in the result + ].join(','); + + const parameters = { + PatientName: filter.patientName, + PatientID: filter.patientId, + AccessionNumber: filter.accessionNumber, + StudyDescription: filter.studyDescription, + ModalitiesInStudy: filter.modalitiesInStudy, + limit: filter.limit, + offset: filter.offset, + fuzzymatching: filter.fuzzymatching, + includefield: serverSupportsQIDOIncludeField ? commaSeparatedFields : 'all', + }; + + // build the StudyDate range parameter + if (filter.studyDateFrom || filter.studyDateTo) { + const dateFrom = _dateToString(new Date(filter.studyDateFrom)); + const dateTo = _dateToString(new Date(filter.studyDateTo)); + parameters.StudyDate = `${dateFrom}-${dateTo}`; + } + + // Build the StudyInstanceUID parameter + if (filter.studyInstanceUid) { + let studyUids = filter.studyInstanceUid; + studyUids = Array.isArray(studyUids) ? studyUids.join() : studyUids; + studyUids = studyUids.replace(/[^0-9.]+/g, '\\'); + parameters.StudyInstanceUID = studyUids; + } + + // Clean query params of undefined values. + // const params = {}; + Object.keys(parameters).forEach(key => { + if (parameters[key] !== undefined && parameters[key] !== '') { + params[key] = parameters[key]; + } + }); + + return params; +} + +/** + * Creates a QIDO date string for a date range query + * Assumes the year is positive, at most 4 digits long. + * + * @param date The Date object to be formatted + * @returns {string} The formatted date string + */ +function _dateToString(date) { + if (!date) return ''; + let year = date.getFullYear().toString(); + let month = (date.getMonth() + 1).toString(); + let day = date.getDate().toString(); + year = '0'.repeat(4 - year.length).concat(year); + month = '0'.repeat(2 - month.length).concat(month); + day = '0'.repeat(2 - day.length).concat(day); + return ''.concat(year, month, day); +} + +export { mapParams, search, processResults }; diff --git a/extensions/default/src/getDataSourcesModule.js b/extensions/default/src/getDataSourcesModule.js index 84e7c9f81..b74d2baf0 100644 --- a/extensions/default/src/getDataSourcesModule.js +++ b/extensions/default/src/getDataSourcesModule.js @@ -2,36 +2,17 @@ // TODO: Use constructor to create an instance of IWebClientApi // TODO: Use existing DICOMWeb configuration (previously, appConfig, to configure instance) +import { createDicomWebApi } from './DicomWebDataSource/index.js'; + /** * */ function getDataSourcesModule() { return [ { - name: 'dicomweb-client', + name: 'dicomweb', type: 'webApi', - /** - * TODO: Create JSDoc for this Data Source implementation. - */ - createDataSource: dataSourceConfiguration => { - // instantiate DicomWebClient - // const dicomWebClient = new DicomWebClient(dataSourceConfiguration); - - return { - query: { - studies: { - mapParams: params => params, - searchStudies: params => { - /** dicomWebClient.searchStudies(params) **/ - }, - searchSeries: params => { - /** dicomWebClient.searchSeries(params) **/ - }, - processResults: results => results, - }, - }, - }; - }, + createDataSource: createDicomWebApi, }, ]; } diff --git a/platform/core/src/DataSources/IWebApiDataSource.js b/platform/core/src/DataSources/IWebApiDataSource.js new file mode 100644 index 000000000..e3f10dde9 --- /dev/null +++ b/platform/core/src/DataSources/IWebApiDataSource.js @@ -0,0 +1,42 @@ +/** + * Factory function that creates a new "Web API" data source. + * A "Web API" data source is any source that fetches data over + * HTTP. This function serves as an "adapter" to wrap those calls + * so that all "Web API" data sources have the same interface and can + * be used interchangeably. + * + * It's worth noting that a single implementation of this interface + * can define different underlying sources for "read" and "write" operations. + */ +function createWebApiDataSource({ query, retrieve }) { + const defaultQuery = { + studies: { + /** + * @param {string} params.patientName + * @param {string} params.mrn + * @param {object} params.studyDate + * @param {string} params.description + * @param {string} params.modality + * @param {string} params.accession + * @param {string} params.sortBy + * @param {string} params.sortDirection - + * @param {number} params.page + * @param {number} params.resultsPerPage + */ + mapParams: params => params, + requestResults: () => {}, + processResults: results => results, + }, + series: {}, + instances: {}, + }; + + const defaultRetrieve = {}; + + return { + query, + retrieve: {}, + }; +} + +export { createWebApiDataSource };