* feat(ecg): add DICOM ECG waveform extension Introduce @ohif/extension-dicom-ecg for rendering DICOM waveform (ECG) data. Register the extension in the basic mode and pluginConfig.json, and remove ECG from NON_IMAGE_MODALITIES so waveform display sets are handled by the new viewport. * refactor(ecg): move ECG support into cornerstone extension per review feedback - Remove standalone dicom-ecg extension; fold all ECG functionality into the cornerstone extension as requested by reviewer - Add ECG SOP class handler (DicomEcgSopClassHandler) to the cornerstone extension getSopClassHandlerModule, registering ECG waveform metadata via genericMetadataProvider on display set creation - Move ECG helpers (buildEcgModule, decodeInt16Multiplex, base64ToArrayBuffer) into extensions/cornerstone/src/utils/ecgMetadata.ts - Handle ECGViewport in CornerstoneViewportService._setDisplaySets by detecting ECGViewport instanceof and calling setEcg(imageId) directly, so OHIFCornerstoneViewport can be used without a custom ECG viewport component - Add ECG support to getCornerstoneViewportType utility - Update basic mode to reference the cornerstone extension's ECG SOP handler and use the base cornerstone viewport for ECG display sets - Migrate ecgMetadata and getCornerstoneViewportType tests * chore: revert bun.lock to upstream origin/master --------- Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
218 lines
7.5 KiB
TypeScript
218 lines
7.5 KiB
TypeScript
import { decodeInt16Multiplex, base64ToArrayBuffer, buildEcgModule } from './ecgMetadata';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// decodeInt16Multiplex
|
|
// ---------------------------------------------------------------------------
|
|
describe('decodeInt16Multiplex', () => {
|
|
it('demultiplexes 2 channels correctly', () => {
|
|
// Interleaved layout: [ch0_s0, ch1_s0, ch0_s1, ch1_s1, ch0_s2, ch1_s2]
|
|
const interleaved = new Int16Array([10, 20, 30, 40, 50, 60]);
|
|
const channels = decodeInt16Multiplex(interleaved.buffer, 2, 3);
|
|
|
|
expect(channels).toHaveLength(2);
|
|
expect(Array.from(channels[0])).toEqual([10, 30, 50]);
|
|
expect(Array.from(channels[1])).toEqual([20, 40, 60]);
|
|
});
|
|
|
|
it('handles a single channel', () => {
|
|
const data = new Int16Array([100, 200, 300]);
|
|
const channels = decodeInt16Multiplex(data.buffer, 1, 3);
|
|
|
|
expect(channels).toHaveLength(1);
|
|
expect(Array.from(channels[0])).toEqual([100, 200, 300]);
|
|
});
|
|
|
|
it('handles negative values (signed short)', () => {
|
|
const data = new Int16Array([-1000, -2000, -3000]);
|
|
const channels = decodeInt16Multiplex(data.buffer, 1, 3);
|
|
|
|
expect(Array.from(channels[0])).toEqual([-1000, -2000, -3000]);
|
|
});
|
|
|
|
it('returns empty arrays when numberOfChannels or numberOfSamples is 0', () => {
|
|
const emptyBuffer = new Int16Array([]).buffer;
|
|
|
|
expect(decodeInt16Multiplex(emptyBuffer, 0, 0)).toHaveLength(0);
|
|
expect(decodeInt16Multiplex(emptyBuffer, 0, 10)).toHaveLength(0);
|
|
});
|
|
|
|
it('produces Int16Array instances for each channel', () => {
|
|
const data = new Int16Array([1, 2]);
|
|
const channels = decodeInt16Multiplex(data.buffer, 2, 1);
|
|
|
|
expect(channels[0]).toBeInstanceOf(Int16Array);
|
|
expect(channels[1]).toBeInstanceOf(Int16Array);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// base64ToArrayBuffer
|
|
// ---------------------------------------------------------------------------
|
|
describe('base64ToArrayBuffer', () => {
|
|
it('decodes a simple base64 string to the correct bytes', () => {
|
|
// "ABC" in ASCII = [65, 66, 67]; base64 of that is 'QUJD'
|
|
const buffer = base64ToArrayBuffer('QUJD');
|
|
const bytes = new Uint8Array(buffer);
|
|
|
|
expect(bytes).toHaveLength(3);
|
|
expect(Array.from(bytes)).toEqual([65, 66, 67]);
|
|
});
|
|
|
|
it('round-trips an Int16Array through base64', () => {
|
|
const original = new Int16Array([500, -300, 0, 32767]);
|
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(original.buffer)));
|
|
const recovered = new Int16Array(base64ToArrayBuffer(base64));
|
|
|
|
expect(Array.from(recovered)).toEqual(Array.from(original));
|
|
});
|
|
|
|
it('returns an ArrayBuffer', () => {
|
|
const result = base64ToArrayBuffer('AA=='); // single zero byte
|
|
expect(result).toBeInstanceOf(ArrayBuffer);
|
|
expect(result.byteLength).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildEcgModule
|
|
// ---------------------------------------------------------------------------
|
|
describe('buildEcgModule', () => {
|
|
const makeInstance = (overrides = {}) => ({
|
|
WaveformSequence: [
|
|
{
|
|
NumberOfWaveformChannels: 12,
|
|
NumberOfWaveformSamples: 5000,
|
|
SamplingFrequency: 500,
|
|
WaveformBitsAllocated: 16,
|
|
WaveformSampleInterpretation: 'SS',
|
|
MultiplexGroupLabel: '12 Lead ECG',
|
|
ChannelDefinitionSequence: [
|
|
{ ChannelSourceSequence: [{ CodeMeaning: 'Lead I' }] },
|
|
{ ChannelSourceSequence: [{ CodeMeaning: 'Lead II' }] },
|
|
],
|
|
WaveformData: { InlineBinary: btoa('\x00\x01') },
|
|
},
|
|
],
|
|
...overrides,
|
|
});
|
|
|
|
it('returns null when WaveformSequence is absent', () => {
|
|
expect(buildEcgModule({})).toBeNull();
|
|
expect(buildEcgModule({ WaveformSequence: [] })).toBeNull();
|
|
});
|
|
|
|
it('returns a valid ecgModule for a complete instance', () => {
|
|
const module = buildEcgModule(makeInstance());
|
|
|
|
expect(module).not.toBeNull();
|
|
expect(module!.numberOfWaveformChannels).toBe(12);
|
|
expect(module!.numberOfWaveformSamples).toBe(5000);
|
|
expect(module!.samplingFrequency).toBe(500);
|
|
expect(module!.waveformBitsAllocated).toBe(16);
|
|
expect(module!.waveformSampleInterpretation).toBe('SS');
|
|
expect(module!.multiplexGroupLabel).toBe('12 Lead ECG');
|
|
expect(module!.channelDefinitionSequence).toHaveLength(2);
|
|
expect(module!.channelDefinitionSequence[0].channelSourceSequence.codeMeaning).toBe('Lead I');
|
|
expect(typeof module!.waveformData.retrieveBulkData).toBe('function');
|
|
});
|
|
|
|
it('applies defaults for missing optional fields', () => {
|
|
const instance = {
|
|
WaveformSequence: [
|
|
{
|
|
// No explicit values — all defaults
|
|
WaveformData: { InlineBinary: btoa('\x00\x00') },
|
|
},
|
|
],
|
|
};
|
|
const module = buildEcgModule(instance);
|
|
|
|
expect(module!.numberOfWaveformChannels).toBe(0);
|
|
expect(module!.numberOfWaveformSamples).toBe(0);
|
|
expect(module!.samplingFrequency).toBe(1);
|
|
expect(module!.waveformBitsAllocated).toBe(16);
|
|
expect(module!.waveformSampleInterpretation).toBe('SS');
|
|
expect(module!.multiplexGroupLabel).toBe('');
|
|
expect(module!.channelDefinitionSequence).toHaveLength(0);
|
|
});
|
|
|
|
it('retrieveBulkData decodes InlineBinary data correctly', async () => {
|
|
const samples = new Int16Array([10, 20, 30]);
|
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(samples.buffer)));
|
|
|
|
const instance = {
|
|
WaveformSequence: [
|
|
{
|
|
NumberOfWaveformChannels: 1,
|
|
NumberOfWaveformSamples: 3,
|
|
WaveformData: { InlineBinary: base64 },
|
|
},
|
|
],
|
|
};
|
|
|
|
const module = buildEcgModule(instance);
|
|
const channels = await module!.waveformData.retrieveBulkData();
|
|
|
|
expect(channels).toHaveLength(1);
|
|
expect(Array.from(channels[0])).toEqual([10, 20, 30]);
|
|
});
|
|
|
|
it('retrieveBulkData returns [] when WaveformData is absent', async () => {
|
|
const instance = {
|
|
WaveformSequence: [
|
|
{ NumberOfWaveformChannels: 1, NumberOfWaveformSamples: 1 },
|
|
],
|
|
};
|
|
const module = buildEcgModule(instance);
|
|
const channels = await module!.waveformData.retrieveBulkData();
|
|
|
|
expect(channels).toEqual([]);
|
|
});
|
|
|
|
it('retrieveBulkData fetches BulkDataURI with auth header when provided', async () => {
|
|
const samples = new Int16Array([1, 2]);
|
|
global.fetch = jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: () => Promise.resolve(samples.buffer),
|
|
});
|
|
|
|
const userAuthenticationService = {
|
|
getAuthorizationHeader: () => ({ Authorization: 'Bearer token123' }),
|
|
};
|
|
const instance = {
|
|
WaveformSequence: [
|
|
{
|
|
NumberOfWaveformChannels: 1,
|
|
NumberOfWaveformSamples: 2,
|
|
WaveformData: { BulkDataURI: 'http://example.com/waveform' },
|
|
},
|
|
],
|
|
};
|
|
|
|
const module = buildEcgModule(instance, userAuthenticationService);
|
|
await module!.waveformData.retrieveBulkData();
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith('http://example.com/waveform', {
|
|
headers: {
|
|
Accept: 'application/octet-stream',
|
|
Authorization: 'Bearer token123',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('retrieveBulkData throws when BulkDataURI response is not ok', async () => {
|
|
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 403 });
|
|
|
|
const instance = {
|
|
WaveformSequence: [
|
|
{
|
|
WaveformData: { BulkDataURI: 'http://example.com/waveform' },
|
|
},
|
|
],
|
|
};
|
|
|
|
const module = buildEcgModule(instance);
|
|
await expect(module!.waveformData.retrieveBulkData()).rejects.toThrow('403');
|
|
});
|
|
});
|