From 43b1c17209502e4876ad59bae09ed9442eda8024 Mon Sep 17 00:00:00 2001 From: Alireza Date: Fri, 10 Nov 2023 16:51:23 -0500 Subject: [PATCH] feat(dicomJSON): Add Loading Other Display Sets and JSON Metadata Generation script (#3777) --- .scripts/dicom-json-generator.js | 265 ++++++++++++++++++ .../src/getSopClassHandlerModule.ts | 6 +- .../src/utils/dicomLoaderService.js | 8 + .../default/src/DicomJSONDataSource/index.js | 25 +- platform/core/src/utils/addAccessors.js | 66 +++++ platform/core/src/utils/index.js | 2 + platform/core/src/utils/index.test.js | 1 + .../configuration/dataSources/dicom-json.md | 16 ++ platform/docs/docs/configuration/url.md | 4 +- 9 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 .scripts/dicom-json-generator.js create mode 100644 platform/core/src/utils/addAccessors.js diff --git a/.scripts/dicom-json-generator.js b/.scripts/dicom-json-generator.js new file mode 100644 index 000000000..5b9f45a16 --- /dev/null +++ b/.scripts/dicom-json-generator.js @@ -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 + * + * 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 '); + 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); diff --git a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts index 5cdf455f6..d89849fdf 100644 --- a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts +++ b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts @@ -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 => diff --git a/extensions/cornerstone/src/utils/dicomLoaderService.js b/extensions/cornerstone/src/utils/dicomLoaderService.js index 79cb2053e..b9d5ea6dd 100644 --- a/extensions/cornerstone/src/utils/dicomLoaderService.js +++ b/extensions/cornerstone/src/utils/dicomLoaderService.js @@ -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 }); } } diff --git a/extensions/default/src/DicomJSONDataSource/index.js b/extensions/default/src/DicomJSONDataSource/index.js index c4bfb75ce..d124c8b73 100644 --- a/extensions/default/src/DicomJSONDataSource/index.js +++ b/extensions/default/src/DicomJSONDataSource/index.js @@ -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, diff --git a/platform/core/src/utils/addAccessors.js b/platform/core/src/utils/addAccessors.js new file mode 100644 index 000000000..c9f2fa528 --- /dev/null +++ b/platform/core/src/utils/addAccessors.js @@ -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; diff --git a/platform/core/src/utils/index.js b/platform/core/src/utils/index.js index 3f5d66e41..d7862618e 100644 --- a/platform/core/src/utils/index.js +++ b/platform/core/src/utils/index.js @@ -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, diff --git a/platform/core/src/utils/index.test.js b/platform/core/src/utils/index.test.js index 5ac07149c..30d8f70ef 100644 --- a/platform/core/src/utils/index.test.js +++ b/platform/core/src/utils/index.test.js @@ -40,6 +40,7 @@ describe('Top level exports', () => { 'progressTrackingUtils', 'subscribeToNextViewportGridChange', 'uuidv4', + 'addAccessors', ].sort(); const exports = Object.keys(utils.default).sort(); diff --git a/platform/docs/docs/configuration/dataSources/dicom-json.md b/platform/docs/docs/configuration/dataSources/dicom-json.md index 37fbda552..f85bf95b7 100644 --- a/platform/docs/docs/configuration/dataSources/dicom-json.md +++ b/platform/docs/docs/configuration/dataSources/dicom-json.md @@ -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": [ diff --git a/platform/docs/docs/configuration/url.md b/platform/docs/docs/configuration/url.md index fc8eaa2e9..25879f4ba 100644 --- a/platform/docs/docs/configuration/url.md +++ b/platform/docs/docs/configuration/url.md @@ -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