diff --git a/platform/core/src/studies/getSeriesInfo.js b/platform/core/src/studies/getSeriesInfo.js new file mode 100644 index 000000000..e8b7d40ea --- /dev/null +++ b/platform/core/src/studies/getSeriesInfo.js @@ -0,0 +1,25 @@ +import DICOMWeb from '../DICOMWeb/'; +import isLowPriorityModality from '../utils/isLowPriorityModality'; + +const INFO = Symbol('INFO'); + +/** + * Creates an object with processed series information and saves its reference + * inside the series object itself. + * @param {Object} series The raw series object + * @returns {Object} object containing some useful info from given series + */ +export default function getSeriesInfo(series) { + let info = series[INFO]; + if (!info) { + const modality = DICOMWeb.getString(series['00080060'], '').toUpperCase(); + info = Object.freeze({ + modality, + isLowPriority: isLowPriorityModality(modality), + seriesInstanceUid: DICOMWeb.getString(series['0020000E']), + seriesNumber: DICOMWeb.getNumber(series['00200011'], 0) || 0, + }); + series[INFO] = info; + } + return info; +} diff --git a/platform/core/src/studies/retrieveStudiesMetadata.js b/platform/core/src/studies/retrieveStudiesMetadata.js index 2e27ac044..341e93394 100644 --- a/platform/core/src/studies/retrieveStudiesMetadata.js +++ b/platform/core/src/studies/retrieveStudiesMetadata.js @@ -7,13 +7,16 @@ import { retrieveStudyMetadata } from './retrieveStudyMetadata'; * This function calls retrieveStudyMetadata several times, asynchronously, * and waits for all of the results to be returned. * - * @param studyInstanceUids The UIDs of the Studies to be retrieved - * @return Promise + * @param {Object} server Object with server configuration parameters + * @param {Array} studyInstanceUids The UIDs of the Studies 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 + * @returns {Promise} that will be resolved with the metadata or rejected with the error */ export default function retrieveStudiesMetadata( server, studyInstanceUids, - seriesInstanceUids + filters ) { // Create an empty array to store the Promises for each metaData retrieval call const promises = []; @@ -21,11 +24,7 @@ export default function retrieveStudiesMetadata( // Loop through the array of studyInstanceUids studyInstanceUids.forEach(function(studyInstanceUid) { // Send the call and resolve or reject the related promise based on its outcome - const promise = retrieveStudyMetadata( - server, - studyInstanceUid, - seriesInstanceUids - ); + const promise = retrieveStudyMetadata(server, studyInstanceUid, filters); // Add the current promise to the array of promises promises.push(promise); diff --git a/platform/core/src/studies/retrieveStudyMetadata.js b/platform/core/src/studies/retrieveStudyMetadata.js index b24ee8da2..58699efff 100644 --- a/platform/core/src/studies/retrieveStudyMetadata.js +++ b/platform/core/src/studies/retrieveStudyMetadata.js @@ -7,11 +7,13 @@ const StudyMetaDataPromises = new Map(); /** * Retrieves study metadata * - * @param {Object} server + * @param {Object} server Object with server configuration parameters * @param {string} studyInstanceUid The UID of the Study 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 * @returns {Promise} that will be resolved with the metadata or rejected with the error */ -export function retrieveStudyMetadata(server, studyInstanceUid) { +export function retrieveStudyMetadata(server, studyInstanceUid, filters) { // @TODO: Whenever a study metadata request has failed, its related promise will be rejected once and for all // and further requests for that metadata will always fail. On failure, we probably need to remove the // corresponding promise from the "StudyMetaDataPromises" map... @@ -32,7 +34,7 @@ export function retrieveStudyMetadata(server, studyInstanceUid) { // Create a promise to handle the data retrieval const promise = new Promise((resolve, reject) => { - RetrieveMetadata(server, studyInstanceUid).then(function(data) { + RetrieveMetadata(server, studyInstanceUid, filters).then(function(data) { resolve(data); }, reject); }); diff --git a/platform/core/src/studies/services/wado/retrieveMetadata.js b/platform/core/src/studies/services/wado/retrieveMetadata.js index 993d0b191..13758f0ca 100644 --- a/platform/core/src/studies/services/wado/retrieveMetadata.js +++ b/platform/core/src/studies/services/wado/retrieveMetadata.js @@ -1,609 +1,30 @@ -import { api } from 'dicomweb-client'; -import DICOMWeb from '../../../DICOMWeb/'; -import isLowPriorityModality from '../../../utils/isLowPriorityModality'; - -const INFO = Symbol('INFO'); - -const WADOProxy = { - convertURL: (url, server) => { - // TODO: Remove all WADOProxy stuff from this file - return url; - }, -}; - -function parseFloatArray(obj) { - const result = []; - - if (!obj) { - return result; - } - - const objs = obj.split('\\'); - for (let i = 0; i < objs.length; i++) { - result.push(parseFloat(objs[i])); - } - - return result; -} - -/** - * Simple cache schema for retrieved color palettes. - */ -const paletteColorCache = { - count: 0, - maxAge: 24 * 60 * 60 * 1000, // 24h cache? - entries: {}, - isValidUID: function(paletteUID) { - return typeof paletteUID === 'string' && paletteUID.length > 0; - }, - get: function(paletteUID) { - let entry = null; - if (this.entries.hasOwnProperty(paletteUID)) { - entry = this.entries[paletteUID]; - // check how the entry is... - if (Date.now() - entry.time > this.maxAge) { - // entry is too old... remove entry. - delete this.entries[paletteUID]; - this.count--; - entry = null; - } - } - return entry; - }, - add: function(entry) { - if (this.isValidUID(entry.uid)) { - let paletteUID = entry.uid; - if (this.entries.hasOwnProperty(paletteUID) !== true) { - this.count++; // increment cache entry count... - } - entry.time = Date.now(); - this.entries[paletteUID] = entry; - // @TODO: Add logic to get rid of old entries and reduce memory usage... - } - }, -}; - -/** Returns a WADO url for an instance - * - * @param studyInstanceUid - * @param seriesInstanceUid - * @param sopInstanceUid - * @returns {string} - */ -function buildInstanceWadoUrl( - server, - studyInstanceUid, - seriesInstanceUid, - sopInstanceUid -) { - // TODO: This can be removed, since DICOMWebClient has the same function. Not urgent, though - const params = []; - - params.push('requestType=WADO'); - params.push(`studyUID=${studyInstanceUid}`); - params.push(`seriesUID=${seriesInstanceUid}`); - params.push(`objectUID=${sopInstanceUid}`); - params.push('contentType=application/dicom'); - params.push('transferSyntax=*'); - - const paramString = params.join('&'); - - return `${server.wadoUriRoot}?${paramString}`; -} - -function buildInstanceWadoRsUri( - server, - studyInstanceUid, - seriesInstanceUid, - sopInstanceUid -) { - return `${server.wadoRoot}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`; -} - -function buildInstanceFrameWadoRsUri( - server, - studyInstanceUid, - seriesInstanceUid, - sopInstanceUid, - frame -) { - const baseWadoRsUri = buildInstanceWadoRsUri( - server, - studyInstanceUid, - seriesInstanceUid, - sopInstanceUid - ); - frame = frame != null || 1; - - return `${baseWadoRsUri}/frames/${frame}`; -} - -/** - * Parses the SourceImageSequence, if it exists, in order - * to return a ReferenceSOPInstanceUID. The ReferenceSOPInstanceUID - * is used to refer to this image in any accompanying DICOM-SR documents. - * - * @param instance - * @returns {String} The ReferenceSOPInstanceUID - */ -function getSourceImageInstanceUid(instance) { - // TODO= Parse the whole Source Image Sequence - // This is a really poor workaround for now. - // Later we should probably parse the whole sequence. - var SourceImageSequence = instance['00082112']; - if ( - SourceImageSequence && - SourceImageSequence.Value && - SourceImageSequence.Value.length && - SourceImageSequence.Value[0]['00081155'].Value - ) { - return SourceImageSequence.Value[0]['00081155'].Value[0]; - } -} - -function getPaletteColor(server, instance, tag, lutDescriptor) { - const numLutEntries = lutDescriptor[0]; - const bits = lutDescriptor[2]; - - let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server); - - // TODO: Workaround for dcm4chee behind SSL-terminating proxy returning - // incorrect bulk data URIs - if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) { - uri = uri.replace('http', 'https'); - } - - const config = { - url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used - headers: DICOMWeb.getAuthorizationHeader(server), - }; - const dicomWeb = new api.DICOMwebClient(config); - const options = { - BulkDataURI: uri, - }; - - const readUInt16 = (byteArray, position) => { - return byteArray[position] + byteArray[position + 1] * 256; - }; - - const arrayBufferToPaletteColorLUT = result => { - const arraybuffer = result[0]; - const byteArray = new Uint8Array(arraybuffer); - const lut = []; - - for (let i = 0; i < numLutEntries; i++) { - if (bits === 16) { - lut[i] = readUInt16(byteArray, i * 2); - } else { - lut[i] = byteArray[i]; - } - } - - return lut; - }; - - return dicomWeb.retrieveBulkData(options).then(arrayBufferToPaletteColorLUT); -} - -/** - * Fetch palette colors for instances with "PALETTE COLOR" photometricInterpretation. - * - * @param server {Object} Current server; - * @param instance {Object} The retrieved instance metadata; - * @returns {String} The ReferenceSOPInstanceUID - */ -async function getPaletteColors(server, instance, lutDescriptor) { - let paletteUID = DICOMWeb.getString(instance['00281199']); - - return new Promise((resolve, reject) => { - let entry; - if (paletteColorCache.isValidUID(paletteUID)) { - entry = paletteColorCache.get(paletteUID); - - if (entry) { - return resolve(entry); - } - } - - // no entry in cache... Fetch remote data. - const r = getPaletteColor(server, instance, '00281201', lutDescriptor); - const g = getPaletteColor(server, instance, '00281202', lutDescriptor); - const b = getPaletteColor(server, instance, '00281203', lutDescriptor); - - const promises = [r, g, b]; - - Promise.all(promises).then(args => { - entry = { - red: args[0], - green: args[1], - blue: args[2], - }; - - // when paletteUID is present, the entry can be cached... - entry.uid = paletteUID; - paletteColorCache.add(entry); - - resolve(entry); - }); - }); -} - -function getFrameIncrementPointer(element) { - const frameIncrementPointerNames = { - '00181065': 'frameTimeVector', - '00181063': 'frameTime', - }; - - if (!element || !element.Value || !element.Value.length) { - return; - } - - const value = element.Value[0]; - return frameIncrementPointerNames[value]; -} - -function getRadiopharmaceuticalInfo(instance) { - const modality = DICOMWeb.getString(instance['00080060']); - - if (modality !== 'PT') { - return; - } - - const radiopharmaceuticalInfo = instance['00540016']; - if ( - radiopharmaceuticalInfo === undefined || - !radiopharmaceuticalInfo.Value || - !radiopharmaceuticalInfo.Value.length - ) { - return; - } - - const firstPetRadiopharmaceuticalInfo = radiopharmaceuticalInfo.Value[0]; - return { - radiopharmaceuticalStartTime: DICOMWeb.getString( - firstPetRadiopharmaceuticalInfo['00181072'] - ), - radionuclideTotalDose: DICOMWeb.getNumber( - firstPetRadiopharmaceuticalInfo['00181074'] - ), - radionuclideHalfLife: DICOMWeb.getNumber( - firstPetRadiopharmaceuticalInfo['00181075'] - ), - }; -} - -async function makeSOPInstance(server, study, instance) { - const { studyInstanceUid } = study; - const seriesInstanceUid = DICOMWeb.getString(instance['0020000E']); - let series = study.seriesMap[seriesInstanceUid]; - - if (!series) { - series = { - seriesInstanceUid, - seriesDescription: DICOMWeb.getString(instance['0008103E']), - modality: DICOMWeb.getString(instance['00080060']), - seriesNumber: DICOMWeb.getNumber(instance['00200011']), - seriesDate: DICOMWeb.getString(instance['00080021']), - seriesTime: DICOMWeb.getString(instance['00080031']), - instances: [], - }; - study.seriesMap[seriesInstanceUid] = series; - study.seriesList.push(series); - } - - const sopInstanceUid = DICOMWeb.getString(instance['00080018']); - const wadouri = buildInstanceWadoUrl( - server, - studyInstanceUid, - seriesInstanceUid, - sopInstanceUid - ); - const baseWadoRsUri = buildInstanceWadoRsUri( - server, - studyInstanceUid, - seriesInstanceUid, - sopInstanceUid - ); - const wadorsuri = buildInstanceFrameWadoRsUri( - server, - studyInstanceUid, - seriesInstanceUid, - sopInstanceUid - ); - - const sopInstance = { - imageType: DICOMWeb.getString(instance['00080008']), - sopClassUid: DICOMWeb.getString(instance['00080016']), - modality: DICOMWeb.getString(instance['00080060']), - sopInstanceUid, - instanceNumber: DICOMWeb.getNumber(instance['00200013']), - imagePositionPatient: DICOMWeb.getString(instance['00200032']), - imageOrientationPatient: DICOMWeb.getString(instance['00200037']), - frameOfReferenceUID: DICOMWeb.getString(instance['00200052']), - sliceLocation: DICOMWeb.getNumber(instance['00201041']), - samplesPerPixel: DICOMWeb.getNumber(instance['00280002']), - photometricInterpretation: DICOMWeb.getString(instance['00280004']), - planarConfiguration: DICOMWeb.getNumber(instance['00280006']), - rows: DICOMWeb.getNumber(instance['00280010']), - columns: DICOMWeb.getNumber(instance['00280011']), - pixelSpacing: DICOMWeb.getString(instance['00280030']), - pixelAspectRatio: DICOMWeb.getString(instance['00280034']), - bitsAllocated: DICOMWeb.getNumber(instance['00280100']), - bitsStored: DICOMWeb.getNumber(instance['00280101']), - highBit: DICOMWeb.getNumber(instance['00280102']), - pixelRepresentation: DICOMWeb.getNumber(instance['00280103']), - smallestPixelValue: DICOMWeb.getNumber(instance['00280106']), - largestPixelValue: DICOMWeb.getNumber(instance['00280107']), - windowCenter: DICOMWeb.getString(instance['00281050']), - windowWidth: DICOMWeb.getString(instance['00281051']), - rescaleIntercept: DICOMWeb.getNumber(instance['00281052']), - rescaleSlope: DICOMWeb.getNumber(instance['00281053']), - rescaleType: DICOMWeb.getNumber(instance['00281054']), - sourceImageInstanceUid: getSourceImageInstanceUid(instance), - laterality: DICOMWeb.getString(instance['00200062']), - viewPosition: DICOMWeb.getString(instance['00185101']), - acquisitionDateTime: DICOMWeb.getString(instance['0008002A']), - numberOfFrames: DICOMWeb.getNumber(instance['00280008']), - frameIncrementPointer: getFrameIncrementPointer(instance['00280009']), - frameTime: DICOMWeb.getNumber(instance['00181063']), - frameTimeVector: parseFloatArray(DICOMWeb.getString(instance['00181065'])), - sliceThickness: DICOMWeb.getNumber(instance['00180050']), - spacingBetweenSlices: DICOMWeb.getString(instance['00180088']), - lossyImageCompression: DICOMWeb.getString(instance['00282110']), - derivationDescription: DICOMWeb.getString(instance['00282111']), - lossyImageCompressionRatio: DICOMWeb.getString(instance['00282112']), - lossyImageCompressionMethod: DICOMWeb.getString(instance['00282114']), - echoNumber: DICOMWeb.getString(instance['00180086']), - contrastBolusAgent: DICOMWeb.getString(instance['00180010']), - radiopharmaceuticalInfo: getRadiopharmaceuticalInfo(instance), - baseWadoRsUri: baseWadoRsUri, - wadouri: WADOProxy.convertURL(wadouri, server), - wadorsuri: WADOProxy.convertURL(wadorsuri, server), - wadoRoot: server.wadoRoot, - imageRendering: server.imageRendering, - thumbnailRendering: server.thumbnailRendering, - }; - - // Get additional information if the instance uses "PALETTE COLOR" photometric interpretation - if (sopInstance.photometricInterpretation === 'PALETTE COLOR') { - const redPaletteColorLookupTableDescriptor = parseFloatArray( - DICOMWeb.getString(instance['00281101']) - ); - const greenPaletteColorLookupTableDescriptor = parseFloatArray( - DICOMWeb.getString(instance['00281102']) - ); - const bluePaletteColorLookupTableDescriptor = parseFloatArray( - DICOMWeb.getString(instance['00281103']) - ); - const palettes = await getPaletteColors( - server, - instance, - redPaletteColorLookupTableDescriptor - ); - - if (palettes) { - if (palettes.uid) { - sopInstance.paletteColorLookupTableUID = palettes.uid; - } - - sopInstance.redPaletteColorLookupTableData = palettes.red; - sopInstance.greenPaletteColorLookupTableData = palettes.green; - sopInstance.bluePaletteColorLookupTableData = palettes.blue; - sopInstance.redPaletteColorLookupTableDescriptor = redPaletteColorLookupTableDescriptor; - sopInstance.greenPaletteColorLookupTableDescriptor = greenPaletteColorLookupTableDescriptor; - sopInstance.bluePaletteColorLookupTableDescriptor = bluePaletteColorLookupTableDescriptor; - } - } - - series.instances.push(sopInstance); - return sopInstance; -} - -/** - * Create a plain JS object that describes a study (a study descriptor object) - * @param {Object} server Object with server configuration paramenters - * @param {Object} aSopInstance a SOP Instance from which study information will be added - */ -function createStudy(server, aSopInstance) { - // TODO: Pass a reference ID to the server instead of including the URLs here - return { - seriesList: [], - seriesMap: Object.create(null), - seriesLoader: null, - wadoUriRoot: server.wadoUriRoot, - wadoRoot: server.wadoRoot, - qidoRoot: server.qidoRoot, - patientName: DICOMWeb.getName(aSopInstance['00100010']), - patientId: DICOMWeb.getString(aSopInstance['00100020']), - patientAge: DICOMWeb.getNumber(aSopInstance['00101010']), - patientSize: DICOMWeb.getNumber(aSopInstance['00101020']), - patientWeight: DICOMWeb.getNumber(aSopInstance['00101030']), - accessionNumber: DICOMWeb.getString(aSopInstance['00080050']), - studyDate: DICOMWeb.getString(aSopInstance['00080020']), - modalities: DICOMWeb.getString(aSopInstance['00080061']), - studyDescription: DICOMWeb.getString(aSopInstance['00081030']), - imageCount: DICOMWeb.getString(aSopInstance['00201208']), - studyInstanceUid: DICOMWeb.getString(aSopInstance['0020000D']), - institutionName: DICOMWeb.getString(aSopInstance['00080080']), - }; -} - -/** - * Add a list of SOP Instances to a given study object descriptor - * @param {Object} server Object with server configuration paramenters - * @param {Object} study The study descriptor to which the given SOP instances will be added - * @param {Array} sopInstanceList A list of SOP instance objects - */ -async function addInstancesToStudy(server, study, sopInstanceList) { - return Promise.all( - sopInstanceList.map(function(sopInstance) { - return makeSOPInstance(server, study, sopInstance); - }) - ); -} - -/** - * Parses result data from a WADO search into Study MetaData - * Returns an object populated with study metadata, including the - * series list. - * - * @param {Object} server Object with server configuration paramenters - * @param {Array} sopInstanceList List of SOP Instances that build up to the study - * @resolves {{seriesList: Array, patientName: *, patientId: *, accessionNumber: *, studyDate: *, modalities: *, studyDescription: *, imageCount: *, studyInstanceUid: *}} - */ -async function createStudyFromSOPInstanceList(server, sopInstanceList) { - if (Array.isArray(sopInstanceList) && sopInstanceList.length > 0) { - const firstSopInstance = sopInstanceList[0]; - const study = createStudy(server, firstSopInstance); - await addInstancesToStudy(server, study, sopInstanceList); - return study; - } - throw new Error('Failed to create study out of provided SOP instance list'); -} +import RetrieveMetadataLoaderSync from './retrieveMetadataLoaderSync'; +import RetrieveMetadataLoaderAsync from './retrieveMetadataLoaderAsync'; /** * Retrieve Study metadata from a DICOM server. If the server is configured to use lazy load, only the first series - * will be loaded and the property "studyLoader" will be set to let consumer load remaining series as needed - * @param {Object} server Object with server configuration paramenters + * will be loaded and the property "studyLoader" will be set to let consumer load remaining series as needed. + * + * @param {Object} server Object with server configuration parameters * @param {string} studyInstanceUid The Study Instance UID of the study which needs to be loaded + * @param {Object} [filters] - Object containing filters to be applied on retrieve metadata process + * @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against * @returns {Object} A study descriptor object */ -async function RetrieveMetadata(server, studyInstanceUid) { - return (server.enableStudyLazyLoad !== false - ? lazyLoadStudyMetadata - : loadStudyMetadata)(server, studyInstanceUid); -} +async function RetrieveMetadata(server, studyInstanceUid, filters = {}) { + const RetrieveMetadataLoader = + server.enableStudyLazyLoad !== false + ? RetrieveMetadataLoaderAsync + : RetrieveMetadataLoaderSync; -/** - * - * @param {*} server - * @param {*} studyInstanceUID - */ -async function loadStudyMetadata(server, studyInstanceUID) { - const dicomWeb = new api.DICOMwebClient({ - url: server.wadoRoot, - headers: DICOMWeb.getAuthorizationHeader(server), - }); - return dicomWeb - .retrieveStudyMetadata({ studyInstanceUID }) - .then(result => createStudyFromSOPInstanceList(server, result)); -} - -async function lazyLoadStudyMetadata(server, studyInstanceUid) { - const seriesInstanceUids = await searchStudySeries(server, studyInstanceUid); - const dicomWeb = new api.DICOMwebClient({ - url: server.wadoRoot, - headers: DICOMWeb.getAuthorizationHeader(server), - }); - const seriesLoader = makeSeriesLoader( - dicomWeb, - studyInstanceUid, - seriesInstanceUids - ); - const firstSeries = await seriesLoader.next(); - const study = await createStudyFromSOPInstanceList( + const retrieveMetadataLoader = new RetrieveMetadataLoader( server, - firstSeries.sopInstances + studyInstanceUid, + filters ); - if (seriesLoader.hasNext()) { - attachSeriesLoader(server, study, seriesLoader); - } - return study; -} + const studyMetadata = retrieveMetadataLoader.execLoad(); -function attachSeriesLoader(server, study, seriesLoader) { - study.seriesLoader = Object.freeze({ - hasNext() { - return seriesLoader.hasNext(); - }, - async next() { - const series = await seriesLoader.next(); - await addInstancesToStudy(server, study, series.sopInstances); - return study.seriesMap[series.seriesInstanceUID]; - }, - }); -} - -/** - * Creates an immutable series loader object which loads each series sequentially using the iterator interface - * @param {DICOMWebClient} dicomWebClient The DICOMWebClient instance to be used for series load - * @param {string} studyInstanceUID The Study Instance UID from which series will be loaded - * @param {Array} seriesInstanceUIDList A list of Series Instance UIDs - * @returns {Object} Returns an object which supports loading of instances from each of given Series Instance UID - */ -function makeSeriesLoader( - dicomWebClient, - studyInstanceUID, - seriesInstanceUIDList -) { - return Object.freeze({ - hasNext() { - return seriesInstanceUIDList.length > 0; - }, - async next() { - const seriesInstanceUID = seriesInstanceUIDList.shift(); - const sopInstances = await dicomWebClient.retrieveSeriesMetadata({ - studyInstanceUID, - seriesInstanceUID, - }); - return { studyInstanceUID, seriesInstanceUID, sopInstances }; - }, - }); -} - -/** - * Search series of a given study - * @param {Object} server Object with server configuration paramenters - * @param {string} studyInstanceUID The Study Instance UID to search series from; - * @returns {Arrays} A list of Series Instance UIDs - */ -async function searchStudySeries(server, studyInstanceUID) { - const dicomWeb = new api.DICOMwebClient({ - url: server.qidoRoot, - headers: DICOMWeb.getAuthorizationHeader(server), - }); - const seriesList = await dicomWeb.searchForSeries({ studyInstanceUID }); - return seriesList - .sort(seriesSortingCriteria) - .map(series => getSeriesInfo(series).seriesInstanceUid); -} - -/** - * Series sorting criteria: series considered low priority are moved to the end - * of the list and series number is used to break ties - * @param {Object} firstSeries - * @param {Object} secondSeries - */ -function seriesSortingCriteria(firstSeries, secondSeries) { - const a = getSeriesInfo(firstSeries); - const b = getSeriesInfo(secondSeries); - if (!a.isLowPriority && b.isLowPriority) { - return -1; - } - if (a.isLowPriority && !b.isLowPriority) { - return 1; - } - return a.seriesNumber - b.seriesNumber; -} - -/** - * Creates an object with processed series information and saves its reference - * inside the series object itself to simplify sorting - * @param {Object} series The raw series object - */ -function getSeriesInfo(series) { - let info = series[INFO]; - if (!info) { - const modality = DICOMWeb.getString(series['00080060'], '').toUpperCase(); - info = Object.freeze({ - modality, - isLowPriority: isLowPriorityModality(modality), - seriesInstanceUid: DICOMWeb.getString(series['0020000E']), - seriesNumber: DICOMWeb.getNumber(series['00200011'], 0) || 0, - }); - series[INFO] = info; - } - return info; + return studyMetadata; } export default RetrieveMetadata; diff --git a/platform/core/src/studies/services/wado/retrieveMetadataLoader.js b/platform/core/src/studies/services/wado/retrieveMetadataLoader.js new file mode 100644 index 000000000..087b6d1dc --- /dev/null +++ b/platform/core/src/studies/services/wado/retrieveMetadataLoader.js @@ -0,0 +1,58 @@ +/** + * Class to define inheritance of load retrieve strategy. + * The process can be async load (lazy) or sync load + * + * There are methods that must be implemented at consumer level + * To retrieve study call execLoad + */ +export default class RetrieveMetadataLoader { + /** + * @constructor + * @param {Object} server Object with server configuration parameters + * @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 + */ + constructor(server, studyInstanceUID, filters = {}) { + this.server = server; + this.studyInstanceUID = studyInstanceUID; + this.filters = filters; + } + + async execLoad() { + await this.configLoad(); + const preLoadData = await this.preLoad(); + const loadData = await this.load(preLoadData); + const postLoadData = await this.posLoad(loadData); + + return postLoadData; + } + + /** + * It iterates over given loaders running each one. Loaders parameters must be bind when getting it. + * @param {Array} loaders - array of loader to retrieve data. + */ + 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) {} + } + + if (loaders.next().done && !result) { + throw 'cant find data'; + } + + return result; + } + + // Methods to be overwrite + async configLoad() {} + async preLoad() {} + async load(preLoadData) {} + async posLoad(loadData) {} +} diff --git a/platform/core/src/studies/services/wado/retrieveMetadataLoaderAsync.js b/platform/core/src/studies/services/wado/retrieveMetadataLoaderAsync.js new file mode 100644 index 000000000..69594ccf9 --- /dev/null +++ b/platform/core/src/studies/services/wado/retrieveMetadataLoaderAsync.js @@ -0,0 +1,145 @@ +import { api } from 'dicomweb-client'; +import DICOMWeb from '../../../DICOMWeb/'; +import RetrieveMetadataLoader from './retrieveMetadataLoader'; +import { sortStudySeries, sortingCriteria } from '../../sortStudy'; +import getSeriesInfo from '../../getSeriesInfo'; +import { + createStudyFromSOPInstanceList, + addInstancesToStudy, +} from './studyInstanceHelpers'; + +/** + * Map seriesList to an array of seriesInstanceUid + * @param {Arrays} seriesList list of Series Instance UIDs + * @returns {Arrays} A list of Series Instance UIDs + */ +function mapStudySeries(seriesList) { + return seriesList.map(series => getSeriesInfo(series).seriesInstanceUid); +} + +function attachSeriesLoader(server, study, seriesLoader) { + study.seriesLoader = Object.freeze({ + hasNext() { + return seriesLoader.hasNext(); + }, + async next() { + const series = await seriesLoader.next(); + await addInstancesToStudy(server, study, series.sopInstances); + return study.seriesMap[series.seriesInstanceUID]; + }, + }); +} + +/** + * Creates an immutable series loader object which loads each series sequentially using the iterator interface + * @param {DICOMWebClient} dicomWebClient The DICOMWebClient instance to be used for series load + * @param {string} studyInstanceUID The Study Instance UID from which series will be loaded + * @param {Array} seriesInstanceUIDList A list of Series Instance UIDs + * @returns {Object} Returns an object which supports loading of instances from each of given Series Instance UID + */ +function makeSeriesAsyncLoader( + dicomWebClient, + studyInstanceUID, + seriesInstanceUIDList +) { + return Object.freeze({ + hasNext() { + return seriesInstanceUIDList.length > 0; + }, + async next() { + const seriesInstanceUID = seriesInstanceUIDList.shift(); + const sopInstances = await dicomWebClient.retrieveSeriesMetadata({ + studyInstanceUID, + seriesInstanceUID, + }); + return { studyInstanceUID, seriesInstanceUID, sopInstances }; + }, + }); +} + +/** + * Class for async load of study metadata. + * It inherits from RetrieveMetadataLoader + * + * It loads the one series and then append to seriesLoader the others to be consumed/loaded + */ +export default class RetrieveMetadataLoaderAsync extends RetrieveMetadataLoader { + configLoad() { + const { server } = this; + + const client = new api.DICOMwebClient({ + url: server.qidoRoot, + headers: DICOMWeb.getAuthorizationHeader(server), + }); + + this.client = client; + } + + /** + * @returns {Array} Array of preLoaders. To be consumed as queue + */ + *getPreLoaders() { + const preLoaders = []; + const { + studyInstanceUID, + filters: { seriesInstanceUID } = {}, + client, + } = this; + + if (seriesInstanceUID) { + const options = { + studyInstanceUID, + queryParams: { SeriesInstanceUID: seriesInstanceUID }, + }; + preLoaders.push(client.searchForSeries.bind(client, options)); + } + // Fallback preloader + preLoaders.push(client.searchForSeries.bind(client, { studyInstanceUID })); + + yield* preLoaders; + } + + async preLoad() { + const preLoaders = this.getPreLoaders(); + const result = await this.runLoaders(preLoaders); + + const seriesSorted = sortStudySeries( + result, + sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria + ); + const seriesInstanceUidsMap = mapStudySeries(seriesSorted); + + return seriesInstanceUidsMap; + } + + async load(preLoadData) { + const { client, studyInstanceUID } = this; + + const seriesAsyncLoader = makeSeriesAsyncLoader( + client, + studyInstanceUID, + preLoadData + ); + + const firstSeries = await seriesAsyncLoader.next(); + + return { + sopInstances: firstSeries.sopInstances, + asyncLoader: seriesAsyncLoader, + }; + } + + async posLoad(loadData) { + const { server } = this; + + const { sopInstances, asyncLoader } = loadData; + + const study = await createStudyFromSOPInstanceList(server, sopInstances); + + if (asyncLoader.hasNext()) { + attachSeriesLoader(server, study, asyncLoader); + } + + return study; + } +} diff --git a/platform/core/src/studies/services/wado/retrieveMetadataLoaderSync.js b/platform/core/src/studies/services/wado/retrieveMetadataLoaderSync.js new file mode 100644 index 000000000..19147d069 --- /dev/null +++ b/platform/core/src/studies/services/wado/retrieveMetadataLoaderSync.js @@ -0,0 +1,76 @@ +import { api } from 'dicomweb-client'; +import DICOMWeb from '../../../DICOMWeb/'; +import { createStudyFromSOPInstanceList } from './studyInstanceHelpers'; +import RetrieveMetadataLoader from './retrieveMetadataLoader'; + +/** + * Class for sync load of study metadata. + * It inherits from RetrieveMetadataLoader + * + * A list of loaders (getLoaders) can be created so, it will be applied a fallback load strategy. + * I.e Retrieve metadata using all loaders possibilities. + */ +export default class RetrieveMetadataLoaderSync extends RetrieveMetadataLoader { + getOptions() { + const { studyInstanceUID, filters } = this; + + const options = { + studyInstanceUID, + }; + + const { seriesInstanceUID } = filters; + if (seriesInstanceUID) { + options['seriesInstanceUID'] = seriesInstanceUID; + } + + return options; + } + + /** + * @returns {Array} Array of loaders. To be consumed as queue + */ + *getLoaders() { + const loaders = []; + const { + studyInstanceUID, + filters: { seriesInstanceUID } = {}, + client, + } = this; + + if (seriesInstanceUID) { + loaders.push( + client.retrieveSeriesMetadata.bind(client, { + studyInstanceUID, + seriesInstanceUID, + }) + ); + } + + loaders.push( + client.retrieveStudyMetadata.bind(client, { studyInstanceUID }) + ); + + yield* loaders; + } + + configLoad() { + const { server } = this; + const client = new api.DICOMwebClient({ + url: server.wadoRoot, + headers: DICOMWeb.getAuthorizationHeader(server), + }); + + this.client = client; + } + + async load(preLoadData) { + const loaders = this.getLoaders(); + const result = this.runLoaders(loaders); + return result; + } + + async posLoad(loadData) { + const { server } = this; + return createStudyFromSOPInstanceList(server, loadData); + } +} diff --git a/platform/core/src/studies/services/wado/studyInstanceHelpers.js b/platform/core/src/studies/services/wado/studyInstanceHelpers.js new file mode 100644 index 000000000..dbf318966 --- /dev/null +++ b/platform/core/src/studies/services/wado/studyInstanceHelpers.js @@ -0,0 +1,413 @@ +import DICOMWeb from '../../../DICOMWeb'; + +const WADOProxy = { + convertURL: (url, server) => { + // TODO: Remove all WADOProxy stuff from this file + return url; + }, +}; +function parseFloatArray(obj) { + const result = []; + + if (!obj) { + return result; + } + + const objs = obj.split('\\'); + for (let i = 0; i < objs.length; i++) { + result.push(parseFloat(objs[i])); + } + + return result; +} + +/** + * Create a plain JS object that describes a study (a study descriptor object) + * @param {Object} server Object with server configuration parameters + * @param {Object} aSopInstance a SOP Instance from which study information will be added + */ +function createStudy(server, aSopInstance) { + // TODO: Pass a reference ID to the server instead of including the URLs here + return { + seriesList: [], + seriesMap: Object.create(null), + seriesLoader: null, + wadoUriRoot: server.wadoUriRoot, + wadoRoot: server.wadoRoot, + qidoRoot: server.qidoRoot, + patientName: DICOMWeb.getName(aSopInstance['00100010']), + patientId: DICOMWeb.getString(aSopInstance['00100020']), + patientAge: DICOMWeb.getNumber(aSopInstance['00101010']), + patientSize: DICOMWeb.getNumber(aSopInstance['00101020']), + patientWeight: DICOMWeb.getNumber(aSopInstance['00101030']), + accessionNumber: DICOMWeb.getString(aSopInstance['00080050']), + studyDate: DICOMWeb.getString(aSopInstance['00080020']), + modalities: DICOMWeb.getString(aSopInstance['00080061']), + studyDescription: DICOMWeb.getString(aSopInstance['00081030']), + imageCount: DICOMWeb.getString(aSopInstance['00201208']), + studyInstanceUid: DICOMWeb.getString(aSopInstance['0020000D']), + institutionName: DICOMWeb.getString(aSopInstance['00080080']), + }; +} + +/** Returns a WADO url for an instance + * + * @param studyInstanceUid + * @param seriesInstanceUid + * @param sopInstanceUid + * @returns {string} + */ +function buildInstanceWadoUrl( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid +) { + // TODO: This can be removed, since DICOMWebClient has the same function. Not urgent, though + const params = []; + + params.push('requestType=WADO'); + params.push(`studyUID=${studyInstanceUid}`); + params.push(`seriesUID=${seriesInstanceUid}`); + params.push(`objectUID=${sopInstanceUid}`); + params.push('contentType=application/dicom'); + params.push('transferSyntax=*'); + + const paramString = params.join('&'); + + return `${server.wadoUriRoot}?${paramString}`; +} + +function buildInstanceWadoRsUri( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid +) { + return `${server.wadoRoot}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`; +} + +function buildInstanceFrameWadoRsUri( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid, + frame +) { + const baseWadoRsUri = buildInstanceWadoRsUri( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid + ); + frame = frame != null || 1; + + return `${baseWadoRsUri}/frames/${frame}`; +} + +function getFrameIncrementPointer(element) { + const frameIncrementPointerNames = { + '00181065': 'frameTimeVector', + '00181063': 'frameTime', + }; + + if (!element || !element.Value || !element.Value.length) { + return; + } + + const value = element.Value[0]; + return frameIncrementPointerNames[value]; +} + +function getRadiopharmaceuticalInfo(instance) { + const modality = DICOMWeb.getString(instance['00080060']); + + if (modality !== 'PT') { + return; + } + + const radiopharmaceuticalInfo = instance['00540016']; + if ( + radiopharmaceuticalInfo === undefined || + !radiopharmaceuticalInfo.Value || + !radiopharmaceuticalInfo.Value.length + ) { + return; + } + + const firstPetRadiopharmaceuticalInfo = radiopharmaceuticalInfo.Value[0]; + return { + radiopharmaceuticalStartTime: DICOMWeb.getString( + firstPetRadiopharmaceuticalInfo['00181072'] + ), + radionuclideTotalDose: DICOMWeb.getNumber( + firstPetRadiopharmaceuticalInfo['00181074'] + ), + radionuclideHalfLife: DICOMWeb.getNumber( + firstPetRadiopharmaceuticalInfo['00181075'] + ), + }; +} + +/** + * Parses the SourceImageSequence, if it exists, in order + * to return a ReferenceSOPInstanceUID. The ReferenceSOPInstanceUID + * is used to refer to this image in any accompanying DICOM-SR documents. + * + * @param instance + * @returns {String} The ReferenceSOPInstanceUID + */ +function getSourceImageInstanceUid(instance) { + // TODO= Parse the whole Source Image Sequence + // This is a really poor workaround for now. + // Later we should probably parse the whole sequence. + var SourceImageSequence = instance['00082112']; + if ( + SourceImageSequence && + SourceImageSequence.Value && + SourceImageSequence.Value.length && + SourceImageSequence.Value[0]['00081155'].Value + ) { + return SourceImageSequence.Value[0]['00081155'].Value[0]; + } +} + +async function makeSOPInstance(server, study, instance) { + const { studyInstanceUid } = study; + const seriesInstanceUid = DICOMWeb.getString(instance['0020000E']); + let series = study.seriesMap[seriesInstanceUid]; + + if (!series) { + series = { + seriesInstanceUid, + seriesDescription: DICOMWeb.getString(instance['0008103E']), + modality: DICOMWeb.getString(instance['00080060']), + seriesNumber: DICOMWeb.getNumber(instance['00200011']), + seriesDate: DICOMWeb.getString(instance['00080021']), + seriesTime: DICOMWeb.getString(instance['00080031']), + instances: [], + }; + study.seriesMap[seriesInstanceUid] = series; + study.seriesList.push(series); + } + + const sopInstanceUid = DICOMWeb.getString(instance['00080018']); + const wadouri = buildInstanceWadoUrl( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid + ); + const baseWadoRsUri = buildInstanceWadoRsUri( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid + ); + const wadorsuri = buildInstanceFrameWadoRsUri( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid + ); + + const sopInstance = { + imageType: DICOMWeb.getString(instance['00080008']), + sopClassUid: DICOMWeb.getString(instance['00080016']), + modality: DICOMWeb.getString(instance['00080060']), + sopInstanceUid, + instanceNumber: DICOMWeb.getNumber(instance['00200013']), + imagePositionPatient: DICOMWeb.getString(instance['00200032']), + imageOrientationPatient: DICOMWeb.getString(instance['00200037']), + frameOfReferenceUID: DICOMWeb.getString(instance['00200052']), + sliceLocation: DICOMWeb.getNumber(instance['00201041']), + samplesPerPixel: DICOMWeb.getNumber(instance['00280002']), + photometricInterpretation: DICOMWeb.getString(instance['00280004']), + planarConfiguration: DICOMWeb.getNumber(instance['00280006']), + rows: DICOMWeb.getNumber(instance['00280010']), + columns: DICOMWeb.getNumber(instance['00280011']), + pixelSpacing: DICOMWeb.getString(instance['00280030']), + pixelAspectRatio: DICOMWeb.getString(instance['00280034']), + bitsAllocated: DICOMWeb.getNumber(instance['00280100']), + bitsStored: DICOMWeb.getNumber(instance['00280101']), + highBit: DICOMWeb.getNumber(instance['00280102']), + pixelRepresentation: DICOMWeb.getNumber(instance['00280103']), + smallestPixelValue: DICOMWeb.getNumber(instance['00280106']), + largestPixelValue: DICOMWeb.getNumber(instance['00280107']), + windowCenter: DICOMWeb.getString(instance['00281050']), + windowWidth: DICOMWeb.getString(instance['00281051']), + rescaleIntercept: DICOMWeb.getNumber(instance['00281052']), + rescaleSlope: DICOMWeb.getNumber(instance['00281053']), + rescaleType: DICOMWeb.getNumber(instance['00281054']), + sourceImageInstanceUid: getSourceImageInstanceUid(instance), + laterality: DICOMWeb.getString(instance['00200062']), + viewPosition: DICOMWeb.getString(instance['00185101']), + acquisitionDateTime: DICOMWeb.getString(instance['0008002A']), + numberOfFrames: DICOMWeb.getNumber(instance['00280008']), + frameIncrementPointer: getFrameIncrementPointer(instance['00280009']), + frameTime: DICOMWeb.getNumber(instance['00181063']), + frameTimeVector: parseFloatArray(DICOMWeb.getString(instance['00181065'])), + sliceThickness: DICOMWeb.getNumber(instance['00180050']), + spacingBetweenSlices: DICOMWeb.getString(instance['00180088']), + lossyImageCompression: DICOMWeb.getString(instance['00282110']), + derivationDescription: DICOMWeb.getString(instance['00282111']), + lossyImageCompressionRatio: DICOMWeb.getString(instance['00282112']), + lossyImageCompressionMethod: DICOMWeb.getString(instance['00282114']), + echoNumber: DICOMWeb.getString(instance['00180086']), + contrastBolusAgent: DICOMWeb.getString(instance['00180010']), + radiopharmaceuticalInfo: getRadiopharmaceuticalInfo(instance), + baseWadoRsUri: baseWadoRsUri, + wadouri: WADOProxy.convertURL(wadouri, server), + wadorsuri: WADOProxy.convertURL(wadorsuri, server), + wadoRoot: server.wadoRoot, + imageRendering: server.imageRendering, + thumbnailRendering: server.thumbnailRendering, + }; + + // Get additional information if the instance uses "PALETTE COLOR" photometric interpretation + if (sopInstance.photometricInterpretation === 'PALETTE COLOR') { + const redPaletteColorLookupTableDescriptor = parseFloatArray( + DICOMWeb.getString(instance['00281101']) + ); + const greenPaletteColorLookupTableDescriptor = parseFloatArray( + DICOMWeb.getString(instance['00281102']) + ); + const bluePaletteColorLookupTableDescriptor = parseFloatArray( + DICOMWeb.getString(instance['00281103']) + ); + const palettes = await getPaletteColors( + server, + instance, + redPaletteColorLookupTableDescriptor + ); + + if (palettes) { + if (palettes.uid) { + sopInstance.paletteColorLookupTableUID = palettes.uid; + } + + sopInstance.redPaletteColorLookupTableData = palettes.red; + sopInstance.greenPaletteColorLookupTableData = palettes.green; + sopInstance.bluePaletteColorLookupTableData = palettes.blue; + sopInstance.redPaletteColorLookupTableDescriptor = redPaletteColorLookupTableDescriptor; + sopInstance.greenPaletteColorLookupTableDescriptor = greenPaletteColorLookupTableDescriptor; + sopInstance.bluePaletteColorLookupTableDescriptor = bluePaletteColorLookupTableDescriptor; + } + } + + series.instances.push(sopInstance); + return sopInstance; +} + +function getPaletteColor(server, instance, tag, lutDescriptor) { + const numLutEntries = lutDescriptor[0]; + const bits = lutDescriptor[2]; + + let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server); + + // TODO: Workaround for dcm4chee behind SSL-terminating proxy returning + // incorrect bulk data URIs + if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) { + uri = uri.replace('http', 'https'); + } + + const config = { + url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used + headers: DICOMWeb.getAuthorizationHeader(server), + }; + const dicomWeb = new api.DICOMwebClient(config); + const options = { + BulkDataURI: uri, + }; + + const readUInt16 = (byteArray, position) => { + return byteArray[position] + byteArray[position + 1] * 256; + }; + + const arrayBufferToPaletteColorLUT = result => { + const arraybuffer = result[0]; + const byteArray = new Uint8Array(arraybuffer); + const lut = []; + + for (let i = 0; i < numLutEntries; i++) { + if (bits === 16) { + lut[i] = readUInt16(byteArray, i * 2); + } else { + lut[i] = byteArray[i]; + } + } + + return lut; + }; + + return dicomWeb.retrieveBulkData(options).then(arrayBufferToPaletteColorLUT); +} + +/** + * Fetch palette colors for instances with "PALETTE COLOR" photometricInterpretation. + * + * @param server {Object} Current server; + * @param instance {Object} The retrieved instance metadata; + * @returns {String} The ReferenceSOPInstanceUID + */ +async function getPaletteColors(server, instance, lutDescriptor) { + let paletteUID = DICOMWeb.getString(instance['00281199']); + + return new Promise((resolve, reject) => { + let entry; + if (paletteColorCache.isValidUID(paletteUID)) { + entry = paletteColorCache.get(paletteUID); + + if (entry) { + return resolve(entry); + } + } + + // no entry in cache... Fetch remote data. + const r = getPaletteColor(server, instance, '00281201', lutDescriptor); + const g = getPaletteColor(server, instance, '00281202', lutDescriptor); + const b = getPaletteColor(server, instance, '00281203', lutDescriptor); + + const promises = [r, g, b]; + + Promise.all(promises).then(args => { + entry = { + red: args[0], + green: args[1], + blue: args[2], + }; + + // when paletteUID is present, the entry can be cached... + entry.uid = paletteUID; + paletteColorCache.add(entry); + + resolve(entry); + }); + }); +} + +/** + * Add a list of SOP Instances to a given study object descriptor + * @param {Object} server Object with server configuration parameters + * @param {Object} study The study descriptor to which the given SOP instances will be added + * @param {Array} sopInstanceList A list of SOP instance objects + */ +async function addInstancesToStudy(server, study, sopInstanceList) { + return Promise.all( + sopInstanceList.map(function(sopInstance) { + return makeSOPInstance(server, study, sopInstance); + }) + ); +} + +const createStudyFromSOPInstanceList = async (server, sopInstanceList) => { + if (Array.isArray(sopInstanceList) && sopInstanceList.length > 0) { + const firstSopInstance = sopInstanceList[0]; + const study = createStudy(server, firstSopInstance); + await addInstancesToStudy(server, study, sopInstanceList); + return study; + } + throw new Error('Failed to create study out of provided SOP instance list'); +}; + +export { createStudyFromSOPInstanceList, addInstancesToStudy }; diff --git a/platform/core/src/studies/sortStudy.js b/platform/core/src/studies/sortStudy.js index b02cbad19..6bfd8b4e6 100644 --- a/platform/core/src/studies/sortStudy.js +++ b/platform/core/src/studies/sortStudy.js @@ -1,17 +1,96 @@ +import getSeriesInfo from './getSeriesInfo'; + /** - * Sorts the series and instances inside a study instance by their series - * and instance numbers in ascending order. + * Series sorting criteria: series considered low priority are moved to the end + * of the list and series number is used to break ties + * @param {Object} firstSeries + * @param {Object} secondSeries + */ +function seriesInfoSortingCriteria(firstSeries, secondSeries) { + const a = getSeriesInfo(firstSeries); + const b = getSeriesInfo(secondSeries); + if (!a.isLowPriority && b.isLowPriority) { + return -1; + } + if (a.isLowPriority && !b.isLowPriority) { + return 1; + } + return a.seriesNumber - b.seriesNumber; +} + +const seriesSortCriteria = { + default: (a, b) => a.seriesNumber - b.seriesNumber, + seriesInfoSortingCriteria, +}; + +const instancesSortCriteria = { + default: (a, b) => a.instanceNumber - b.instanceNumber, +}; + +const sortingCriteria = { + seriesSortCriteria, + instancesSortCriteria, +}; + +/** + * Sorts given seriesList (given param is modified) + * The default criteria is based on series number in ascending order. + * + * @param {Array} seriesList List of series + * @param {function} seriesSortingCriteria method for sorting + * @returns {Array} sorted seriesList object + */ +const sortStudySeries = ( + seriesList, + seriesSortingCriteria = seriesSortCriteria.default +) => { + return seriesList.sort(seriesSortingCriteria); +}; + +/** + * Sorts given instancesList (given param is modified) + * The default criteria is based on instance number in ascending order. + * + * @param {Array} instancesList List of series + * @param {function} instancesSortingCriteria method for sorting + * @returns {Array} sorted instancesList object + */ +const sortStudyInstances = ( + instancesList, + instancesSortingCriteria = instancesSortCriteria.default +) => { + return instancesList.sort(instancesSortingCriteria); +}; + +/** + * Sorts the series and instances (by default) inside a study instance based on sortingCriteria (given param is modified) + * The default criteria is based on series and instance numbers in ascending order. * * @param {Object} study The study instance + * @param {boolean} [deepSort = true] to sort instance also + * @param {function} [seriesSortingCriteria = seriesSortCriteria.default] method for sorting series + * @param {function} [instancesSortingCriteria = instancesSortCriteria.default] method for sorting instances + * @returns {Object} sorted study object */ -export default function sortStudy(study) { +export default function sortStudy( + study, + deepSort = true, + seriesSortingCriteria = seriesSortCriteria.default, + instancesSortingCriteria = instancesSortCriteria.default +) { if (!study || !study.seriesList) { throw new Error('Insufficient study data was provided to sortStudy'); } - study.seriesList.sort((a, b) => a.seriesNumber - b.seriesNumber); + sortStudySeries(study.seriesList, seriesSortingCriteria); - study.seriesList.forEach(series => { - series.instances.sort((a, b) => a.instanceNumber - b.instanceNumber); - }); + if (deepSort) { + study.seriesList.forEach(series => { + sortStudyInstances(series.instances, instancesSortingCriteria); + }); + } + + return study; } + +export { sortStudySeries, sortStudyInstances, sortingCriteria }; diff --git a/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js b/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js index 1fe47c813..d898c3968 100644 --- a/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js +++ b/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js @@ -4,6 +4,7 @@ import { metadata, studies, utils, log } from '@ohif/core'; import ConnectedViewer from './ConnectedViewer.js'; import PropTypes from 'prop-types'; import { extensionManager } from './../App.js'; +import { withSnackbar } from '@ohif/ui'; const { OHIFStudyMetadata, OHIFSeriesMetadata } = metadata; const { retrieveStudiesMetadata, deleteStudyMetadataPromise } = studies; @@ -29,11 +30,21 @@ class ViewerRetrieveStudyData extends Component { async loadStudies() { try { const { server, studyInstanceUids, seriesInstanceUids } = this.props; + const filters = {}; + + // Use the first, discard others + const seriesInstanceUID = seriesInstanceUids && seriesInstanceUids[0]; + + if (seriesInstanceUID) { + filters.seriesInstanceUID = seriesInstanceUID; + } + const studies = await retrieveStudiesMetadata( server, studyInstanceUids, - seriesInstanceUids + filters ); + this.validateFilters(studies, filters); this.setStudies(studies); } catch (e) { this.setState({ error: true }); @@ -41,6 +52,32 @@ class ViewerRetrieveStudyData extends Component { } } + /** + * Validate filters and promp user a message in case filter is unsuccessfully applied. + * In case of success, studies array contains, as the first element, the queried content (from filter) + * @param {Array} studies array of studies to be evaluated + * @param {Object} filters filters to test against + */ + validateFilters(studies = [], filters = {}) { + const { seriesInstanceUID } = filters; + + const { snackbarContext } = this.props; + // skip in case no filter or no toast manager + if (!seriesInstanceUID || !snackbarContext) { + return; + } + + const firstStudy = studies[0] || {}; + const { seriesList = [] } = firstStudy; + const firstSeries = seriesList[0]; + + if (!firstSeries || firstSeries.seriesInstanceUid !== seriesInstanceUID) { + snackbarContext.show({ + message: 'No series for given filter: ' + seriesInstanceUID, + }); + } + } + setStudies(givenStudies) { if (Array.isArray(givenStudies) && givenStudies.length > 0) { const sopClassHandlerModules = @@ -151,4 +188,4 @@ class ViewerRetrieveStudyData extends Component { } } -export default ViewerRetrieveStudyData; +export default withSnackbar(ViewerRetrieveStudyData); diff --git a/platform/viewer/src/routes/ViewerRouting.js b/platform/viewer/src/routes/ViewerRouting.js index b26cd99d2..ade4e7c3f 100644 --- a/platform/viewer/src/routes/ViewerRouting.js +++ b/platform/viewer/src/routes/ViewerRouting.js @@ -7,15 +7,15 @@ const { urlUtil: UrlUtil } = OHIF.utils; /** * Get array of seriesUIDs from param or from queryString - * @param {*} seriesInstanceUIDs + * @param {*} seriesInstanceUids * @param {*} location */ -const getSeriesInstanceUIDs = (seriesInstanceUIDs, routeLocation) => { +const getSeriesInstanceUIDs = (seriesInstanceUids, routeLocation) => { const queryFilters = UrlUtil.queryString.getQueryFilters(routeLocation); - const querySeriesUIDs = queryFilters && queryFilters['SeriesInstanceUID']; - const _seriesInstanceUIDs = seriesInstanceUIDs || querySeriesUIDs; + const querySeriesUIDs = queryFilters && queryFilters['seriesInstanceUID']; + const _seriesInstanceUids = seriesInstanceUids || querySeriesUIDs; - return UrlUtil.paramString.parseParam(_seriesInstanceUIDs); + return UrlUtil.paramString.parseParam(_seriesInstanceUids); }; function ViewerRouting({ match: routeMatch, location: routeLocation }) { @@ -29,14 +29,14 @@ function ViewerRouting({ match: routeMatch, location: routeLocation }) { } = routeMatch.params; const server = useServer({ project, location, dataset, dicomStore }); - const studyUIDs = UrlUtil.paramString.parseParam(studyInstanceUids); - const seriesUIDs = getSeriesInstanceUIDs(seriesInstanceUids, routeLocation); + const studyUids = UrlUtil.paramString.parseParam(studyInstanceUids); + const seriesUids = getSeriesInstanceUIDs(seriesInstanceUids, routeLocation); - if (server && studyUIDs) { + if (server && studyUids) { return ( ); }