feat(dicomJSON): Add Loading Other Display Sets and JSON Metadata Generation script (#3777)

This commit is contained in:
Alireza 2023-11-10 16:51:23 -05:00 committed by GitHub
parent 5b513fd30e
commit 43b1c17209
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 390 additions and 3 deletions

View 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);

View File

@ -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 =>

View File

@ -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 });
}
}

View File

@ -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,

View 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;

View File

@ -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,

View File

@ -40,6 +40,7 @@ describe('Top level exports', () => {
'progressTrackingUtils',
'subscribeToNextViewportGridChange',
'uuidv4',
'addAccessors',
].sort();
const exports = Object.keys(utils.default).sort();

View File

@ -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": [

View File

@ -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