diff --git a/extensions/default/src/DicomWebDataSource/index.ts b/extensions/default/src/DicomWebDataSource/index.ts index 209cd406ce0..cb0db498c7b 100644 --- a/extensions/default/src/DicomWebDataSource/index.ts +++ b/extensions/default/src/DicomWebDataSource/index.ts @@ -142,6 +142,54 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { // this is part of hte base standard. dicomWebConfig.bulkDataURI ||= { enabled: true }; + /** + * Adds the retrieve bulkdata function to naturalized DICOM data. + * This is done recursively, for sub-sequences. Shared by both the lazy + * (async) and non-lazy (sync) series-metadata retrieval paths. + */ + const addRetrieveBulkDataNaturalized = (naturalized, instance = naturalized) => { + if (!naturalized) { + return naturalized; + } + for (const key of Object.keys(naturalized)) { + const value = naturalized[key]; + + if (Array.isArray(value) && typeof value[0] === 'object') { + // Fix recursive values + const validValues = value.filter(Boolean); + validValues.forEach(child => addRetrieveBulkDataNaturalized(child, instance)); + continue; + } + + // 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) { + // handle the scenarios where bulkDataURI is relative path + fixBulkDataURI(value, instance, dicomWebConfig); + // Provide a method to fetch bulkdata + value.retrieveBulkData = retrieveBulkData.bind(qidoDicomWebClient, value); + } + } + return naturalized; + }; + + /** + * naturalizes the dataset, and adds a retrieve bulkdata method + * to any values containing BulkDataURI. + * @param {*} instance + * @returns naturalized dataset, with retrieveBulkData methods + */ + const addRetrieveBulkData = instance => { + const naturalized = naturalizeDataset(instance); + + // if we know the server doesn't use bulkDataURI, then don't + if (!dicomWebConfig.bulkDataURI?.enabled) { + return naturalized; + } + + return addRetrieveBulkDataNaturalized(naturalized); + }; + const implementation = { initialize: ({ params, query }) => { if (dicomWebConfig.onConfiguration && typeof dicomWebConfig.onConfiguration === 'function') { @@ -408,8 +456,14 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { dicomWebConfig ); - // first naturalize the data - const naturalizedInstancesMetadata = data.map(naturalizeDataset); + // first naturalize the data, attaching bulkdata retrieve methods so that + // bulkdata-valued tags can be resolved (matching the lazy-load path). + const naturalizedInstancesMetadata = data.map(addRetrieveBulkData); + + // Resolve the registered bulkdata tags (e.g. the Philips SUV Scale + // Factor) delivered as bulkdata into plain numbers BEFORE + // INSTANCES_ADDED fires. + await utils.resolveBulkDataTags(naturalizedInstancesMetadata); const seriesSummaryMetadata = {}; const instancesPerSeries = {}; @@ -483,57 +537,17 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { dicomWebConfig ); - /** - * Adds the retrieve bulkdata function to naturalized DICOM data. - * This is done recursively, for sub-sequences. - */ - const addRetrieveBulkDataNaturalized = (naturalized, instance = naturalized) => { - if (!naturalized) { - return naturalized; - } - for (const key of Object.keys(naturalized)) { - const value = naturalized[key]; - - if (Array.isArray(value) && typeof value[0] === 'object') { - // Fix recursive values - const validValues = value.filter(Boolean); - validValues.forEach(child => addRetrieveBulkDataNaturalized(child, instance)); - continue; - } - - // 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) { - // handle the scenarios where bulkDataURI is relative path - fixBulkDataURI(value, instance, dicomWebConfig); - // Provide a method to fetch bulkdata - value.retrieveBulkData = retrieveBulkData.bind(qidoDicomWebClient, value); - } - } - return naturalized; - }; - - /** - * naturalizes the dataset, and adds a retrieve bulkdata method - * to any values containing BulkDataURI. - * @param {*} instance - * @returns naturalized dataset, with retrieveBulkData methods - */ - const addRetrieveBulkData = instance => { - const naturalized = naturalizeDataset(instance); - - // if we know the server doesn't use bulkDataURI, then don't - if (!dicomWebConfig.bulkDataURI?.enabled) { - return naturalized; - } - - return addRetrieveBulkDataNaturalized(naturalized); - }; - // Async load series, store as retrieved - function storeInstances(instances) { + async function storeInstances(instances) { const naturalizedInstances = instances.map(addRetrieveBulkData); + // Resolve the registered bulkdata tags (e.g. the Philips SUV Scale + // Factor) that the server delivered as bulkdata into plain numbers + // BEFORE INSTANCES_ADDED fires, so SUV scaling and every other + // subscriber read a fully-resolved value rather than an unresolved + // { BulkDataURI }. + await utils.resolveBulkDataTags(naturalizedInstances); + // Adding instanceMetadata to OHIF MetadataProvider naturalizedInstances.forEach(instance => { instance.wadoRoot = dicomWebConfig.wadoRoot; @@ -589,9 +603,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { if (!returnPromises) { promise?.start(); } - return promise.then(instances => { - storeInstances(instances); - }); + return promise.then(instances => storeInstances(instances)); }); if (returnPromises) { diff --git a/extensions/default/src/getPTImageIdInstanceMetadata.test.ts b/extensions/default/src/getPTImageIdInstanceMetadata.test.ts new file mode 100644 index 00000000000..7caae221266 --- /dev/null +++ b/extensions/default/src/getPTImageIdInstanceMetadata.test.ts @@ -0,0 +1,102 @@ +const mockGet = jest.fn(); + +// Minimal @ohif/core mock: the real utils.toNumber (used by coerceNumber) and a +// stubbed MetadataProvider.get. +jest.mock('@ohif/core', () => { + const toNumber = (val: unknown) => + Array.isArray(val) + ? val.map(v => (v !== undefined ? Number(v) : v)) + : val !== undefined + ? Number(val) + : val; + const core = { + classes: { MetadataProvider: { get: (...args: unknown[]) => mockGet(...args) } }, + utils: { toNumber }, + }; + return { __esModule: true, default: core, utils: core.utils }; +}); + +import getPTImageIdInstanceMetadata from './getPTImageIdInstanceMetadata'; + +// A valid PT instance with the radiopharmaceutical sequence in dcmjs array form. +const baseInstance = () => ({ + Modality: 'PT', + Units: 'CNTS', + CorrectedImage: ['DECY', 'ATTN'], + SeriesDate: '20130606', + SeriesTime: '120000', + AcquisitionDate: '20130606', + AcquisitionTime: '120000', + DecayCorrection: 'START', + PatientWeight: 70, + RadiopharmaceuticalInformationSequence: [ + { + RadionuclideHalfLife: 6586.2, + RadionuclideTotalDose: 370000000, + RadiopharmaceuticalStartTime: '110000', + }, + ], +}); + +afterEach(() => mockGet.mockReset()); + +describe('getPTImageIdInstanceMetadata', () => { + it('throws when no metadata is available', () => { + mockGet.mockReturnValue(undefined); + expect(() => getPTImageIdInstanceMetadata('img:1')).toThrow('dicom metadata are required'); + }); + + it('throws when required metadata is missing', () => { + const inst = baseInstance(); + delete inst.Units; + mockGet.mockReturnValue(inst); + expect(() => getPTImageIdInstanceMetadata('img:1')).toThrow('required metadata are missing'); + }); + + it('reads RadionuclideHalfLife/TotalDose from the array-shaped sequence (firstSequenceItem)', () => { + mockGet.mockReturnValue(baseInstance()); + const result = getPTImageIdInstanceMetadata('img:1'); + expect(result.RadionuclideHalfLife).toBe(6586.2); + expect(result.RadionuclideTotalDose).toBe(370000000); + }); + + it('still reads the sequence when flattened to a single object', () => { + const inst = baseInstance(); + // some servers/paths deliver a flattened (non-array) sequence + inst.RadiopharmaceuticalInformationSequence = + inst.RadiopharmaceuticalInformationSequence[0]; + mockGet.mockReturnValue(inst); + expect(getPTImageIdInstanceMetadata('img:1').RadionuclideHalfLife).toBe(6586.2); + }); + + it('populates PhilipsPETPrivateGroup when the private tags are numbers', () => { + const inst = baseInstance(); + inst['70531000'] = 0.00038; + inst['70531009'] = 1.881732; + mockGet.mockReturnValue(inst); + const result = getPTImageIdInstanceMetadata('img:1'); + expect(result.PhilipsPETPrivateGroup).toEqual({ + SUVScaleFactor: 0.00038, + ActivityConcentrationScaleFactor: 1.881732, + }); + }); + + it('drops an unresolved bulkdata { BulkDataURI } private tag (coerceNumber backstop)', () => { + const inst = baseInstance(); + inst['70531000'] = { BulkDataURI: 'http://x/bulk/70531000' }; + inst['70531009'] = { BulkDataURI: 'http://x/bulk/70531009' }; + mockGet.mockReturnValue(inst); + // Neither tag coerces to a number, so the group is not attached at all - + // an object can never reach calculate-suv. + expect(getPTImageIdInstanceMetadata('img:1').PhilipsPETPrivateGroup).toBeUndefined(); + }); + + it('coerces a numeric DS string private tag to a number', () => { + const inst = baseInstance(); + inst['70531000'] = '0.00038'; + mockGet.mockReturnValue(inst); + expect(getPTImageIdInstanceMetadata('img:1').PhilipsPETPrivateGroup.SUVScaleFactor).toBe( + 0.00038 + ); + }); +}); diff --git a/extensions/default/src/getPTImageIdInstanceMetadata.ts b/extensions/default/src/getPTImageIdInstanceMetadata.ts index 7d28827e3f1..5c33dd82baf 100644 --- a/extensions/default/src/getPTImageIdInstanceMetadata.ts +++ b/extensions/default/src/getPTImageIdInstanceMetadata.ts @@ -1,6 +1,6 @@ -import OHIF from '@ohif/core'; +import OHIF, { utils } from '@ohif/core'; -import type { InstanceMetadata, PhilipsPETPrivateGroup } from '@cornerstonejs/calculate-suv/src/types'; +import type { InstanceMetadata, PhilipsPETPrivateGroup } from '@cornerstonejs/calculate-suv'; const metadataProvider = OHIF.classes.MetadataProvider; @@ -11,21 +11,25 @@ export default function getPTImageIdInstanceMetadata(imageId: string): InstanceM throw new Error('dicom metadata are required'); } + const radiopharmaceuticalInfo = firstSequenceItem>( + dicomMetaData.RadiopharmaceuticalInformationSequence + ); + const radionuclideHalfLife = coerceNumber(radiopharmaceuticalInfo?.RadionuclideHalfLife); + const radionuclideTotalDose = coerceNumber(radiopharmaceuticalInfo?.RadionuclideTotalDose); + if ( dicomMetaData.SeriesDate === undefined || dicomMetaData.SeriesTime === undefined || dicomMetaData.CorrectedImage === undefined || dicomMetaData.Units === undefined || - !dicomMetaData.RadiopharmaceuticalInformationSequence || - dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife === undefined || - dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose === undefined || + !radiopharmaceuticalInfo || + radionuclideHalfLife === undefined || + radionuclideTotalDose === undefined || dicomMetaData.DecayCorrection === undefined || dicomMetaData.AcquisitionDate === undefined || dicomMetaData.AcquisitionTime === undefined || - (dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartDateTime === - undefined && - dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime === - undefined) + (radiopharmaceuticalInfo.RadiopharmaceuticalStartDateTime === undefined && + radiopharmaceuticalInfo.RadiopharmaceuticalStartTime === undefined) ) { throw new Error('required metadata are missing'); } @@ -37,73 +41,84 @@ export default function getPTImageIdInstanceMetadata(imageId: string): InstanceM const instanceMetadata: InstanceMetadata = { CorrectedImage: dicomMetaData.CorrectedImage, Units: dicomMetaData.Units, - RadionuclideHalfLife: dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife, - RadionuclideTotalDose: - dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose, - RadiopharmaceuticalStartDateTime: - dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartDateTime, - RadiopharmaceuticalStartTime: - dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime, + RadionuclideHalfLife: radionuclideHalfLife, + RadionuclideTotalDose: radionuclideTotalDose, + RadiopharmaceuticalStartDateTime: radiopharmaceuticalInfo.RadiopharmaceuticalStartDateTime, + RadiopharmaceuticalStartTime: radiopharmaceuticalInfo.RadiopharmaceuticalStartTime, DecayCorrection: dicomMetaData.DecayCorrection, - PatientWeight: dicomMetaData.PatientWeight, + PatientWeight: coerceNumber(dicomMetaData.PatientWeight), SeriesDate: dicomMetaData.SeriesDate, SeriesTime: dicomMetaData.SeriesTime, AcquisitionDate: dicomMetaData.AcquisitionDate, AcquisitionTime: dicomMetaData.AcquisitionTime, }; - if ( - dicomMetaData['70531000'] || - dicomMetaData['70531000'] !== undefined || - dicomMetaData['70531009'] || - dicomMetaData['70531009'] !== undefined - ) { + // Philips PET private group. Only populated with values that coerce to real + // numbers; an unresolved bulkdata object yields undefined and is dropped so it + // can never corrupt the SUV calculation. SUVScaleFactor is (7053,1000) and + // ActivityConcentrationScaleFactor is (7053,1009). These are resolved from + // bulkdata upstream during ingestion (utils.resolveBulkDataTags). + const suvScaleFactor = coerceNumber(dicomMetaData['70531000']); + const activityConcentrationScaleFactor = coerceNumber(dicomMetaData['70531009']); + if (suvScaleFactor !== undefined || activityConcentrationScaleFactor !== undefined) { const philipsPETPrivateGroup: PhilipsPETPrivateGroup = { - SUVScaleFactor: dicomMetaData['70531000'], - ActivityConcentrationScaleFactor: dicomMetaData['70531009'], + SUVScaleFactor: suvScaleFactor, + ActivityConcentrationScaleFactor: activityConcentrationScaleFactor, }; instanceMetadata.PhilipsPETPrivateGroup = philipsPETPrivateGroup; } - if (dicomMetaData['0009100d'] && dicomMetaData['0009100d'] !== undefined) { + if (dicomMetaData['0009100d'] !== undefined) { instanceMetadata.GEPrivatePostInjectionDateTime = dicomMetaData['0009100d']; } - if (dicomMetaData.FrameReferenceTime && dicomMetaData.FrameReferenceTime !== undefined) { - instanceMetadata.FrameReferenceTime = dicomMetaData.FrameReferenceTime; + const frameReferenceTime = coerceNumber(dicomMetaData.FrameReferenceTime); + if (frameReferenceTime !== undefined) { + instanceMetadata.FrameReferenceTime = frameReferenceTime; } - if (dicomMetaData.ActualFrameDuration && dicomMetaData.ActualFrameDuration !== undefined) { - instanceMetadata.ActualFrameDuration = dicomMetaData.ActualFrameDuration; + const actualFrameDuration = coerceNumber(dicomMetaData.ActualFrameDuration); + if (actualFrameDuration !== undefined) { + instanceMetadata.ActualFrameDuration = actualFrameDuration; } - if (dicomMetaData.PatientSex && dicomMetaData.PatientSex !== undefined) { + if (dicomMetaData.PatientSex !== undefined) { instanceMetadata.PatientSex = dicomMetaData.PatientSex; } - if (dicomMetaData.PatientSize && dicomMetaData.PatientSize !== undefined) { - instanceMetadata.PatientSize = dicomMetaData.PatientSize; + const patientSize = coerceNumber(dicomMetaData.PatientSize); + if (patientSize !== undefined) { + instanceMetadata.PatientSize = patientSize; } return instanceMetadata; } -function convertInterfaceTimeToString(time): string { - const hours = `${time.hours || '00'}`.padStart(2, '0'); - const minutes = `${time.minutes || '00'}`.padStart(2, '0'); - const seconds = `${time.seconds || '00'}`.padStart(2, '0'); - - const fractionalSeconds = `${time.fractionalSeconds || '000000'}`.padEnd(6, '0'); +export { getPTImageIdInstanceMetadata }; - const timeString = `${hours}${minutes}${seconds}.${fractionalSeconds}`; - return timeString; +/** + * Coerces a naturalized DICOM value into a finite number, or returns undefined. + * + * Delegates to OHIF's `utils.toNumber` and then requires a finite scalar, so an + * object value - such as an unresolved bulkdata reference `{ BulkDataURI }` or + * an array - becomes undefined. This is the final backstop ensuring such a value + * can never reach calculate-suv (which treats it as truthy and silently corrupts + * the SUV factors). Bulkdata is meant to be resolved upstream during ingestion + * (see utils.resolveBulkDataTags); this guard catches anything that slips + * through. + */ +function coerceNumber(value: unknown): number | undefined { + const n = utils.toNumber(value); + return typeof n === 'number' && Number.isFinite(n) ? n : undefined; } -function convertInterfaceDateToString(date): string { - const month = `${date.month}`.padStart(2, '0'); - const day = `${date.day}`.padStart(2, '0'); - const dateString = `${date.year}${month}${day}`; - return dateString; +/** + * Returns the first item of a DICOM sequence, tolerating either the dcmjs + * naturalized array shape or an already-flattened single-object shape. + */ +function firstSequenceItem>(seq: unknown): T | undefined { + if (seq == null || typeof seq !== 'object') { + return undefined; + } + return (Array.isArray(seq) ? seq[0] : seq) as T; } - -export { getPTImageIdInstanceMetadata }; diff --git a/platform/core/src/utils/index.ts b/platform/core/src/utils/index.ts index 026fd0ce695..cb06622e502 100644 --- a/platform/core/src/utils/index.ts +++ b/platform/core/src/utils/index.ts @@ -49,6 +49,12 @@ import areAllImageOrientationsEqual from './areAllImageOrientationsEqual'; import { structuredCloneWithFunctions } from './structuredCloneWithFunctions'; import { buildButtonCommands } from './buildButtonCommands'; import { thumbnailNoImageModalities } from './thumbnailNoImageModalities'; +import { + resolveBulkDataTags, + registerResolvedBulkDataTags, + getResolvedBulkDataTags, + decodeNumericBulkData, +} from './resolveBulkDataTags'; import { downloadBlob, downloadUrl, downloadCsv, downloadDicom } from './downloadBlob'; @@ -109,6 +115,10 @@ const utils = { downloadUrl, downloadCsv, downloadDicom, + resolveBulkDataTags, + registerResolvedBulkDataTags, + getResolvedBulkDataTags, + decodeNumericBulkData, }; export { @@ -152,6 +162,10 @@ export { downloadUrl, downloadCsv, downloadDicom, + resolveBulkDataTags, + registerResolvedBulkDataTags, + getResolvedBulkDataTags, + decodeNumericBulkData, }; export default utils; diff --git a/platform/core/src/utils/resolveBulkDataTags.test.ts b/platform/core/src/utils/resolveBulkDataTags.test.ts new file mode 100644 index 00000000000..dfa211fcbf4 --- /dev/null +++ b/platform/core/src/utils/resolveBulkDataTags.test.ts @@ -0,0 +1,195 @@ +import { TextEncoder, TextDecoder } from 'util'; +import { + decodeNumericBulkData, + resolveBulkDataTags, + registerResolvedBulkDataTags, + getResolvedBulkDataTags, +} from './resolveBulkDataTags'; + +// jsdom does not expose TextEncoder/TextDecoder; the Node util implementations +// are spec-compatible and match what browsers provide at runtime. +Object.assign(globalThis, { TextEncoder, TextDecoder }); + +const textBuffer = (s: string): ArrayBuffer => new TextEncoder().encode(s).buffer; +const float32Buffer = (n: number): ArrayBuffer => new Float32Array([n]).buffer; +const float64Buffer = (n: number): ArrayBuffer => new Float64Array([n]).buffer; + +describe('decodeNumericBulkData', () => { + it('decodes a space-padded DS string (Philips SUVScaleFactor)', () => { + // The actual bytes returned by Orthanc for (7053,1000): "0.00038 " + expect(decodeNumericBulkData(textBuffer('0.00038 '))).toBeCloseTo(0.00038, 8); + }); + + it('decodes a plain DS string', () => { + expect(decodeNumericBulkData(textBuffer('1.881732'))).toBeCloseTo(1.881732, 6); + }); + + it('decodes scientific notation', () => { + expect(decodeNumericBulkData(textBuffer('3.8e-4'))).toBeCloseTo(0.00038, 8); + }); + + it('takes the first value of a multi-valued DS string', () => { + expect(decodeNumericBulkData(textBuffer('1.5\\2.5'))).toBe(1.5); + }); + + it('decodes a 4-byte little-endian FL value', () => { + expect(decodeNumericBulkData(float32Buffer(0.00038))).toBeCloseTo(0.00038, 7); + }); + + it('decodes an 8-byte little-endian FD value', () => { + expect(decodeNumericBulkData(float64Buffer(0.00038))).toBeCloseTo(0.00038, 12); + }); + + it('accepts a typed-array view, not just an ArrayBuffer', () => { + expect(decodeNumericBulkData(new Uint8Array(textBuffer('2.0')))).toBe(2); + }); + + it('returns undefined for an empty buffer', () => { + expect(decodeNumericBulkData(new ArrayBuffer(0))).toBeUndefined(); + }); + + it('returns undefined for non-numeric text', () => { + expect(decodeNumericBulkData(textBuffer('not-a-number'))).toBeUndefined(); + }); + + it('returns undefined for null / undefined / non-buffer input', () => { + expect(decodeNumericBulkData(null)).toBeUndefined(); + expect(decodeNumericBulkData(undefined)).toBeUndefined(); + expect(decodeNumericBulkData({ BulkDataURI: 'http://x' })).toBeUndefined(); + }); +}); + +describe('resolveBulkDataTags', () => { + const SUV_TAG = '70531000'; + const AC_TAG = '70531009'; + + it('registers the Philips PET scalar tags by default', () => { + expect(getResolvedBulkDataTags()).toEqual(expect.arrayContaining([SUV_TAG, AC_TAG])); + }); + + it('resolves a Philips bulkdata tag to a number via retrieveBulkData', async () => { + const instance: Record = { + Modality: 'PT', + [SUV_TAG]: { + BulkDataURI: 'http://x/bulk/70531000', + retrieveBulkData: jest.fn().mockResolvedValue(textBuffer('0.00038 ')), + }, + }; + + await resolveBulkDataTags([instance]); + + expect(instance[SUV_TAG]).toBeCloseTo(0.00038, 8); + }); + + it('resolves both Philips scalar tags', async () => { + const instance: Record = { + Modality: 'PT', + [SUV_TAG]: { + BulkDataURI: 'http://x/1', + retrieveBulkData: jest.fn().mockResolvedValue(textBuffer('0.00038')), + }, + [AC_TAG]: { + BulkDataURI: 'http://x/2', + retrieveBulkData: jest.fn().mockResolvedValue(textBuffer('1.881732')), + }, + }; + + await resolveBulkDataTags([instance]); + + expect(instance[SUV_TAG]).toBeCloseTo(0.00038, 8); + expect(instance[AC_TAG]).toBeCloseTo(1.881732, 6); + }); + + it('resolves registered tags regardless of modality', async () => { + const instance: Record = { + Modality: 'CT', + [SUV_TAG]: { + BulkDataURI: 'http://x', + retrieveBulkData: jest.fn().mockResolvedValue(textBuffer('0.5')), + }, + }; + + await resolveBulkDataTags([instance]); + + expect(instance[SUV_TAG]).toBe(0.5); + }); + + it('resolves additionally registered tags', async () => { + const CUSTOM_TAG = '00091001'; + registerResolvedBulkDataTags(CUSTOM_TAG); + expect(getResolvedBulkDataTags()).toContain(CUSTOM_TAG); + + const instance: Record = { + [CUSTOM_TAG]: { + BulkDataURI: 'http://x/custom', + retrieveBulkData: jest.fn().mockResolvedValue(textBuffer('42.5')), + }, + }; + + await resolveBulkDataTags([instance]); + + expect(instance[CUSTOM_TAG]).toBe(42.5); + }); + + it('registers arrays of tags and normalizes casing', async () => { + registerResolvedBulkDataTags(['0019100a']); + expect(getResolvedBulkDataTags()).toContain('0019100A'); + + // The naturalized dataset may key the tag in either casing. + const instance: Record = { + '0019100a': { + BulkDataURI: 'http://x', + retrieveBulkData: jest.fn().mockResolvedValue(textBuffer('7')), + }, + }; + + await resolveBulkDataTags([instance]); + + expect(instance['0019100a']).toBe(7); + }); + + it('uses the cached value.Value without fetching again', async () => { + const retrieveBulkData = jest.fn(); + const instance: Record = { + [SUV_TAG]: { BulkDataURI: 'http://x', Value: textBuffer('0.5'), retrieveBulkData }, + }; + + await resolveBulkDataTags([instance]); + + expect(instance[SUV_TAG]).toBe(0.5); + expect(retrieveBulkData).not.toHaveBeenCalled(); + }); + + it('leaves an already-numeric value untouched', async () => { + const instance: Record = { [SUV_TAG]: 0.00038 }; + await resolveBulkDataTags([instance]); + expect(instance[SUV_TAG]).toBe(0.00038); + }); + + it('leaves the value untouched when bulkdata cannot be fetched (no retrieveBulkData)', async () => { + const value = { BulkDataURI: 'http://x' }; + const instance: Record = { [SUV_TAG]: value }; + + await resolveBulkDataTags([instance]); + + expect(instance[SUV_TAG]).toBe(value); + }); + + it('does not throw and leaves the value when retrieveBulkData rejects', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const value = { + BulkDataURI: 'http://x', + retrieveBulkData: jest.fn().mockRejectedValue(new Error('request failed')), + }; + const instance: Record = { [SUV_TAG]: value }; + + await expect(resolveBulkDataTags([instance])).resolves.toBeUndefined(); + expect(instance[SUV_TAG]).toBe(value); + warn.mockRestore(); + }); + + it('is a no-op for empty / non-array input', async () => { + await expect(resolveBulkDataTags([])).resolves.toBeUndefined(); + await expect(resolveBulkDataTags(undefined as unknown as unknown[])).resolves.toBeUndefined(); + }); +}); diff --git a/platform/core/src/utils/resolveBulkDataTags.ts b/platform/core/src/utils/resolveBulkDataTags.ts new file mode 100644 index 00000000000..020763a8660 --- /dev/null +++ b/platform/core/src/utils/resolveBulkDataTags.ts @@ -0,0 +1,191 @@ +/** + * A static registry of DICOM tags whose values must be resolved from bulkdata + * into plain numbers during metadata ingestion, before INSTANCES_ADDED fires. + * + * Background: some servers return small scalar values - notably the Philips + * SUV Scale Factor (7053,1000) and Activity Concentration Scale Factor + * (7053,1009) - as bulkdata rather than inline. dcmjs `naturalizeDataset` then + * leaves them as `{ BulkDataURI: '...' }` objects under their raw hex key. + * Downstream consumers (e.g. SUV scaling via calculate-suv) expect numbers and + * silently corrupt when handed an object, so data sources resolve the + * registered tags eagerly so that every subscriber reads a fully-resolved + * number. + * + * The registry is intentionally not tied to any data source: it is a static + * list that any data source can consume via `resolveBulkDataTags`, and that + * extensions can extend via `registerResolvedBulkDataTags`. + * + * Resolution reuses the `retrieveBulkData` method that data sources bind onto + * each bulkdata value; when it is absent the value is left untouched and + * consumers fall back gracefully. + */ + +// Tags that may arrive as bulkdata and must be resolved to numbers, keyed by +// the naturalized (comma-less) hex tag. Seeded with the Philips PET Private +// Group scalar tags; both are VR DS in the standard Philips definition, but +// the VR is auto-detected (see decode below) because servers occasionally +// encode them as FL/FD. +const resolvedBulkDataTags = new Set([ + '70531000', // Philips SUV Scale Factor + '70531009', // Philips Activity Concentration Scale Factor +]); + +/** + * Registers additional tags (naturalized comma-less hex form, e.g. + * '70531000') to be resolved from bulkdata during metadata ingestion. + */ +export function registerResolvedBulkDataTags(tags: string | string[]): void { + const list = Array.isArray(tags) ? tags : [tags]; + for (const tag of list) { + if (typeof tag === 'string' && tag.length) { + resolvedBulkDataTags.add(tag.toUpperCase()); + } + } +} + +/** + * Returns the tags currently registered for eager bulkdata resolution. + */ +export function getResolvedBulkDataTags(): string[] { + return [...resolvedBulkDataTags]; +} + +function toUint8(raw: unknown): Uint8Array | undefined { + // Check views first: ArrayBuffer.isView is realm-agnostic. + if (ArrayBuffer.isView(raw)) { + const view = raw as ArrayBufferView; + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + // `instanceof ArrayBuffer` is realm-specific, so fall back to a tag check so + // that buffers created in another realm (workers, tests) are still handled. + if ( + raw instanceof ArrayBuffer || + Object.prototype.toString.call(raw) === '[object ArrayBuffer]' + ) { + return new Uint8Array(raw as ArrayBuffer); + } + return undefined; +} + +// A DS/IS value is printable ASCII (digits, sign, decimal point, exponent, +// backslash separator, spaces, null padding); raw FL/FD bytes generally are +// not. This lets us auto-detect the encoding without trusting a VR field, which +// dcmjs drops when a value is delivered as bulkdata. +function isPrintableNumeric(bytes: Uint8Array): boolean { + if (bytes.length === 0) { + return false; + } + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + const isDigit = b >= 0x30 && b <= 0x39; // 0-9 + const isAllowed = + b === 0x2b || // + + b === 0x2d || // - + b === 0x2e || // . + b === 0x45 || // E + b === 0x65 || // e + b === 0x5c || // backslash (multi-value separator) + b === 0x20 || // space (padding) + b === 0x00; // null (padding) + if (!isDigit && !isAllowed) { + return false; + } + } + return true; +} + +function decodeText(bytes: Uint8Array): number | undefined { + const text = new TextDecoder().decode(bytes).trim(); + // DS/IS may be multi-valued (backslash-delimited); take the first value. + const first = text.split('\\')[0].trim(); + if (!first) { + return undefined; + } + const n = Number(first); + return Number.isFinite(n) ? n : undefined; +} + +function decodeBinaryFloat(bytes: Uint8Array): number | undefined { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let n: number | undefined; + if (bytes.byteLength === 4) { + n = dv.getFloat32(0, /* littleEndian */ true); + } else if (bytes.byteLength === 8) { + n = dv.getFloat64(0, /* littleEndian */ true); + } + return n !== undefined && Number.isFinite(n) ? n : undefined; +} + +/** + * Decodes a bulkdata buffer into a single number. The VR is not available on a + * naturalized bulkdata value (dcmjs drops it), so the encoding is auto-detected: + * printable-ASCII payloads are decoded as DS/IS text, otherwise the bytes are + * read as little-endian IEEE-754 (FL = 4 bytes, FD = 8 bytes). + */ +export function decodeNumericBulkData(raw: unknown): number | undefined { + const bytes = toUint8(raw); + if (!bytes || bytes.byteLength === 0) { + return undefined; + } + if (isPrintableNumeric(bytes)) { + return decodeText(bytes); + } + return decodeBinaryFloat(bytes) ?? decodeText(bytes); +} + +async function resolveValueToNumber(value): Promise { + // retrieveBulkData caches the resolved buffer on value.Value, so prefer it. + let buffer = value.Value; + if (buffer == null && typeof value.retrieveBulkData === 'function') { + buffer = await value.retrieveBulkData(); + } + if (buffer == null) { + return undefined; + } + return decodeNumericBulkData(buffer); +} + +/** + * Resolves, in place, the registered bulkdata tags on a single naturalized + * instance. No-op for values that are already numbers or that have no + * resolvable bulkdata. + */ +async function resolveInstance(instance): Promise { + if (!instance) { + return; + } + + await Promise.all( + [...resolvedBulkDataTags].map(async tag => { + // Registered tags are normalized to uppercase hex; naturalized datasets + // key unknown private tags by their hex tag, so check both casings. + const key = tag in instance ? tag : tag.toLowerCase(); + const value = instance[key]; + // Inline (already a number) or absent: nothing to resolve. + if (value == null || typeof value !== 'object') { + return; + } + try { + const num = await resolveValueToNumber(value); + if (num !== undefined) { + instance[key] = num; + } + } catch (error) { + console.warn(`resolveBulkDataTags: failed to resolve tag ${tag}`, error); + } + }) + ); +} + +/** + * Resolves the registered bulkdata tags across a set of naturalized + * instances, mutating them in place. + */ +export async function resolveBulkDataTags(instances): Promise { + if (!Array.isArray(instances) || !instances.length) { + return; + } + await Promise.all(instances.map(resolveInstance)); +} + +export default resolveBulkDataTags;