From 9049509e9f637dcc7da3f17a482c3943ec7622e7 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 9 Oct 2026 12:24:09 -0400 Subject: [PATCH 1/8] fix(cornerstone): decode ECG waveform bulkdata through the data source The ECG module builder fetched WaveformData.BulkDataURI itself and read the response as raw Int16 samples. DICOMweb returns bulkdata as multipart/related by default, so the samples were shifted, or the Int16Array constructor threw on an odd byte count. An instance without data returned no channels, and the ECG viewport then failed in canvasToWorld. Build the module with buildEcgModuleFromInstance from @cornerstonejs/metadata. When the data source attached retrieveBulkData to WaveformData, use it to get the bytes: the DICOMweb client decodes multipart or single part, and applies the auth headers and the relative bulkdata URI fix. Co-Authored-By: Claude Opus 5.5 --- .../cornerstone/src/utils/ecgMetadata.test.ts | 239 ++++-------------- .../cornerstone/src/utils/ecgMetadata.ts | 141 ++--------- 2 files changed, 69 insertions(+), 311 deletions(-) diff --git a/extensions/cornerstone/src/utils/ecgMetadata.test.ts b/extensions/cornerstone/src/utils/ecgMetadata.test.ts index 511b1dbc7d4..e750e3c8871 100644 --- a/extensions/cornerstone/src/utils/ecgMetadata.test.ts +++ b/extensions/cornerstone/src/utils/ecgMetadata.test.ts @@ -1,217 +1,64 @@ -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); - }); +import { buildEcgModule } from './ecgMetadata'; + +// Two channels, two samples, interleaved: ch0=[1, -2], ch1=[3, 4]. +const SAMPLES = new Int16Array([1, 3, -2, 4]); + +const makeInstance = WaveformData => ({ + WaveformSequence: [ + { + NumberOfWaveformChannels: 2, + NumberOfWaveformSamples: 2, + SamplingFrequency: 500, + WaveformBitsAllocated: 16, + WaveformSampleInterpretation: 'SS', + MultiplexGroupLabel: '12 Lead ECG', + ChannelDefinitionSequence: [ + { ChannelSourceSequence: [{ CodeMeaning: 'Lead I' }] }, + { ChannelSourceSequence: [{ CodeMeaning: 'Lead II' }] }, + ], + WaveformData, + }, + ], }); -// --------------------------------------------------------------------------- -// 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]); - }); +const channelsOf = (channels: Int16Array[]) => channels.map(channel => Array.from(channel)); - 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()); + it('returns the waveform attributes and the lead names', () => { + const module = buildEcgModule(makeInstance({})); - expect(module).not.toBeNull(); - expect(module!.numberOfWaveformChannels).toBe(12); - expect(module!.numberOfWaveformSamples).toBe(5000); + expect(module!.numberOfWaveformChannels).toBe(2); + expect(module!.numberOfWaveformSamples).toBe(2); 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(); + it('decodes InlineBinary data', async () => { + const base64 = btoa(String.fromCharCode(...new Uint8Array(SAMPLES.buffer))); + const module = buildEcgModule(makeInstance({ InlineBinary: base64 })); - expect(channels).toEqual([]); + expect(channelsOf(await module!.waveformData.retrieveBulkData())).toEqual([ + [1, -2], + [3, 4], + ]); }); - 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' }, - }, - ], - }; + it('decodes the bytes that the data source retrieveBulkData returns', async () => { + const retrieveBulkData = jest.fn().mockResolvedValue(SAMPLES.buffer.slice(0)); + const module = buildEcgModule( + makeInstance({ BulkDataURI: 'http://example.com/waveform', retrieveBulkData }) + ); - const module = buildEcgModule(instance); - await expect(module!.waveformData.retrieveBulkData()).rejects.toThrow('403'); + expect(channelsOf(await module!.waveformData.retrieveBulkData())).toEqual([ + [1, -2], + [3, 4], + ]); + expect(retrieveBulkData).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/cornerstone/src/utils/ecgMetadata.ts b/extensions/cornerstone/src/utils/ecgMetadata.ts index 22f9e83ff2e..756d1af8f0b 100644 --- a/extensions/cornerstone/src/utils/ecgMetadata.ts +++ b/extensions/cornerstone/src/utils/ecgMetadata.ts @@ -1,129 +1,40 @@ -/** - * 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; -} +import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata'; -export type EcgModule = { - numberOfWaveformChannels: number; - numberOfWaveformSamples: number; - samplingFrequency: number; - waveformBitsAllocated: number; - waveformSampleInterpretation: string; - multiplexGroupLabel: string; - channelDefinitionSequence: Array<{ channelSourceSequence: { codeMeaning: string } }>; - waveformData: { retrieveBulkData: () => Promise }; -}; +export type EcgModule = NonNullable< + ReturnType +>; /** * Parse the naturalized DICOM instance's WaveformSequence and build the ecgModule * that Cornerstone's ECGViewport.setEcg() expects via * metaData.get(MetadataModules.ECG, imageId). * + * Cornerstone decodes the waveform. When the data source attached a + * `retrieveBulkData` to `WaveformData`, that method fetches the bytes, so the + * data source handles multipart/related, auth and relative bulkdata URIs. + * * Returns null if the instance has no WaveformSequence. */ -export function buildEcgModule( - instance: any, - userAuthenticationService?: any -): EcgModule | null { - const waveformGroups = instance?.WaveformSequence; - if (!waveformGroups?.length) { +export function buildEcgModule(instance: any, userAuthenticationService?: any): EcgModule | null { + if (!instance?.WaveformSequence?.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); - }; + const ecgModule = csMetadataUtilities.buildEcgModuleFromInstance(instance, instance.imageId, { + getHeaders: () => userAuthenticationService?.getAuthorizationHeader?.(), + }); + const [group] = instance.WaveformSequence; + const waveformData = Array.isArray(group.WaveformData) + ? group.WaveformData[0] + : group.WaveformData; + + if (ecgModule && typeof waveformData?.retrieveBulkData === 'function') { + ecgModule.waveformData.retrieveBulkData = async () => { + const bytes = await waveformData.retrieveBulkData(); + const loaded = { WaveformSequence: [{ ...group, WaveformData: bytes }] }; + return csMetadataUtilities.buildEcgModuleFromInstance(loaded).waveformData.retrieveBulkData(); + }; + } - return { - numberOfWaveformChannels: numberOfChannels, - numberOfWaveformSamples: numberOfSamples, - samplingFrequency, - waveformBitsAllocated: bitsAllocated, - waveformSampleInterpretation: sampleInterpretation, - multiplexGroupLabel, - channelDefinitionSequence, - waveformData: { retrieveBulkData }, - }; + return ecgModule; } From 6ca04620a9ebcc389368398ee0e9ae719caef82a Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Wed, 14 Oct 2026 10:53:02 -0400 Subject: [PATCH 2/8] fix(cornerstone): cache ECG waveform bulkdata and report load failures - Decode typed arrays from a data source retrieveBulkData on the released @cornerstonejs/metadata, by passing the bytes as an ArrayBuffer. - Use the cached WaveformData.Value and keep the decoded channels, so a re-mount does not fetch again. - Restore the lowercase codeMeaning fallback for lead names. - Notify the user when the ECG waveform does not load, for both ECG viewports. - Set the current auth headers on the shared DICOMweb client at bulkdata fetch time. Co-Authored-By: Claude Opus 5.5 --- .../CornerstoneViewportService.ts | 12 ++- .../cornerstone/src/utils/ecgMetadata.test.ts | 58 ++++++++++++++ .../cornerstone/src/utils/ecgMetadata.ts | 80 ++++++++++++++++--- .../default/src/DicomWebDataSource/index.ts | 8 +- 4 files changed, 144 insertions(+), 14 deletions(-) diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index b2eafece760..7bc24245b0f 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -910,7 +910,17 @@ class CornerstoneViewportService return; } - return this.backend.mountEcg(viewport, displaySet, imageId); + try { + await this.backend.mountEcg(viewport, displaySet, imageId); + } catch (error) { + // No caller handles a rejected mount, so report the failed waveform load here. + console.error('[CornerstoneViewportService] Failed to load the ECG waveform', error); + this.servicesManager.services.uiNotificationService?.show({ + title: 'ECG', + message: 'The ECG waveform could not be loaded.', + type: 'error', + }); + } } // Public so the viewport backends (IViewportServiceInternals) can dispatch to it. diff --git a/extensions/cornerstone/src/utils/ecgMetadata.test.ts b/extensions/cornerstone/src/utils/ecgMetadata.test.ts index e750e3c8871..f16e134763f 100644 --- a/extensions/cornerstone/src/utils/ecgMetadata.test.ts +++ b/extensions/cornerstone/src/utils/ecgMetadata.test.ts @@ -1,5 +1,15 @@ +import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata'; import { buildEcgModule } from './ecgMetadata'; +jest.mock('@cornerstonejs/metadata', () => { + const actual = jest.requireActual('@cornerstonejs/metadata'); + const { buildEcgModuleFromInstance } = actual.utilities; + return { + ...actual, + utilities: { ...actual.utilities, buildEcgModuleFromInstance: jest.fn(buildEcgModuleFromInstance) }, + }; +}); + // Two channels, two samples, interleaved: ch0=[1, -2], ch1=[3, 4]. const SAMPLES = new Int16Array([1, 3, -2, 4]); @@ -61,4 +71,52 @@ describe('buildEcgModule', () => { ]); expect(retrieveBulkData).toHaveBeenCalledTimes(1); }); + + it('decodes a typed array from the data source retrieveBulkData', async () => { + const bytes = new Uint8Array(SAMPLES.buffer.slice(0)); + const module = buildEcgModule( + makeInstance({ BulkDataURI: 'x', retrieveBulkData: jest.fn().mockResolvedValue(bytes) }) + ); + + expect(channelsOf(await module!.waveformData.retrieveBulkData())).toEqual([ + [1, -2], + [3, 4], + ]); + }); + + it('uses the cached Value and does not fetch again on a re-mount', async () => { + const retrieveBulkData = jest.fn(); + const module = buildEcgModule( + makeInstance({ BulkDataURI: 'x', Value: SAMPLES.buffer.slice(0), retrieveBulkData }) + ); + + await module!.waveformData.retrieveBulkData(); + expect(channelsOf(await module!.waveformData.retrieveBulkData())).toEqual([ + [1, -2], + [3, 4], + ]); + expect(retrieveBulkData).not.toHaveBeenCalled(); + }); + + it('gives the auth headers to the Cornerstone bulkdata fetch', () => { + const builder = csMetadataUtilities.buildEcgModuleFromInstance as jest.Mock; + const authHeader = { Authorization: 'Bearer token' }; + buildEcgModule(makeInstance({ BulkDataURI: 'x' }), { + getAuthorizationHeader: () => authHeader, + }); + + const options = builder.mock.lastCall[2] as { getHeaders: () => unknown }; + expect(options.getHeaders()).toBe(authHeader); + }); + + it('reads lead names from a lowercase codeMeaning', () => { + const instance = makeInstance({}); + instance.WaveformSequence[0].ChannelDefinitionSequence[0].ChannelSourceSequence = [ + { codeMeaning: 'Lead I' } as never, + ]; + + const module = buildEcgModule(instance); + + expect(module!.channelDefinitionSequence[0].channelSourceSequence.codeMeaning).toBe('Lead I'); + }); }); diff --git a/extensions/cornerstone/src/utils/ecgMetadata.ts b/extensions/cornerstone/src/utils/ecgMetadata.ts index 756d1af8f0b..7c818cba54a 100644 --- a/extensions/cornerstone/src/utils/ecgMetadata.ts +++ b/extensions/cornerstone/src/utils/ecgMetadata.ts @@ -4,6 +4,48 @@ export type EcgModule = NonNullable< ReturnType >; +type BuildEcgModuleFromInstance = ( + instance: Record, + imageId?: string, + // Released @cornerstonejs/metadata versions ignore this argument. + options?: { getHeaders?: () => Record | undefined } +) => EcgModule | null; + +const buildEcgModuleFromInstance = (...args: Parameters) => + (csMetadataUtilities.buildEcgModuleFromInstance as BuildEcgModuleFromInstance)(...args); + +/** Returns the items of a naturalized sequence: an array, an array-like, or one item. */ +function toList(value: any): any[] { + if (!value) { + return []; + } + return typeof value.length === 'number' ? Array.from(value) : [value]; +} + +/** Returns the first item of a naturalized value, but keeps binary data whole. */ +function firstItem(value: any): any { + if (!value || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + return value; + } + return typeof value.length === 'number' ? value[0] : value; +} + +/** Copies the bytes of a typed array into an ArrayBuffer, which every Cornerstone version decodes. */ +function toArrayBuffer(bytes: unknown): unknown { + return ArrayBuffer.isView(bytes) + ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + : bytes; +} + +async function decodeBulkData(group: any, waveformData: any): Promise { + let bytes = waveformData.Value; + if (bytes === undefined) { + bytes = await waveformData.retrieveBulkData(); + } + const loaded = { WaveformSequence: [{ ...group, WaveformData: toArrayBuffer(bytes) }] }; + return buildEcgModuleFromInstance(loaded).waveformData.retrieveBulkData(); +} + /** * Parse the naturalized DICOM instance's WaveformSequence and build the ecgModule * that Cornerstone's ECGViewport.setEcg() expects via @@ -12,6 +54,9 @@ export type EcgModule = NonNullable< * Cornerstone decodes the waveform. When the data source attached a * `retrieveBulkData` to `WaveformData`, that method fetches the bytes, so the * data source handles multipart/related, auth and relative bulkdata URIs. + * The bytes come from `WaveformData.Value` when the data source already + * cached them there. The decoded channels are kept, so a re-mount of the + * same display set does not fetch or decode again; a failed load is retried. * * Returns null if the instance has no WaveformSequence. */ @@ -20,19 +65,32 @@ export function buildEcgModule(instance: any, userAuthenticationService?: any): return null; } - const ecgModule = csMetadataUtilities.buildEcgModuleFromInstance(instance, instance.imageId, { + const ecgModule = buildEcgModuleFromInstance(instance, instance.imageId, { getHeaders: () => userAuthenticationService?.getAuthorizationHeader?.(), }); - const [group] = instance.WaveformSequence; - const waveformData = Array.isArray(group.WaveformData) - ? group.WaveformData[0] - : group.WaveformData; - - if (ecgModule && typeof waveformData?.retrieveBulkData === 'function') { - ecgModule.waveformData.retrieveBulkData = async () => { - const bytes = await waveformData.retrieveBulkData(); - const loaded = { WaveformSequence: [{ ...group, WaveformData: bytes }] }; - return csMetadataUtilities.buildEcgModuleFromInstance(loaded).waveformData.retrieveBulkData(); + if (!ecgModule) { + return null; + } + + const group = firstItem(instance.WaveformSequence); + const channelDefinitions = toList(group.ChannelDefinitionSequence); + ecgModule.channelDefinitionSequence.forEach((definition, index) => { + const source = definition.channelSourceSequence; + if (source && !source.codeMeaning) { + source.codeMeaning = + firstItem(channelDefinitions[index]?.ChannelSourceSequence)?.codeMeaning ?? ''; + } + }); + + const waveformData = firstItem(group.WaveformData ?? group.waveformData); + if (typeof waveformData?.retrieveBulkData === 'function') { + let channels: Promise | undefined; + ecgModule.waveformData.retrieveBulkData = () => { + channels ??= decodeBulkData(group, waveformData).catch(error => { + channels = undefined; + throw error; + }); + return channels; }; } diff --git a/extensions/default/src/DicomWebDataSource/index.ts b/extensions/default/src/DicomWebDataSource/index.ts index 9417c528d63..39cab405c5d 100644 --- a/extensions/default/src/DicomWebDataSource/index.ts +++ b/extensions/default/src/DicomWebDataSource/index.ts @@ -167,8 +167,12 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { 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(dicomWebClient, value); + // Provide a method to fetch bulkdata. The client is shared, so set the + // current auth headers at fetch time, as the other ops here do. + value.retrieveBulkData = options => { + dicomWebClient.headers = getAuthorizationHeader(); + return retrieveBulkData.call(dicomWebClient, value, options); + }; } } return naturalized; From e05d3563a72a57bbf2c9d012d10c1fee1fa42177 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Wed, 14 Oct 2026 13:59:14 -0400 Subject: [PATCH 3/8] feat(cornerstone): pass the ECG trace layout from the hanging protocol and the lead code value - viewportOptions.traceLayout in a hanging protocol takes a registered ECG layout name, such as '3x4+1', or a full layout definition. - The viewport service passes traceLayout in the viewport input defaultOptions, and applies it again after the ECG mounts. A Generic Viewport ECG takes it in its view state. An older ECG viewport takes a name as layoutType. An ECG viewport without layouts ignores it and keeps one row for each lead. - buildEcgModule passes codeValue and codingSchemeDesignator of each channel source, which older @cornerstonejs/metadata versions omit. A trace layout names a lead by its code value, and falls back to the code meaning when the code value is missing. Co-Authored-By: Claude Opus 5.5 --- .../CornerstoneViewportService.ts | 7 +++- .../src/services/ViewportService/Viewport.ts | 12 +++++++ .../src/utils/applyEcgTraceLayout.test.ts | 33 +++++++++++++++++++ .../src/utils/applyEcgTraceLayout.ts | 27 +++++++++++++++ .../cornerstone/src/utils/ecgMetadata.test.ts | 15 +++++++++ .../cornerstone/src/utils/ecgMetadata.ts | 14 +++++--- platform/core/src/types/HangingProtocol.ts | 5 +++ 7 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 extensions/cornerstone/src/utils/applyEcgTraceLayout.test.ts create mode 100644 extensions/cornerstone/src/utils/applyEcgTraceLayout.ts diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 7bc24245b0f..37d2c4c4814 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -45,6 +45,7 @@ import { import { BlendModes } from '@cornerstonejs/core/enums'; import { isNextViewportsEnabled } from '../../utils/nextViewports'; import { isAutoHydrateViewportType } from '../../utils/autoHydrateViewportTypes'; +import { applyEcgTraceLayout } from '../../utils/applyEcgTraceLayout'; import type { IViewportBackend } from './backends/IViewportBackend'; import type { IViewportServiceInternals } from './backends/IViewportServiceInternals'; import { LegacyViewportBackend } from './backends/LegacyViewportBackend'; @@ -564,6 +565,7 @@ class CornerstoneViewportService const background = viewportInfo.getBackground(); const orientation = viewportInfo.getOrientation(); const displayArea = viewportInfo.getDisplayArea(); + const traceLayout = viewportInfo.getTraceLayout(); const viewportInput: Types.PublicViewportInput = { viewportId, @@ -573,7 +575,9 @@ class CornerstoneViewportService background, orientation, displayArea, - }, + // An ECG viewport reads the trace layout; other viewports ignore it. + ...(traceLayout !== undefined && { traceLayout }), + } as Types.ViewportInputOptions, }; // Rendering Engine Id set should happen before enabling the element @@ -912,6 +916,7 @@ class CornerstoneViewportService try { await this.backend.mountEcg(viewport, displaySet, imageId); + applyEcgTraceLayout(viewport, this.viewportsById.get(viewport.id)?.getTraceLayout()); } catch (error) { // No caller handles a rejected mount, so report the failed waveform load here. console.error('[CornerstoneViewportService] Failed to load the ECG waveform', error); diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index e45340529f1..e6a264d6714 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -18,6 +18,12 @@ export type InitialImageOptions = { useOnce?: boolean; }; +/** + * Trace layout of an ECG viewport: a registered layout name, or a full layout + * definition. Cornerstone versions without trace layouts ignore it. + */ +export type TraceLayout = string | { regions: unknown[] }; + export type ViewportOptions = { id?: string; viewportType: Enums.ViewportType; @@ -26,6 +32,7 @@ export type ViewportOptions = { // Presentation ID to store/load presentation state from presentationIds?: AppTypes.PresentationIds; orientation?: Enums.OrientationAxis; + traceLayout?: TraceLayout; background?: Types.Point3; displayArea?: Types.DisplayArea; syncGroups?: SyncGroup[]; @@ -48,6 +55,7 @@ export type PublicViewportOptions = { presentationIds?: string[]; viewportId?: string; orientation?: Enums.OrientationAxis; + traceLayout?: TraceLayout; background?: Types.Point3; displayArea?: Types.DisplayArea; syncGroups?: SyncGroup[]; @@ -323,6 +331,10 @@ class ViewportInfo { return this.viewportOptions.orientation; } + public getTraceLayout(): TraceLayout | undefined { + return this.viewportOptions.traceLayout; + } + public setOrientation(orientation: Enums.OrientationAxis): void { this.viewportOptions.orientation = orientation; } diff --git a/extensions/cornerstone/src/utils/applyEcgTraceLayout.test.ts b/extensions/cornerstone/src/utils/applyEcgTraceLayout.test.ts new file mode 100644 index 00000000000..1b3dc893653 --- /dev/null +++ b/extensions/cornerstone/src/utils/applyEcgTraceLayout.test.ts @@ -0,0 +1,33 @@ +import { applyEcgTraceLayout } from './applyEcgTraceLayout'; + +describe('applyEcgTraceLayout', () => { + it('sets the trace layout of a Generic Viewport ECG', () => { + const viewport = { setViewState: jest.fn(), setProperties: jest.fn() }; + const definition = { regions: [] }; + + applyEcgTraceLayout(viewport, '3x4+1'); + applyEcgTraceLayout(viewport, definition); + + expect(viewport.setViewState).toHaveBeenNthCalledWith(1, { traceLayout: '3x4+1' }); + expect(viewport.setViewState).toHaveBeenNthCalledWith(2, { traceLayout: definition }); + expect(viewport.setProperties).not.toHaveBeenCalled(); + }); + + it('gives an older ECG viewport the layout name as layoutType', () => { + const viewport = { setProperties: jest.fn() }; + + applyEcgTraceLayout(viewport, '3x4'); + applyEcgTraceLayout(viewport, { regions: [] }); + + expect(viewport.setProperties).toHaveBeenCalledTimes(1); + expect(viewport.setProperties).toHaveBeenCalledWith({ layoutType: '3x4' }); + }); + + it('leaves the viewport alone when the hanging protocol gives no layout', () => { + const viewport = { setViewState: jest.fn() }; + + applyEcgTraceLayout(viewport, undefined); + + expect(viewport.setViewState).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/cornerstone/src/utils/applyEcgTraceLayout.ts b/extensions/cornerstone/src/utils/applyEcgTraceLayout.ts new file mode 100644 index 00000000000..cb23c3cd77d --- /dev/null +++ b/extensions/cornerstone/src/utils/applyEcgTraceLayout.ts @@ -0,0 +1,27 @@ +import type { TraceLayout } from '../services/ViewportService/Viewport'; + +type EcgLayoutTarget = { + setViewState?: (patch: { traceLayout: TraceLayout }) => void; + setProperties?: (props: { layoutType: string }) => void; +}; + +/** + * Applies the trace layout of a hanging protocol to a mounted ECG viewport. + * + * A Generic Viewport ECG takes `traceLayout` in its view state. An older ECG + * viewport without a view state takes a layout name as `layoutType`. An ECG + * viewport that has no layouts ignores both, and keeps one row for each lead. + */ +export function applyEcgTraceLayout(viewport: unknown, traceLayout?: TraceLayout): void { + if (traceLayout === undefined || traceLayout === null) { + return; + } + + const target = viewport as EcgLayoutTarget; + + if (typeof target.setViewState === 'function') { + target.setViewState({ traceLayout }); + } else if (typeof traceLayout === 'string' && typeof target.setProperties === 'function') { + target.setProperties({ layoutType: traceLayout }); + } +} diff --git a/extensions/cornerstone/src/utils/ecgMetadata.test.ts b/extensions/cornerstone/src/utils/ecgMetadata.test.ts index f16e134763f..a4091de5857 100644 --- a/extensions/cornerstone/src/utils/ecgMetadata.test.ts +++ b/extensions/cornerstone/src/utils/ecgMetadata.test.ts @@ -119,4 +119,19 @@ describe('buildEcgModule', () => { expect(module!.channelDefinitionSequence[0].channelSourceSequence.codeMeaning).toBe('Lead I'); }); + + it('passes the lead code value, which a trace layout uses to find a lead', () => { + const instance = makeInstance({}); + instance.WaveformSequence[0].ChannelDefinitionSequence[1].ChannelSourceSequence = [ + { CodeValue: '5.6.3-9-2', CodingSchemeDesignator: 'SCPECG', CodeMeaning: 'Lead II' } as never, + ]; + + const source = buildEcgModule(instance)!.channelDefinitionSequence[1].channelSourceSequence as { + codeValue?: string; + codingSchemeDesignator?: string; + }; + + expect(source.codeValue).toBe('5.6.3-9-2'); + expect(source.codingSchemeDesignator).toBe('SCPECG'); + }); }); diff --git a/extensions/cornerstone/src/utils/ecgMetadata.ts b/extensions/cornerstone/src/utils/ecgMetadata.ts index 7c818cba54a..f939c1bed71 100644 --- a/extensions/cornerstone/src/utils/ecgMetadata.ts +++ b/extensions/cornerstone/src/utils/ecgMetadata.ts @@ -74,12 +74,18 @@ export function buildEcgModule(instance: any, userAuthenticationService?: any): const group = firstItem(instance.WaveformSequence); const channelDefinitions = toList(group.ChannelDefinitionSequence); + // Older @cornerstonejs/metadata versions read only an upper-case CodeMeaning, + // and pass no lead code. An ECG trace layout names a lead by its code value, + // and falls back to the code meaning when the code value is missing. ecgModule.channelDefinitionSequence.forEach((definition, index) => { - const source = definition.channelSourceSequence; - if (source && !source.codeMeaning) { - source.codeMeaning = - firstItem(channelDefinitions[index]?.ChannelSourceSequence)?.codeMeaning ?? ''; + const source = definition.channelSourceSequence as Record | undefined; + const raw = firstItem(channelDefinitions[index]?.ChannelSourceSequence); + if (!source || !raw) { + return; } + source.codeMeaning ||= raw.codeMeaning ?? raw.CodeMeaning ?? ''; + source.codeValue ??= raw.CodeValue ?? raw.codeValue; + source.codingSchemeDesignator ??= raw.CodingSchemeDesignator ?? raw.codingSchemeDesignator; }); const waveformData = firstItem(group.WaveformData ?? group.waveformData); diff --git a/platform/core/src/types/HangingProtocol.ts b/platform/core/src/types/HangingProtocol.ts index 6b7be084ca9..504407afb24 100644 --- a/platform/core/src/types/HangingProtocol.ts +++ b/platform/core/src/types/HangingProtocol.ts @@ -188,6 +188,11 @@ export type ViewportOptions = { viewportType?: CustomOption; id?: string; orientation?: CustomOption; + /** + * Trace layout of an ECG viewport: a registered layout name such as + * `'3x4+1'`, or a full layout definition with its trace regions. + */ + traceLayout?: CustomOption>; background?: CustomOption<[number, number, number]>; viewportId?: string; displayArea?: DisplayArea; From e7cd14cd17485505d339b37db01b4a04c4c8d0f0 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 15 Oct 2026 10:02:13 -0400 Subject: [PATCH 4/8] fix(cornerstone): report a failed ECG mount, and defer the ECG trace layout - Remove the traceLayout viewport option. The viewport service passes only named options to Cornerstone, so the trace layout needs its own wiring, which a later ECG change adds. - _setEcgViewport shows the notification and then rejects, so setViewportData logs the error and does not broadcast VIEWPORT_DATA_CHANGED. - decodeBulkData throws a clear error when the builder returns null. - Keep the lead codeValue and codingSchemeDesignator, and document them as a workaround for buildEcgModuleFromInstance. Co-Authored-By: Claude Opus 5.5 --- .../CornerstoneViewportService.ts | 26 +++++++-------- .../src/services/ViewportService/Viewport.ts | 12 ------- .../src/utils/applyEcgTraceLayout.test.ts | 33 ------------------- .../src/utils/applyEcgTraceLayout.ts | 27 --------------- .../cornerstone/src/utils/ecgMetadata.test.ts | 2 +- .../cornerstone/src/utils/ecgMetadata.ts | 13 +++++--- platform/core/src/types/HangingProtocol.ts | 5 --- 7 files changed, 22 insertions(+), 96 deletions(-) delete mode 100644 extensions/cornerstone/src/utils/applyEcgTraceLayout.test.ts delete mode 100644 extensions/cornerstone/src/utils/applyEcgTraceLayout.ts diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 37d2c4c4814..2b550f910bb 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -45,7 +45,6 @@ import { import { BlendModes } from '@cornerstonejs/core/enums'; import { isNextViewportsEnabled } from '../../utils/nextViewports'; import { isAutoHydrateViewportType } from '../../utils/autoHydrateViewportTypes'; -import { applyEcgTraceLayout } from '../../utils/applyEcgTraceLayout'; import type { IViewportBackend } from './backends/IViewportBackend'; import type { IViewportServiceInternals } from './backends/IViewportServiceInternals'; import { LegacyViewportBackend } from './backends/LegacyViewportBackend'; @@ -565,7 +564,6 @@ class CornerstoneViewportService const background = viewportInfo.getBackground(); const orientation = viewportInfo.getOrientation(); const displayArea = viewportInfo.getDisplayArea(); - const traceLayout = viewportInfo.getTraceLayout(); const viewportInput: Types.PublicViewportInput = { viewportId, @@ -575,9 +573,7 @@ class CornerstoneViewportService background, orientation, displayArea, - // An ECG viewport reads the trace layout; other viewports ignore it. - ...(traceLayout !== undefined && { traceLayout }), - } as Types.ViewportInputOptions, + }, }; // Rendering Engine Id set should happen before enabling the element @@ -611,12 +607,15 @@ class CornerstoneViewportService // The broadcast event here ensures that listeners have a valid, up to date // viewport to access. Doing it too early can result in exceptions or // invalid data. - displaySetPromise.then(() => { - this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, { - viewportData, - viewportId, - }); - }); + displaySetPromise.then( + () => { + this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, { + viewportData, + viewportId, + }); + }, + error => console.error('[CornerstoneViewportService] Failed to set the viewport data', error) + ); } public getViewportOptions(viewportId: string): ViewportOptions { @@ -916,15 +915,14 @@ class CornerstoneViewportService try { await this.backend.mountEcg(viewport, displaySet, imageId); - applyEcgTraceLayout(viewport, this.viewportsById.get(viewport.id)?.getTraceLayout()); } catch (error) { - // No caller handles a rejected mount, so report the failed waveform load here. - console.error('[CornerstoneViewportService] Failed to load the ECG waveform', error); + // Tell the user, then reject so that setViewportData does not report a successful mount. this.servicesManager.services.uiNotificationService?.show({ title: 'ECG', message: 'The ECG waveform could not be loaded.', type: 'error', }); + throw error; } } diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index e6a264d6714..e45340529f1 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -18,12 +18,6 @@ export type InitialImageOptions = { useOnce?: boolean; }; -/** - * Trace layout of an ECG viewport: a registered layout name, or a full layout - * definition. Cornerstone versions without trace layouts ignore it. - */ -export type TraceLayout = string | { regions: unknown[] }; - export type ViewportOptions = { id?: string; viewportType: Enums.ViewportType; @@ -32,7 +26,6 @@ export type ViewportOptions = { // Presentation ID to store/load presentation state from presentationIds?: AppTypes.PresentationIds; orientation?: Enums.OrientationAxis; - traceLayout?: TraceLayout; background?: Types.Point3; displayArea?: Types.DisplayArea; syncGroups?: SyncGroup[]; @@ -55,7 +48,6 @@ export type PublicViewportOptions = { presentationIds?: string[]; viewportId?: string; orientation?: Enums.OrientationAxis; - traceLayout?: TraceLayout; background?: Types.Point3; displayArea?: Types.DisplayArea; syncGroups?: SyncGroup[]; @@ -331,10 +323,6 @@ class ViewportInfo { return this.viewportOptions.orientation; } - public getTraceLayout(): TraceLayout | undefined { - return this.viewportOptions.traceLayout; - } - public setOrientation(orientation: Enums.OrientationAxis): void { this.viewportOptions.orientation = orientation; } diff --git a/extensions/cornerstone/src/utils/applyEcgTraceLayout.test.ts b/extensions/cornerstone/src/utils/applyEcgTraceLayout.test.ts deleted file mode 100644 index 1b3dc893653..00000000000 --- a/extensions/cornerstone/src/utils/applyEcgTraceLayout.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { applyEcgTraceLayout } from './applyEcgTraceLayout'; - -describe('applyEcgTraceLayout', () => { - it('sets the trace layout of a Generic Viewport ECG', () => { - const viewport = { setViewState: jest.fn(), setProperties: jest.fn() }; - const definition = { regions: [] }; - - applyEcgTraceLayout(viewport, '3x4+1'); - applyEcgTraceLayout(viewport, definition); - - expect(viewport.setViewState).toHaveBeenNthCalledWith(1, { traceLayout: '3x4+1' }); - expect(viewport.setViewState).toHaveBeenNthCalledWith(2, { traceLayout: definition }); - expect(viewport.setProperties).not.toHaveBeenCalled(); - }); - - it('gives an older ECG viewport the layout name as layoutType', () => { - const viewport = { setProperties: jest.fn() }; - - applyEcgTraceLayout(viewport, '3x4'); - applyEcgTraceLayout(viewport, { regions: [] }); - - expect(viewport.setProperties).toHaveBeenCalledTimes(1); - expect(viewport.setProperties).toHaveBeenCalledWith({ layoutType: '3x4' }); - }); - - it('leaves the viewport alone when the hanging protocol gives no layout', () => { - const viewport = { setViewState: jest.fn() }; - - applyEcgTraceLayout(viewport, undefined); - - expect(viewport.setViewState).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/cornerstone/src/utils/applyEcgTraceLayout.ts b/extensions/cornerstone/src/utils/applyEcgTraceLayout.ts deleted file mode 100644 index cb23c3cd77d..00000000000 --- a/extensions/cornerstone/src/utils/applyEcgTraceLayout.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { TraceLayout } from '../services/ViewportService/Viewport'; - -type EcgLayoutTarget = { - setViewState?: (patch: { traceLayout: TraceLayout }) => void; - setProperties?: (props: { layoutType: string }) => void; -}; - -/** - * Applies the trace layout of a hanging protocol to a mounted ECG viewport. - * - * A Generic Viewport ECG takes `traceLayout` in its view state. An older ECG - * viewport without a view state takes a layout name as `layoutType`. An ECG - * viewport that has no layouts ignores both, and keeps one row for each lead. - */ -export function applyEcgTraceLayout(viewport: unknown, traceLayout?: TraceLayout): void { - if (traceLayout === undefined || traceLayout === null) { - return; - } - - const target = viewport as EcgLayoutTarget; - - if (typeof target.setViewState === 'function') { - target.setViewState({ traceLayout }); - } else if (typeof traceLayout === 'string' && typeof target.setProperties === 'function') { - target.setProperties({ layoutType: traceLayout }); - } -} diff --git a/extensions/cornerstone/src/utils/ecgMetadata.test.ts b/extensions/cornerstone/src/utils/ecgMetadata.test.ts index a4091de5857..39c819a1e5a 100644 --- a/extensions/cornerstone/src/utils/ecgMetadata.test.ts +++ b/extensions/cornerstone/src/utils/ecgMetadata.test.ts @@ -120,7 +120,7 @@ describe('buildEcgModule', () => { expect(module!.channelDefinitionSequence[0].channelSourceSequence.codeMeaning).toBe('Lead I'); }); - it('passes the lead code value, which a trace layout uses to find a lead', () => { + it('passes the lead code value and coding scheme', () => { const instance = makeInstance({}); instance.WaveformSequence[0].ChannelDefinitionSequence[1].ChannelSourceSequence = [ { CodeValue: '5.6.3-9-2', CodingSchemeDesignator: 'SCPECG', CodeMeaning: 'Lead II' } as never, diff --git a/extensions/cornerstone/src/utils/ecgMetadata.ts b/extensions/cornerstone/src/utils/ecgMetadata.ts index f939c1bed71..3aea939d8b1 100644 --- a/extensions/cornerstone/src/utils/ecgMetadata.ts +++ b/extensions/cornerstone/src/utils/ecgMetadata.ts @@ -43,7 +43,11 @@ async function decodeBulkData(group: any, waveformData: any): Promise { const source = definition.channelSourceSequence as Record | undefined; const raw = firstItem(channelDefinitions[index]?.ChannelSourceSequence); diff --git a/platform/core/src/types/HangingProtocol.ts b/platform/core/src/types/HangingProtocol.ts index 504407afb24..6b7be084ca9 100644 --- a/platform/core/src/types/HangingProtocol.ts +++ b/platform/core/src/types/HangingProtocol.ts @@ -188,11 +188,6 @@ export type ViewportOptions = { viewportType?: CustomOption; id?: string; orientation?: CustomOption; - /** - * Trace layout of an ECG viewport: a registered layout name such as - * `'3x4+1'`, or a full layout definition with its trace regions. - */ - traceLayout?: CustomOption>; background?: CustomOption<[number, number, number]>; viewportId?: string; displayArea?: DisplayArea; From 6e49ed089eef04e15a0d75107bd04385457302e3 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 15 Oct 2026 21:17:16 -0400 Subject: [PATCH 5/8] fix(hanging-protocols): rank image, ECG and SM display sets first in mnGrid The required numImageFrames rule of mnGrid used greaterThan 0, and the greaterThan validator tests >=. Thus every display set with a frame count passed, and SR and SEG display sets got the same score as images. The rule now gives weight to image frames, to the ECG modality and to the SM modality, so these display sets rank before display sets with nothing to show. Co-Authored-By: Claude Opus 5.5 --- .../utils/seriesSelectors.test.ts | 14 ++++++++++++++ .../hangingprotocols/utils/seriesSelectors.ts | 19 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 extensions/default/src/hangingprotocols/utils/seriesSelectors.test.ts diff --git a/extensions/default/src/hangingprotocols/utils/seriesSelectors.test.ts b/extensions/default/src/hangingprotocols/utils/seriesSelectors.test.ts new file mode 100644 index 00000000000..ab9018d44c2 --- /dev/null +++ b/extensions/default/src/hangingprotocols/utils/seriesSelectors.test.ts @@ -0,0 +1,14 @@ +import { HPMatcher } from '@ohif/core/src/services/HangingProtocolService/HPMatcher'; +import { seriesWithImages } from './seriesSelectors'; + +const score = displaySet => HPMatcher.match(displaySet, seriesWithImages, {}, {}).score; + +describe('seriesWithImages', () => { + it('ranks image, ECG and SM display sets above display sets without frames', () => { + const noFrames = score({ Modality: 'SR', numImageFrames: 0 }); + + expect(score({ Modality: 'CT', numImageFrames: 10 })).toBeGreaterThan(noFrames); + expect(score({ Modality: 'ECG', numImageFrames: 0 })).toBeGreaterThan(noFrames); + expect(score({ Modality: 'SM', numImageFrames: 0 })).toBeGreaterThan(noFrames); + }); +}); diff --git a/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts b/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts index ea77a304031..32b20252fba 100644 --- a/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts +++ b/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts @@ -3,13 +3,28 @@ import { Types } from '@ohif/core'; type MatchingRule = Types.HangingProtocol.MatchingRule; export const seriesWithImages: MatchingRule[] = [ + // Prefer viewable display sets: images, ECG waveforms or whole slide images. + // greaterThan tests >=, so value 1 means at least one frame. { attribute: 'numImageFrames', constraint: { - greaterThan: { value: 0 }, + greaterThan: { value: 1 }, + }, + weight: 1, + }, + { + attribute: 'Modality', + constraint: { + equals: 'ECG', + }, + weight: 1, + }, + { + attribute: 'Modality', + constraint: { + equals: 'SM', }, weight: 1, - required: true, }, // This display set will select the specified items by preference // It has no affect if nothing is specified in the URL. From e662b126b3ee6affd6045fbecc207feb4db8ebe8 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 15 Oct 2026 21:17:16 -0400 Subject: [PATCH 6/8] fix(cornerstone): skip the orientation markers until the viewport has a world position The ECG and whole slide viewports return undefined from canvasToWorld until their data loads, and vec3.sub threw into the route error boundary. Co-Authored-By: Claude Opus 5.5 --- .../src/Viewport/Overlays/ViewportOrientationMarkers.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx index 1e2787b248a..b599897537d 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx @@ -77,6 +77,10 @@ function ViewportOrientationMarkers({ const p00 = viewport.canvasToWorld([0, 0]); const p10 = viewport.canvasToWorld([1, 0]); const p01 = viewport.canvasToWorld([0, 1]); + // WSIViewport returns undefined until the slide metadata loads. + if (!p00 || !p10 || !p01) { + return ''; + } const rowCosines = vec3.sub(vec3.create(), p10, p00); const columnCosines = vec3.sub(vec3.create(), p01, p00); From a96c66d6d9aee2918ab6ae177e4bc55d05ecc29c Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Mon, 19 Oct 2026 08:52:19 -0400 Subject: [PATCH 7/8] revert(hanging-protocols): move the mnGrid rule change to its own PR The mnGrid change put SEG display sets into the default layout of the segmentation mode. The change now has its own PR, so this PR keeps the mnGrid rules of master. Co-Authored-By: Claude Opus 5.5 --- .../utils/seriesSelectors.test.ts | 14 -------------- .../hangingprotocols/utils/seriesSelectors.ts | 19 ++----------------- 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 extensions/default/src/hangingprotocols/utils/seriesSelectors.test.ts diff --git a/extensions/default/src/hangingprotocols/utils/seriesSelectors.test.ts b/extensions/default/src/hangingprotocols/utils/seriesSelectors.test.ts deleted file mode 100644 index ab9018d44c2..00000000000 --- a/extensions/default/src/hangingprotocols/utils/seriesSelectors.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { HPMatcher } from '@ohif/core/src/services/HangingProtocolService/HPMatcher'; -import { seriesWithImages } from './seriesSelectors'; - -const score = displaySet => HPMatcher.match(displaySet, seriesWithImages, {}, {}).score; - -describe('seriesWithImages', () => { - it('ranks image, ECG and SM display sets above display sets without frames', () => { - const noFrames = score({ Modality: 'SR', numImageFrames: 0 }); - - expect(score({ Modality: 'CT', numImageFrames: 10 })).toBeGreaterThan(noFrames); - expect(score({ Modality: 'ECG', numImageFrames: 0 })).toBeGreaterThan(noFrames); - expect(score({ Modality: 'SM', numImageFrames: 0 })).toBeGreaterThan(noFrames); - }); -}); diff --git a/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts b/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts index 32b20252fba..ea77a304031 100644 --- a/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts +++ b/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts @@ -3,28 +3,13 @@ import { Types } from '@ohif/core'; type MatchingRule = Types.HangingProtocol.MatchingRule; export const seriesWithImages: MatchingRule[] = [ - // Prefer viewable display sets: images, ECG waveforms or whole slide images. - // greaterThan tests >=, so value 1 means at least one frame. { attribute: 'numImageFrames', constraint: { - greaterThan: { value: 1 }, - }, - weight: 1, - }, - { - attribute: 'Modality', - constraint: { - equals: 'ECG', - }, - weight: 1, - }, - { - attribute: 'Modality', - constraint: { - equals: 'SM', + greaterThan: { value: 0 }, }, weight: 1, + required: true, }, // This display set will select the specified items by preference // It has no affect if nothing is specified in the URL. From 12980b1354616f569ccbb14a1f88afb2e060c4ad Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Mon, 19 Oct 2026 09:18:27 -0400 Subject: [PATCH 8/8] fix(cornerstone): log a failed viewport re-mount instead of an unhandled rejection With ECG_NEXT, a failed waveform load on re-mount rejects through updateViewport. The notification showed, but the rejection had no handler, so the console also logged Uncaught (in promise). Co-Authored-By: Claude Opus 5.5 --- .../ViewportService/CornerstoneViewportService.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 2b550f910bb..77a4424a776 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -1541,12 +1541,15 @@ class CornerstoneViewportService return; } - displaySetPromise.then(() => { - this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, { - viewportData, - viewportId, - }); - }); + displaySetPromise.then( + () => { + this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, { + viewportData, + viewportId, + }); + }, + error => console.error('[CornerstoneViewportService] Failed to update the viewport', error) + ); } _setDisplaySets(