fix(pt): resolve Philips PET private SUV bulkdata before scaling (#6096)
* fix(pt): resolve Philips PET private SUV bulkdata before scaling
When a DICOMweb server delivers the Philips PET private tags SUVScaleFactor
(7053,1000) / ActivityConcentrationScaleFactor (7053,1009) as bulkdata, dcmjs
naturalization leaves them as { BulkDataURI } objects. These were fed verbatim
to calculate-suv, which treats the object as a valid value and silently
corrupts the SUV scaling factors.
Resolve these scalar private tags to numbers during ingestion - in both the
lazy (async) and non-lazy (sync) DICOMweb metadata paths, before INSTANCES_ADDED
fires - by decoding the bulkdata (VR-aware: DS/IS text or little-endian FL/FD).
Harden getPTImageIdInstanceMetadata to coerce values to finite numbers (reusing
@ohif/core utils.toNumber) and reject unresolved bulkdata objects so they can
never reach calculate-suv. Share the bulkdata-attach helper between both
metadata paths. Adds unit tests for the bulkdata decoder/resolver and for
getPTImageIdInstanceMetadata.
* refactor(bulkdata): move PET bulkdata resolution to a generic core tag registry
Generalize resolvePETPrivateScalarBulkData into a datasource-agnostic
utils.resolveBulkDataTags in @ohif/core, backed by a static tag registry
seeded with the Philips PET SUV/activity-concentration scalar tags and
extensible via registerResolvedBulkDataTags.
* fix(bulkdata): strip NUL padding and refresh qido auth before resolution
Address review feedback:
- decodeText now strips NUL (0x00) padding, which String.trim() leaves
intact, so NUL-padded DS/IS values no longer decode as NaN. Adds a
regression test.
- refresh qidoDicomWebClient.headers before resolveBulkDataTags in both
series-metadata paths; retrieveBulkData is bound to qidoDicomWebClient,
matching every other qido op in this file.
* fix(dicomweb): await deferred metadata storage
* fix(dicomweb): support single-part bulkdata responses
This commit is contained in:
parent
3c0e245398
commit
70421225d7
@ -25,6 +25,7 @@ import {
|
|||||||
} from '../utils/dicomWriter';
|
} from '../utils/dicomWriter';
|
||||||
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';
|
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';
|
||||||
import { getRenderedURL } from './retrieveRendered';
|
import { getRenderedURL } from './retrieveRendered';
|
||||||
|
import retrieveBulkData from './retrieveBulkData';
|
||||||
|
|
||||||
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
|
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
|
||||||
|
|
||||||
@ -147,6 +148,54 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
// this is part of hte base standard.
|
// this is part of hte base standard.
|
||||||
dicomWebConfig.bulkDataURI ||= { enabled: true };
|
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 = {
|
const implementation = {
|
||||||
initialize: ({ params, query }) => {
|
initialize: ({ params, query }) => {
|
||||||
if (dicomWebConfig.onConfiguration && typeof dicomWebConfig.onConfiguration === 'function') {
|
if (dicomWebConfig.onConfiguration && typeof dicomWebConfig.onConfiguration === 'function') {
|
||||||
@ -501,8 +550,16 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
dicomWebConfig
|
dicomWebConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
// first naturalize the data
|
// first naturalize the data, attaching bulkdata retrieve methods so that
|
||||||
const naturalizedInstancesMetadata = data.map(naturalizeDataset);
|
// 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. retrieveBulkData is bound to qidoDicomWebClient,
|
||||||
|
// so refresh its auth headers first (matching every other qido op here).
|
||||||
|
qidoDicomWebClient.headers = getAuthorizationHeader();
|
||||||
|
await utils.resolveBulkDataTags(naturalizedInstancesMetadata);
|
||||||
|
|
||||||
const seriesSummaryMetadata = {};
|
const seriesSummaryMetadata = {};
|
||||||
const instancesPerSeries = {};
|
const instancesPerSeries = {};
|
||||||
@ -576,57 +633,19 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
dicomWebConfig
|
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
|
// Async load series, store as retrieved
|
||||||
function storeInstances(instances) {
|
async function storeInstances(instances) {
|
||||||
const naturalizedInstances = instances.map(addRetrieveBulkData);
|
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 }. retrieveBulkData is bound to qidoDicomWebClient, so
|
||||||
|
// refresh its auth headers first (matching every other qido op here).
|
||||||
|
qidoDicomWebClient.headers = getAuthorizationHeader();
|
||||||
|
await utils.resolveBulkDataTags(naturalizedInstances);
|
||||||
|
|
||||||
// Adding instanceMetadata to OHIF MetadataProvider
|
// Adding instanceMetadata to OHIF MetadataProvider
|
||||||
naturalizedInstances.forEach(instance => {
|
naturalizedInstances.forEach(instance => {
|
||||||
setNonEnumerableInstanceProperty(instance, 'wadoRoot', dicomWebConfig.wadoRoot);
|
setNonEnumerableInstanceProperty(instance, 'wadoRoot', dicomWebConfig.wadoRoot);
|
||||||
@ -678,20 +697,44 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
|
|
||||||
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
|
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
|
||||||
|
|
||||||
|
let completedSeriesCount = 0;
|
||||||
const seriesDeliveredPromises = seriesPromises.map(promise => {
|
const seriesDeliveredPromises = seriesPromises.map(promise => {
|
||||||
if (!returnPromises) {
|
let deliveredPromise;
|
||||||
promise?.start();
|
|
||||||
}
|
return {
|
||||||
return promise.then(instances => {
|
metadata: promise.metadata,
|
||||||
storeInstances(instances);
|
start: () => {
|
||||||
});
|
if (!deliveredPromise) {
|
||||||
|
deliveredPromise = promise.start().then(async instances => {
|
||||||
|
await storeInstances(instances);
|
||||||
|
|
||||||
|
completedSeriesCount++;
|
||||||
|
if (returnPromises && completedSeriesCount === seriesPromises.length) {
|
||||||
|
setSuccessFlag();
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return deliveredPromise;
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (returnPromises) {
|
if (returnPromises) {
|
||||||
Promise.all(seriesDeliveredPromises).then(() => setSuccessFlag());
|
if (!seriesDeliveredPromises.length) {
|
||||||
return seriesPromises;
|
setSuccessFlag();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The route starts only the series required by the hanging protocol,
|
||||||
|
// then starts the remainder in the background. Return wrappers whose
|
||||||
|
// start() resolves after async metadata post-processing has stored the
|
||||||
|
// instances and fired INSTANCES_ADDED; resolving the raw retrieval here
|
||||||
|
// races hanging-protocol application against display-set creation.
|
||||||
|
return seriesDeliveredPromises;
|
||||||
} else {
|
} else {
|
||||||
await Promise.all(seriesDeliveredPromises);
|
await Promise.all(seriesDeliveredPromises.map(promise => promise.start()));
|
||||||
setSuccessFlag();
|
setSuccessFlag();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -761,34 +804,4 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
return IWebApiDataSource.create(implementation);
|
return IWebApiDataSource.create(implementation);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A bindable function that retrieves the bulk data against this as the
|
|
||||||
* dicomweb client, and on the given value element.
|
|
||||||
*
|
|
||||||
* @param value - a bind value that stores the retrieve value to short circuit the
|
|
||||||
* next retrieve instance.
|
|
||||||
* @param options - to allow specifying the content type.
|
|
||||||
*/
|
|
||||||
function retrieveBulkData(value, options = {}) {
|
|
||||||
const { mediaType } = options;
|
|
||||||
const useOptions = {
|
|
||||||
// The bulkdata fetches work with either multipart or
|
|
||||||
// singlepart, so set multipart to false to let the server
|
|
||||||
// decide which type to respond with.
|
|
||||||
multipart: false,
|
|
||||||
BulkDataURI: value.BulkDataURI,
|
|
||||||
mediaTypes: mediaType ? [{ mediaType }, { mediaType: 'application/octet-stream' }] : undefined,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
return this.retrieveBulkData(useOptions).then(val => {
|
|
||||||
// There are DICOM PDF cases where the first ArrayBuffer in the array is
|
|
||||||
// the bulk data and DICOM video cases where the second ArrayBuffer is
|
|
||||||
// the bulk data. Here we play it safe and do a find.
|
|
||||||
const ret =
|
|
||||||
(val instanceof Array && val.find(arrayBuffer => arrayBuffer?.byteLength)) || undefined;
|
|
||||||
value.Value = ret;
|
|
||||||
return ret;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createDicomWebApi };
|
export { createDicomWebApi };
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
import retrieveBulkData from './retrieveBulkData';
|
||||||
|
|
||||||
|
describe('retrieveBulkData', () => {
|
||||||
|
it('returns and caches a single-part ArrayBuffer response', async () => {
|
||||||
|
const buffer = new Uint8Array([1]).buffer;
|
||||||
|
const client = {
|
||||||
|
retrieveBulkData: jest.fn().mockResolvedValue(buffer),
|
||||||
|
};
|
||||||
|
const value: { BulkDataURI: string; Value?: ArrayBuffer } = {
|
||||||
|
BulkDataURI: 'https://example.com/bulk/70531000',
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(retrieveBulkData.call(client, value)).resolves.toBe(buffer);
|
||||||
|
expect(value.Value).toBe(buffer);
|
||||||
|
expect(client.retrieveBulkData).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
multipart: false,
|
||||||
|
BulkDataURI: value.BulkDataURI,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still selects the non-empty buffer from a multipart response', async () => {
|
||||||
|
const buffer = new Uint8Array([2]).buffer;
|
||||||
|
const client = {
|
||||||
|
retrieveBulkData: jest.fn().mockResolvedValue([new ArrayBuffer(0), buffer]),
|
||||||
|
};
|
||||||
|
const value: { BulkDataURI: string; Value?: ArrayBuffer } = {
|
||||||
|
BulkDataURI: 'https://example.com/bulk/70531009',
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(retrieveBulkData.call(client, value)).resolves.toBe(buffer);
|
||||||
|
expect(value.Value).toBe(buffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* A bindable function that retrieves bulkdata against `this` DICOMweb client
|
||||||
|
* and caches the resolved buffer on the given value element.
|
||||||
|
*
|
||||||
|
* @param value - A bound value that stores the retrieved buffer.
|
||||||
|
* @param options - Options such as the requested content type.
|
||||||
|
*/
|
||||||
|
export default function retrieveBulkData(value, options = {}) {
|
||||||
|
const { mediaType } = options;
|
||||||
|
const useOptions = {
|
||||||
|
// The bulkdata fetches work with either multipart or singlepart, so set
|
||||||
|
// multipart to false to let the server decide which type to respond with.
|
||||||
|
multipart: false,
|
||||||
|
BulkDataURI: value.BulkDataURI,
|
||||||
|
mediaTypes: mediaType ? [{ mediaType }, { mediaType: 'application/octet-stream' }] : undefined,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.retrieveBulkData(useOptions).then(val => {
|
||||||
|
// Single-part clients return a bare ArrayBuffer. Multipart clients return
|
||||||
|
// an array; DICOM PDF/video payloads may occupy different positions, so
|
||||||
|
// select the first non-empty part in that response shape.
|
||||||
|
const ret = Array.isArray(val)
|
||||||
|
? val.find(arrayBuffer => arrayBuffer?.byteLength)
|
||||||
|
: val?.byteLength
|
||||||
|
? val
|
||||||
|
: undefined;
|
||||||
|
value.Value = ret;
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
}
|
||||||
102
extensions/default/src/getPTImageIdInstanceMetadata.test.ts
Normal file
102
extensions/default/src/getPTImageIdInstanceMetadata.test.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
const metadataProvider = OHIF.classes.MetadataProvider;
|
||||||
|
|
||||||
@ -11,21 +11,25 @@ export default function getPTImageIdInstanceMetadata(imageId: string): InstanceM
|
|||||||
throw new Error('dicom metadata are required');
|
throw new Error('dicom metadata are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const radiopharmaceuticalInfo = firstSequenceItem<Record<string, unknown>>(
|
||||||
|
dicomMetaData.RadiopharmaceuticalInformationSequence
|
||||||
|
);
|
||||||
|
const radionuclideHalfLife = coerceNumber(radiopharmaceuticalInfo?.RadionuclideHalfLife);
|
||||||
|
const radionuclideTotalDose = coerceNumber(radiopharmaceuticalInfo?.RadionuclideTotalDose);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
dicomMetaData.SeriesDate === undefined ||
|
dicomMetaData.SeriesDate === undefined ||
|
||||||
dicomMetaData.SeriesTime === undefined ||
|
dicomMetaData.SeriesTime === undefined ||
|
||||||
dicomMetaData.CorrectedImage === undefined ||
|
dicomMetaData.CorrectedImage === undefined ||
|
||||||
dicomMetaData.Units === undefined ||
|
dicomMetaData.Units === undefined ||
|
||||||
!dicomMetaData.RadiopharmaceuticalInformationSequence ||
|
!radiopharmaceuticalInfo ||
|
||||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife === undefined ||
|
radionuclideHalfLife === undefined ||
|
||||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose === undefined ||
|
radionuclideTotalDose === undefined ||
|
||||||
dicomMetaData.DecayCorrection === undefined ||
|
dicomMetaData.DecayCorrection === undefined ||
|
||||||
dicomMetaData.AcquisitionDate === undefined ||
|
dicomMetaData.AcquisitionDate === undefined ||
|
||||||
dicomMetaData.AcquisitionTime === undefined ||
|
dicomMetaData.AcquisitionTime === undefined ||
|
||||||
(dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartDateTime ===
|
(radiopharmaceuticalInfo.RadiopharmaceuticalStartDateTime === undefined &&
|
||||||
undefined &&
|
radiopharmaceuticalInfo.RadiopharmaceuticalStartTime === undefined)
|
||||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime ===
|
|
||||||
undefined)
|
|
||||||
) {
|
) {
|
||||||
throw new Error('required metadata are missing');
|
throw new Error('required metadata are missing');
|
||||||
}
|
}
|
||||||
@ -37,73 +41,84 @@ export default function getPTImageIdInstanceMetadata(imageId: string): InstanceM
|
|||||||
const instanceMetadata: InstanceMetadata = {
|
const instanceMetadata: InstanceMetadata = {
|
||||||
CorrectedImage: dicomMetaData.CorrectedImage,
|
CorrectedImage: dicomMetaData.CorrectedImage,
|
||||||
Units: dicomMetaData.Units,
|
Units: dicomMetaData.Units,
|
||||||
RadionuclideHalfLife: dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife,
|
RadionuclideHalfLife: radionuclideHalfLife,
|
||||||
RadionuclideTotalDose:
|
RadionuclideTotalDose: radionuclideTotalDose,
|
||||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose,
|
RadiopharmaceuticalStartDateTime: radiopharmaceuticalInfo.RadiopharmaceuticalStartDateTime,
|
||||||
RadiopharmaceuticalStartDateTime:
|
RadiopharmaceuticalStartTime: radiopharmaceuticalInfo.RadiopharmaceuticalStartTime,
|
||||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartDateTime,
|
|
||||||
RadiopharmaceuticalStartTime:
|
|
||||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime,
|
|
||||||
DecayCorrection: dicomMetaData.DecayCorrection,
|
DecayCorrection: dicomMetaData.DecayCorrection,
|
||||||
PatientWeight: dicomMetaData.PatientWeight,
|
PatientWeight: coerceNumber(dicomMetaData.PatientWeight),
|
||||||
SeriesDate: dicomMetaData.SeriesDate,
|
SeriesDate: dicomMetaData.SeriesDate,
|
||||||
SeriesTime: dicomMetaData.SeriesTime,
|
SeriesTime: dicomMetaData.SeriesTime,
|
||||||
AcquisitionDate: dicomMetaData.AcquisitionDate,
|
AcquisitionDate: dicomMetaData.AcquisitionDate,
|
||||||
AcquisitionTime: dicomMetaData.AcquisitionTime,
|
AcquisitionTime: dicomMetaData.AcquisitionTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
// Philips PET private group. Only populated with values that coerce to real
|
||||||
dicomMetaData['70531000'] ||
|
// numbers; an unresolved bulkdata object yields undefined and is dropped so it
|
||||||
dicomMetaData['70531000'] !== undefined ||
|
// can never corrupt the SUV calculation. SUVScaleFactor is (7053,1000) and
|
||||||
dicomMetaData['70531009'] ||
|
// ActivityConcentrationScaleFactor is (7053,1009). These are resolved from
|
||||||
dicomMetaData['70531009'] !== undefined
|
// bulkdata upstream during ingestion (utils.resolveBulkDataTags).
|
||||||
) {
|
const suvScaleFactor = coerceNumber(dicomMetaData['70531000']);
|
||||||
|
const activityConcentrationScaleFactor = coerceNumber(dicomMetaData['70531009']);
|
||||||
|
if (suvScaleFactor !== undefined || activityConcentrationScaleFactor !== undefined) {
|
||||||
const philipsPETPrivateGroup: PhilipsPETPrivateGroup = {
|
const philipsPETPrivateGroup: PhilipsPETPrivateGroup = {
|
||||||
SUVScaleFactor: dicomMetaData['70531000'],
|
SUVScaleFactor: suvScaleFactor,
|
||||||
ActivityConcentrationScaleFactor: dicomMetaData['70531009'],
|
ActivityConcentrationScaleFactor: activityConcentrationScaleFactor,
|
||||||
};
|
};
|
||||||
instanceMetadata.PhilipsPETPrivateGroup = philipsPETPrivateGroup;
|
instanceMetadata.PhilipsPETPrivateGroup = philipsPETPrivateGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dicomMetaData['0009100d'] && dicomMetaData['0009100d'] !== undefined) {
|
if (dicomMetaData['0009100d'] !== undefined) {
|
||||||
instanceMetadata.GEPrivatePostInjectionDateTime = dicomMetaData['0009100d'];
|
instanceMetadata.GEPrivatePostInjectionDateTime = dicomMetaData['0009100d'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dicomMetaData.FrameReferenceTime && dicomMetaData.FrameReferenceTime !== undefined) {
|
const frameReferenceTime = coerceNumber(dicomMetaData.FrameReferenceTime);
|
||||||
instanceMetadata.FrameReferenceTime = dicomMetaData.FrameReferenceTime;
|
if (frameReferenceTime !== undefined) {
|
||||||
|
instanceMetadata.FrameReferenceTime = frameReferenceTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dicomMetaData.ActualFrameDuration && dicomMetaData.ActualFrameDuration !== undefined) {
|
const actualFrameDuration = coerceNumber(dicomMetaData.ActualFrameDuration);
|
||||||
instanceMetadata.ActualFrameDuration = dicomMetaData.ActualFrameDuration;
|
if (actualFrameDuration !== undefined) {
|
||||||
|
instanceMetadata.ActualFrameDuration = actualFrameDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dicomMetaData.PatientSex && dicomMetaData.PatientSex !== undefined) {
|
if (dicomMetaData.PatientSex !== undefined) {
|
||||||
instanceMetadata.PatientSex = dicomMetaData.PatientSex;
|
instanceMetadata.PatientSex = dicomMetaData.PatientSex;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dicomMetaData.PatientSize && dicomMetaData.PatientSize !== undefined) {
|
const patientSize = coerceNumber(dicomMetaData.PatientSize);
|
||||||
instanceMetadata.PatientSize = dicomMetaData.PatientSize;
|
if (patientSize !== undefined) {
|
||||||
|
instanceMetadata.PatientSize = patientSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
return instanceMetadata;
|
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');
|
|
||||||
|
|
||||||
const timeString = `${hours}${minutes}${seconds}.${fractionalSeconds}`;
|
|
||||||
return timeString;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { getPTImageIdInstanceMetadata };
|
export { getPTImageIdInstanceMetadata };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first item of a DICOM sequence, tolerating either the dcmjs
|
||||||
|
* naturalized array shape or an already-flattened single-object shape.
|
||||||
|
*/
|
||||||
|
function firstSequenceItem<T = Record<string, unknown>>(seq: unknown): T | undefined {
|
||||||
|
if (seq == null || typeof seq !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return (Array.isArray(seq) ? seq[0] : seq) as T;
|
||||||
|
}
|
||||||
|
|||||||
@ -50,6 +50,12 @@ import areAllImageOrientationsEqual from './areAllImageOrientationsEqual';
|
|||||||
import { structuredCloneWithFunctions } from './structuredCloneWithFunctions';
|
import { structuredCloneWithFunctions } from './structuredCloneWithFunctions';
|
||||||
import { buildButtonCommands } from './buildButtonCommands';
|
import { buildButtonCommands } from './buildButtonCommands';
|
||||||
import { thumbnailNoImageModalities } from './thumbnailNoImageModalities';
|
import { thumbnailNoImageModalities } from './thumbnailNoImageModalities';
|
||||||
|
import {
|
||||||
|
resolveBulkDataTags,
|
||||||
|
registerResolvedBulkDataTags,
|
||||||
|
getResolvedBulkDataTags,
|
||||||
|
decodeNumericBulkData,
|
||||||
|
} from './resolveBulkDataTags';
|
||||||
|
|
||||||
import { downloadBlob, downloadUrl, downloadCsv, downloadDicom } from './downloadBlob';
|
import { downloadBlob, downloadUrl, downloadCsv, downloadDicom } from './downloadBlob';
|
||||||
|
|
||||||
@ -111,6 +117,10 @@ const utils = {
|
|||||||
downloadUrl,
|
downloadUrl,
|
||||||
downloadCsv,
|
downloadCsv,
|
||||||
downloadDicom,
|
downloadDicom,
|
||||||
|
resolveBulkDataTags,
|
||||||
|
registerResolvedBulkDataTags,
|
||||||
|
getResolvedBulkDataTags,
|
||||||
|
decodeNumericBulkData,
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -155,6 +165,10 @@ export {
|
|||||||
downloadUrl,
|
downloadUrl,
|
||||||
downloadCsv,
|
downloadCsv,
|
||||||
downloadDicom,
|
downloadDicom,
|
||||||
|
resolveBulkDataTags,
|
||||||
|
registerResolvedBulkDataTags,
|
||||||
|
getResolvedBulkDataTags,
|
||||||
|
decodeNumericBulkData,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default utils;
|
export default utils;
|
||||||
|
|||||||
201
platform/core/src/utils/resolveBulkDataTags.test.ts
Normal file
201
platform/core/src/utils/resolveBulkDataTags.test.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
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 a NUL-padded DS string', () => {
|
||||||
|
// Some servers pad DS/IS values with NUL (0x00) rather than space; trim()
|
||||||
|
// does not strip NUL, so this guards the explicit NUL handling in decodeText.
|
||||||
|
expect(decodeNumericBulkData(textBuffer('0.00038\0'))).toBeCloseTo(0.00038, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
[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<string, unknown> = {
|
||||||
|
'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<string, unknown> = {
|
||||||
|
[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<string, unknown> = { [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<string, unknown> = { [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<string, unknown> = { [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();
|
||||||
|
});
|
||||||
|
});
|
||||||
194
platform/core/src/utils/resolveBulkDataTags.ts
Normal file
194
platform/core/src/utils/resolveBulkDataTags.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* 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<string>([
|
||||||
|
'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 {
|
||||||
|
// `String.trim()` strips ASCII/Unicode whitespace but NOT NUL (0x00), which
|
||||||
|
// `isPrintableNumeric` treats as valid padding, so strip NULs explicitly -
|
||||||
|
// otherwise a NUL-padded value like "0.00038\0" survives as NaN.
|
||||||
|
const text = new TextDecoder().decode(bytes).replace(/\0+/g, '').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<number | undefined> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (!Array.isArray(instances) || !instances.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all(instances.map(resolveInstance));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default resolveBulkDataTags;
|
||||||
Loading…
Reference in New Issue
Block a user