* 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
125 lines
5.0 KiB
TypeScript
125 lines
5.0 KiB
TypeScript
import OHIF, { utils } from '@ohif/core';
|
|
|
|
import type { InstanceMetadata, PhilipsPETPrivateGroup } from '@cornerstonejs/calculate-suv';
|
|
|
|
const metadataProvider = OHIF.classes.MetadataProvider;
|
|
|
|
export default function getPTImageIdInstanceMetadata(imageId: string): InstanceMetadata {
|
|
const dicomMetaData = metadataProvider.get('instance', imageId);
|
|
|
|
if (!dicomMetaData) {
|
|
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 ||
|
|
!radiopharmaceuticalInfo ||
|
|
radionuclideHalfLife === undefined ||
|
|
radionuclideTotalDose === undefined ||
|
|
dicomMetaData.DecayCorrection === undefined ||
|
|
dicomMetaData.AcquisitionDate === undefined ||
|
|
dicomMetaData.AcquisitionTime === undefined ||
|
|
(radiopharmaceuticalInfo.RadiopharmaceuticalStartDateTime === undefined &&
|
|
radiopharmaceuticalInfo.RadiopharmaceuticalStartTime === undefined)
|
|
) {
|
|
throw new Error('required metadata are missing');
|
|
}
|
|
|
|
if (dicomMetaData.PatientWeight === undefined) {
|
|
console.warn('PatientWeight missing from PT instance metadata');
|
|
}
|
|
|
|
const instanceMetadata: InstanceMetadata = {
|
|
CorrectedImage: dicomMetaData.CorrectedImage,
|
|
Units: dicomMetaData.Units,
|
|
RadionuclideHalfLife: radionuclideHalfLife,
|
|
RadionuclideTotalDose: radionuclideTotalDose,
|
|
RadiopharmaceuticalStartDateTime: radiopharmaceuticalInfo.RadiopharmaceuticalStartDateTime,
|
|
RadiopharmaceuticalStartTime: radiopharmaceuticalInfo.RadiopharmaceuticalStartTime,
|
|
DecayCorrection: dicomMetaData.DecayCorrection,
|
|
PatientWeight: coerceNumber(dicomMetaData.PatientWeight),
|
|
SeriesDate: dicomMetaData.SeriesDate,
|
|
SeriesTime: dicomMetaData.SeriesTime,
|
|
AcquisitionDate: dicomMetaData.AcquisitionDate,
|
|
AcquisitionTime: dicomMetaData.AcquisitionTime,
|
|
};
|
|
|
|
// 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: suvScaleFactor,
|
|
ActivityConcentrationScaleFactor: activityConcentrationScaleFactor,
|
|
};
|
|
instanceMetadata.PhilipsPETPrivateGroup = philipsPETPrivateGroup;
|
|
}
|
|
|
|
if (dicomMetaData['0009100d'] !== undefined) {
|
|
instanceMetadata.GEPrivatePostInjectionDateTime = dicomMetaData['0009100d'];
|
|
}
|
|
|
|
const frameReferenceTime = coerceNumber(dicomMetaData.FrameReferenceTime);
|
|
if (frameReferenceTime !== undefined) {
|
|
instanceMetadata.FrameReferenceTime = frameReferenceTime;
|
|
}
|
|
|
|
const actualFrameDuration = coerceNumber(dicomMetaData.ActualFrameDuration);
|
|
if (actualFrameDuration !== undefined) {
|
|
instanceMetadata.ActualFrameDuration = actualFrameDuration;
|
|
}
|
|
|
|
if (dicomMetaData.PatientSex !== undefined) {
|
|
instanceMetadata.PatientSex = dicomMetaData.PatientSex;
|
|
}
|
|
|
|
const patientSize = coerceNumber(dicomMetaData.PatientSize);
|
|
if (patientSize !== undefined) {
|
|
instanceMetadata.PatientSize = patientSize;
|
|
}
|
|
|
|
return instanceMetadata;
|
|
}
|
|
|
|
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;
|
|
}
|