From 819967692045c467d8a4ef9d4b0a73bfddacac06 Mon Sep 17 00:00:00 2001 From: Sjors Gielen Date: Sat, 12 Oct 2024 16:47:07 +0200 Subject: [PATCH 1/6] Initial version of a DICOM custom loader function. It allows dropping instances or series into an image drop area, and loading the DICOMs from there. The current version fails, because it tries to load the DICOMs as browser image objects. Instead, it should use the DICOM parsing from the dicomImageLoader. This isn't exposed yet, at the moment, which is the next thing I'm going to address. --- .../examples/dicomLoader/customImageLoader.ts | 193 ++++++++++++++++++ .../dicomLoader/hardcodedMetaDataProvider.ts | 65 ++++++ .../examples/dicomLoader/imageDropArea.ts | 122 +++++++++++ packages/core/examples/dicomLoader/index.ts | 92 +++++++++ packages/core/examples/dicomLoader/logArea.ts | 58 ++++++ 5 files changed, 530 insertions(+) create mode 100644 packages/core/examples/dicomLoader/customImageLoader.ts create mode 100644 packages/core/examples/dicomLoader/hardcodedMetaDataProvider.ts create mode 100644 packages/core/examples/dicomLoader/imageDropArea.ts create mode 100644 packages/core/examples/dicomLoader/index.ts create mode 100644 packages/core/examples/dicomLoader/logArea.ts diff --git a/packages/core/examples/dicomLoader/customImageLoader.ts b/packages/core/examples/dicomLoader/customImageLoader.ts new file mode 100644 index 0000000000..ce105b93e5 --- /dev/null +++ b/packages/core/examples/dicomLoader/customImageLoader.ts @@ -0,0 +1,193 @@ +import type * as cornerstone from '@cornerstonejs/core'; +import hardcodedMetaDataProvider from './hardcodedMetaDataProvider'; +import type { AddLogFn } from './logArea'; + +const canvas = document.createElement('canvas'); +let lastImageIdDrawn; + +// Todo: this loader should exist in a separate package in the same monorepo + +/** + * creates a cornerstone Image object for the specified Image and imageId + * + * @param image - An Image + * @param imageId - the imageId for this image + * @returns Cornerstone Image Object + */ +function createImage(image: HTMLImageElement, imageId: string) { + // extract the attributes we need + const rows = image.naturalHeight; + const columns = image.naturalWidth; + + function getPixelData(targetBuffer?: any) { + const imageData = getImageData(); + + let targetArray; + + // Check if targetBuffer is provided for volume viewports + if (targetBuffer) { + targetArray = new Uint8Array( + targetBuffer.arrayBuffer, + targetBuffer.offset, + targetBuffer.length + ); + } else { + targetArray = new Uint8Array(imageData.width * imageData.height * 3); + } + + // modify original image data and remove alpha channel (RGBA to RGB) + convertImageDataToRGB(imageData, targetArray); + + return targetArray; + } + + function convertImageDataToRGB(imageData, targetArray) { + for (let i = 0, j = 0; i < imageData.data.length; i += 4, j += 3) { + targetArray[j] = imageData.data[i]; + targetArray[j + 1] = imageData.data[i + 1]; + targetArray[j + 2] = imageData.data[i + 2]; + } + } + + function getImageData() { + let context; + + if (lastImageIdDrawn === imageId) { + context = canvas.getContext('2d'); + } else { + canvas.height = image.naturalHeight; + canvas.width = image.naturalWidth; + context = canvas.getContext('2d'); + context.drawImage(image, 0, 0); + lastImageIdDrawn = imageId; + } + + return context.getImageData(0, 0, image.naturalWidth, image.naturalHeight); + } + + function getCanvas() { + if (lastImageIdDrawn === imageId) { + return canvas; + } + + canvas.height = image.naturalHeight; + canvas.width = image.naturalWidth; + const context = canvas.getContext('2d'); + + context!.drawImage(image, 0, 0); + lastImageIdDrawn = imageId; + + return canvas; + } + + // Extract the various attributes we need + return { + imageId, + minPixelValue: 0, + maxPixelValue: 255, + slope: 1, + intercept: 0, + windowCenter: 128, + windowWidth: 255, + getPixelData, + getCanvas, + getImage: () => image, + rows, + columns, + height: rows, + width: columns, + color: true, + // we converted the canvas rgba already to rgb above + rgba: false, + columnPixelSpacing: 1, // for web it's always 1 + rowPixelSpacing: 1, // for web it's always 1 + invert: false, + sizeInBytes: rows * columns * 3, + numberOfComponents: 3, + }; +} + +function arrayBufferToImage( + arrayBuffer: ArrayBuffer +): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + const arrayBufferView = new Uint8Array(arrayBuffer); + const blob = new Blob([arrayBufferView]); + const urlCreator = window.URL || window.webkitURL; + const imageUrl = urlCreator.createObjectURL(blob); + + image.src = imageUrl; + image.onload = () => { + resolve(image); + urlCreator.revokeObjectURL(imageUrl); + }; + + image.onerror = (error) => { + urlCreator.revokeObjectURL(imageUrl); + reject(error); + }; + }); +} + +function _loadImageIntoBuffer( + imageId: string, + options: + | { + targetBuffer?: { + arrayBuffer: ArrayBuffer; + offset: number; + length: number; + }; + } + | undefined, + logFn: AddLogFn, + instanceToBytes: (instanceId: string) => Promise +): { + promise: Promise | boolean>; + cancelFn: () => void; +} { + const sopInstanceUid = imageId.replace('custom:', ''); + logFn('Custom loader is starting to load image: ', sopInstanceUid); + + const promise = async () => { + try { + const buffer = await instanceToBytes(sopInstanceUid); + const image = await arrayBufferToImage(buffer); + const imageObject = createImage(image, imageId); + + if ( + !options?.targetBuffer || + !options.targetBuffer.length || + !options.targetBuffer.offset + ) { + return imageObject; + } + + imageObject.getPixelData(options.targetBuffer); + return true; + } catch (e) { + logFn('failed to load image ID', imageId, e); + return false; + } + }; + + return { + promise: promise(), + cancelFn: () => {}, + }; +} + +function createCustomImageLoader( + logFn: AddLogFn, + instanceToBytes: (instanceId: string) => Promise +) { + return { + imageLoadFunction: (imageId: string, options: never) => { + return _loadImageIntoBuffer(imageId, options, logFn, instanceToBytes); + }, + metadataProvider: hardcodedMetaDataProvider, + }; +} + +export default createCustomImageLoader; diff --git a/packages/core/examples/dicomLoader/hardcodedMetaDataProvider.ts b/packages/core/examples/dicomLoader/hardcodedMetaDataProvider.ts new file mode 100644 index 0000000000..86717d9eb3 --- /dev/null +++ b/packages/core/examples/dicomLoader/hardcodedMetaDataProvider.ts @@ -0,0 +1,65 @@ +// Add hardcoded meta data provider for color images +export default function hardcodedMetaDataProvider(type, imageId, imageIds) { + const colonIndex = imageId.indexOf(':'); + const scheme = imageId.substring(0, colonIndex); + if (scheme !== 'web') { + return; + } + + if (type === 'imagePixelModule') { + const imagePixelModule = { + pixelRepresentation: 0, + bitsAllocated: 24, + bitsStored: 24, + highBit: 24, + photometricInterpretation: 'RGB', + samplesPerPixel: 3, + }; + + return imagePixelModule; + } else if (type === 'generalSeriesModule') { + const generalSeriesModule = { + modality: 'SC', + seriesNumber: 1, + seriesDescription: 'Color', + seriesDate: '20190201', + seriesTime: '120000', + seriesInstanceUID: '1.2.276.0.7230010.3.1.4.83233.20190201120000.1', + }; + + return generalSeriesModule; + } else if (type === 'imagePlaneModule') { + const index = imageIds.indexOf(imageId); + // console.warn(index); + const imagePlaneModule = { + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + imagePositionPatient: [0, 0, index * 5], + pixelSpacing: [1, 1], + columnPixelSpacing: 1, + rowPixelSpacing: 1, + frameOfReferenceUID: 'FORUID', + columns: 2048, + rows: 1216, + rowCosines: [1, 0, 0], + columnCosines: [0, 1, 0], + }; + + return imagePlaneModule; + } else if (type === 'voiLutModule') { + return { + // According to the DICOM standard, the width is the number of samples + // in the input, so 256 samples. + windowWidth: [256], + // The center is offset by 0.5 to allow for an integer value for even + // sample counts + windowCenter: [128], + }; + } else if (type === 'modalityLutModule') { + return { + rescaleSlope: 1, + rescaleIntercept: 0, + }; + } else { + return undefined; + } +} diff --git a/packages/core/examples/dicomLoader/imageDropArea.ts b/packages/core/examples/dicomLoader/imageDropArea.ts new file mode 100644 index 0000000000..e4ae6faad5 --- /dev/null +++ b/packages/core/examples/dicomLoader/imageDropArea.ts @@ -0,0 +1,122 @@ +/** + * The Image Drop Area represents any source of images not native to + * Cornerstone or its existing image loaders. For example, your image source + * may be a proprierary URL format, offline local storage or Websockets. + * + * For this file to achieve its intended goal, it should *not* import any + * Cornerstone code, but it may import the dicomParser. + */ + +import dicomParser from 'dicom-parser'; +import type { AddLogFn } from './logArea'; + +const SOP_INSTANCE_UID_TAG = 'x00080018'; +const SERIES_INSTANCE_UID_TAG = 'x0020000e'; + +function createImageDropArea(logFn: AddLogFn) { + const area = document.createElement('div'); + area.id = 'image-drop-area'; + area.style.width = '500px'; + area.style.height = '300px'; + area.style.background = 'lightblue'; + area.style.margin = '5px'; + area.style.padding = '5px'; + + const p = document.createElement('p'); + p.appendChild( + document.createTextNode( + 'Drop instances or series here to load them. Click on a series name to render it in Cornerstone.' + ) + ); + area.appendChild(p); + + const seriesDiv = document.createElement('div'); + area.appendChild(seriesDiv); + + const bytes: Record = {}; + const series: Record = {}; + + // This particular getInstanceBytes doesn't have to be async, but often, it will be. + const getInstanceBytes = (sopInstanceUid: string): Promise => { + if (sopInstanceUid in bytes) { + return Promise.resolve(bytes[sopInstanceUid]); + } else { + return Promise.reject('SOP instance UID not present in image drop area'); + } + }; + + let emit = (sopInstanceUids: string[]) => {}; + const setEmit = (newEmit: typeof emit) => { + emit = newEmit; + }; + + const redisplay = () => { + seriesDiv.replaceChildren(); + Object.entries(series).forEach(([series, instances]) => { + const div = document.createElement('div'); + div.appendChild( + document.createTextNode(`${series}: ${instances.length} instances.`) + ); + div.style.cursor = 'pointer'; + div.addEventListener('click', () => { + emit(instances); + }); + seriesDiv.append(div); + }); + }; + + area.ondragover = (event) => event.preventDefault(); + area.addEventListener('drop', async (event) => { + event.preventDefault(); + + const files: File[] = []; + if (event.dataTransfer?.items?.length) { + for (let i = 0; i < event.dataTransfer.items.length; ++i) { + const item = event.dataTransfer.items[i]; + if (item.kind === 'file') { + files.push(item.getAsFile()!); + } + } + } else { + for (let i = 0; i < (event.dataTransfer?.files?.length ?? 0); ++i) { + files.push(event.dataTransfer!.files[i]); + } + } + + for (let i = 0; i < files.length; ++i) { + try { + const buffer = await files[i].arrayBuffer(); + const dataset = dicomParser.parseDicom(new Uint8Array(buffer)); + + const sopInstanceUid = dataset.string(SOP_INSTANCE_UID_TAG); + if (!sopInstanceUid) { + throw new Error('DICOM instance must have a SOP instance UID'); + } + bytes[sopInstanceUid] = buffer; + + let seriesInstanceUid = dataset.string(SERIES_INSTANCE_UID_TAG); + // This is a bit of a hack: if the dataset has no series UID, use the + // sop instance UID as a series UID. + if (!seriesInstanceUid) { + seriesInstanceUid = sopInstanceUid; + } + if (!(seriesInstanceUid in series)) { + series[seriesInstanceUid] = []; + } + series[seriesInstanceUid].push(sopInstanceUid); + + redisplay(); + } catch (e) { + logFn('Failed to parse DICOM: ', e); + } + } + }); + + return { + area, + getInstanceBytes, + setEmit, + }; +} + +export default createImageDropArea; diff --git a/packages/core/examples/dicomLoader/index.ts b/packages/core/examples/dicomLoader/index.ts new file mode 100644 index 0000000000..9072b09bec --- /dev/null +++ b/packages/core/examples/dicomLoader/index.ts @@ -0,0 +1,92 @@ +import type { Types } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + imageLoader, + metaData, +} from '@cornerstonejs/core'; +import { + initDemo, + setTitleAndDescription, +} from '../../../../utils/demo/helpers'; +import createCustomImageLoader from './customImageLoader'; +import createImageDropArea from './imageDropArea'; +import createLogArea from './logArea'; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { ViewportType } = Enums; + +// ======== Set up page ======== // +setTitleAndDescription( + 'Custom DICOM Image Loaders', + 'Demonstrates how to write a custom Image Loader for DICOMs' +); + +const content = document.getElementById('content'); + +const { area: logArea, addLog } = createLogArea(); + +const { + area: imageDropArea, + setEmit, + getInstanceBytes, +} = createImageDropArea(addLog); + +const element = document.createElement('div'); +element.id = 'cornerstone-element'; +element.style.width = '500px'; +element.style.height = '500px'; + +content!.appendChild(logArea); +content!.appendChild(imageDropArea); +content!.appendChild(element); + +const renderingEngineId = 'myRenderingEngine'; +const viewportId = 'STACK'; + +const { imageLoadFunction, metadataProvider } = createCustomImageLoader( + addLog, + getInstanceBytes +); + +imageLoader.registerImageLoader( + 'custom', + imageLoadFunction as Types.ImageLoaderFn +); + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + metaData.addProvider(metadataProvider, 10000); + + // Instantiate a rendering engine + const renderingEngine = new RenderingEngine(renderingEngineId); + + const viewportInputArray: Types.PublicViewportInput[] = [ + { + viewportId, + type: ViewportType.STACK, + element: element, + }, + ]; + renderingEngine.setViewports(viewportInputArray); + + // render stack viewport + setEmit((sopInstanceUids) => { + const imageIds = sopInstanceUids.map((uid) => `custom:${uid}`); + renderingEngine.getStackViewports()[0].setStack(imageIds); + }); + + // render volume viewports + renderingEngine.render(); +} + +run(); diff --git a/packages/core/examples/dicomLoader/logArea.ts b/packages/core/examples/dicomLoader/logArea.ts new file mode 100644 index 0000000000..120ca49f5e --- /dev/null +++ b/packages/core/examples/dicomLoader/logArea.ts @@ -0,0 +1,58 @@ +export type AddLogFn = (message: string, ...args: unknown[]) => void; + +function createLogArea() { + const area = document.createElement('div'); + area.id = 'log-area'; + area.style.width = '500px'; + area.style.height = '1000px'; + area.style.background = 'lightblue'; + area.style.margin = '5px'; + area.style.padding = '5px'; + area.style.float = 'right'; + + const addLog = (message: string, ...args: unknown[]) => { + console.log(message, ...args); + const p = document.createElement('p'); + p.appendChild(document.createTextNode(message)); + area.appendChild(p); + for (let i = 0; i < args.length; ++i) { + let arg = args[i] as any; + try { + arg = arg.error || arg.message || arg.exception || arg; + arg = arg.error || arg.message || arg.exception || arg; + } catch (err) { + /* ignore */ + } + + if (arg && arg.toString) { + arg = arg.toString(); + } + if (typeof arg != 'string') { + try { + arg = JSON.stringify(arg, null, 0); + } catch (err) { + arg = String(arg); + } + } + + if (arg.startsWith('[object')) { + arg += ' (see console for dump)'; + } + + const p = document.createElement('p'); + p.style.margin = ''; + p.style.marginLeft = '20px'; + p.appendChild(document.createTextNode(arg)); + area.appendChild(p); + } + }; + + addLog('Ready to rumble.'); + + return { + area, + addLog, + }; +} + +export default createLogArea; From d1990c7b8d245c0da9cddb51a265c6787a9fccea Mon Sep 17 00:00:00 2001 From: Sjors Gielen Date: Sat, 12 Oct 2024 19:40:41 +0200 Subject: [PATCH 2/6] Expose parts of the DICOM Image Loader and use them in the custom image loader. The two parts of the DICOM Image Loader our custom image loader has to use are: - Convert a dicomParser.DataSet to a Cornerstone image object - Convert a dicomParser.DataSet to metadata as requested by Cornerstone (among others) I'm not too happy with the tight coupling we have yet, but at least this shows basic functionality and provides an idea to the design changes necessary to prevent tight coupling. --- .../examples/dicomLoader/customImageLoader.ts | 179 ++++-------------- .../dicomLoader/hardcodedMetaDataProvider.ts | 65 ------- packages/core/examples/dicomLoader/index.ts | 28 ++- .../core/examples/dicomLoader/metadata.ts | 15 ++ .../src/imageLoader/wadouri/index.ts | 4 + .../src/imageLoader/wadouri/metaData/index.ts | 5 +- .../wadouri/metaData/metaDataProvider.ts | 10 + 7 files changed, 98 insertions(+), 208 deletions(-) delete mode 100644 packages/core/examples/dicomLoader/hardcodedMetaDataProvider.ts create mode 100644 packages/core/examples/dicomLoader/metadata.ts diff --git a/packages/core/examples/dicomLoader/customImageLoader.ts b/packages/core/examples/dicomLoader/customImageLoader.ts index ce105b93e5..ede02448de 100644 --- a/packages/core/examples/dicomLoader/customImageLoader.ts +++ b/packages/core/examples/dicomLoader/customImageLoader.ts @@ -1,134 +1,9 @@ -import type * as cornerstone from '@cornerstonejs/core'; -import hardcodedMetaDataProvider from './hardcodedMetaDataProvider'; +import type { Types } from '@cornerstonejs/core'; +//import cornerstoneDICOMImageLoader from '@cornerstonejs/dicom-image-loader'; +import cornerstoneDICOMImageLoader from '../../../dicomImageLoader/src'; +import dicomParser from 'dicom-parser'; import type { AddLogFn } from './logArea'; - -const canvas = document.createElement('canvas'); -let lastImageIdDrawn; - -// Todo: this loader should exist in a separate package in the same monorepo - -/** - * creates a cornerstone Image object for the specified Image and imageId - * - * @param image - An Image - * @param imageId - the imageId for this image - * @returns Cornerstone Image Object - */ -function createImage(image: HTMLImageElement, imageId: string) { - // extract the attributes we need - const rows = image.naturalHeight; - const columns = image.naturalWidth; - - function getPixelData(targetBuffer?: any) { - const imageData = getImageData(); - - let targetArray; - - // Check if targetBuffer is provided for volume viewports - if (targetBuffer) { - targetArray = new Uint8Array( - targetBuffer.arrayBuffer, - targetBuffer.offset, - targetBuffer.length - ); - } else { - targetArray = new Uint8Array(imageData.width * imageData.height * 3); - } - - // modify original image data and remove alpha channel (RGBA to RGB) - convertImageDataToRGB(imageData, targetArray); - - return targetArray; - } - - function convertImageDataToRGB(imageData, targetArray) { - for (let i = 0, j = 0; i < imageData.data.length; i += 4, j += 3) { - targetArray[j] = imageData.data[i]; - targetArray[j + 1] = imageData.data[i + 1]; - targetArray[j + 2] = imageData.data[i + 2]; - } - } - - function getImageData() { - let context; - - if (lastImageIdDrawn === imageId) { - context = canvas.getContext('2d'); - } else { - canvas.height = image.naturalHeight; - canvas.width = image.naturalWidth; - context = canvas.getContext('2d'); - context.drawImage(image, 0, 0); - lastImageIdDrawn = imageId; - } - - return context.getImageData(0, 0, image.naturalWidth, image.naturalHeight); - } - - function getCanvas() { - if (lastImageIdDrawn === imageId) { - return canvas; - } - - canvas.height = image.naturalHeight; - canvas.width = image.naturalWidth; - const context = canvas.getContext('2d'); - - context!.drawImage(image, 0, 0); - lastImageIdDrawn = imageId; - - return canvas; - } - - // Extract the various attributes we need - return { - imageId, - minPixelValue: 0, - maxPixelValue: 255, - slope: 1, - intercept: 0, - windowCenter: 128, - windowWidth: 255, - getPixelData, - getCanvas, - getImage: () => image, - rows, - columns, - height: rows, - width: columns, - color: true, - // we converted the canvas rgba already to rgb above - rgba: false, - columnPixelSpacing: 1, // for web it's always 1 - rowPixelSpacing: 1, // for web it's always 1 - invert: false, - sizeInBytes: rows * columns * 3, - numberOfComponents: 3, - }; -} - -function arrayBufferToImage( - arrayBuffer: ArrayBuffer -): Promise { - return new Promise((resolve, reject) => { - const image = new Image(); - const arrayBufferView = new Uint8Array(arrayBuffer); - const blob = new Blob([arrayBufferView]); - const urlCreator = window.URL || window.webkitURL; - const imageUrl = urlCreator.createObjectURL(blob); - - image.src = imageUrl; - image.onload = () => { - resolve(image); - urlCreator.revokeObjectURL(imageUrl); - }; - - image.onerror = (error) => { - urlCreator.revokeObjectURL(imageUrl); - reject(error); - }; - }); -} +import { addToCache, dropFromCache, getFromCache } from './metadata'; function _loadImageIntoBuffer( imageId: string, @@ -143,28 +18,36 @@ function _loadImageIntoBuffer( | undefined, logFn: AddLogFn, instanceToBytes: (instanceId: string) => Promise -): { - promise: Promise | boolean>; - cancelFn: () => void; -} { +): Types.IImageLoadObject { const sopInstanceUid = imageId.replace('custom:', ''); logFn('Custom loader is starting to load image: ', sopInstanceUid); const promise = async () => { try { const buffer = await instanceToBytes(sopInstanceUid); - const image = await arrayBufferToImage(buffer); - const imageObject = createImage(image, imageId); + const dataSet = dicomParser.parseDicom(new Uint8Array(buffer)); + // Add the dataSet to the cache immediately, since createImage() + // already reads metadata. + addToCache(imageId, dataSet); + const pixelData = + cornerstoneDICOMImageLoader.wadouri.getPixelData(dataSet); + const transferSyntax = dataSet.string('x00020010'); + const image = await cornerstoneDICOMImageLoader.createImage( + imageId, + pixelData, + transferSyntax, + options as any + ); if ( !options?.targetBuffer || !options.targetBuffer.length || !options.targetBuffer.offset ) { - return imageObject; + return image; } - imageObject.getPixelData(options.targetBuffer); + (image as any).getPixelData(options.targetBuffer); return true; } catch (e) { logFn('failed to load image ID', imageId, e); @@ -173,8 +56,13 @@ function _loadImageIntoBuffer( }; return { - promise: promise(), - cancelFn: () => {}, + promise: promise() as Promise, + cancelFn: () => { + dropFromCache(imageId); + }, + decache: () => { + dropFromCache(imageId); + }, }; } @@ -186,7 +74,16 @@ function createCustomImageLoader( imageLoadFunction: (imageId: string, options: never) => { return _loadImageIntoBuffer(imageId, options, logFn, instanceToBytes); }, - metadataProvider: hardcodedMetaDataProvider, + metadataProvider: (type, imageId) => { + const dataset = getFromCache(imageId); + if (dataset) { + return cornerstoneDICOMImageLoader.wadouri.metaData.metadataForDataset( + type, + imageId, + dataset + ); + } + }, }; } diff --git a/packages/core/examples/dicomLoader/hardcodedMetaDataProvider.ts b/packages/core/examples/dicomLoader/hardcodedMetaDataProvider.ts deleted file mode 100644 index 86717d9eb3..0000000000 --- a/packages/core/examples/dicomLoader/hardcodedMetaDataProvider.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Add hardcoded meta data provider for color images -export default function hardcodedMetaDataProvider(type, imageId, imageIds) { - const colonIndex = imageId.indexOf(':'); - const scheme = imageId.substring(0, colonIndex); - if (scheme !== 'web') { - return; - } - - if (type === 'imagePixelModule') { - const imagePixelModule = { - pixelRepresentation: 0, - bitsAllocated: 24, - bitsStored: 24, - highBit: 24, - photometricInterpretation: 'RGB', - samplesPerPixel: 3, - }; - - return imagePixelModule; - } else if (type === 'generalSeriesModule') { - const generalSeriesModule = { - modality: 'SC', - seriesNumber: 1, - seriesDescription: 'Color', - seriesDate: '20190201', - seriesTime: '120000', - seriesInstanceUID: '1.2.276.0.7230010.3.1.4.83233.20190201120000.1', - }; - - return generalSeriesModule; - } else if (type === 'imagePlaneModule') { - const index = imageIds.indexOf(imageId); - // console.warn(index); - const imagePlaneModule = { - imageOrientationPatient: [1, 0, 0, 0, 1, 0], - imagePositionPatient: [0, 0, index * 5], - pixelSpacing: [1, 1], - columnPixelSpacing: 1, - rowPixelSpacing: 1, - frameOfReferenceUID: 'FORUID', - columns: 2048, - rows: 1216, - rowCosines: [1, 0, 0], - columnCosines: [0, 1, 0], - }; - - return imagePlaneModule; - } else if (type === 'voiLutModule') { - return { - // According to the DICOM standard, the width is the number of samples - // in the input, so 256 samples. - windowWidth: [256], - // The center is offset by 0.5 to allow for an integer value for even - // sample counts - windowCenter: [128], - }; - } else if (type === 'modalityLutModule') { - return { - rescaleSlope: 1, - rescaleIntercept: 0, - }; - } else { - return undefined; - } -} diff --git a/packages/core/examples/dicomLoader/index.ts b/packages/core/examples/dicomLoader/index.ts index 9072b09bec..fe068ec3a3 100644 --- a/packages/core/examples/dicomLoader/index.ts +++ b/packages/core/examples/dicomLoader/index.ts @@ -4,10 +4,12 @@ import { Enums, imageLoader, metaData, + getRenderingEngine, } from '@cornerstonejs/core'; import { initDemo, setTitleAndDescription, + addSliderToToolbar, } from '../../../../utils/demo/helpers'; import createCustomImageLoader from './customImageLoader'; import createImageDropArea from './imageDropArea'; @@ -55,9 +57,33 @@ const { imageLoadFunction, metadataProvider } = createCustomImageLoader( imageLoader.registerImageLoader( 'custom', - imageLoadFunction as Types.ImageLoaderFn + imageLoadFunction as unknown as Types.ImageLoaderFn ); +// ============================= // + +addSliderToToolbar({ + title: 'Slice Index', + range: [0, 9], + defaultValue: 0, + onSelectedValueChange: (value) => { + const valueAsNumber = Number(value); + + // Get the rendering engine + const renderingEngine = getRenderingEngine(renderingEngineId); + + // Get the volume viewport + const viewport = renderingEngine.getViewport( + viewportId + ) as Types.IStackViewport; + + if (valueAsNumber < viewport.getImageIds().length) { + viewport.setImageIdIndex(valueAsNumber); + } + viewport.render(); + }, +}); + /** * Runs the demo */ diff --git a/packages/core/examples/dicomLoader/metadata.ts b/packages/core/examples/dicomLoader/metadata.ts new file mode 100644 index 0000000000..227a839378 --- /dev/null +++ b/packages/core/examples/dicomLoader/metadata.ts @@ -0,0 +1,15 @@ +import type { DataSet } from 'dicom-parser'; + +const activeDatasets: Record = {}; + +export function addToCache(imageId: string, dataSet: DataSet) { + activeDatasets[imageId] = dataSet; +} + +export function dropFromCache(imageId: string) { + delete activeDatasets[imageId]; +} + +export function getFromCache(imageId: string) { + return activeDatasets[imageId]; +} diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/index.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/index.ts index 7ccfa60b98..d00f04cde9 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/index.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/index.ts @@ -4,6 +4,7 @@ import { getModalityLUTOutputPixelRepresentation, getNumberValues, metaDataProvider, + metadataForDataset, } from './metaData/index'; import dataSetCacheManager from './dataSetCacheManager'; @@ -11,6 +12,7 @@ import fileManager from './fileManager'; import getEncapsulatedImageFrame from './getEncapsulatedImageFrame'; import getUncompressedImageFrame from './getUncompressedImageFrame'; import loadFileRequest from './loadFileRequest'; +import getPixelData from './getPixelData'; import { loadImageFromPromise, getLoaderForScheme, @@ -26,6 +28,7 @@ const metaData = { getModalityLUTOutputPixelRepresentation, getNumberValues, metaDataProvider, + metadataForDataset, }; export default { @@ -37,6 +40,7 @@ export default { loadFileRequest, loadImageFromPromise, getLoaderForScheme, + getPixelData, loadImage, parseImageId, unpackBinaryFrame, diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/index.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/index.ts index 67af79802d..6e74d11508 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/index.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/index.ts @@ -2,4 +2,7 @@ export { default as getImagePixelModule } from './getImagePixelModule'; export { default as getLUTs } from './getLUTs'; export { default as getModalityLUTOutputPixelRepresentation } from './getModalityLUTOutputPixelRepresentation'; export { default as getNumberValues } from './getNumberValues'; -export { default as metaDataProvider } from './metaDataProvider'; +export { + default as metaDataProvider, + metadataForDataset, +} from './metaDataProvider'; diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts index 664d976b58..a0b6a3dfa8 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts @@ -62,6 +62,16 @@ function metaDataProvider(type, imageId) { return; } + return metadataForDataset(type, imageId, dataSet); +} + +export function metadataForDataset( + type, + imageId, + dataSet: dicomParser.DataSet +) { + const { MetadataModules } = Enums; + if (type === MetadataModules.GENERAL_STUDY) { return { studyDescription: dataSet.string('x00081030'), From b09b5de70b48d8d14280ddd66c933c93d65f8ba8 Mon Sep 17 00:00:00 2001 From: Sjors Gielen Date: Sun, 13 Oct 2024 10:16:23 +0200 Subject: [PATCH 3/6] Fix the Slice Index slider and its location. --- packages/core/examples/dicomLoader/index.ts | 45 ++++++++++----------- utils/demo/helpers/addSliderToToolbar.ts | 12 +++++- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/core/examples/dicomLoader/index.ts b/packages/core/examples/dicomLoader/index.ts index fe068ec3a3..467cfe2a10 100644 --- a/packages/core/examples/dicomLoader/index.ts +++ b/packages/core/examples/dicomLoader/index.ts @@ -38,6 +38,9 @@ const { getInstanceBytes, } = createImageDropArea(addLog); +const toolbar = document.createElement('div'); +toolbar.id = 'toolbar'; + const element = document.createElement('div'); element.id = 'cornerstone-element'; element.style.width = '500px'; @@ -45,6 +48,7 @@ element.style.height = '500px'; content!.appendChild(logArea); content!.appendChild(imageDropArea); +content!.appendChild(toolbar); content!.appendChild(element); const renderingEngineId = 'myRenderingEngine'; @@ -60,29 +64,7 @@ imageLoader.registerImageLoader( imageLoadFunction as unknown as Types.ImageLoaderFn ); -// ============================= // - -addSliderToToolbar({ - title: 'Slice Index', - range: [0, 9], - defaultValue: 0, - onSelectedValueChange: (value) => { - const valueAsNumber = Number(value); - - // Get the rendering engine - const renderingEngine = getRenderingEngine(renderingEngineId); - - // Get the volume viewport - const viewport = renderingEngine.getViewport( - viewportId - ) as Types.IStackViewport; - - if (valueAsNumber < viewport.getImageIds().length) { - viewport.setImageIdIndex(valueAsNumber); - } - viewport.render(); - }, -}); +let sliderRemoveFn = () => {}; /** * Runs the demo @@ -109,6 +91,23 @@ async function run() { setEmit((sopInstanceUids) => { const imageIds = sopInstanceUids.map((uid) => `custom:${uid}`); renderingEngine.getStackViewports()[0].setStack(imageIds); + + sliderRemoveFn(); + sliderRemoveFn = addSliderToToolbar({ + title: 'Slice Index', + range: [0, imageIds.length - 1], + defaultValue: 0, + container: toolbar, + onSelectedValueChange: (value) => { + const valueAsNumber = Number(value); + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport( + viewportId + ) as Types.IStackViewport; + viewport.setImageIdIndex(valueAsNumber); + viewport.render(); + }, + }); }); // render volume viewports diff --git a/utils/demo/helpers/addSliderToToolbar.ts b/utils/demo/helpers/addSliderToToolbar.ts index 6da081d33a..58b341dfa5 100644 --- a/utils/demo/helpers/addSliderToToolbar.ts +++ b/utils/demo/helpers/addSliderToToolbar.ts @@ -1,6 +1,7 @@ import { utilities as csUtilities } from '@cornerstonejs/core'; -import createElement, { configElement } from './createElement'; +import type { configElement } from './createElement'; +import createElement from './createElement'; import addLabelToToolbar from './addLabelToToolbar'; interface configSlider extends configElement { @@ -15,7 +16,9 @@ interface configSlider extends configElement { label?: configElement; } -export default function addSliderToToolbar(config: configSlider): void { +export type DeleteFn = () => void; + +export default function addSliderToToolbar(config: configSlider): DeleteFn { config = csUtilities.deepMerge(config, config.merge); config.container = @@ -74,4 +77,9 @@ export default function addSliderToToolbar(config: configSlider): void { elInput.max = String(config.range[1]); elInput.value = String(config.defaultValue); + + return () => { + elLabel.remove(); + elInput.remove(); + }; } From d6b988bb0eefd0880b93d40a278416632a6a3139 Mon Sep 17 00:00:00 2001 From: Sjors Gielen Date: Sun, 13 Oct 2024 10:25:53 +0200 Subject: [PATCH 4/6] Improve logging and display of the logs. Deduplicate by message if possible. Also some small cosmetic improvements to the example. --- .../examples/dicomLoader/customImageLoader.ts | 17 ++++++-- .../examples/dicomLoader/imageDropArea.ts | 5 +++ packages/core/examples/dicomLoader/index.ts | 33 ++++++++-------- packages/core/examples/dicomLoader/logArea.ts | 39 ++++++++++++++++--- 4 files changed, 69 insertions(+), 25 deletions(-) diff --git a/packages/core/examples/dicomLoader/customImageLoader.ts b/packages/core/examples/dicomLoader/customImageLoader.ts index ede02448de..e6cf384d52 100644 --- a/packages/core/examples/dicomLoader/customImageLoader.ts +++ b/packages/core/examples/dicomLoader/customImageLoader.ts @@ -20,7 +20,7 @@ function _loadImageIntoBuffer( instanceToBytes: (instanceId: string) => Promise ): Types.IImageLoadObject { const sopInstanceUid = imageId.replace('custom:', ''); - logFn('Custom loader is starting to load image: ', sopInstanceUid); + logFn('Loader: starting to load image: ', sopInstanceUid); const promise = async () => { try { @@ -39,6 +39,8 @@ function _loadImageIntoBuffer( options as any ); + logFn('Loader: done loading image: ', sopInstanceUid); + if ( !options?.targetBuffer || !options.targetBuffer.length || @@ -50,7 +52,7 @@ function _loadImageIntoBuffer( (image as any).getPixelData(options.targetBuffer); return true; } catch (e) { - logFn('failed to load image ID', imageId, e); + logFn('Loader: failed to load image ID', imageId, e); return false; } }; @@ -58,9 +60,11 @@ function _loadImageIntoBuffer( return { promise: promise() as Promise, cancelFn: () => { + logFn('Loader: cancelling loading image: ', sopInstanceUid); dropFromCache(imageId); }, decache: () => { + logFn('Loader: decaching loaded image: ', sopInstanceUid); dropFromCache(imageId); }, }; @@ -74,7 +78,14 @@ function createCustomImageLoader( imageLoadFunction: (imageId: string, options: never) => { return _loadImageIntoBuffer(imageId, options, logFn, instanceToBytes); }, - metadataProvider: (type, imageId) => { + metadataProvider: (type: string, imageId: string) => { + if (!imageId.startsWith('custom:')) { + return; + } + + const sopInstanceUid = imageId.replace('custom:', ''); + logFn('Loader: metadata request: ', type, sopInstanceUid); + const dataset = getFromCache(imageId); if (dataset) { return cornerstoneDICOMImageLoader.wadouri.metaData.metadataForDataset( diff --git a/packages/core/examples/dicomLoader/imageDropArea.ts b/packages/core/examples/dicomLoader/imageDropArea.ts index e4ae6faad5..4e13046012 100644 --- a/packages/core/examples/dicomLoader/imageDropArea.ts +++ b/packages/core/examples/dicomLoader/imageDropArea.ts @@ -92,6 +92,11 @@ function createImageDropArea(logFn: AddLogFn) { if (!sopInstanceUid) { throw new Error('DICOM instance must have a SOP instance UID'); } + if (sopInstanceUid in bytes) { + // Prevent the SOP instance UID from being added to the series twice + logFn('ignoring duplicate drop of SOP instance UID ', sopInstanceUid); + continue; + } bytes[sopInstanceUid] = buffer; let seriesInstanceUid = dataset.string(SERIES_INSTANCE_UID_TAG); diff --git a/packages/core/examples/dicomLoader/index.ts b/packages/core/examples/dicomLoader/index.ts index 467cfe2a10..386a1afa95 100644 --- a/packages/core/examples/dicomLoader/index.ts +++ b/packages/core/examples/dicomLoader/index.ts @@ -90,24 +90,25 @@ async function run() { // render stack viewport setEmit((sopInstanceUids) => { const imageIds = sopInstanceUids.map((uid) => `custom:${uid}`); - renderingEngine.getStackViewports()[0].setStack(imageIds); + const viewport = renderingEngine.getViewport( + viewportId + ) as Types.IStackViewport; + viewport.setStack(imageIds); sliderRemoveFn(); - sliderRemoveFn = addSliderToToolbar({ - title: 'Slice Index', - range: [0, imageIds.length - 1], - defaultValue: 0, - container: toolbar, - onSelectedValueChange: (value) => { - const valueAsNumber = Number(value); - const renderingEngine = getRenderingEngine(renderingEngineId); - const viewport = renderingEngine.getViewport( - viewportId - ) as Types.IStackViewport; - viewport.setImageIdIndex(valueAsNumber); - viewport.render(); - }, - }); + if (imageIds.length > 1) { + sliderRemoveFn = addSliderToToolbar({ + title: 'Slice Index', + range: [0, imageIds.length - 1], + defaultValue: 0, + container: toolbar, + onSelectedValueChange: (value) => { + const valueAsNumber = Number(value); + viewport.setImageIdIndex(valueAsNumber); + viewport.render(); + }, + }); + } }); // render volume viewports diff --git a/packages/core/examples/dicomLoader/logArea.ts b/packages/core/examples/dicomLoader/logArea.ts index 120ca49f5e..bca2f5dccf 100644 --- a/packages/core/examples/dicomLoader/logArea.ts +++ b/packages/core/examples/dicomLoader/logArea.ts @@ -4,17 +4,44 @@ function createLogArea() { const area = document.createElement('div'); area.id = 'log-area'; area.style.width = '500px'; - area.style.height = '1000px'; + area.style.height = '80vh'; area.style.background = 'lightblue'; area.style.margin = '5px'; area.style.padding = '5px'; area.style.float = 'right'; + area.style.overflow = 'scroll'; + + let lastMessage = ''; + let lastElement: HTMLDivElement | undefined; const addLog = (message: string, ...args: unknown[]) => { console.log(message, ...args); - const p = document.createElement('p'); - p.appendChild(document.createTextNode(message)); - area.appendChild(p); + + const argOffset = '20px'; + + if (message != lastMessage) { + lastElement = document.createElement('div'); + lastElement.style.margin = ''; + if (lastMessage != '') { + lastElement.style.borderTop = '1px dashed gray'; + } + area.appendChild(lastElement); + + lastMessage = message; + + const p = document.createElement('p'); + p.style.margin = '0'; + p.appendChild(document.createTextNode(message)); + area.appendChild(p); + } else if (lastMessage != '') { + const hr = document.createElement('hr'); + hr.style.margin = '0'; + hr.style.marginLeft = argOffset; + hr.style.border = '0'; + hr.style.borderTop = '1px dashed gray'; + area.appendChild(hr); + } + for (let i = 0; i < args.length; ++i) { let arg = args[i] as any; try { @@ -40,8 +67,8 @@ function createLogArea() { } const p = document.createElement('p'); - p.style.margin = ''; - p.style.marginLeft = '20px'; + p.style.margin = '0'; + p.style.marginLeft = argOffset; p.appendChild(document.createTextNode(arg)); area.appendChild(p); } From cf278890be0c32be3a27e34ddee5618ec41c458d Mon Sep 17 00:00:00 2001 From: Sjors Gielen Date: Sun, 13 Oct 2024 22:10:27 +0200 Subject: [PATCH 5/6] Add support for displaying CT volumes in a volume viewport. --- .../examples/dicomLoader/customImageLoader.ts | 2 +- packages/core/examples/dicomLoader/index.ts | 116 +++++++++++++----- 2 files changed, 87 insertions(+), 31 deletions(-) diff --git a/packages/core/examples/dicomLoader/customImageLoader.ts b/packages/core/examples/dicomLoader/customImageLoader.ts index e6cf384d52..a868a09fa8 100644 --- a/packages/core/examples/dicomLoader/customImageLoader.ts +++ b/packages/core/examples/dicomLoader/customImageLoader.ts @@ -31,7 +31,7 @@ function _loadImageIntoBuffer( addToCache(imageId, dataSet); const pixelData = cornerstoneDICOMImageLoader.wadouri.getPixelData(dataSet); - const transferSyntax = dataSet.string('x00020010'); + const transferSyntax = dataSet.string('x00020010') ?? ''; const image = await cornerstoneDICOMImageLoader.createImage( imageId, pixelData, diff --git a/packages/core/examples/dicomLoader/index.ts b/packages/core/examples/dicomLoader/index.ts index 386a1afa95..850aae1e6b 100644 --- a/packages/core/examples/dicomLoader/index.ts +++ b/packages/core/examples/dicomLoader/index.ts @@ -4,12 +4,13 @@ import { Enums, imageLoader, metaData, - getRenderingEngine, + volumeLoader, } from '@cornerstonejs/core'; import { initDemo, setTitleAndDescription, addSliderToToolbar, + addToggleButtonToToolbar, } from '../../../../utils/demo/helpers'; import createCustomImageLoader from './customImageLoader'; import createImageDropArea from './imageDropArea'; @@ -65,6 +66,77 @@ imageLoader.registerImageLoader( ); let sliderRemoveFn = () => {}; +let renderingEngine: RenderingEngine; + +function resetViewports(volume: boolean) { + const viewportInputArray: Types.PublicViewportInput[] = []; + if (!volume) { + viewportInputArray.push({ + viewportId, + type: ViewportType.STACK, + element: element, + }); + } else { + viewportInputArray.push({ + viewportId, + type: ViewportType.ORTHOGRAPHIC, + element: element, + defaultOptions: { + orientation: Enums.OrientationAxis.AXIAL, + }, + }); + } + renderingEngine.setViewports(viewportInputArray); + renderImages(); +} + +let imageIds: string[] = []; + +async function renderImages() { + if (imageIds.length == 0) { + return; + } + + const viewport = renderingEngine.getViewport(viewportId) as + | Types.IStackViewport + | Types.IVolumeViewport; + if ('setStack' in viewport) { + viewport.setStack(imageIds); + } else if ('setVolumes' in viewport) { + // TODO: In the current version of Cornerstone, we need to load all + // individual slices before we can load the volume. + for (let i = 0; i < imageIds.length; ++i) { + await imageLoader.loadImage(imageIds[i]); + } + + const volumeId = `cornerstoneStreamingImageVolume:${imageIds[0]}`; + const volume = (await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + })) as Types.IStreamingImageVolume; + + // Set the volume to load + volume.load(); + + viewport.setVolumes([{ volumeId }]); + } + + sliderRemoveFn(); + if (imageIds.length > 1) { + sliderRemoveFn = addSliderToToolbar({ + title: 'Slice Index', + range: [0, imageIds.length - 1], + defaultValue: 0, + container: toolbar, + onSelectedValueChange: (value) => { + const valueAsNumber = Number(value); + if ('setImageIdIndex' in viewport) { + viewport.setImageIdIndex(valueAsNumber); + } + viewport.render(); + }, + }); + } +} /** * Runs the demo @@ -76,39 +148,23 @@ async function run() { metaData.addProvider(metadataProvider, 10000); // Instantiate a rendering engine - const renderingEngine = new RenderingEngine(renderingEngineId); - - const viewportInputArray: Types.PublicViewportInput[] = [ - { - viewportId, - type: ViewportType.STACK, - element: element, + renderingEngine = new RenderingEngine(renderingEngineId); + + addToggleButtonToToolbar({ + title: 'Toggle volume viewport', + defaultToggle: false, + container: toolbar, + onClick: (toggle) => { + resetViewports(toggle); }, - ]; - renderingEngine.setViewports(viewportInputArray); + }); + + resetViewports(false); // render stack viewport setEmit((sopInstanceUids) => { - const imageIds = sopInstanceUids.map((uid) => `custom:${uid}`); - const viewport = renderingEngine.getViewport( - viewportId - ) as Types.IStackViewport; - viewport.setStack(imageIds); - - sliderRemoveFn(); - if (imageIds.length > 1) { - sliderRemoveFn = addSliderToToolbar({ - title: 'Slice Index', - range: [0, imageIds.length - 1], - defaultValue: 0, - container: toolbar, - onSelectedValueChange: (value) => { - const valueAsNumber = Number(value); - viewport.setImageIdIndex(valueAsNumber); - viewport.render(); - }, - }); - } + imageIds = sopInstanceUids.map((uid) => `custom:${uid}`); + renderImages(); }); // render volume viewports From b6316141a7299ba50c12ba993b53ea2677c3b264 Mon Sep 17 00:00:00 2001 From: Sjors Gielen Date: Wed, 16 Oct 2024 21:35:21 +0200 Subject: [PATCH 6/6] Link the issue with the TODO --- packages/core/examples/dicomLoader/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/examples/dicomLoader/index.ts b/packages/core/examples/dicomLoader/index.ts index 850aae1e6b..f476eed0bf 100644 --- a/packages/core/examples/dicomLoader/index.ts +++ b/packages/core/examples/dicomLoader/index.ts @@ -103,8 +103,7 @@ async function renderImages() { if ('setStack' in viewport) { viewport.setStack(imageIds); } else if ('setVolumes' in viewport) { - // TODO: In the current version of Cornerstone, we need to load all - // individual slices before we can load the volume. + // TODO: https://github.com/cornerstonejs/cornerstone3D/issues/889 for (let i = 0; i < imageIds.length; ++i) { await imageLoader.loadImage(imageIds[i]); }