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:
1 parent
3c0e245398
commit
70421225d7
8 files changed
+745
-140
No files matched your search
@@ -50,6 +50,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';
|
||||
|
||||
@@ -111,6 +117,10 @@ const utils = {
|
||||
downloadUrl,
|
||||
downloadCsv,
|
||||
downloadDicom,
|
||||
resolveBulkDataTags,
|
||||
registerResolvedBulkDataTags,
|
||||
getResolvedBulkDataTags,
|
||||
decodeNumericBulkData,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -155,6 +165,10 @@ export {
|
||||
downloadUrl,
|
||||
downloadCsv,
|
||||
downloadDicom,
|
||||
resolveBulkDataTags,
|
||||
registerResolvedBulkDataTags,
|
||||
getResolvedBulkDataTags,
|
||||
decodeNumericBulkData,
|
||||
};
|
||||
|
||||
export default utils;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user