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:
Alireza authored and GitHub committed 2026-07-11 12:16:32 -04:00
1 parent 3c0e245398
commit 70421225d7
8 files changed
+745 -140

No files matched your search

@@ -25,6 +25,7 @@ import {
} from '../utils/dicomWriter';
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';
import { getRenderedURL } from './retrieveRendered';
import retrieveBulkData from './retrieveBulkData';
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
@@ -147,6 +148,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') {
@@ -501,8 +550,16 @@ 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. 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 instancesPerSeries = {};
@@ -576,57 +633,19 @@ 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 }. 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
naturalizedInstances.forEach(instance => {
setNonEnumerableInstanceProperty(instance, 'wadoRoot', dicomWebConfig.wadoRoot);
@@ -678,20 +697,44 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
let completedSeriesCount = 0;
const seriesDeliveredPromises = seriesPromises.map(promise => {
if (!returnPromises) {
promise?.start();
}
return promise.then(instances => {
storeInstances(instances);
});
let deliveredPromise;
return {
metadata: promise.metadata,
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) {
Promise.all(seriesDeliveredPromises).then(() => setSuccessFlag());
return seriesPromises;
if (!seriesDeliveredPromises.length) {
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 {
await Promise.all(seriesDeliveredPromises);
await Promise.all(seriesDeliveredPromises.map(promise => promise.start()));
setSuccessFlag();
}
@@ -761,34 +804,4 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
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 };
@@ -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;
});
}
@@ -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;
@@ -11,21 +11,25 @@ export default function getPTImageIdInstanceMetadata(imageId: string): InstanceM
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 (
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');
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 };
/**
* 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;
}