diff --git a/extensions/cornerstone-dicom-rt/src/loadRTStruct.js b/extensions/cornerstone-dicom-rt/src/loadRTStruct.js index 52e1bd7c7..613c2fb65 100644 --- a/extensions/cornerstone-dicom-rt/src/loadRTStruct.js +++ b/extensions/cornerstone-dicom-rt/src/loadRTStruct.js @@ -7,12 +7,13 @@ async function checkAndLoadContourData(instance, datasource) { return Promise.reject('Invalid instance object or ROIContourSequence'); } - const promises = []; - let counter = 0; + const promisesMap = new Map(); for (const ROIContour of instance.ROIContourSequence) { + const referencedROINumber = ROIContour.ReferencedROINumber; if (!ROIContour || !ROIContour.ContourSequence) { - return Promise.reject('Invalid ROIContour or ContourSequence'); + promisesMap.set(referencedROINumber, [Promise.resolve([])]); + continue; } for (const Contour of ROIContour.ContourSequence) { @@ -21,9 +22,15 @@ async function checkAndLoadContourData(instance, datasource) { } const contourData = Contour.ContourData; - counter++; + if (Array.isArray(contourData)) { - promises.push(Promise.resolve(contourData)); + promisesMap.has(referencedROINumber) + ? promisesMap + .get(referencedROINumber) + .push(Promise.resolve(contourData)) + : promisesMap.set(referencedROINumber, [ + Promise.resolve(contourData), + ]); } else if (contourData && contourData.BulkDataURI) { const bulkDataURI = contourData.BulkDataURI; @@ -44,53 +51,59 @@ async function checkAndLoadContourData(instance, datasource) { SOPInstanceUID: instance.SOPInstanceUID, }); - promises.push(bulkDataPromise); + promisesMap.has(referencedROINumber) + ? promisesMap.get(referencedROINumber).push(bulkDataPromise) + : promisesMap.set(referencedROINumber, [bulkDataPromise]); } else { return Promise.reject(`Invalid ContourData: ${contourData}`); } } } - const flattenedPromises = promises.flat(); - const resolvedPromises = await Promise.allSettled(flattenedPromises); - // Modify contourData and replace it in its corresponding ROIContourSequence's Contour's contourData - let index = 0; - instance.ROIContourSequence.forEach((ROIContour, roiIndex) => { - ROIContour.ContourSequence.forEach((Contour, contourIndex) => { - const promise = resolvedPromises[index++]; + const resolvedPromisesMap = new Map(); + for (const [key, promiseArray] of promisesMap.entries()) { + resolvedPromisesMap.set(key, await Promise.allSettled(promiseArray)); + } - if (promise.status === 'fulfilled') { - const uint8Array = new Uint8Array(promise.value); - const textDecoder = new TextDecoder(); - const dataUint8Array = textDecoder.decode(uint8Array); - if ( - typeof dataUint8Array === 'string' && - dataUint8Array.includes('\\') - ) { - const numSlashes = (dataUint8Array.match(/\\/g) || []).length; - let startIndex = 0; - let endIndex = dataUint8Array.indexOf('\\', startIndex); - let numbersParsed = 0; - const ContourData = []; + instance.ROIContourSequence.forEach(ROIContour => { + try { + const referencedROINumber = ROIContour.ReferencedROINumber; + const resolvedPromises = resolvedPromisesMap.get(referencedROINumber); - while (numbersParsed !== numSlashes + 1) { - const str = dataUint8Array.substring(startIndex, endIndex); - let value = parseFloat(str); - - ContourData.push(value); - startIndex = endIndex + 1; - endIndex = dataUint8Array.indexOf('\\', startIndex); - endIndex === -1 ? (endIndex = dataUint8Array.length) : endIndex; - numbersParsed++; + if (ROIContour.ContourSequence) { + ROIContour.ContourSequence.forEach((Contour, index) => { + const promise = resolvedPromises[index]; + if (promise.status === 'fulfilled') { + if ( + Array.isArray(promise.value) && + promise.value.every(Number.isFinite) + ) { + // If promise.value is already an array of numbers, use it directly + Contour.ContourData = promise.value; + } else { + // If the resolved promise value is a byte array (Blob), it needs to be decoded + const uint8Array = new Uint8Array(promise.value); + const textDecoder = new TextDecoder(); + const dataUint8Array = textDecoder.decode(uint8Array); + if ( + typeof dataUint8Array === 'string' && + dataUint8Array.includes('\\') + ) { + Contour.ContourData = dataUint8Array + .split('\\') + .map(parseFloat); + } else { + Contour.ContourData = []; + } + } + } else { + console.error(promise.reason); } - Contour.ContourData = ContourData; - } else { - Contour.ContourData = []; - } - } else { - console.error(promise.reason); + }); } - }); + } catch (error) { + console.error(error); + } }); } @@ -104,7 +117,7 @@ export default async function loadRTStruct( '@ohif/extension-cornerstone.utilityModule.common' ); const dataSource = extensionManager.getActiveDataSource()[0]; - const { useBulkDataURI } = dataSource.getConfig?.() || {}; + const { bulkDataURI } = dataSource.getConfig?.() || {}; const { dicomLoaderService } = utilityModule.exports; const imageIdSopInstanceUidPairs = _getImageIdSopInstanceUidPairsForDisplaySet( @@ -116,7 +129,7 @@ export default async function loadRTStruct( rtStructDisplaySet.isLoaded = true; let instance = rtStructDisplaySet.instance; - if (!useBulkDataURI) { + if (!bulkDataURI || !bulkDataURI.enabled) { const segArrayBuffer = await dicomLoaderService.findDicomDataPromise( rtStructDisplaySet, null, diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx similarity index 100% rename from extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx rename to extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js b/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.test.js similarity index 100% rename from extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js rename to extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.test.js diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts b/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts similarity index 100% rename from extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts rename to extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts diff --git a/extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts b/extensions/default/src/CustomizableContextMenu/defaultContextMenu.ts similarity index 100% rename from extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts rename to extensions/default/src/CustomizableContextMenu/defaultContextMenu.ts diff --git a/extensions/default/src/CustomizeableContextMenu/index.ts b/extensions/default/src/CustomizableContextMenu/index.ts similarity index 75% rename from extensions/default/src/CustomizeableContextMenu/index.ts rename to extensions/default/src/CustomizableContextMenu/index.ts index 7dc08dd3d..d0abca069 100644 --- a/extensions/default/src/CustomizeableContextMenu/index.ts +++ b/extensions/default/src/CustomizableContextMenu/index.ts @@ -1,11 +1,11 @@ import ContextMenuController from './ContextMenuController'; import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder'; import defaultContextMenu from './defaultContextMenu'; -import * as CustomizeableContextMenuTypes from './types'; +import * as CustomizableContextMenuTypes from './types'; export { ContextMenuController, - CustomizeableContextMenuTypes, + CustomizableContextMenuTypes, ContextMenuItemsBuilder, defaultContextMenu, }; diff --git a/extensions/default/src/CustomizeableContextMenu/types.ts b/extensions/default/src/CustomizableContextMenu/types.ts similarity index 100% rename from extensions/default/src/CustomizeableContextMenu/types.ts rename to extensions/default/src/CustomizableContextMenu/types.ts diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index 01943417e..00f82841e 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -24,6 +24,7 @@ import { } from './retrieveStudyMetadata.js'; import StaticWadoClient from './utils/StaticWadoClient'; import getDirectURL from '../utils/getDirectURL'; +import { fixBulkDataURI } from './utils/fixBulkDataURI'; const { DicomMetaDictionary, DicomDict } = dcmjs.data; @@ -373,13 +374,23 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { */ const addRetrieveBulkData = instance => { const naturalized = naturalizeDataset(instance); + + // if we konw the server doesn't use bulkDataURI, then don't + if (!dicomWebConfig.bulkDataURI?.enabled) { + return naturalized; + } + Object.keys(naturalized).forEach(key => { const value = naturalized[key]; + // The value.Value will be set with the bulkdata read value // in which case it isn't necessary to re-read this. if (value && value.BulkDataURI && !value.Value) { // Provide a method to fetch bulkdata value.retrieveBulkData = () => { + // handle the scenarios where bulkDataURI is relative path + fixBulkDataURI(value, naturalized, dicomWebConfig); + const options = { // The bulkdata fetches work with either multipart or // singlepart, so set multipart to false to let the server diff --git a/extensions/default/src/DicomWebDataSource/utils/fixBulkDataURI.ts b/extensions/default/src/DicomWebDataSource/utils/fixBulkDataURI.ts new file mode 100644 index 000000000..1a408b597 --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/utils/fixBulkDataURI.ts @@ -0,0 +1,56 @@ +/** + * Modifies a bulkDataURI to ensure it is absolute based on the DICOMWeb configuration and + * instance data. The modification is in-place. + * + * If the bulkDataURI is relative to the series or study (according to the DICOM standard), + * it is made absolute by prepending the relevant paths. + * + * In scenarios where the bulkDataURI is a server-relative path (starting with '/'), the function + * handles two cases: + * + * 1. If the wado root is absolute (starts with 'http'), it prepends the wado root to the bulkDataURI. + * 2. If the wado root is relative, no changes are needed as the bulkDataURI is already correctly relative to the server root. + * + * @param value - The object containing BulkDataURI to be fixed. + * @param instance - The object (DICOM instance data) containing StudyInstanceUID and SeriesInstanceUID. + * @param dicomWebConfig - The DICOMWeb configuration object, containing wadoRoot and potentially bulkDataURI.relativeResolution. + * @returns The function modifies `value` in-place, it does not return a value. + */ +function fixBulkDataURI(value, instance, dicomWebConfig) { + // in case of the relative path, make it absolute. The current DICOM standard says + // the bulkdataURI is relative to the series. However, there are situations where + // it can be relative to the study too + if ( + !value.BulkDataURI.startsWith('http') && + !value.BulkDataURI.startsWith('/') + ) { + if (dicomWebConfig.bulkDataURI?.relativeResolution === 'studies') { + value.BulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${instance.StudyInstanceUID}/${value.BulkDataURI}`; + } else if ( + dicomWebConfig.bulkDataURI?.relativeResolution === 'series' || + !dicomWebConfig.bulkDataURI?.relativeResolution + ) { + value.BulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${instance.StudyInstanceUID}/series/${instance.SeriesInstanceUID}/${value.BulkDataURI}`; + } + + return; + } + + // in case it is relative path but starts at the server (e.g., /bulk/1e, note the missing http + // in the beginning and the first character is /) There are two scenarios, whether the wado root + // is absolute or relative. In case of absolute, we need to prepend the wado root to the bulkdata + // uri (e.g., bulkData: /bulk/1e, wado root: http://myserver.com/dicomweb, output: http://myserver.com/bulk/1e) + // and in case of relative wado root, we need to prepend the bulkdata uri to the wado root (e.g,. bulkData: /bulk/1e + // wado root: /dicomweb, output: /bulk/1e) + if (value.BulkDataURI[0] === '/') { + if (dicomWebConfig.wadoRoot.startsWith('http')) { + // Absolute wado root + const url = new URL(dicomWebConfig.wadoRoot); + value.BulkDataURI = `${url.origin}${value.BulkDataURI}`; + } else { + // Relative wado root, we don't need to do anything, bulkdata uri is already correct + } + } +} + +export { fixBulkDataURI }; diff --git a/extensions/default/src/DicomWebDataSource/utils/index.ts b/extensions/default/src/DicomWebDataSource/utils/index.ts new file mode 100644 index 000000000..2132253fe --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/utils/index.ts @@ -0,0 +1,3 @@ +import { fixBulkDataURI } from './fixBulkDataURI'; + +export { fixBulkDataURI }; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 230a82943..958f34b60 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -3,14 +3,14 @@ import { ServicesManager, utils, Types } from '@ohif/core'; import { ContextMenuController, defaultContextMenu, -} from './CustomizeableContextMenu'; +} from './CustomizableContextMenu'; import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'; import reuseCachedLayouts from './utils/reuseCachedLayouts'; import findViewportsByPosition, { findOrCreateViewport as layoutFindOrCreate, } from './findViewportsByPosition'; -import { ContextMenuProps } from './CustomizeableContextMenu/types'; +import { ContextMenuProps } from './CustomizableContextMenu/types'; import { NavigateHistory } from './types/commandModuleTypes'; import { history } from '@ohif/viewer'; diff --git a/extensions/default/src/index.ts b/extensions/default/src/index.ts index c56f39b7e..2b023d415 100644 --- a/extensions/default/src/index.ts +++ b/extensions/default/src/index.ts @@ -13,8 +13,9 @@ import { id } from './id.js'; import preRegistration from './init'; import { ContextMenuController, - CustomizeableContextMenuTypes, -} from './CustomizeableContextMenu'; + CustomizableContextMenuTypes, +} from './CustomizableContextMenu'; +import * as dicomWebUtils from './DicomWebDataSource/utils'; const defaultExtension: Types.Extensions.Extension = { /** @@ -47,6 +48,7 @@ export default defaultExtension; export { ContextMenuController, - CustomizeableContextMenuTypes, + CustomizableContextMenuTypes, getStudiesForPatientByMRN, + dicomWebUtils, }; diff --git a/extensions/default/src/utils/getDirectURL.js b/extensions/default/src/utils/getDirectURL.js index 4580b8d30..c3905e9f2 100644 --- a/extensions/default/src/utils/getDirectURL.js +++ b/extensions/default/src/utils/getDirectURL.js @@ -1,10 +1,4 @@ -import { - DicomMetadataStore, - IWebApiDataSource, - utils, - errorHandler, - classes, -} from '@ohif/core'; +import { utils } from '@ohif/core'; /** * Generates a URL that can be used for direct retrieve of the bulkdata @@ -57,31 +51,19 @@ const getDirectURL = (config, params) => { const BulkDataURI = (value && value.BulkDataURI) || `series/${SeriesInstanceUID}/instances/${SOPInstanceUID}${defaultPath}`; - const hasQuery = BulkDataURI.indexOf('?') != -1; - const hasAccept = BulkDataURI.indexOf('accept=') != -1; + const hasQuery = BulkDataURI.indexOf('?') !== -1; + const hasAccept = BulkDataURI.indexOf('accept=') !== -1; const acceptUri = BulkDataURI + (hasAccept ? '' : (hasQuery ? '&' : '?') + `accept=${defaultType}`); - if (BulkDataURI.indexOf('http') === 0) { - if (tag === 'PixelData' || tag === 'EncapsulatedDocument') { - return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/rendered`; - } else { - return acceptUri; - } + + if (tag === 'PixelData' || tag === 'EncapsulatedDocument') { + return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/rendered`; } - if (BulkDataURI.indexOf('/') === 0) { - return wadoRoot + acceptUri; - } - if (BulkDataURI.indexOf('series/') == 0) { - return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`; - } - if (BulkDataURI.indexOf('instances/') === 0) { - return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/${acceptUri}`; - } - if (BulkDataURI.indexOf('bulkdata/') === 0) { - return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`; - } - throw new Error('BulkDataURI in unknown format:' + BulkDataURI); + + // The DICOMweb standard states that the default is multipart related, and then + // separately states that the accept parameter is the URL parameter equivalent of the accept header. + return acceptUri; }; export default getDirectURL; diff --git a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx index 4c562ece1..dc794a896 100644 --- a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx +++ b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import ReactResizeDetector from 'react-resize-detector'; import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; +import { LoadingIndicatorProgress } from '@ohif/ui'; import './DicomMicroscopyViewport.css'; import ViewportOverlay from './components/ViewportOverlay'; @@ -10,9 +11,20 @@ import dcmjs from 'dcmjs'; import cleanDenaturalizedDataset from './utils/cleanDenaturalizedDataset'; import MicroscopyService from './services/MicroscopyService'; +function transformImageTypeUnnaturalized(entry) { + if (entry.vr === 'CS') { + return { + vr: 'US', + Value: entry.Value[0].split('\\'), + }; + } + return entry; +} + class DicomMicroscopyViewport extends Component { state = { error: null as any, + isLoaded: false, }; microscopyService: MicroscopyService; @@ -132,6 +144,10 @@ class DicomMicroscopyViewport extends Component { // ); // m['00200052'].Value[0] = volumeImages[0].FrameOfReferenceUID; // } + // NOTE: depending on different data source, image.ImageType sometimes + // is a string, not a string array. + // m['00080008'] = transformImageTypeUnnaturalized(m['00080008']); + // const image = new metadataUtils.VLWholeSlideMicroscopyImage({ // metadata: m, // }); @@ -143,8 +159,20 @@ class DicomMicroscopyViewport extends Component { // } metadata.forEach(m => { + // NOTE: depending on different data source, image.ImageType sometimes + // is a string, not a string array. + m.ImageType = + typeof m.ImageType === 'string' + ? m.ImageType.split('\\') + : m.ImageType; + const inst = cleanDenaturalizedDataset( - dcmjs.data.DicomMetaDictionary.denaturalizeDataset(m) + dcmjs.data.DicomMetaDictionary.denaturalizeDataset(m), + { + StudyInstanceUID: m.StudyInstanceUID, + SeriesInstanceUID: m.SeriesInstanceUID, + dataSourceConfig: this.props.dataSource.getConfig(), + } ); if (!inst['00480105']) { // Optical Path Sequence, no OpticalPathIdentifier? @@ -165,13 +193,7 @@ class DicomMicroscopyViewport extends Component { metadata: inst, }); - // NOTE: depending on different data source, image.ImageType sometimes - // is a string, not a string array. - const imageType = - typeof image.ImageType === 'string' - ? image.ImageType.split('\\') - : image.ImageType; - const imageFlavor = imageType[2]; + const imageFlavor = image.ImageType[2]; if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') { volumeImages.push(image); } @@ -182,7 +204,7 @@ class DicomMicroscopyViewport extends Component { client, metadata: volumeImages, retrieveRendered: false, - controls: ['overview', 'position', 'zoom'], + controls: ['overview', 'position'], }; this.viewer = new microscopyViewer(options); @@ -237,7 +259,11 @@ class DicomMicroscopyViewport extends Component { componentDidMount() { const { displaySets, viewportIndex } = this.props; const displaySet = displaySets[viewportIndex]; - this.installOpenLayersRenderer(this.container.current, displaySet); + this.installOpenLayersRenderer(this.container.current, displaySet).then( + () => { + this.setState({ isLoaded: true }); + } + ); } componentDidUpdate( @@ -316,6 +342,9 @@ class DicomMicroscopyViewport extends Component { ) : (
)} + {this.state.isLoaded ? null : ( +