feat(ecg): add DICOM ECG waveform extension (#5856)
* 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>
This commit is contained in:
parent
30bb5d0652
commit
70f76aeba1
@ -49,6 +49,7 @@ provided by the <a href="https://ohif.org/">Open Health Imaging Foundation (OHIF
|
|||||||
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-4d.webp?raw=true" alt="4D" width="350"/> | 4D | [Demo](https://viewer.ohif.org/dynamic-volume?StudyInstanceUIDs=2.25.232704420736447710317909004159492840763) |
|
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-4d.webp?raw=true" alt="4D" width="350"/> | 4D | [Demo](https://viewer.ohif.org/dynamic-volume?StudyInstanceUIDs=2.25.232704420736447710317909004159492840763) |
|
||||||
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-video.webp?raw=true" alt="VIDEO" width="350"/> | Video | [Demo](https://viewer.ohif.org/viewer?StudyInstanceUIDs=2.25.96975534054447904995905761963464388233) |
|
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-video.webp?raw=true" alt="VIDEO" width="350"/> | Video | [Demo](https://viewer.ohif.org/viewer?StudyInstanceUIDs=2.25.96975534054447904995905761963464388233) |
|
||||||
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/microscopy.webp?raw=true" alt="microscopy" width="350"/> | Slide Microscopy | [Demo](https://viewer.ohif.org/microscopy?StudyInstanceUIDs=2.25.141277760791347900862109212450152067508) |
|
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/microscopy.webp?raw=true" alt="microscopy" width="350"/> | Slide Microscopy | [Demo](https://viewer.ohif.org/microscopy?StudyInstanceUIDs=2.25.141277760791347900862109212450152067508) |
|
||||||
|
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-ecg.webp?raw=true" alt="ECG" width="350"/> | ECG Waveform | [Demo](https://viewer-dev.ohif.org/viewer?StudyInstanceUIDs=2.25.209974489360710696739324151261716440238) |
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import i18n from '@ohif/i18n';
|
|||||||
import { utilities as csUtils, Enums as csEnums } from '@cornerstonejs/core';
|
import { utilities as csUtils, Enums as csEnums } from '@cornerstonejs/core';
|
||||||
import dcmjs from 'dcmjs';
|
import dcmjs from 'dcmjs';
|
||||||
import { dicomWebUtils } from '@ohif/extension-default';
|
import { dicomWebUtils } from '@ohif/extension-default';
|
||||||
|
import { buildEcgModule } from './utils/ecgMetadata';
|
||||||
|
|
||||||
const { MetadataModules } = csEnums;
|
const { MetadataModules } = csEnums;
|
||||||
const { utils } = OHIF;
|
const { utils } = OHIF;
|
||||||
@ -155,6 +156,84 @@ export function getDicomMicroscopySopClassHandler({ servicesManager, extensionMa
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSopClassHandlerModule(params) {
|
/**
|
||||||
return [getDicomMicroscopySopClassHandler(params)];
|
* DICOM Waveform SOP Class UIDs for ECG / cardiac electrophysiology.
|
||||||
|
* Reference: https://dicom.nema.org/medical/dicom/current/output/chtml/part04/sect_B.5.html
|
||||||
|
*/
|
||||||
|
const ECG_SOP_CLASS_UIDS = {
|
||||||
|
TWELVE_LEAD_ECG_WAVEFORM_STORAGE: '1.2.840.10008.5.1.4.1.1.9.1.1',
|
||||||
|
GENERAL_ECG_WAVEFORM_STORAGE: '1.2.840.10008.5.1.4.1.1.9.1.2',
|
||||||
|
AMBULATORY_ECG_WAVEFORM_STORAGE: '1.2.840.10008.5.1.4.1.1.9.1.3',
|
||||||
|
HEMODYNAMIC_WAVEFORM_STORAGE: '1.2.840.10008.5.1.4.1.1.9.2.1',
|
||||||
|
CARDIAC_ELECTROPHYSIOLOGY_WAVEFORM_STORAGE: '1.2.840.10008.5.1.4.1.1.9.3.1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ecgSopClassUids = Object.values(ECG_SOP_CLASS_UIDS);
|
||||||
|
|
||||||
|
const DicomEcgSOPClassHandlerId =
|
||||||
|
'@ohif/extension-cornerstone.sopClassHandlerModule.DicomEcgSopClassHandler';
|
||||||
|
|
||||||
|
function _getEcgDisplaySetsFromSeries(instances, servicesManager) {
|
||||||
|
const { userAuthenticationService } = servicesManager.services;
|
||||||
|
|
||||||
|
return instances.map(instance => {
|
||||||
|
const { Modality, SOPInstanceUID } = instance;
|
||||||
|
const { SeriesDescription, SeriesNumber, SeriesDate } = instance;
|
||||||
|
const { SeriesInstanceUID, StudyInstanceUID, SOPClassUID } = instance;
|
||||||
|
const imageId = instance.imageId;
|
||||||
|
|
||||||
|
// Register ECG metadata in the OHIF metadata provider so that
|
||||||
|
// Cornerstone's ECGViewport can retrieve it via metaData.get('ecgModule', imageId).
|
||||||
|
if (imageId) {
|
||||||
|
const ecgModule = buildEcgModule(instance, userAuthenticationService);
|
||||||
|
if (ecgModule) {
|
||||||
|
csUtils.genericMetadataProvider.addRaw(imageId, {
|
||||||
|
type: MetadataModules.ECG,
|
||||||
|
metadata: ecgModule,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Modality,
|
||||||
|
displaySetInstanceUID: utils.guid(),
|
||||||
|
SeriesDescription,
|
||||||
|
SeriesNumber,
|
||||||
|
SeriesDate,
|
||||||
|
SOPInstanceUID,
|
||||||
|
SeriesInstanceUID,
|
||||||
|
StudyInstanceUID,
|
||||||
|
SOPClassHandlerId: DicomEcgSOPClassHandlerId,
|
||||||
|
SOPClassUID,
|
||||||
|
referencedImages: null,
|
||||||
|
measurements: null,
|
||||||
|
viewportType: csEnums.ViewportType.ECG,
|
||||||
|
instances: [instance],
|
||||||
|
instance,
|
||||||
|
thumbnailSrc: null,
|
||||||
|
isDerivedDisplaySet: false,
|
||||||
|
isLoaded: false,
|
||||||
|
sopClassUids: ecgSopClassUids,
|
||||||
|
numImageFrames: 0,
|
||||||
|
numInstances: 1,
|
||||||
|
imageIds: imageId ? [imageId] : [],
|
||||||
|
supportsWindowLevel: false,
|
||||||
|
label: SeriesDescription || 'ECG',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDicomEcgSopClassHandler({ servicesManager }) {
|
||||||
|
const getDisplaySetsFromSeries = instances =>
|
||||||
|
_getEcgDisplaySetsFromSeries(instances, servicesManager);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'DicomEcgSopClassHandler',
|
||||||
|
sopClassUids: ecgSopClassUids,
|
||||||
|
getDisplaySetsFromSeries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSopClassHandlerModule(params) {
|
||||||
|
return [getDicomMicroscopySopClassHandler(params), getDicomEcgSopClassHandler(params)];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
utilities as csUtils,
|
utilities as csUtils,
|
||||||
VolumeViewport,
|
VolumeViewport,
|
||||||
VolumeViewport3D,
|
VolumeViewport3D,
|
||||||
|
ECGViewport,
|
||||||
cache,
|
cache,
|
||||||
Enums as csEnums,
|
Enums as csEnums,
|
||||||
BaseVolumeViewport,
|
BaseVolumeViewport,
|
||||||
@ -778,6 +779,19 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
/**
|
/**
|
||||||
* Sets the image data for the given viewport.
|
* Sets the image data for the given viewport.
|
||||||
*/
|
*/
|
||||||
|
private async _setEcgViewport(
|
||||||
|
viewport: Types.IECGViewport,
|
||||||
|
viewportData: StackViewportData
|
||||||
|
): Promise<void> {
|
||||||
|
const [displaySet] = viewportData.data;
|
||||||
|
const imageId = displaySet.imageIds?.[0];
|
||||||
|
if (!imageId) {
|
||||||
|
console.error('[CornerstoneViewportService] ECG display set has no imageId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return viewport.setEcg(imageId);
|
||||||
|
}
|
||||||
|
|
||||||
private async _setOtherViewport(
|
private async _setOtherViewport(
|
||||||
viewport: Types.IStackViewport,
|
viewport: Types.IStackViewport,
|
||||||
viewportData: StackViewportData,
|
viewportData: StackViewportData,
|
||||||
@ -1257,6 +1271,10 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (viewport instanceof ECGViewport) {
|
||||||
|
return this._setEcgViewport(viewport as unknown as Types.IECGViewport, viewportData as StackViewportData);
|
||||||
|
}
|
||||||
|
|
||||||
return this._setOtherViewport(
|
return this._setOtherViewport(
|
||||||
viewport,
|
viewport,
|
||||||
viewportData as StackViewportData,
|
viewportData as StackViewportData,
|
||||||
|
|||||||
217
extensions/cornerstone/src/utils/ecgMetadata.test.ts
Normal file
217
extensions/cornerstone/src/utils/ecgMetadata.test.ts
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
129
extensions/cornerstone/src/utils/ecgMetadata.ts
Normal file
129
extensions/cornerstone/src/utils/ecgMetadata.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Decode a multiplexed Int16 buffer into per-channel arrays.
|
||||||
|
* Layout: sample0ch0, sample0ch1 ... sample0chN, sample1ch0, …
|
||||||
|
* Note: DICOM ECG data is canonically SS (signed short). The sampleInterpretation
|
||||||
|
* field is forwarded to ECGViewport for its own use; the raw buffer is always
|
||||||
|
* treated as Int16 because Cornerstone ECGViewport expects Int16Array[].
|
||||||
|
*/
|
||||||
|
export function decodeInt16Multiplex(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
numberOfChannels: number,
|
||||||
|
numberOfSamples: number
|
||||||
|
): Int16Array[] {
|
||||||
|
const src = new Int16Array(buffer);
|
||||||
|
const channels: Int16Array[] = [];
|
||||||
|
for (let ch = 0; ch < numberOfChannels; ch++) {
|
||||||
|
const out = new Int16Array(numberOfSamples);
|
||||||
|
for (let s = 0; s < numberOfSamples; s++) {
|
||||||
|
out[s] = src[s * numberOfChannels + ch];
|
||||||
|
}
|
||||||
|
channels.push(out);
|
||||||
|
}
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a base64 InlineBinary string into a raw ArrayBuffer.
|
||||||
|
*/
|
||||||
|
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||||
|
const binaryStr = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binaryStr.length);
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EcgModule = {
|
||||||
|
numberOfWaveformChannels: number;
|
||||||
|
numberOfWaveformSamples: number;
|
||||||
|
samplingFrequency: number;
|
||||||
|
waveformBitsAllocated: number;
|
||||||
|
waveformSampleInterpretation: string;
|
||||||
|
multiplexGroupLabel: string;
|
||||||
|
channelDefinitionSequence: Array<{ channelSourceSequence: { codeMeaning: string } }>;
|
||||||
|
waveformData: { retrieveBulkData: () => Promise<Int16Array[]> };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the naturalized DICOM instance's WaveformSequence and build the ecgModule
|
||||||
|
* that Cornerstone's ECGViewport.setEcg() expects via
|
||||||
|
* metaData.get(MetadataModules.ECG, imageId).
|
||||||
|
*
|
||||||
|
* Returns null if the instance has no WaveformSequence.
|
||||||
|
*/
|
||||||
|
export function buildEcgModule(
|
||||||
|
instance: any,
|
||||||
|
userAuthenticationService?: any
|
||||||
|
): EcgModule | null {
|
||||||
|
const waveformGroups = instance?.WaveformSequence;
|
||||||
|
if (!waveformGroups?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first (and typically only) multiplex group
|
||||||
|
const group = waveformGroups[0];
|
||||||
|
|
||||||
|
const numberOfChannels = group.NumberOfWaveformChannels ?? 0;
|
||||||
|
const numberOfSamples = group.NumberOfWaveformSamples ?? 0;
|
||||||
|
const samplingFrequency = group.SamplingFrequency ?? 1;
|
||||||
|
const bitsAllocated = group.WaveformBitsAllocated ?? 16;
|
||||||
|
const sampleInterpretation = group.WaveformSampleInterpretation ?? 'SS';
|
||||||
|
const multiplexGroupLabel = group.MultiplexGroupLabel ?? '';
|
||||||
|
|
||||||
|
const channelDefinitionSequence = (group.ChannelDefinitionSequence ?? []).map(ch => ({
|
||||||
|
channelSourceSequence: {
|
||||||
|
codeMeaning:
|
||||||
|
ch?.ChannelSourceSequence?.[0]?.CodeMeaning ??
|
||||||
|
ch?.ChannelSourceSequence?.[0]?.codeMeaning ??
|
||||||
|
'',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const retrieveBulkData = async (): Promise<Int16Array[]> => {
|
||||||
|
const waveformData = group.WaveformData;
|
||||||
|
|
||||||
|
if (!waveformData) {
|
||||||
|
console.warn('[ECGViewport] No WaveformData found on instance');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer: ArrayBuffer;
|
||||||
|
|
||||||
|
if (waveformData.InlineBinary) {
|
||||||
|
buffer = base64ToArrayBuffer(waveformData.InlineBinary);
|
||||||
|
} else if (waveformData.BulkDataURI) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/octet-stream',
|
||||||
|
};
|
||||||
|
const authHeader = userAuthenticationService?.getAuthorizationHeader?.();
|
||||||
|
if (authHeader) {
|
||||||
|
Object.assign(headers, authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(waveformData.BulkDataURI, { headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`[ECGViewport] Failed to fetch waveform BulkDataURI: ${response.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
buffer = await response.arrayBuffer();
|
||||||
|
} else {
|
||||||
|
console.warn('[ECGViewport] WaveformData has no InlineBinary or BulkDataURI');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeInt16Multiplex(buffer, numberOfChannels, numberOfSamples);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
numberOfWaveformChannels: numberOfChannels,
|
||||||
|
numberOfWaveformSamples: numberOfSamples,
|
||||||
|
samplingFrequency,
|
||||||
|
waveformBitsAllocated: bitsAllocated,
|
||||||
|
waveformSampleInterpretation: sampleInterpretation,
|
||||||
|
multiplexGroupLabel,
|
||||||
|
channelDefinitionSequence,
|
||||||
|
waveformData: { retrieveBulkData },
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ jest.mock('@cornerstonejs/core', () => ({
|
|||||||
WHOLE_SLIDE: 'wholeslide',
|
WHOLE_SLIDE: 'wholeslide',
|
||||||
ORTHOGRAPHIC: 'orthographic',
|
ORTHOGRAPHIC: 'orthographic',
|
||||||
VOLUME_3D: 'volume3d',
|
VOLUME_3D: 'volume3d',
|
||||||
|
ECG: 'ecg',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -50,9 +51,14 @@ describe('getCornerstoneViewportType', () => {
|
|||||||
expect(result).toBe(Enums.ViewportType.VOLUME_3D);
|
expect(result).toBe(Enums.ViewportType.VOLUME_3D);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return ECG when viewportType is ecg', () => {
|
||||||
|
const result = getCornerstoneViewportType('ecg');
|
||||||
|
expect(result).toBe(Enums.ViewportType.ECG);
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw error for invalid viewport type', () => {
|
it('should throw error for invalid viewport type', () => {
|
||||||
expect(() => getCornerstoneViewportType('invalid')).toThrow(
|
expect(() => getCornerstoneViewportType('invalid')).toThrow(
|
||||||
'Invalid viewport type: invalid. Valid types are: stack, volume, video, wholeslide'
|
'Invalid viewport type: invalid. Valid types are: stack, volume, video, wholeslide, ecg'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const ORTHOGRAPHIC = 'orthographic';
|
|||||||
const VOLUME_3D = 'volume3d';
|
const VOLUME_3D = 'volume3d';
|
||||||
const VIDEO = 'video';
|
const VIDEO = 'video';
|
||||||
const WHOLESLIDE = 'wholeslide';
|
const WHOLESLIDE = 'wholeslide';
|
||||||
|
const ECG = 'ecg';
|
||||||
|
|
||||||
export default function getCornerstoneViewportType(
|
export default function getCornerstoneViewportType(
|
||||||
viewportType: string,
|
viewportType: string,
|
||||||
@ -25,6 +26,10 @@ export default function getCornerstoneViewportType(
|
|||||||
return Enums.ViewportType.WHOLE_SLIDE;
|
return Enums.ViewportType.WHOLE_SLIDE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lowerViewportType === ECG) {
|
||||||
|
return Enums.ViewportType.ECG;
|
||||||
|
}
|
||||||
|
|
||||||
if (lowerViewportType === VOLUME || lowerViewportType === ORTHOGRAPHIC) {
|
if (lowerViewportType === VOLUME || lowerViewportType === ORTHOGRAPHIC) {
|
||||||
return Enums.ViewportType.ORTHOGRAPHIC;
|
return Enums.ViewportType.ORTHOGRAPHIC;
|
||||||
}
|
}
|
||||||
@ -34,6 +39,6 @@ export default function getCornerstoneViewportType(
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume, video, wholeslide`
|
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume, video, wholeslide, ecg`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const { structuredCloneWithFunctions } = utils;
|
|||||||
* This list used to include SM, for whole slide imaging, but this is now supported
|
* This list used to include SM, for whole slide imaging, but this is now supported
|
||||||
* by cornerstone. Others of these may get added.
|
* by cornerstone. Others of these may get added.
|
||||||
*/
|
*/
|
||||||
export const NON_IMAGE_MODALITIES = ['ECG', 'SEG', 'RTSTRUCT', 'RTPLAN', 'PR', 'SR'];
|
export const NON_IMAGE_MODALITIES = ['SEG', 'RTSTRUCT', 'RTPLAN', 'PR', 'SR'];
|
||||||
|
|
||||||
export const ohif = {
|
export const ohif = {
|
||||||
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
|
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
|
||||||
@ -47,6 +47,10 @@ export const dicomvideo = {
|
|||||||
viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video',
|
viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dicomecg = {
|
||||||
|
sopClassHandler: '@ohif/extension-cornerstone.sopClassHandlerModule.DicomEcgSopClassHandler',
|
||||||
|
};
|
||||||
|
|
||||||
export const dicompdf = {
|
export const dicompdf = {
|
||||||
sopClassHandler: '@ohif/extension-dicom-pdf.sopClassHandlerModule.dicom-pdf',
|
sopClassHandler: '@ohif/extension-dicom-pdf.sopClassHandlerModule.dicom-pdf',
|
||||||
viewport: '@ohif/extension-dicom-pdf.viewportModule.dicom-pdf',
|
viewport: '@ohif/extension-dicom-pdf.viewportModule.dicom-pdf',
|
||||||
@ -86,6 +90,7 @@ export const extensionDependencies = {
|
|||||||
|
|
||||||
export const sopClassHandlers = [
|
export const sopClassHandlers = [
|
||||||
dicomvideo.sopClassHandler,
|
dicomvideo.sopClassHandler,
|
||||||
|
dicomecg.sopClassHandler,
|
||||||
dicomSeg.sopClassHandler,
|
dicomSeg.sopClassHandler,
|
||||||
dicomPmap.sopClassHandler,
|
dicomPmap.sopClassHandler,
|
||||||
ohif.sopClassHandler,
|
ohif.sopClassHandler,
|
||||||
@ -291,6 +296,7 @@ export const basicLayout = {
|
|||||||
ohif.sopClassHandler,
|
ohif.sopClassHandler,
|
||||||
dicomvideo.sopClassHandler,
|
dicomvideo.sopClassHandler,
|
||||||
ohif.wsiSopClassHandler,
|
ohif.wsiSopClassHandler,
|
||||||
|
dicomecg.sopClassHandler,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user