feat: Use QIDO + WADO to load series metadata individually rather than the entire study metadata at once (#953)

This commit is contained in:
Emanuel Fiuza de Oliveira 2019-10-03 04:51:29 -03:00 committed by Erik Ziegler
parent c5cbd77cb9
commit 9e10c2b2de
12 changed files with 675 additions and 414 deletions

View File

@ -1,10 +1,12 @@
// import { api } from 'dicomweb-client' // import { api } from 'dicomweb-client'
const api = { const api = {
DICOMwebClient: jest.fn().mockImplementation(() => { DICOMwebClient: jest.fn().mockImplementation(function() {
return { this.retrieveStudyMetadata = jest.fn().mockResolvedValue([]);
retrieveStudyMetadata: jest.fn().mockResolvedValue([]), this.retrieveSeriesMetadata = jest.fn(function(options) {
}; const { studyInstanceUID, seriesInstanceUID } = options;
return Promise.resolve([{ studyInstanceUID, seriesInstanceUID }]);
});
}), }),
}; };

View File

@ -8,7 +8,8 @@ import { SeriesMetadata } from './SeriesMetadata';
// - createStacks // - createStacks
import { api } from 'dicomweb-client'; import { api } from 'dicomweb-client';
// - createStacks // - createStacks
import { isImage } from './../../utils/isImage'; import { isImage } from '../../utils/isImage';
import isLowPriorityModality from '../../utils/isLowPriorityModality';
export class StudyMetadata extends Metadata { export class StudyMetadata extends Metadata {
constructor(data, uid) { constructor(data, uid) {
@ -88,6 +89,100 @@ export class StudyMetadata extends Metadata {
return this._displaySets.slice(); 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 * Creates a set of series to be placed in the Study Metadata
* The series that appear in the Study Metadata must represent * 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 * @returns {Array} An array of series to be placed in the Study Metadata
*/ */
createDisplaySets(sopClassHandlerModules) { createDisplaySets(sopClassHandlerModules) {
const study = this;
const displaySets = []; const displaySets = [];
const anyDisplaySets = study.getSeriesCount(); const anyDisplaySets = this.getSeriesCount();
const anySopClassHandlerModules =
sopClassHandlerModules && sopClassHandlerModules.length > 0;
if (!anyDisplaySets) { if (!anyDisplaySets) {
return displaySets; return displaySets;
} }
// Loop through the series (SeriesMetadata) // Loop through the series (SeriesMetadata)
this.forEachSeries(series => { this.forEachSeries(
const anyInstances = series.getInstanceCount() > 0; series =>
if (!anyInstances) { void this._createDisplaySetsForSeries(
return;
}
const sopClassUids = getSopClassUids(series);
if (anySopClassHandlerModules) {
const displaySet = _getDisplaySetFromSopClassModule(
sopClassHandlerModules, sopClassHandlerModules,
series, series,
study, displaySets
sopClassUids )
); );
if (displaySet) { return sortDisplaySetList(displaySets);
displaySet.sopClassModule = true; }
displaySets.push(displaySet);
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 * 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
// Search through the instances (InstanceMetadata object) of this series * @param {SeriesMetadata} series The series metadata object from which the display sets will be created
// Split Multi-frame instances and Single-image modalities * @returns {boolean} Returns true on success or false on failure (e.g., the series does not belong to this study)
// into their own specific display sets. Place the rest of each */
// series into another display set. createAndAddDisplaySetsForSeries(sopClassHandlerModules, series) {
const stackableInstances = []; if (this.containsSeries(series)) {
series.forEachInstance(instance => { this.setDisplaySets(
// All imaging modalities must have a valid value for sopClassUid (x00080016) or rows (x00280010) this._createDisplaySetsForSeries(sopClassHandlerModules, series)
if ( );
!isImage(instance.getRawValue('x00080016')) && return true;
!instance.getRawValue('x00280010') }
) { return false;
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;
} }
/** /**
@ -207,7 +241,10 @@ export class StudyMetadata extends Metadata {
* @param {Array} displaySets Array of display sets (ImageSet[]) * @param {Array} displaySets Array of display sets (ImageSet[])
*/ */
setDisplaySets(displaySets) { 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; return found;
} }
containsSeries(series) {
return (
series instanceof SeriesMetadata && this._series.indexOf(series) >= 0
);
}
/** /**
* Retrieve the number of series within the current study. * Retrieve the number of series within the current study.
* @returns {number} The number of series in 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 {*} a - DisplaySet
* @param {*} b - 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 = const seriesNumberAIsGreaterOrUndefined =
a.seriesNumber > b.seriesNumber || (!a.seriesNumber && b.seriesNumber); 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 * Sorts a list of display set objects
* display sets. This is a workaround to prevent issues when * @param {Array} list A list of display sets to be sorted
* 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
*/ */
function _sortSegToEndOfList(a, b) { function sortDisplaySetList(list) {
const displaySetAIsSeg = a.modality === 'SEG'; return list.sort(seriesSortingCriteria);
const displaySetBIsSeg = b.modality === 'SEG'; }
if (displaySetAIsSeg && displaySetBIsSeg) {
return 0;
}
return displaySetAIsSeg ? 1 : -1;
}

View File

@ -1,5 +1,8 @@
import { api } from 'dicomweb-client'; import { api } from 'dicomweb-client';
import DICOMWeb from '../../../DICOMWeb/'; import DICOMWeb from '../../../DICOMWeb/';
import isLowPriorityModality from '../../../utils/isLowPriorityModality';
const INFO = Symbol('INFO');
const WADOProxy = { const WADOProxy = {
convertURL: (url, server) => { convertURL: (url, server) => {
@ -94,9 +97,7 @@ function buildInstanceWadoRsUri(
seriesInstanceUid, seriesInstanceUid,
sopInstanceUid sopInstanceUid
) { ) {
return `${ return `${server.wadoRoot}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`;
server.wadoRoot
}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`;
} }
function buildInstanceFrameWadoRsUri( function buildInstanceFrameWadoRsUri(
@ -166,9 +167,9 @@ function getPaletteColor(server, instance, tag, lutDescriptor) {
}; };
const arrayBufferToPaletteColorLUT = result => { const arrayBufferToPaletteColorLUT = result => {
const arraybuffer = result[0] const arraybuffer = result[0];
const byteArray = new Uint8Array(arraybuffer) const byteArray = new Uint8Array(arraybuffer);
const lut = [] const lut = [];
for (let i = 0; i < numLutEntries; i++) { for (let i = 0; i < numLutEntries; i++) {
if (bits === 16) { 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 * Parses result data from a WADO search into Study MetaData
* Returns an object populated with study metadata, including the * Returns an object populated with study metadata, including the
* series list. * series list.
* *
* @param server * @param {Object} server Object with server configuration paramenters
* @param studyInstanceUid * @param {Array} sopInstanceList List of SOP Instances that build up to the study
* @param resultData * @resolves {{seriesList: Array, patientName: *, patientId: *, accessionNumber: *, studyDate: *, modalities: *, studyDescription: *, imageCount: *, studyInstanceUid: *}}
* @returns {{seriesList: Array, patientName: *, patientId: *, accessionNumber: *, studyDate: *, modalities: *, studyDescription: *, imageCount: *, studyInstanceUid: *}}
*/ */
async function resultDataToStudyMetadata(server, studyInstanceUid, resultData) { async function createStudyFromSOPInstanceList(server, sopInstanceList) {
if (!resultData.length) { if (Array.isArray(sopInstanceList) && sopInstanceList.length > 0) {
return; 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');
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;
} }
/** /**
* Retrieve Study MetaData from a DICOM server using a WADO call * 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 server * @param {Object} server Object with server configuration paramenters
* @param studyInstanceUid * @param {string} studyInstanceUid The Study Instance UID of the study which needs to be loaded
* @returns {Promise} * @returns {Object} A study descriptor object
*/ */
async function RetrieveMetadata(server, studyInstanceUid) { 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, url: server.wadoRoot,
headers: DICOMWeb.getAuthorizationHeader(server), headers: DICOMWeb.getAuthorizationHeader(server),
}; });
const dicomWeb = new api.DICOMwebClient(config); return dicomWeb
const options = { .retrieveStudyMetadata({ studyInstanceUID })
studyInstanceUID: studyInstanceUid, .then(result => createStudyFromSOPInstanceList(server, result));
}; }
return dicomWeb.retrieveStudyMetadata(options).then(result => { async function lazyLoadStudyMetadata(server, studyInstanceUid) {
return resultDataToStudyMetadata(server, studyInstanceUid, result); 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; export default RetrieveMetadata;

View File

@ -0,0 +1,5 @@
const LOW_PRIORITY_MODALITIES = Object.freeze(['SEG', 'KO', 'PR']);
export default function isLowPriorityModality(modality) {
return LOW_PRIORITY_MODALITIES.includes(modality);
}

View File

@ -70,32 +70,48 @@ const getWadoRsInstanceMetaData = (study, series, instance) => {
.toJSON(); .toJSON();
}; };
export default function updateMetaDataManager(study) { /**
study.seriesList.forEach(series => { * Update metadata manager with instances of a specifc series
series.instances.forEach(instance => { * @param {Object} study A plain study descriptor object
// Cache just images that are going to be loaded via WADO-RS * @param {Object} series A Series descriptor object contaning the instances to be added to the manager
if ( */
instance.imageRendering !== 'wadors' && function updateMetaDataManagerForSeries(study, series) {
instance.thumbnailRendering !== 'wadors' series.instances.forEach(instance => {
) { // Cache just images that are going to be loaded via WADO-RS
return; if (
} instance.imageRendering !== 'wadors' &&
instance.thumbnailRendering !== 'wadors'
) {
return;
}
const metaData = getWadoRsInstanceMetaData(study, series, instance); const metaData = getWadoRsInstanceMetaData(study, series, instance);
const numberOfFrames = instance.numberOfFrames || 1; const numberOfFrames = instance.numberOfFrames || 1;
// We can share the same metaData with all frames because it doesn't have // We can share the same metaData with all frames because it doesn't have
// any frame specific data, such as frameNumber, pixelData, offset, etc. // any frame specific data, such as frameNumber, pixelData, offset, etc.
// WADO-RS frame number is 1-based // WADO-RS frame number is 1-based
for (let frameNumber = 0; frameNumber < numberOfFrames; frameNumber++) { for (let frameNumber = 0; frameNumber < numberOfFrames; frameNumber++) {
const imageId = getWADORSImageId(instance, frameNumber); const imageId = getWADORSImageId(instance, frameNumber);
// TODO Drop dependency on this // TODO Drop dependency on this
cornerstoneWADOImageLoader.wadors.metaDataManager.add( cornerstoneWADOImageLoader.wadors.metaDataManager.add(imageId, metaData);
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);
});
}
}

View File

@ -1,3 +1,4 @@
/* global cornerstone */
import './ImageThumbnail.styl'; import './ImageThumbnail.styl';
import React, { PureComponent } from 'react'; 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? // - Pass in the entire load and render function as a prop?
//import cornerstone from 'cornerstone-core'; //import cornerstone from 'cornerstone-core';
/**
* Asynchronous wrapper around Cornerstone's renderToCanvas method.
*
* @param {HTMLElement} canvasElement An HTML <canvas> 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 { export default class ImageThumbnail extends PureComponent {
static propTypes = { static propTypes = {
imageSrc: PropTypes.string, imageSrc: PropTypes.string,
@ -52,40 +34,32 @@ export default class ImageThumbnail extends PureComponent {
super(props); super(props);
this.canvas = React.createRef(); this.canvas = React.createRef();
const renderIntoCanvas = this.props.imageId && !this.props.imageSrc;
this.state = { this.state = {
loading: renderIntoCanvas, loading: this.shouldRenderToCanvas(),
}; };
} }
shouldRenderToCanvas() {
return this.props.imageId && !this.props.imageSrc;
}
componentDidMount() { componentDidMount() {
const renderIntoCanvas = this.props.imageId && !this.props.imageSrc; if (this.shouldRenderToCanvas()) {
cornerstone
if (renderIntoCanvas) { .loadAndCacheImage(this.props.imageId)
const { imageId } = this.props; .then(image => {
const canvas = this.canvas.current; cornerstone.renderToCanvas(this.canvas.current, image);
this.setState({
cornerstone.loadAndCacheImage(imageId).then( loading: false,
image => { });
renderAsync(canvas, image).then( })
() => { .catch(error => {
this.setState({ this.setState({
loading: false, loading: false,
}); error: true,
}, });
error => {
// TODO: Set state?
throw new Error(error);
}
);
},
error => {
// TODO: Set state?
throw new Error(error); throw new Error(error);
} });
);
} }
} }
@ -100,12 +74,10 @@ export default class ImageThumbnail extends PureComponent {
const showStackLoadingProgressBar = const showStackLoadingProgressBar =
this.props.stackPercentComplete !== undefined; this.props.stackPercentComplete !== undefined;
const renderIntoCanvas = this.props.imageId && !this.props.imageSrc;
return ( return (
<div className="ImageThumbnail"> <div className="ImageThumbnail">
<div className="image-thumbnail-canvas"> <div className="image-thumbnail-canvas">
{renderIntoCanvas ? ( {this.shouldRenderToCanvas() ? (
<canvas <canvas
ref={this.canvas} ref={this.canvas}
width={this.props.width} width={this.props.width}
@ -130,6 +102,9 @@ export default class ImageThumbnail extends PureComponent {
/> />
</div> </div>
)} )}
{this.state.loading && (
<div className="image-thumbnail-loading-indicator"></div>
)}
</div> </div>
); );
} }

View File

@ -26,6 +26,36 @@
-webkit-background-clip: padding -webkit-background-clip: padding
background-clip: padding-box 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 .image-thumbnail-canvas
height: 100% height: 100%
overflow: hidden overflow: hidden
@ -36,7 +66,6 @@
-webkit-user-drag: none -webkit-user-drag: none
pointer-events: none pointer-events: none
.thumbnailLoadingIndicator .thumbnailLoadingIndicator
display: none display: none
pointer-events: none pointer-events: none

View File

@ -25,7 +25,7 @@ class StudyBrowser extends Component {
if (this.props.supportsDragAndDrop) { if (this.props.supportsDragAndDrop) {
return ( return (
<ThumbnailEntryDragSource <ThumbnailEntryDragSource
key={`${studyIndex}_${thumbIndex}`} key={thumb.displaySetInstanceUid}
{...study} {...study}
{...thumb} {...thumb}
id={`${studyIndex}_${thumbIndex}`} id={`${studyIndex}_${thumbIndex}`}
@ -37,7 +37,7 @@ class StudyBrowser extends Component {
return ( return (
<div className="ThumbnailEntryContainer"> <div className="ThumbnailEntryContainer">
<ThumbnailEntry <ThumbnailEntry
key={`${studyIndex}_${thumbIndex}`} key={thumb.displaySetInstanceUid}
{...study} {...study}
{...thumb} {...thumb}
id={`${studyIndex}_${thumbIndex}`} id={`${studyIndex}_${thumbIndex}`}

View File

@ -13,6 +13,7 @@ window.config = {
qidoSupportsIncludeField: true, qidoSupportsIncludeField: true,
imageRendering: 'wadors', imageRendering: 'wadors',
thumbnailRendering: 'wadors', thumbnailRendering: 'wadors',
enableStudyLazyLoad: true,
}, },
], ],
}, },

View File

@ -12,6 +12,7 @@ window.config = {
qidoSupportsIncludeField: true, qidoSupportsIncludeField: true,
imageRendering: 'wadors', imageRendering: 'wadors',
thumbnailRendering: 'wadors', thumbnailRendering: 'wadors',
enableStudyLazyLoad: true,
}, },
], ],
}, },

View File

@ -11,6 +11,7 @@ window.config = {
qidoSupportsIncludeField: true, qidoSupportsIncludeField: true,
imageRendering: 'wadors', imageRendering: 'wadors',
thumbnailRendering: 'wadors', thumbnailRendering: 'wadors',
enableStudyLazyLoad: true,
}, },
], ],
}, },

View File

@ -1,11 +1,11 @@
import React, { Component } from "react"; import React, { Component } from 'react';
import { metadata, studies, utils } from "@ohif/core"; import { metadata, studies, utils, log } from '@ohif/core';
import ConnectedViewer from "./ConnectedViewer.js"; import ConnectedViewer from './ConnectedViewer.js';
import PropTypes from "prop-types"; import PropTypes from 'prop-types';
import { extensionManager } from "./../App.js"; import { extensionManager } from './../App.js';
const { OHIFStudyMetadata } = metadata; const { OHIFStudyMetadata, OHIFSeriesMetadata } = metadata;
const { retrieveStudiesMetadata } = studies; const { retrieveStudiesMetadata } = studies;
const { studyMetadataManager, updateMetaDataManager } = utils; const { studyMetadataManager, updateMetaDataManager } = utils;
@ -13,65 +13,97 @@ class ViewerRetrieveStudyData extends Component {
static propTypes = { static propTypes = {
studyInstanceUids: PropTypes.array.isRequired, studyInstanceUids: PropTypes.array.isRequired,
seriesInstanceUids: PropTypes.array, seriesInstanceUids: PropTypes.array,
server: PropTypes.object server: PropTypes.object,
}; };
state = { constructor(props) {
studies: null, super(props);
error: null this.state = {
}; studies: null,
error: null,
async componentDidMount() { };
// TODO: Avoid using timepoints here }
//const params = { studyInstanceUids, seriesInstanceUids, timepointId, timepointsFilter={} };
const { studyInstanceUids, seriesInstanceUids, server } = this.props;
async loadStudies() {
try { try {
const { server, studyInstanceUids, seriesInstanceUids } = this.props;
const studies = await retrieveStudiesMetadata( const studies = await retrieveStudiesMetadata(
server, server,
studyInstanceUids, studyInstanceUids,
seriesInstanceUids seriesInstanceUids
); );
this.setStudies(studies);
} catch (e) {
this.setState({ error: true });
log.error(e);
}
}
// Render the viewer when the data is ready setStudies(givenStudies) {
// TODO: CLEAR THIS SOMEWHERE ELSE if (Array.isArray(givenStudies) && givenStudies.length > 0) {
studyMetadataManager.purge(); const sopClassHandlerModules =
extensionManager.modules['sopClassHandlerModule'];
// Map studies to new format, update metadata manager? // Map studies to new format, update metadata manager?
const updatedStudies = studies.map(study => { const studies = givenStudies.map(study => {
const studyMetadata = new OHIFStudyMetadata( const studyMetadata = new OHIFStudyMetadata(
study, study,
study.studyInstanceUid study.studyInstanceUid
); );
const sopClassHandlerModules = if (!study.displaySets) {
extensionManager.modules["sopClassHandlerModule"]; study.displaySets = studyMetadata.createDisplaySets(
sopClassHandlerModules
study.displaySets = );
study.displaySets || }
studyMetadata.createDisplaySets(sopClassHandlerModules);
studyMetadata.setDisplaySets(study.displaySets); studyMetadata.setDisplaySets(study.displaySets);
// Updates WADO-RS metaDataManager // Updates WADO-RS metaDataManager
updateMetaDataManager(study); updateMetaDataManager(study);
studyMetadataManager.add(studyMetadata); studyMetadataManager.add(studyMetadata);
// Attempt to load remaning series if any
this._attemptToLoadRemainingSeries(studyMetadata);
return study; return study;
}); });
this.setState({ studies });
this.setState({
studies: updatedStudies
});
} catch (err) {
this.setState({
error: true
});
// TODO: Handle gracefully instead of throwing?
throw new Error(err);
} }
} }
_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() { render() {
if (this.state.error) { if (this.state.error) {
return <div>Error: {JSON.stringify(this.state.error)}</div>; return <div>Error: {JSON.stringify(this.state.error)}</div>;