From 9e10c2b2ded5dcf5c05779ca9ef9a7fc453750ad Mon Sep 17 00:00:00 2001 From: Emanuel Fiuza de Oliveira Date: Thu, 3 Oct 2019 04:51:29 -0300 Subject: [PATCH] feat: Use QIDO + WADO to load series metadata individually rather than the entire study metadata at once (#953) --- .../core/src/__mocks__/dicomweb-client.js | 10 +- .../src/classes/metadata/StudyMetadata.js | 276 ++++++---- .../studies/services/wado/retrieveMetadata.js | 507 +++++++++++------- .../core/src/utils/isLowPriorityModality.js | 5 + .../core/src/utils/updateMetaDataManager.js | 64 ++- .../components/studyBrowser/ImageThumbnail.js | 75 +-- .../studyBrowser/ImageThumbnail.styl | 31 +- .../components/studyBrowser/StudyBrowser.js | 4 +- platform/viewer/public/config/default.js | 1 + platform/viewer/public/config/demo.js | 1 + platform/viewer/public/config/netlify.js | 1 + .../ViewerRetrieveStudyData.js | 114 ++-- 12 files changed, 675 insertions(+), 414 deletions(-) create mode 100644 platform/core/src/utils/isLowPriorityModality.js diff --git a/platform/core/src/__mocks__/dicomweb-client.js b/platform/core/src/__mocks__/dicomweb-client.js index e91680ed0..c076a1d72 100644 --- a/platform/core/src/__mocks__/dicomweb-client.js +++ b/platform/core/src/__mocks__/dicomweb-client.js @@ -1,10 +1,12 @@ // import { api } from 'dicomweb-client' const api = { - DICOMwebClient: jest.fn().mockImplementation(() => { - return { - retrieveStudyMetadata: jest.fn().mockResolvedValue([]), - }; + DICOMwebClient: jest.fn().mockImplementation(function() { + this.retrieveStudyMetadata = jest.fn().mockResolvedValue([]); + this.retrieveSeriesMetadata = jest.fn(function(options) { + const { studyInstanceUID, seriesInstanceUID } = options; + return Promise.resolve([{ studyInstanceUID, seriesInstanceUID }]); + }); }), }; diff --git a/platform/core/src/classes/metadata/StudyMetadata.js b/platform/core/src/classes/metadata/StudyMetadata.js index 82d00bf1c..c43a50f2d 100644 --- a/platform/core/src/classes/metadata/StudyMetadata.js +++ b/platform/core/src/classes/metadata/StudyMetadata.js @@ -8,7 +8,8 @@ import { SeriesMetadata } from './SeriesMetadata'; // - createStacks import { api } from 'dicomweb-client'; // - createStacks -import { isImage } from './../../utils/isImage'; +import { isImage } from '../../utils/isImage'; +import isLowPriorityModality from '../../utils/isLowPriorityModality'; export class StudyMetadata extends Metadata { constructor(data, uid) { @@ -88,6 +89,100 @@ export class StudyMetadata extends Metadata { return this._displaySets.slice(); } + /** + * Split a series metadata object into display sets + * @param {Array} sopClassHandlerModules List of SOP Class Modules + * @param {SeriesMetadata} series The series metadata object from which the display sets will be created + * @param {Array} [givenDisplaySets] An optional list to which the display sets will be appended + * @returns {Array} The list of display sets created for the given series object + */ + _createDisplaySetsForSeries( + sopClassHandlerModules, + series, + givenDisplaySets + ) { + const study = this; + const displaySets = Array.isArray(givenDisplaySets) ? givenDisplaySets : []; + const anyInstances = series.getInstanceCount() > 0; + + if (!anyInstances) { + return; + } + + const sopClassUids = getSopClassUids(series); + + if (sopClassHandlerModules && sopClassHandlerModules.length > 0) { + const displaySet = _getDisplaySetFromSopClassModule( + sopClassHandlerModules, + series, + study, + sopClassUids + ); + if (displaySet) { + displaySet.sopClassModule = true; + displaySets.push(displaySet); + return; + } + } + + // WE NEED A BETTER WAY TO NOTE THAT THIS IS THE DEFAULT BEHAVIOR FOR LOADING + // A DISPLAY SET IF THERE IS NO MATCHING SOP CLASS PLUGIN + + // Search through the instances (InstanceMetadata object) of this series + // Split Multi-frame instances and Single-image modalities + // into their own specific display sets. Place the rest of each + // series into another display set. + const stackableInstances = []; + series.forEachInstance(instance => { + // All imaging modalities must have a valid value for sopClassUid (x00080016) or rows (x00280010) + if ( + !isImage(instance.getRawValue('x00080016')) && + !instance.getRawValue('x00280010') + ) { + return; + } + + let displaySet; + + if (isMultiFrame(instance)) { + displaySet = makeDisplaySet(series, [instance]); + displaySet.setAttributes({ + sopClassUids, + isClip: true, + seriesInstanceUid: series.getSeriesInstanceUID(), + studyInstanceUid: study.getStudyInstanceUID(), // Include the study instance Uid for drag/drop purposes + numImageFrames: instance.getRawValue('x00280008'), // Override the default value of instances.length + instanceNumber: instance.getRawValue('x00200013'), // Include the instance number + acquisitionDatetime: instance.getRawValue('x0008002a'), // Include the acquisition datetime + }); + displaySets.push(displaySet); + } else if (isSingleImageModality(instance.modality)) { + displaySet = makeDisplaySet(series, [instance]); + displaySet.setAttributes({ + sopClassUids, + studyInstanceUid: study.getStudyInstanceUID(), // Include the study instance Uid + seriesInstanceUid: series.getSeriesInstanceUID(), + instanceNumber: instance.getRawValue('x00200013'), // Include the instance number + acquisitionDatetime: instance.getRawValue('x0008002a'), // Include the acquisition datetime + }); + displaySets.push(displaySet); + } else { + stackableInstances.push(instance); + } + }); + + if (stackableInstances.length) { + const displaySet = makeDisplaySet(series, stackableInstances); + displaySet.setAttribute('studyInstanceUid', study.getStudyInstanceUID()); + displaySet.setAttributes({ + sopClassUids, + }); + displaySets.push(displaySet); + } + + return displaySets; + } + /** * Creates a set of series to be placed in the Study Metadata * The series that appear in the Study Metadata must represent @@ -101,105 +196,44 @@ export class StudyMetadata extends Metadata { * @returns {Array} An array of series to be placed in the Study Metadata */ createDisplaySets(sopClassHandlerModules) { - const study = this; const displaySets = []; - const anyDisplaySets = study.getSeriesCount(); - const anySopClassHandlerModules = - sopClassHandlerModules && sopClassHandlerModules.length > 0; + const anyDisplaySets = this.getSeriesCount(); if (!anyDisplaySets) { return displaySets; } // Loop through the series (SeriesMetadata) - this.forEachSeries(series => { - const anyInstances = series.getInstanceCount() > 0; - if (!anyInstances) { - return; - } - - const sopClassUids = getSopClassUids(series); - - if (anySopClassHandlerModules) { - const displaySet = _getDisplaySetFromSopClassModule( + this.forEachSeries( + series => + void this._createDisplaySetsForSeries( sopClassHandlerModules, series, - study, - sopClassUids - ); + displaySets + ) + ); - if (displaySet) { - displaySet.sopClassModule = true; - displaySets.push(displaySet); + return sortDisplaySetList(displaySets); + } - return; - } - } + sortDisplaySets() { + sortDisplaySetList(this._displaySets); + } - // WE NEED A BETTER WAY TO NOTE THAT THIS IS THE DEFAULT BEHAVIOR FOR LOADING - // A DISPLAY SET IF THERE IS NO MATCHING SOP CLASS PLUGIN - - // Search through the instances (InstanceMetadata object) of this series - // Split Multi-frame instances and Single-image modalities - // into their own specific display sets. Place the rest of each - // series into another display set. - const stackableInstances = []; - series.forEachInstance(instance => { - // All imaging modalities must have a valid value for sopClassUid (x00080016) or rows (x00280010) - if ( - !isImage(instance.getRawValue('x00080016')) && - !instance.getRawValue('x00280010') - ) { - return; - } - - let displaySet; - - if (isMultiFrame(instance)) { - displaySet = makeDisplaySet(series, [instance]); - displaySet.setAttributes({ - sopClassUids, - isClip: true, - seriesInstanceUid: series.getSeriesInstanceUID(), - studyInstanceUid: study.getStudyInstanceUID(), // Include the study instance Uid for drag/drop purposes - numImageFrames: instance.getRawValue('x00280008'), // Override the default value of instances.length - instanceNumber: instance.getRawValue('x00200013'), // Include the instance number - acquisitionDatetime: instance.getRawValue('x0008002a'), // Include the acquisition datetime - }); - displaySets.push(displaySet); - } else if (isSingleImageModality(instance.modality)) { - displaySet = makeDisplaySet(series, [instance]); - displaySet.setAttributes({ - sopClassUids, - studyInstanceUid: study.getStudyInstanceUID(), // Include the study instance Uid - seriesInstanceUid: series.getSeriesInstanceUID(), - instanceNumber: instance.getRawValue('x00200013'), // Include the instance number - acquisitionDatetime: instance.getRawValue('x0008002a'), // Include the acquisition datetime - }); - displaySets.push(displaySet); - } else { - stackableInstances.push(instance); - } - }); - - if (stackableInstances.length) { - const displaySet = makeDisplaySet(series, stackableInstances); - displaySet.setAttribute( - 'studyInstanceUid', - study.getStudyInstanceUID() - ); - displaySet.setAttributes({ - sopClassUids, - }); - displaySets.push(displaySet); - } - }); - - // TODO - displaySets.sort(_sortBySeriesNumber); - displaySets.sort(_sortSegToEndOfList); - - return displaySets; + /** + * Method to append display sets from a given series to the internal list of display sets + * @param {Array} sopClassHandlerModules A list of SOP Class Handler Modules + * @param {SeriesMetadata} series The series metadata object from which the display sets will be created + * @returns {boolean} Returns true on success or false on failure (e.g., the series does not belong to this study) + */ + createAndAddDisplaySetsForSeries(sopClassHandlerModules, series) { + if (this.containsSeries(series)) { + this.setDisplaySets( + this._createDisplaySetsForSeries(sopClassHandlerModules, series) + ); + return true; + } + return false; } /** @@ -207,7 +241,10 @@ export class StudyMetadata extends Metadata { * @param {Array} displaySets Array of display sets (ImageSet[]) */ setDisplaySets(displaySets) { - displaySets.forEach(displaySet => this.addDisplaySet(displaySet)); + if (Array.isArray(displaySets) && displaySets.length > 0) { + displaySets.forEach(displaySet => this.addDisplaySet(displaySet)); + this.sortDisplaySets(); + } } /** @@ -321,6 +358,12 @@ export class StudyMetadata extends Metadata { return found; } + containsSeries(series) { + return ( + series instanceof SeriesMetadata && this._series.indexOf(series) >= 0 + ); + } + /** * Retrieve the number of series within the current study. * @returns {number} The number of series in the current study. @@ -651,11 +694,41 @@ function _getDisplaySetFromSopClassModule( } /** + * Sort series primarily by modality (i.e., series with references to other + * series like SEG, KO or PR are grouped in the end of the list) and then by + * series number: + * + * -------- + * | CT #3 | + * | CT #4 | + * | CT #5 | + * -------- + * | SEG #1 | + * | SEG #2 | + * -------- * * @param {*} a - DisplaySet * @param {*} b - DisplaySet */ -function _sortBySeriesNumber(a, b) { + +function seriesSortingCriteria(a, b) { + const isLowPriorityA = isLowPriorityModality(a.modality); + const isLowPriorityB = isLowPriorityModality(b.modality); + if (!isLowPriorityA && isLowPriorityB) { + return -1; + } + if (isLowPriorityA && !isLowPriorityB) { + return 1; + } + return sortBySeriesNumber(a, b); +} + +/** + * Sort series by series number. Series with low + * @param {*} a - DisplaySet + * @param {*} b - DisplaySet + */ +function sortBySeriesNumber(a, b) { const seriesNumberAIsGreaterOrUndefined = a.seriesNumber > b.seriesNumber || (!a.seriesNumber && b.seriesNumber); @@ -663,22 +736,9 @@ function _sortBySeriesNumber(a, b) { } /** - * Move Segmentation modality files to the end of the list of - * display sets. This is a workaround to prevent issues when - * the referenced dataset's metadata is not yet available. - * - * It will be removed once proper SEG ingestion is added. - * - * @param {*} a - DisplaySet - * @param {*} b - DisplaySet + * Sorts a list of display set objects + * @param {Array} list A list of display sets to be sorted */ -function _sortSegToEndOfList(a, b) { - const displaySetAIsSeg = a.modality === 'SEG'; - const displaySetBIsSeg = b.modality === 'SEG'; - - if (displaySetAIsSeg && displaySetBIsSeg) { - return 0; - } - - return displaySetAIsSeg ? 1 : -1; -} \ No newline at end of file +function sortDisplaySetList(list) { + return list.sort(seriesSortingCriteria); +} diff --git a/platform/core/src/studies/services/wado/retrieveMetadata.js b/platform/core/src/studies/services/wado/retrieveMetadata.js index a2fdebb00..c33708e14 100644 --- a/platform/core/src/studies/services/wado/retrieveMetadata.js +++ b/platform/core/src/studies/services/wado/retrieveMetadata.js @@ -1,5 +1,8 @@ import { api } from 'dicomweb-client'; import DICOMWeb from '../../../DICOMWeb/'; +import isLowPriorityModality from '../../../utils/isLowPriorityModality'; + +const INFO = Symbol('INFO'); const WADOProxy = { convertURL: (url, server) => { @@ -94,9 +97,7 @@ function buildInstanceWadoRsUri( seriesInstanceUid, sopInstanceUid ) { - return `${ - server.wadoRoot - }/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`; + return `${server.wadoRoot}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`; } function buildInstanceFrameWadoRsUri( @@ -166,9 +167,9 @@ function getPaletteColor(server, instance, tag, lutDescriptor) { }; const arrayBufferToPaletteColorLUT = result => { - const arraybuffer = result[0] - const byteArray = new Uint8Array(arraybuffer) - const lut = [] + const arraybuffer = result[0]; + const byteArray = new Uint8Array(arraybuffer); + const lut = []; for (let i = 0; i < numLutEntries; i++) { if (bits === 16) { @@ -271,200 +272,338 @@ function getRadiopharmaceuticalInfo(instance) { }; } +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 server - * @param studyInstanceUid - * @param resultData - * @returns {{seriesList: Array, patientName: *, patientId: *, accessionNumber: *, studyDate: *, modalities: *, studyDescription: *, imageCount: *, studyInstanceUid: *}} + * @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 resultDataToStudyMetadata(server, studyInstanceUid, resultData) { - if (!resultData.length) { - return; +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; } - - const anInstance = resultData[0]; - if (!anInstance) { - return; - } - - // TODO: Pass a reference ID to the server instead of including the URLs here - const studyData = { - seriesList: [], - wadoUriRoot: server.wadoUriRoot, - wadoRoot: server.wadoRoot, - qidoRoot: server.qidoRoot, - patientName: DICOMWeb.getName(anInstance['00100010']), - patientId: DICOMWeb.getString(anInstance['00100020']), - patientAge: DICOMWeb.getNumber(anInstance['00101010']), - patientSize: DICOMWeb.getNumber(anInstance['00101020']), - patientWeight: DICOMWeb.getNumber(anInstance['00101030']), - accessionNumber: DICOMWeb.getString(anInstance['00080050']), - studyDate: DICOMWeb.getString(anInstance['00080020']), - modalities: DICOMWeb.getString(anInstance['00080061']), - studyDescription: DICOMWeb.getString(anInstance['00081030']), - imageCount: DICOMWeb.getString(anInstance['00201208']), - studyInstanceUid: DICOMWeb.getString(anInstance['0020000D']), - institutionName: DICOMWeb.getString(anInstance['00080080']), - }; - - const seriesMap = {}; - - await Promise.all( - resultData.map(async function(instance) { - const seriesInstanceUid = DICOMWeb.getString(instance['0020000E']); - let series = seriesMap[seriesInstanceUid]; - - if (!series) { - series = { - seriesDescription: DICOMWeb.getString(instance['0008103E']), - modality: DICOMWeb.getString(instance['00080060']), - seriesInstanceUid: seriesInstanceUid, - seriesNumber: DICOMWeb.getNumber(instance['00200011']), - seriesDate: DICOMWeb.getString(instance['00080021']), - seriesTime: DICOMWeb.getString(instance['00080031']), - instances: [], - }; - seriesMap[seriesInstanceUid] = series; - studyData.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 instanceSummary = { - 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 (instanceSummary.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) { - instanceSummary.paletteColorLookupTableUID = palettes.uid; - } - - instanceSummary.redPaletteColorLookupTableData = palettes.red; - instanceSummary.greenPaletteColorLookupTableData = palettes.green; - instanceSummary.bluePaletteColorLookupTableData = palettes.blue; - instanceSummary.redPaletteColorLookupTableDescriptor = redPaletteColorLookupTableDescriptor; - instanceSummary.greenPaletteColorLookupTableDescriptor = greenPaletteColorLookupTableDescriptor; - instanceSummary.bluePaletteColorLookupTableDescriptor = bluePaletteColorLookupTableDescriptor; - } - } - - series.instances.push(instanceSummary); - }) - ); - - return studyData; + throw new Error('Failed to create study out of provided SOP instance list'); } /** - * Retrieve Study MetaData from a DICOM server using a WADO call - * - * @param server - * @param studyInstanceUid - * @returns {Promise} + * 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 + * @param {string} studyInstanceUid The Study Instance UID of the study which needs to be loaded + * @returns {Object} A study descriptor object */ async function RetrieveMetadata(server, studyInstanceUid) { - const config = { + return (server.enableStudyLazyLoad + ? lazyLoadStudyMetadata + : loadStudyMetadata)(server, studyInstanceUid); +} + +/** + * + * @param {*} server + * @param {*} studyInstanceUID + */ +async function loadStudyMetadata(server, studyInstanceUID) { + const dicomWeb = new api.DICOMwebClient({ url: server.wadoRoot, headers: DICOMWeb.getAuthorizationHeader(server), - }; - const dicomWeb = new api.DICOMwebClient(config); - const options = { - studyInstanceUID: studyInstanceUid, - }; + }); + return dicomWeb + .retrieveStudyMetadata({ studyInstanceUID }) + .then(result => createStudyFromSOPInstanceList(server, result)); +} - return dicomWeb.retrieveStudyMetadata(options).then(result => { - return resultDataToStudyMetadata(server, studyInstanceUid, 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( + server, + firstSeries.sopInstances + ); + if (seriesLoader.hasNext()) { + attachSeriesLoader(server, study, seriesLoader); + } + return study; +} + +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; +} + export default RetrieveMetadata; diff --git a/platform/core/src/utils/isLowPriorityModality.js b/platform/core/src/utils/isLowPriorityModality.js new file mode 100644 index 000000000..218a3c3b5 --- /dev/null +++ b/platform/core/src/utils/isLowPriorityModality.js @@ -0,0 +1,5 @@ +const LOW_PRIORITY_MODALITIES = Object.freeze(['SEG', 'KO', 'PR']); + +export default function isLowPriorityModality(modality) { + return LOW_PRIORITY_MODALITIES.includes(modality); +} diff --git a/platform/core/src/utils/updateMetaDataManager.js b/platform/core/src/utils/updateMetaDataManager.js index e4e60a40d..e4746f0d1 100644 --- a/platform/core/src/utils/updateMetaDataManager.js +++ b/platform/core/src/utils/updateMetaDataManager.js @@ -70,32 +70,48 @@ const getWadoRsInstanceMetaData = (study, series, instance) => { .toJSON(); }; -export default function updateMetaDataManager(study) { - study.seriesList.forEach(series => { - series.instances.forEach(instance => { - // Cache just images that are going to be loaded via WADO-RS - if ( - instance.imageRendering !== 'wadors' && - instance.thumbnailRendering !== 'wadors' - ) { - return; - } +/** + * Update metadata manager with instances of a specifc series + * @param {Object} study A plain study descriptor object + * @param {Object} series A Series descriptor object contaning the instances to be added to the manager + */ +function updateMetaDataManagerForSeries(study, series) { + series.instances.forEach(instance => { + // Cache just images that are going to be loaded via WADO-RS + if ( + instance.imageRendering !== 'wadors' && + instance.thumbnailRendering !== 'wadors' + ) { + return; + } - const metaData = getWadoRsInstanceMetaData(study, series, instance); - const numberOfFrames = instance.numberOfFrames || 1; + const metaData = getWadoRsInstanceMetaData(study, series, instance); + const numberOfFrames = instance.numberOfFrames || 1; - // We can share the same metaData with all frames because it doesn't have - // any frame specific data, such as frameNumber, pixelData, offset, etc. - // WADO-RS frame number is 1-based - for (let frameNumber = 0; frameNumber < numberOfFrames; frameNumber++) { - const imageId = getWADORSImageId(instance, frameNumber); + // We can share the same metaData with all frames because it doesn't have + // any frame specific data, such as frameNumber, pixelData, offset, etc. + // WADO-RS frame number is 1-based + for (let frameNumber = 0; frameNumber < numberOfFrames; frameNumber++) { + const imageId = getWADORSImageId(instance, frameNumber); - // TODO Drop dependency on this - cornerstoneWADOImageLoader.wadors.metaDataManager.add( - imageId, - metaData - ); - } - }); + // TODO Drop dependency on this + cornerstoneWADOImageLoader.wadors.metaDataManager.add(imageId, metaData); + } }); } + +/** + * Update metadata manager + * @param {Object} study A plain study descriptor object + * @param {string} [seriesInstanceUid] The Series Instance UID of the series to be added (Optional) + */ +export default function updateMetaDataManager(study, seriesInstanceUid) { + if (seriesInstanceUid) { + const series = study.seriesMap[seriesInstanceUid]; + updateMetaDataManagerForSeries(study, series); + } else { + study.seriesList.forEach(series => { + updateMetaDataManagerForSeries(study, series); + }); + } +} diff --git a/platform/ui/src/components/studyBrowser/ImageThumbnail.js b/platform/ui/src/components/studyBrowser/ImageThumbnail.js index 95a5e34a8..01b99c9c4 100644 --- a/platform/ui/src/components/studyBrowser/ImageThumbnail.js +++ b/platform/ui/src/components/studyBrowser/ImageThumbnail.js @@ -1,3 +1,4 @@ +/* global cornerstone */ import './ImageThumbnail.styl'; import React, { PureComponent } from 'react'; @@ -12,25 +13,6 @@ import ViewportLoadingIndicator from '../../viewer/ViewportLoadingIndicator'; // - Pass in the entire load and render function as a prop? //import cornerstone from 'cornerstone-core'; -/** - * Asynchronous wrapper around Cornerstone's renderToCanvas method. - * - * @param {HTMLElement} canvasElement An HTML element - * @param {Image} image A Cornerstone Image - * - * @return {Promise} A promise tracking the progress of the rendering. Resolves empty. - */ -function renderAsync(canvasElement, image) { - return new Promise((resolve, reject) => { - try { - cornerstone.renderToCanvas(canvasElement, image); - resolve(); - } catch (error) { - reject(error); - } - }); -} - export default class ImageThumbnail extends PureComponent { static propTypes = { imageSrc: PropTypes.string, @@ -52,40 +34,32 @@ export default class ImageThumbnail extends PureComponent { super(props); this.canvas = React.createRef(); - - const renderIntoCanvas = this.props.imageId && !this.props.imageSrc; - this.state = { - loading: renderIntoCanvas, + loading: this.shouldRenderToCanvas(), }; } + shouldRenderToCanvas() { + return this.props.imageId && !this.props.imageSrc; + } + componentDidMount() { - const renderIntoCanvas = this.props.imageId && !this.props.imageSrc; - - if (renderIntoCanvas) { - const { imageId } = this.props; - const canvas = this.canvas.current; - - cornerstone.loadAndCacheImage(imageId).then( - image => { - renderAsync(canvas, image).then( - () => { - this.setState({ - loading: false, - }); - }, - error => { - // TODO: Set state? - throw new Error(error); - } - ); - }, - error => { - // TODO: Set state? + if (this.shouldRenderToCanvas()) { + cornerstone + .loadAndCacheImage(this.props.imageId) + .then(image => { + cornerstone.renderToCanvas(this.canvas.current, image); + this.setState({ + loading: false, + }); + }) + .catch(error => { + this.setState({ + loading: false, + error: true, + }); throw new Error(error); - } - ); + }); } } @@ -100,12 +74,10 @@ export default class ImageThumbnail extends PureComponent { const showStackLoadingProgressBar = this.props.stackPercentComplete !== undefined; - const renderIntoCanvas = this.props.imageId && !this.props.imageSrc; - return (
- {renderIntoCanvas ? ( + {this.shouldRenderToCanvas() ? (
)} + {this.state.loading && ( +
+ )}
); } diff --git a/platform/ui/src/components/studyBrowser/ImageThumbnail.styl b/platform/ui/src/components/studyBrowser/ImageThumbnail.styl index 606ffb9d1..dce5c7d41 100644 --- a/platform/ui/src/components/studyBrowser/ImageThumbnail.styl +++ b/platform/ui/src/components/studyBrowser/ImageThumbnail.styl @@ -26,6 +26,36 @@ -webkit-background-clip: padding background-clip: padding-box + // Thumbnail Loading Indicator + + .image-thumbnail-loading-indicator + position: absolute + left: 50% + top: 50% + margin: -22px 0 0 -22px + display: block + width: 44px + height: 44px + + &:after + content: " " + display: block + width: 32px + height: 32px + margin: 1px + border-radius: 50% + border: 5px solid var(--ui-border-color-dark) + border-color: var(--ui-border-color-dark) transparent var(--ui-border-color-dark) transparent + animation: image-thumbnail-loading-indicator 1.2s linear infinite + + @keyframes image-thumbnail-loading-indicator + 0% + transform: rotate(0deg) + 100% + transform: rotate(360deg) + + // The End... + .image-thumbnail-canvas height: 100% overflow: hidden @@ -36,7 +66,6 @@ -webkit-user-drag: none pointer-events: none - .thumbnailLoadingIndicator display: none pointer-events: none diff --git a/platform/ui/src/components/studyBrowser/StudyBrowser.js b/platform/ui/src/components/studyBrowser/StudyBrowser.js index 59cda8c17..8b72f359f 100644 --- a/platform/ui/src/components/studyBrowser/StudyBrowser.js +++ b/platform/ui/src/components/studyBrowser/StudyBrowser.js @@ -25,7 +25,7 @@ class StudyBrowser extends Component { if (this.props.supportsDragAndDrop) { return ( 0) { + const sopClassHandlerModules = + extensionManager.modules['sopClassHandlerModule']; // Map studies to new format, update metadata manager? - const updatedStudies = studies.map(study => { + const studies = givenStudies.map(study => { const studyMetadata = new OHIFStudyMetadata( study, study.studyInstanceUid ); - const sopClassHandlerModules = - extensionManager.modules["sopClassHandlerModule"]; - - study.displaySets = - study.displaySets || - studyMetadata.createDisplaySets(sopClassHandlerModules); + if (!study.displaySets) { + study.displaySets = studyMetadata.createDisplaySets( + sopClassHandlerModules + ); + } studyMetadata.setDisplaySets(study.displaySets); - // Updates WADO-RS metaDataManager updateMetaDataManager(study); - studyMetadataManager.add(studyMetadata); - + // Attempt to load remaning series if any + this._attemptToLoadRemainingSeries(studyMetadata); return study; }); - - this.setState({ - studies: updatedStudies - }); - } catch (err) { - this.setState({ - error: true - }); - - // TODO: Handle gracefully instead of throwing? - throw new Error(err); + this.setState({ studies }); } } + _addSeriesToStudy(studyMetadata, series) { + const sopClassHandlerModules = + extensionManager.modules['sopClassHandlerModule']; + const study = studyMetadata.getData(); + const seriesMetadata = new OHIFSeriesMetadata(series, study); + studyMetadata.addSeries(seriesMetadata); + studyMetadata.createAndAddDisplaySetsForSeries( + sopClassHandlerModules, + seriesMetadata + ); + study.displaySets = studyMetadata.getDisplaySets(); + updateMetaDataManager(study, series.seriesInstanceUid); + this.setState(function(state) { + return { studies: state.studies.slice() }; + }); + } + + _attemptToLoadRemainingSeries(studyMetadata) { + const { seriesLoader } = studyMetadata.getData(); + if (!seriesLoader) { + return; + } + while (seriesLoader.hasNext()) { + seriesLoader + .next() + .then( + series => void this._addSeriesToStudy(studyMetadata, series), + error => void log.error(error) + ); + } + } + + componentDidMount() { + // TODO: CLEAR THIS SOMEWHERE ELSE + studyMetadataManager.purge(); + this.loadStudies(); + } + render() { if (this.state.error) { return
Error: {JSON.stringify(this.state.error)}
;