diff --git a/README.md b/README.md index 6aa501f1d..06d2262e2 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ provided by the Open Health Imaging Foundation (OHIF | 4D | 4D | [Demo](https://viewer.ohif.org/dynamic-volume?StudyInstanceUIDs=2.25.232704420736447710317909004159492840763) | | VIDEO | Video | [Demo](https://viewer.ohif.org/viewer?StudyInstanceUIDs=2.25.96975534054447904995905761963464388233) | | microscopy | Slide Microscopy | [Demo](https://viewer.ohif.org/microscopy?StudyInstanceUIDs=2.25.141277760791347900862109212450152067508) | +| ECG | ECG Waveform | [Demo](https://viewer-dev.ohif.org/viewer?StudyInstanceUIDs=2.25.209974489360710696739324151261716440238) | ## About diff --git a/extensions/cornerstone/src/getSopClassHandlerModule.js b/extensions/cornerstone/src/getSopClassHandlerModule.js index e5767efb1..157b7aef2 100644 --- a/extensions/cornerstone/src/getSopClassHandlerModule.js +++ b/extensions/cornerstone/src/getSopClassHandlerModule.js @@ -3,6 +3,7 @@ import i18n from '@ohif/i18n'; import { utilities as csUtils, Enums as csEnums } from '@cornerstonejs/core'; import dcmjs from 'dcmjs'; import { dicomWebUtils } from '@ohif/extension-default'; +import { buildEcgModule } from './utils/ecgMetadata'; const { MetadataModules } = csEnums; 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)]; } diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 698c8e4a0..ed281997a 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -9,6 +9,7 @@ import { utilities as csUtils, VolumeViewport, VolumeViewport3D, + ECGViewport, cache, Enums as csEnums, BaseVolumeViewport, @@ -778,6 +779,19 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi /** * Sets the image data for the given viewport. */ + private async _setEcgViewport( + viewport: Types.IECGViewport, + viewportData: StackViewportData + ): Promise { + 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( viewport: Types.IStackViewport, 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( viewport, viewportData as StackViewportData, diff --git a/extensions/cornerstone/src/utils/ecgMetadata.test.ts b/extensions/cornerstone/src/utils/ecgMetadata.test.ts new file mode 100644 index 000000000..511b1dbc7 --- /dev/null +++ b/extensions/cornerstone/src/utils/ecgMetadata.test.ts @@ -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'); + }); +}); diff --git a/extensions/cornerstone/src/utils/ecgMetadata.ts b/extensions/cornerstone/src/utils/ecgMetadata.ts new file mode 100644 index 000000000..22f9e83ff --- /dev/null +++ b/extensions/cornerstone/src/utils/ecgMetadata.ts @@ -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 }; +}; + +/** + * 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 => { + 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 = { + 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 }, + }; +} diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts index 7afd41f68..b6d02f02b 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts @@ -10,6 +10,7 @@ jest.mock('@cornerstonejs/core', () => ({ WHOLE_SLIDE: 'wholeslide', ORTHOGRAPHIC: 'orthographic', VOLUME_3D: 'volume3d', + ECG: 'ecg', }, }, })); @@ -50,9 +51,14 @@ describe('getCornerstoneViewportType', () => { 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', () => { 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' ); }); diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts index d70410188..fd89f0193 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts @@ -7,6 +7,7 @@ const ORTHOGRAPHIC = 'orthographic'; const VOLUME_3D = 'volume3d'; const VIDEO = 'video'; const WHOLESLIDE = 'wholeslide'; +const ECG = 'ecg'; export default function getCornerstoneViewportType( viewportType: string, @@ -25,6 +26,10 @@ export default function getCornerstoneViewportType( return Enums.ViewportType.WHOLE_SLIDE; } + if (lowerViewportType === ECG) { + return Enums.ViewportType.ECG; + } + if (lowerViewportType === VOLUME || lowerViewportType === ORTHOGRAPHIC) { return Enums.ViewportType.ORTHOGRAPHIC; } @@ -34,6 +39,6 @@ export default function getCornerstoneViewportType( } 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` ); } diff --git a/modes/basic/src/index.tsx b/modes/basic/src/index.tsx index 8fcc859ad..f9de0101a 100644 --- a/modes/basic/src/index.tsx +++ b/modes/basic/src/index.tsx @@ -15,7 +15,7 @@ const { structuredCloneWithFunctions } = utils; * This list used to include SM, for whole slide imaging, but this is now supported * 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 = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', @@ -47,6 +47,10 @@ export const dicomvideo = { viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video', }; +export const dicomecg = { + sopClassHandler: '@ohif/extension-cornerstone.sopClassHandlerModule.DicomEcgSopClassHandler', +}; + export const dicompdf = { sopClassHandler: '@ohif/extension-dicom-pdf.sopClassHandlerModule.dicom-pdf', viewport: '@ohif/extension-dicom-pdf.viewportModule.dicom-pdf', @@ -86,6 +90,7 @@ export const extensionDependencies = { export const sopClassHandlers = [ dicomvideo.sopClassHandler, + dicomecg.sopClassHandler, dicomSeg.sopClassHandler, dicomPmap.sopClassHandler, ohif.sopClassHandler, @@ -291,6 +296,7 @@ export const basicLayout = { ohif.sopClassHandler, dicomvideo.sopClassHandler, ohif.wsiSopClassHandler, + dicomecg.sopClassHandler, ], }, {