feat(dicomJSON): Add Loading Other Display Sets and JSON Metadata Generation script (#3777)
This commit is contained in:
parent
5b513fd30e
commit
43b1c17209
265
.scripts/dicom-json-generator.js
Normal file
265
.scripts/dicom-json-generator.js
Normal file
@ -0,0 +1,265 @@
|
||||
/*
|
||||
* This script uses nodejs to generate a JSON file from a DICOM study folder.
|
||||
* You need to have dcmjs installed in your project.
|
||||
* The JSON file can be used to load the study into the OHIF Viewer. You can get more detail
|
||||
* in the DICOM JSON Data source on docs.ohif.org
|
||||
*
|
||||
* Usage: node dicomStudyToJSONLaunch.js <studyFolder> <urlPrefix> <outputJSONPath>
|
||||
*
|
||||
* params:
|
||||
* - studyFolder: path to the study folder
|
||||
* - urlPrefix: prefix to the url that will be used to load the study into the viewer. For instance
|
||||
* we use https://ohif-assets.s3.us-east-2.amazonaws.com/dicom-json/data as the urlPrefix for the
|
||||
* example since the data is hosted on S3 and each study is in a folder. So the url in the generated
|
||||
* json file for the first instance of the first series of the first study will be
|
||||
* dicomweb:https://ohif-assets.s3.us-east-2.amazonaws.com/dicom-json/data/Series1/Instance1
|
||||
* - outputJSONPath: path to the output JSON file
|
||||
*/
|
||||
const dcmjs = require('dcmjs');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const [studyDirectory, urlPrefix, outputPath] = args;
|
||||
|
||||
if (args.length !== 3) {
|
||||
console.error('Usage: node dicomStudyToJSONLaunch.js <studyFolder> <urlPrefix> <outputJSONPath>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const model = {
|
||||
studies: [],
|
||||
};
|
||||
|
||||
async function convertDICOMToJSON(studyDirectory, urlPrefix, outputPath) {
|
||||
try {
|
||||
const files = await recursiveReadDir(studyDirectory);
|
||||
console.debug('Processing...');
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.includes('.DS_Store') && !file.includes('.xml')) {
|
||||
const arrayBuffer = await fs.readFile(file);
|
||||
const dicomDict = dcmjs.data.DicomMessage.readFile(arrayBuffer.buffer);
|
||||
const instance = dcmjs.data.DicomMetaDictionary.naturalizeDataset(dicomDict.dict);
|
||||
|
||||
instance.fileLocation = createImageId(file, urlPrefix, studyDirectory);
|
||||
processInstance(instance);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Successfully loaded data');
|
||||
|
||||
model.studies.forEach(study => {
|
||||
study.NumInstances = findInstancesNumber(study);
|
||||
study.Modalities = findModalities(study).join('/');
|
||||
});
|
||||
|
||||
await fs.writeFile(outputPath, JSON.stringify(model, null, 2));
|
||||
console.log('JSON saved');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function recursiveReadDir(dir) {
|
||||
let results = [];
|
||||
const list = await fs.readdir(dir);
|
||||
for (const file of list) {
|
||||
const filePath = path.resolve(dir, file);
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
const res = await recursiveReadDir(filePath);
|
||||
results = results.concat(res);
|
||||
} else {
|
||||
results.push(filePath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function createImageId(fileLocation, urlPrefix, studyDirectory) {
|
||||
const relativePath = path.relative(studyDirectory, fileLocation);
|
||||
const normalizedPath = path.normalize(relativePath).replace(/\\/g, '/');
|
||||
return `dicomweb:${urlPrefix}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function processInstance(instance) {
|
||||
const { StudyInstanceUID, SeriesInstanceUID } = instance;
|
||||
let study = getStudy(StudyInstanceUID);
|
||||
|
||||
if (!study) {
|
||||
study = createStudyMetadata(StudyInstanceUID, instance);
|
||||
model.studies.push(study);
|
||||
}
|
||||
|
||||
let series = getSeries(StudyInstanceUID, SeriesInstanceUID);
|
||||
|
||||
if (!series) {
|
||||
series = createSeriesMetadata(instance);
|
||||
study.series.push(series);
|
||||
}
|
||||
|
||||
const instanceMetaData =
|
||||
instance.NumberOfFrames > 1
|
||||
? createInstanceMetaDataMultiFrame(instance)
|
||||
: createInstanceMetaData(instance);
|
||||
|
||||
series.instances.push(...[].concat(instanceMetaData));
|
||||
}
|
||||
|
||||
function getStudy(StudyInstanceUID) {
|
||||
return model.studies.find(study => study.StudyInstanceUID === StudyInstanceUID);
|
||||
}
|
||||
|
||||
function getSeries(StudyInstanceUID, SeriesInstanceUID) {
|
||||
const study = getStudy(StudyInstanceUID);
|
||||
return study
|
||||
? study.series.find(series => series.SeriesInstanceUID === SeriesInstanceUID)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const findInstancesNumber = study => {
|
||||
let numInstances = 0;
|
||||
study.series.forEach(aSeries => {
|
||||
numInstances = numInstances + aSeries.instances.length;
|
||||
});
|
||||
return numInstances;
|
||||
};
|
||||
|
||||
const findModalities = study => {
|
||||
let modalities = new Set();
|
||||
study.series.forEach(aSeries => {
|
||||
modalities.add(aSeries.Modality);
|
||||
});
|
||||
return Array.from(modalities);
|
||||
};
|
||||
|
||||
function createStudyMetadata(StudyInstanceUID, instance) {
|
||||
return {
|
||||
StudyInstanceUID,
|
||||
StudyDescription: instance.StudyDescription,
|
||||
StudyDate: instance.StudyDate,
|
||||
StudyTime: instance.StudyTime,
|
||||
PatientName: instance.PatientName,
|
||||
PatientID: instance.PatientID || '1234', // this is critical to have
|
||||
AccessionNumber: instance.AccessionNumber,
|
||||
PatientAge: instance.PatientAge,
|
||||
PatientSex: instance.PatientSex,
|
||||
PatientWeight: instance.PatientWeight,
|
||||
series: [],
|
||||
};
|
||||
}
|
||||
function createSeriesMetadata(instance) {
|
||||
return {
|
||||
SeriesInstanceUID: instance.SeriesInstanceUID,
|
||||
SeriesDescription: instance.SeriesDescription,
|
||||
SeriesNumber: instance.SeriesNumber,
|
||||
SeriesTime: instance.SeriesTime,
|
||||
Modality: instance.Modality,
|
||||
SliceThickness: instance.SliceThickness,
|
||||
instances: [],
|
||||
};
|
||||
}
|
||||
function commonMetaData(instance) {
|
||||
return {
|
||||
Columns: instance.Columns,
|
||||
Rows: instance.Rows,
|
||||
InstanceNumber: instance.InstanceNumber,
|
||||
SOPClassUID: instance.SOPClassUID,
|
||||
AcquisitionNumber: instance.AcquisitionNumber,
|
||||
PhotometricInterpretation: instance.PhotometricInterpretation,
|
||||
BitsAllocated: instance.BitsAllocated,
|
||||
BitsStored: instance.BitsStored,
|
||||
PixelRepresentation: instance.PixelRepresentation,
|
||||
SamplesPerPixel: instance.SamplesPerPixel,
|
||||
PixelSpacing: instance.PixelSpacing,
|
||||
HighBit: instance.HighBit,
|
||||
ImageOrientationPatient: instance.ImageOrientationPatient,
|
||||
ImagePositionPatient: instance.ImagePositionPatient,
|
||||
FrameOfReferenceUID: instance.FrameOfReferenceUID,
|
||||
ImageType: instance.ImageType,
|
||||
Modality: instance.Modality,
|
||||
SOPInstanceUID: instance.SOPInstanceUID,
|
||||
SeriesInstanceUID: instance.SeriesInstanceUID,
|
||||
StudyInstanceUID: instance.StudyInstanceUID,
|
||||
WindowCenter: instance.WindowCenter,
|
||||
WindowWidth: instance.WindowWidth,
|
||||
RescaleIntercept: instance.RescaleIntercept,
|
||||
RescaleSlope: instance.RescaleSlope,
|
||||
};
|
||||
}
|
||||
|
||||
function conditionalMetaData(instance) {
|
||||
return {
|
||||
...(instance.ConceptNameCodeSequence && {
|
||||
ConceptNameCodeSequence: instance.ConceptNameCodeSequence,
|
||||
}),
|
||||
...(instance.SeriesDate && { SeriesDate: instance.SeriesDate }),
|
||||
...(instance.ReferencedSeriesSequence && {
|
||||
ReferencedSeriesSequence: instance.ReferencedSeriesSequence,
|
||||
}),
|
||||
...(instance.SharedFunctionalGroupsSequence && {
|
||||
SharedFunctionalGroupsSequence: instance.SharedFunctionalGroupsSequence,
|
||||
}),
|
||||
...(instance.PerFrameFunctionalGroupsSequence && {
|
||||
PerFrameFunctionalGroupsSequence: instance.PerFrameFunctionalGroupsSequence,
|
||||
}),
|
||||
...(instance.ContentSequence && { ContentSequence: instance.ContentSequence }),
|
||||
...(instance.ContentTemplateSequence && {
|
||||
ContentTemplateSequence: instance.ContentTemplateSequence,
|
||||
}),
|
||||
...(instance.CurrentRequestedProcedureEvidenceSequence && {
|
||||
CurrentRequestedProcedureEvidenceSequence: instance.CurrentRequestedProcedureEvidenceSequence,
|
||||
}),
|
||||
...(instance.CodingSchemeIdentificationSequence && {
|
||||
CodingSchemeIdentificationSequence: instance.CodingSchemeIdentificationSequence,
|
||||
}),
|
||||
...(instance.RadiopharmaceuticalInformationSequence && {
|
||||
RadiopharmaceuticalInformationSequence: instance.RadiopharmaceuticalInformationSequence,
|
||||
}),
|
||||
...(instance.ROIContourSequence && {
|
||||
ROIContourSequence: instance.ROIContourSequence,
|
||||
}),
|
||||
...(instance.StructureSetROISequence && {
|
||||
StructureSetROISequence: instance.StructureSetROISequence,
|
||||
}),
|
||||
...(instance.ReferencedFrameOfReferenceSequence && {
|
||||
ReferencedFrameOfReferenceSequence: instance.ReferencedFrameOfReferenceSequence,
|
||||
}),
|
||||
...(instance.CorrectedImage && { CorrectedImage: instance.CorrectedImage }),
|
||||
...(instance.Units && { Units: instance.Units }),
|
||||
...(instance.DecayCorrection && { DecayCorrection: instance.DecayCorrection }),
|
||||
...(instance.AcquisitionDate && { AcquisitionDate: instance.AcquisitionDate }),
|
||||
...(instance.AcquisitionTime && { AcquisitionTime: instance.AcquisitionTime }),
|
||||
...(instance.PatientWeight && { PatientWeight: instance.PatientWeight }),
|
||||
...(instance.NumberOfFrames && { NumberOfFrames: instance.NumberOfFrames }),
|
||||
...(instance.FrameTime && { FrameTime: instance.FrameTime }),
|
||||
...(instance.EncapsulatedDocument && { EncapsulatedDocument: instance.EncapsulatedDocument }),
|
||||
...(instance.SequenceOfUltrasoundRegions && {
|
||||
SequenceOfUltrasoundRegions: instance.SequenceOfUltrasoundRegions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createInstanceMetaData(instance) {
|
||||
const metadata = {
|
||||
...commonMetaData(instance),
|
||||
...conditionalMetaData(instance),
|
||||
};
|
||||
return { metadata, url: instance.fileLocation };
|
||||
}
|
||||
|
||||
function createInstanceMetaDataMultiFrame(instance) {
|
||||
const instances = [];
|
||||
const commonData = commonMetaData(instance);
|
||||
const conditionalData = conditionalMetaData(instance);
|
||||
|
||||
for (let i = 1; i <= instance.NumberOfFrames; i++) {
|
||||
const metadata = { ...commonData, ...conditionalData };
|
||||
const result = { metadata, url: instance.fileLocation + `?frame=${i}` };
|
||||
instances.push(result);
|
||||
}
|
||||
return instances;
|
||||
}
|
||||
|
||||
convertDICOMToJSON(studyDirectory, urlPrefix, outputPath);
|
||||
@ -200,7 +200,11 @@ function _checkIfCanAddMeasurementsToDisplaySet(srDisplaySet, newDisplaySet, dat
|
||||
return;
|
||||
}
|
||||
|
||||
const { sopClassUids, images } = newDisplaySet;
|
||||
if (newDisplaySet.unsupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sopClassUids } = newDisplaySet;
|
||||
|
||||
// Check if any have the newDisplaySet is the correct SOPClass.
|
||||
unloadedMeasurements = unloadedMeasurements.filter(measurement =>
|
||||
|
||||
@ -176,6 +176,7 @@ class DicomLoaderService {
|
||||
authorizationHeaders,
|
||||
wadoRoot,
|
||||
wadoUri,
|
||||
instance,
|
||||
} = dataset;
|
||||
// Retrieve wadors or just try to fetch wadouri
|
||||
if (!someInvalidStrings(wadoRoot)) {
|
||||
@ -188,6 +189,13 @@ class DicomLoaderService {
|
||||
);
|
||||
} else if (!someInvalidStrings(wadoUri)) {
|
||||
return fetchIt(wadoUri, { headers: authorizationHeaders });
|
||||
} else if (!someInvalidStrings(instance?.url)) {
|
||||
// make sure the url is absolute, remove the scope
|
||||
// from it if it is not absolute. For instance it might be dicomweb:http://....
|
||||
// and we need to remove the dicomweb: part
|
||||
const url = instance.url;
|
||||
const absoluteUrl = url.startsWith('http') ? url : url.substring(url.indexOf(':') + 1);
|
||||
return fetchIt(absoluteUrl, { headers: authorizationHeaders });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,23 @@ let _store = {
|
||||
// }
|
||||
};
|
||||
|
||||
function wrapSequences(obj) {
|
||||
return Object.keys(obj).reduce(
|
||||
(acc, key) => {
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
// Recursively wrap sequences for nested objects
|
||||
acc[key] = wrapSequences(obj[key]);
|
||||
} else {
|
||||
acc[key] = obj[key];
|
||||
}
|
||||
if (key.endsWith('Sequence')) {
|
||||
acc[key] = OHIF.utils.addAccessors(acc[key]);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
Array.isArray(obj) ? [] : {}
|
||||
);
|
||||
}
|
||||
const getMetaDataByURL = url => {
|
||||
return _store.urls.find(metaData => metaData.url === url);
|
||||
};
|
||||
@ -190,8 +207,14 @@ function createDicomJSONApi(dicomJsonConfig) {
|
||||
const numberOfSeries = series.length;
|
||||
series.forEach((series, index) => {
|
||||
const instances = series.instances.map(instance => {
|
||||
// for instance.metadata if the key ends with sequence then
|
||||
// we need to add a proxy to the first item in the sequence
|
||||
// so that we can access the value of the sequence
|
||||
// by using sequenceName.value
|
||||
const modifiedMetadata = wrapSequences(instance.metadata);
|
||||
|
||||
const obj = {
|
||||
...instance.metadata,
|
||||
...modifiedMetadata,
|
||||
url: instance.url,
|
||||
imageId: instance.url,
|
||||
...series,
|
||||
|
||||
66
platform/core/src/utils/addAccessors.js
Normal file
66
platform/core/src/utils/addAccessors.js
Normal file
@ -0,0 +1,66 @@
|
||||
const handler = {
|
||||
/**
|
||||
* Get a proxied value from the array or property value
|
||||
* Note that the property value get works even if you update the underlying object.
|
||||
* Also, return true of proxy.__isProxy in order to distinguish proxies and not double proxy them.
|
||||
*/
|
||||
get: (target, prop) => {
|
||||
if (prop == '__isProxy') {
|
||||
return true;
|
||||
}
|
||||
if (prop in target) {
|
||||
return target[prop];
|
||||
}
|
||||
return target[0][prop];
|
||||
},
|
||||
|
||||
set: (obj, prop, value) => {
|
||||
if (typeof prop === 'number' || prop in obj) {
|
||||
obj[prop] = value;
|
||||
} else {
|
||||
obj[0][prop] = value;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a proxy object for sqZero or the src[0] element if sqZero is unspecified, AND
|
||||
* src is an array of length 1.
|
||||
*
|
||||
* If sqZero isn't passed in, then assume this is a create call on the destination object
|
||||
* itself, by:
|
||||
* 1. If not an object, return dest
|
||||
* 2. If an array of length != 1, return dest
|
||||
* 3. If an array, use dest[0] as sqZero
|
||||
* 4. Use dest as sqZero
|
||||
*
|
||||
* @example
|
||||
* src = [{a:5,b:'string', c:null}]
|
||||
* addAccessors(src)
|
||||
* src.c = 'outerChange'
|
||||
* src[0].b='innerChange'
|
||||
*
|
||||
* assert src.a===5
|
||||
* assert src[0].c === 'outerChange'
|
||||
* assert src.b === 'innerChange'
|
||||
*/
|
||||
const addAccessors = (dest, sqZero) => {
|
||||
if (dest.__isProxy) {
|
||||
return dest;
|
||||
}
|
||||
let itemZero = sqZero;
|
||||
if (itemZero === undefined) {
|
||||
if (typeof dest !== 'object') {
|
||||
return dest;
|
||||
}
|
||||
if (Array.isArray(dest) && dest.length !== 1) {
|
||||
return dest;
|
||||
}
|
||||
itemZero = Array.isArray(dest) ? dest[0] : dest;
|
||||
}
|
||||
const ret = [itemZero];
|
||||
return new Proxy(ret, handler);
|
||||
};
|
||||
|
||||
export default addAccessors;
|
||||
@ -27,6 +27,7 @@ import debounce from './debounce';
|
||||
import roundNumber from './roundNumber';
|
||||
import downloadCSVReport from './downloadCSVReport';
|
||||
import isEqualWithin from './isEqualWithin';
|
||||
import addAccessors from './addAccessors';
|
||||
import {
|
||||
sortStudy,
|
||||
sortStudySeries,
|
||||
@ -65,6 +66,7 @@ const utils = {
|
||||
Queue,
|
||||
isDicomUid,
|
||||
isEqualWithin,
|
||||
addAccessors,
|
||||
resolveObjectPath,
|
||||
hierarchicalListUtils,
|
||||
progressTrackingUtils,
|
||||
|
||||
@ -40,6 +40,7 @@ describe('Top level exports', () => {
|
||||
'progressTrackingUtils',
|
||||
'subscribeToNextViewportGridChange',
|
||||
'uuidv4',
|
||||
'addAccessors',
|
||||
].sort();
|
||||
|
||||
const exports = Object.keys(utils.default).sort();
|
||||
|
||||
@ -28,6 +28,20 @@ dataset. Let's have a look at the JSON file:
|
||||
JSON file stores the metadata for the study level, series level and instance
|
||||
level. A JSON launch file should follow the same structure as the one below.
|
||||
|
||||
:::tip
|
||||
You can use our script to generate the JSON file from a hosted endpoint. See
|
||||
`.scripts/dicom-json-generator.js`
|
||||
|
||||
You could run it like this:
|
||||
|
||||
```bash
|
||||
node .scripts/dicom-json-generator.js '/path/to/study/folder' 'url/to/dicom/server/folder' 'json/output/file.json'
|
||||
```
|
||||
|
||||
Some modalities require additional metadata to be added to the JSON file. You can read more about the minimum amount of metadata required for the viewer to work [here](../../faq.md#what-are-the-list-of-required-metadata-for-the-ohif-viewer-to-work). We will handle this in the script. For example, the script will add the CodeSequences for SR in order to display the measurements in the viewer.
|
||||
:::
|
||||
|
||||
|
||||
Note that at the instance level metadata we are storing both the `metadata` and
|
||||
also the `url` for the dicom file on the dicom server. In this case we are
|
||||
referring to
|
||||
@ -35,6 +49,8 @@ referring to
|
||||
which is stored in another directory in our s3. (You can actually try
|
||||
downloading the dicom file by opening the url in your browser).
|
||||
|
||||
The URL to the script in the given example is `https://ohif-dicom-json-example.s3.amazonaws.com/LIDC-IDRI-0001/01-01-2000-30178`. This URL serves as the parent directory that contains all the series within their respective folders.
|
||||
|
||||
```json
|
||||
{
|
||||
"studies": [
|
||||
|
||||
@ -62,7 +62,9 @@ WorkList by adding the `dataSources` query parameter.
|
||||
/?dataSources=orthanc
|
||||
```
|
||||
|
||||
Note: you should pass the `sourceName` of the data source in the configuration file (not the friendly name nor the name)
|
||||
Note1: You should pass the `sourceName` of the data source in the configuration file (not the friendly name nor the name)
|
||||
Note2: Make sure that the configuration file you are using actually includes that data source. You cannot use a data source from another configuration file.
|
||||
|
||||
|
||||
:::tip
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user