Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 64 additions & 53 deletions extensions/default/src/DicomWebDataSource/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { retrieveStudyMetadata, deleteStudyMetadataPromise } from './retrieveStu
import StaticWadoClient from './utils/StaticWadoClient';
import getDirectURL from '../utils/getDirectURL';
import { fixBulkDataURI } from './utils/fixBulkDataURI';
import { resolvePETPrivateScalarBulkData } from './utils/resolvePETPrivateScalarBulkData';
import { HeadersInterface } from '@ohif/core/src/types/RequestHeaders';
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';

Expand Down Expand Up @@ -142,6 +143,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') {
Expand Down Expand Up @@ -408,8 +457,13 @@ 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 PET private scalar tags (e.g. the Philips SUV Scale Factor)
// delivered as bulkdata into plain numbers BEFORE INSTANCES_ADDED fires.
await resolvePETPrivateScalarBulkData(naturalizedInstancesMetadata);

@sedghi sedghi Jun 21, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really hate this solution, open to what you think is best, the issue is dicomimageloader needs this tags for scaling at earliest time before decodeimageframe


const seriesSummaryMetadata = {};
const instancesPerSeries = {};
Expand Down Expand Up @@ -483,57 +537,16 @@ 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 PET private scalar tags (e.g. the Philips SUV Scale Factor)
// that the server delivered as bulkdata into plain numbers BEFORE
// INSTANCES_ADDED fires, so SUV scaling and every other subscriber read
// a fully-resolved value rather than an unresolved { BulkDataURI }.
await resolvePETPrivateScalarBulkData(naturalizedInstances);

// Adding instanceMetadata to OHIF MetadataProvider
naturalizedInstances.forEach(instance => {
instance.wadoRoot = dicomWebConfig.wadoRoot;
Expand Down Expand Up @@ -589,9 +602,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
if (!returnPromises) {
promise?.start();
}
return promise.then(instances => {
storeInstances(instances);
});
return promise.then(instances => storeInstances(instances));
});

if (returnPromises) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { TextEncoder, TextDecoder } from 'util';
import {
decodeNumericBulkData,
resolvePETPrivateScalarBulkData,
} from './resolvePETPrivateScalarBulkData';

// jsdom does not expose TextEncoder/TextDecoder; the Node util implementations
// are spec-compatible and match what browsers provide at runtime.
Object.assign(globalThis, { TextEncoder, TextDecoder });

const textBuffer = (s: string): ArrayBuffer => new TextEncoder().encode(s).buffer;
const float32Buffer = (n: number): ArrayBuffer => new Float32Array([n]).buffer;
const float64Buffer = (n: number): ArrayBuffer => new Float64Array([n]).buffer;

describe('decodeNumericBulkData', () => {
it('decodes a space-padded DS string (Philips SUVScaleFactor)', () => {
// The actual bytes returned by Orthanc for (7053,1000): "0.00038 "
expect(decodeNumericBulkData(textBuffer('0.00038 '))).toBeCloseTo(0.00038, 8);
});

it('decodes a plain DS string', () => {
expect(decodeNumericBulkData(textBuffer('1.881732'))).toBeCloseTo(1.881732, 6);
});

it('decodes scientific notation', () => {
expect(decodeNumericBulkData(textBuffer('3.8e-4'))).toBeCloseTo(0.00038, 8);
});

it('takes the first value of a multi-valued DS string', () => {
expect(decodeNumericBulkData(textBuffer('1.5\\2.5'))).toBe(1.5);
});

it('decodes a 4-byte little-endian FL value', () => {
expect(decodeNumericBulkData(float32Buffer(0.00038))).toBeCloseTo(0.00038, 7);
});

it('decodes an 8-byte little-endian FD value', () => {
expect(decodeNumericBulkData(float64Buffer(0.00038))).toBeCloseTo(0.00038, 12);
});

it('accepts a typed-array view, not just an ArrayBuffer', () => {
expect(decodeNumericBulkData(new Uint8Array(textBuffer('2.0')))).toBe(2);
});

it('returns undefined for an empty buffer', () => {
expect(decodeNumericBulkData(new ArrayBuffer(0))).toBeUndefined();
});

it('returns undefined for non-numeric text', () => {
expect(decodeNumericBulkData(textBuffer('not-a-number'))).toBeUndefined();
});

it('returns undefined for null / undefined / non-buffer input', () => {
expect(decodeNumericBulkData(null)).toBeUndefined();
expect(decodeNumericBulkData(undefined)).toBeUndefined();
expect(decodeNumericBulkData({ BulkDataURI: 'http://x' })).toBeUndefined();
});
});

describe('resolvePETPrivateScalarBulkData', () => {
const SUV_TAG = '70531000';
const AC_TAG = '70531009';

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 resolvePETPrivateScalarBulkData([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 resolvePETPrivateScalarBulkData([instance]);

expect(instance[SUV_TAG]).toBeCloseTo(0.00038, 8);
expect(instance[AC_TAG]).toBeCloseTo(1.881732, 6);
});

it('uses the cached value.Value without fetching again', async () => {
const retrieveBulkData = jest.fn();
const instance: Record<string, unknown> = {
Modality: 'PT',
[SUV_TAG]: { BulkDataURI: 'http://x', Value: textBuffer('0.5'), retrieveBulkData },
};

await resolvePETPrivateScalarBulkData([instance]);

expect(instance[SUV_TAG]).toBe(0.5);
expect(retrieveBulkData).not.toHaveBeenCalled();
});

it('ignores non-PT instances', async () => {
const retrieveBulkData = jest.fn().mockResolvedValue(textBuffer('0.5'));
const value = { BulkDataURI: 'http://x', retrieveBulkData };
const instance: Record<string, unknown> = { Modality: 'CT', [SUV_TAG]: value };

await resolvePETPrivateScalarBulkData([instance]);

expect(instance[SUV_TAG]).toBe(value);
expect(retrieveBulkData).not.toHaveBeenCalled();
});

it('leaves an already-numeric value untouched', async () => {
const instance: Record<string, unknown> = { Modality: 'PT', [SUV_TAG]: 0.00038 };
await resolvePETPrivateScalarBulkData([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> = { Modality: 'PT', [SUV_TAG]: value };

await resolvePETPrivateScalarBulkData([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> = { Modality: 'PT', [SUV_TAG]: value };

await expect(resolvePETPrivateScalarBulkData([instance])).resolves.toBeUndefined();
expect(instance[SUV_TAG]).toBe(value);
warn.mockRestore();
});

it('is a no-op for empty / non-array input', async () => {
await expect(resolvePETPrivateScalarBulkData([])).resolves.toBeUndefined();
await expect(
resolvePETPrivateScalarBulkData(undefined as unknown as unknown[])
).resolves.toBeUndefined();
});
});
Loading
Loading