@cornerstonejs/metadata 5.15.2 → 5.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { MetadataModules } from '../enums/index.js';
2
2
  import { addTyped } from '../metaData.js';
3
3
  export function registerDisplaySetMetadata(imageIds, displaySet, options = {}) {
4
- const idsToRegister = new Set(imageIds);
4
+ const idsToRegister = new Set([displaySet.displaySetId, ...imageIds]);
5
5
  if (options.includeImageIds) {
6
6
  for (const frameId of displaySet.imageIds) {
7
7
  idsToRegister.add(frameId);
@@ -1,4 +1,5 @@
1
1
  import type { IDisplaySet } from '../displayset/IDisplaySet.js';
2
+ import type { EcgWaveformAnnotation } from '../utilities/metadataProvider/ecgFromInstance.js';
2
3
  export interface DicomDateObject {
3
4
  year: number;
4
5
  month: number;
@@ -72,6 +73,7 @@ export interface EcgModuleMetadata {
72
73
  seriesTime?: string;
73
74
  seriesInstanceUID: string;
74
75
  studyInstanceUID: string;
76
+ waveformAnnotationSequence?: EcgWaveformAnnotation[];
75
77
  }
76
78
  export interface FrameMetadata extends SopCommonModuleMetadata {
77
79
  frameNumber: number;
@@ -1,3 +1,4 @@
1
1
  export type { IListenerInfo, MetadataType, MetadataValueType, } from './DicomStreamTypes.js';
2
2
  export type { IImageCalibration } from './IImageCalibration.js';
3
3
  export type { DicomDateObject, DicomTimeObject, GeneralSeriesModuleMetadata, PatientStudyModuleMetadata, ImagePlaneModuleMetadata, ImagePixelModuleMetadata, SopCommonModuleMetadata, EcgModuleMetadata, FrameMetadata, TransferSyntaxMetadata, CompressedFrameDataMetadata, MetadataModuleType, } from './MetadataModuleTypes.js';
4
+ export type { DicomCodeSequenceItem, DicomChannelDefinitionItem, DicomWaveformAnnotationItem, DicomWaveformData, DicomWaveformGroupItem, DicomEcgInstance, EcgWaveformAnnotation, EcgAnnotations, EcgNumericMetricKey, } from '../utilities/metadataProvider/ecgFromInstance.js';
@@ -0,0 +1 @@
1
+ export declare function decodeMultipartRelated(response: ArrayBuffer, contentType?: string): ArrayBuffer[];
@@ -0,0 +1,55 @@
1
+ const CRLF = [13, 10];
2
+ const HEADER_END = [13, 10, 13, 10];
3
+ const DASH_DASH = [45, 45];
4
+ export function decodeMultipartRelated(response, contentType) {
5
+ const message = new Uint8Array(response);
6
+ const boundary = getBoundaryFromContentType(contentType) ?? getBoundaryFromBody(message);
7
+ if (!boundary) {
8
+ return [response];
9
+ }
10
+ const delimiter = Array.from(new TextEncoder().encode(`--${boundary}`));
11
+ const parts = [];
12
+ let index = indexOf(message, delimiter, 0);
13
+ while (index !== -1) {
14
+ const afterDelimiter = index + delimiter.length;
15
+ if (startsWith(message, DASH_DASH, afterDelimiter)) {
16
+ break;
17
+ }
18
+ const headerEnd = indexOf(message, HEADER_END, afterDelimiter);
19
+ if (headerEnd === -1) {
20
+ break;
21
+ }
22
+ const dataStart = headerEnd + HEADER_END.length;
23
+ const next = indexOf(message, [...CRLF, ...delimiter], dataStart);
24
+ const dataEnd = next === -1 ? message.length : next;
25
+ parts.push(response.slice(dataStart, dataEnd));
26
+ index = next === -1 ? -1 : next + CRLF.length;
27
+ }
28
+ return parts.length ? parts : [response];
29
+ }
30
+ function getBoundaryFromContentType(contentType) {
31
+ const match = contentType?.match(/boundary=(?:"([^"]+)"|([^\s;]+))/i);
32
+ return match ? (match[1] ?? match[2]) : undefined;
33
+ }
34
+ function getBoundaryFromBody(message) {
35
+ if (!startsWith(message, DASH_DASH, 0)) {
36
+ return undefined;
37
+ }
38
+ const lineEnd = indexOf(message, CRLF, 0, 200);
39
+ if (lineEnd <= DASH_DASH.length) {
40
+ return undefined;
41
+ }
42
+ return new TextDecoder().decode(message.subarray(DASH_DASH.length, lineEnd));
43
+ }
44
+ function startsWith(message, token, at) {
45
+ return token.every((byte, i) => message[at + i] === byte);
46
+ }
47
+ function indexOf(message, token, from, maxLength = message.length) {
48
+ const end = Math.min(message.length, from + maxLength) - token.length;
49
+ for (let i = from; i <= end; i++) {
50
+ if (message[i] === token[0] && startsWith(message, token, i)) {
51
+ return i;
52
+ }
53
+ }
54
+ return -1;
55
+ }
@@ -1,4 +1,5 @@
1
1
  export { getSingleBufferFromArray } from './bulkDataFromArray.js';
2
+ export { decodeMultipartRelated } from './decodeMultipartRelated.js';
2
3
  export { definedAttributesOf } from './definedAttributesOf.js';
3
4
  export { toNumber, toFiniteNumber, toFiniteFirst, toFiniteVector, } from './toNumber.js';
4
5
  export { default as toNumberDefault } from './toNumber.js';
@@ -1,4 +1,5 @@
1
1
  export { getSingleBufferFromArray } from './bulkDataFromArray.js';
2
+ export { decodeMultipartRelated } from './decodeMultipartRelated.js';
2
3
  export { definedAttributesOf } from './definedAttributesOf.js';
3
4
  export { toNumber, toFiniteNumber, toFiniteFirst, toFiniteVector, } from './toNumber.js';
4
5
  export { default as toNumberDefault } from './toNumber.js';
@@ -1,3 +1,76 @@
1
+ export interface DicomCodeSequenceItem {
2
+ CodeValue?: string;
3
+ CodingSchemeDesignator?: string;
4
+ CodeMeaning?: string;
5
+ }
6
+ export interface DicomChannelDefinitionItem {
7
+ ChannelSourceSequence?: DicomCodeSequenceItem[] | ArrayLike<DicomCodeSequenceItem>;
8
+ ChannelSensitivity?: number | string;
9
+ ChannelSensitivityUnitsSequence?: DicomCodeSequenceItem[] | ArrayLike<DicomCodeSequenceItem>;
10
+ ChannelSensitivityCorrectionFactor?: number | string;
11
+ ChannelBaseline?: number | string;
12
+ ChannelSampleSkew?: number | string;
13
+ WaveformBitsStored?: number | string;
14
+ }
15
+ export interface DicomWaveformAnnotationItem {
16
+ ConceptNameCodeSequence?: DicomCodeSequenceItem[] | ArrayLike<DicomCodeSequenceItem>;
17
+ NumericValue?: number | number[] | string | string[];
18
+ MeasurementUnitsCodeSequence?: DicomCodeSequenceItem[] | ArrayLike<DicomCodeSequenceItem>;
19
+ ConceptCodeSequence?: DicomCodeSequenceItem[] | ArrayLike<DicomCodeSequenceItem>;
20
+ UnformattedTextValue?: string | string[];
21
+ TextValue?: string | string[];
22
+ ReferencedWaveformChannels?: number | number[];
23
+ TemporalRangeType?: string | string[];
24
+ ReferencedSamplePositions?: number | number[];
25
+ ReferencedTimeOffsets?: number | number[];
26
+ ReferencedDateTime?: string | string[];
27
+ }
28
+ export interface DicomWaveformData {
29
+ retrieveBulkData?: () => Promise<unknown>;
30
+ BulkDataURI?: string;
31
+ InlineBinary?: string;
32
+ Value?: ArrayBuffer | Uint8Array | Int16Array[] | string;
33
+ }
34
+ export interface DicomWaveformGroupItem {
35
+ NumberOfWaveformChannels?: number;
36
+ NumberOfWaveformSamples?: number;
37
+ SamplingFrequency?: number;
38
+ WaveformBitsAllocated?: number;
39
+ WaveformSampleInterpretation?: string;
40
+ MultiplexGroupLabel?: string;
41
+ ChannelDefinitionSequence?: DicomChannelDefinitionItem[] | ArrayLike<DicomChannelDefinitionItem>;
42
+ WaveformData?: DicomWaveformData;
43
+ waveformData?: DicomWaveformData;
44
+ }
45
+ export interface DicomEcgInstance {
46
+ [key: string]: unknown;
47
+ WaveformSequence?: DicomWaveformGroupItem[] | ArrayLike<DicomWaveformGroupItem>;
48
+ WaveformAnnotationSequence?: DicomWaveformAnnotationItem[] | ArrayLike<DicomWaveformAnnotationItem>;
49
+ }
50
+ export interface EcgWaveformAnnotation {
51
+ concept?: {
52
+ codeMeaning?: string;
53
+ codeValue?: string;
54
+ codingSchemeDesignator?: string;
55
+ };
56
+ numericValue?: number;
57
+ unit?: string;
58
+ textValue?: string;
59
+ }
60
+ export interface EcgAnnotations {
61
+ ventricularHeartRate?: number;
62
+ prInterval?: number;
63
+ qrsDuration?: number;
64
+ qtInterval?: number;
65
+ qtcInterval?: number;
66
+ rrInterval?: number;
67
+ pAxis?: number;
68
+ qrsAxis?: number;
69
+ tAxis?: number;
70
+ units?: Partial<Record<EcgNumericMetricKey, string>>;
71
+ interpretations?: string[];
72
+ all?: EcgWaveformAnnotation[];
73
+ }
1
74
  export interface EcgModuleFull {
2
75
  numberOfWaveformChannels: number;
3
76
  numberOfWaveformSamples: number;
@@ -5,14 +78,50 @@ export interface EcgModuleFull {
5
78
  waveformBitsAllocated: number;
6
79
  waveformSampleInterpretation: string;
7
80
  multiplexGroupLabel: string;
81
+ multiplexGroupIndex?: number;
82
+ totalMultiplexGroups?: number;
8
83
  channelDefinitionSequence: Array<{
9
84
  channelSourceSequence?: {
10
85
  codeMeaning?: string;
86
+ codeValue?: string;
87
+ codingSchemeDesignator?: string;
11
88
  };
89
+ mvPerUnit: number;
12
90
  }>;
13
91
  waveformData: {
14
92
  retrieveBulkData: () => Promise<Int16Array[]>;
15
93
  };
94
+ annotations?: EcgAnnotations;
95
+ }
96
+ export declare const ECG_DEFAULT_MV_PER_UNIT = 0.001;
97
+ export type EcgNumericMetricKey = 'ventricularHeartRate' | 'prInterval' | 'qrsDuration' | 'qtInterval' | 'qtcInterval' | 'rrInterval' | 'pAxis' | 'qrsAxis' | 'tAxis';
98
+ export declare function parseEcgAnnotations(instance: Record<string, unknown> | DicomEcgInstance): EcgAnnotations | undefined;
99
+ export interface BuildEcgModuleOptions {
100
+ getHeaders?: () => Record<string, string> | undefined;
101
+ multiplexGroupIndex?: number;
16
102
  }
17
- export declare function buildEcgModuleFromInstance(instance: Record<string, unknown>, imageId?: string): EcgModuleFull | null;
103
+ export declare function getEcgMultiplexGroupIndex(options: unknown): number | undefined;
104
+ export declare function findPrimaryWaveformGroup(groups: Record<string, unknown>[], preferredIndex?: number): {
105
+ group: Record<string, unknown>;
106
+ index: number;
107
+ };
108
+ export declare function buildEcgModuleFromInstance(instance: Record<string, unknown>, imageId?: string, options?: BuildEcgModuleOptions): EcgModuleFull | null;
109
+ export declare const ECG_AMPLITUDE_INDEX_SIZE = 65536;
110
+ export declare function buildEcgCalibrationFromInstance(instance: Record<string, unknown> | undefined, options?: {
111
+ multiplexGroupIndex?: number;
112
+ }): {
113
+ sequenceOfUltrasoundRegions: {
114
+ regionLocationMinX0: number;
115
+ regionLocationMaxX1: number;
116
+ regionLocationMinY0: number;
117
+ regionLocationMaxY1: number;
118
+ referencePixelX0: number;
119
+ referencePixelY0: number;
120
+ physicalDeltaX: number;
121
+ physicalDeltaY: number;
122
+ physicalUnitsXDirection: number;
123
+ physicalUnitsYDirection: number;
124
+ regionDataType: number;
125
+ }[];
126
+ };
18
127
  export declare function registerEcgFromInstanceProvider(): void;
@@ -1,7 +1,36 @@
1
1
  import { addTypedProvider } from '../../metaData.js';
2
2
  import { MetadataModules } from '../../enums/index.js';
3
- import { logging as cornerstoneLogging } from '@cornerstonejs/utils';
3
+ import { asArrayFirst, logging as cornerstoneLogging, toFiniteFirst, } from '@cornerstonejs/utils';
4
+ import { getSingleBufferFromArray } from '../bulkDataFromArray.js';
5
+ import { decodeMultipartRelated } from '../decodeMultipartRelated.js';
4
6
  const cs3dLogger = cornerstoneLogging.metadataLog.getLogger('utilities.metadataProvider.ecgFromInstance');
7
+ export const ECG_DEFAULT_MV_PER_UNIT = 0.001;
8
+ const MV_PER_SENSITIVITY_UNIT = {
9
+ v: 1000,
10
+ mv: 1,
11
+ uv: 0.001,
12
+ µv: 0.001,
13
+ nv: 0.000001,
14
+ };
15
+ function getChannelMvPerUnit(channel) {
16
+ const sensitivity = toFiniteFirst(channel.ChannelSensitivity);
17
+ if (sensitivity === undefined || sensitivity === 0) {
18
+ return ECG_DEFAULT_MV_PER_UNIT;
19
+ }
20
+ const correction = toFiniteFirst(channel.ChannelSensitivityCorrectionFactor) || 1;
21
+ const unitsSeq = toArray(channel.ChannelSensitivityUnitsSequence);
22
+ const unitCode = (unitsSeq[0]?.CodeValue ??
23
+ unitsSeq[0]?.CodeMeaning ??
24
+ '')
25
+ .trim()
26
+ .toLowerCase();
27
+ const mvPerSensitivityUnit = MV_PER_SENSITIVITY_UNIT[unitCode];
28
+ if (mvPerSensitivityUnit === undefined) {
29
+ cs3dLogger.warn(`[ecgFromInstance] Unknown ChannelSensitivityUnits "${unitCode}". Using ${ECG_DEFAULT_MV_PER_UNIT} mV for each unit.`);
30
+ return ECG_DEFAULT_MV_PER_UNIT;
31
+ }
32
+ return sensitivity * correction * mvPerSensitivityUnit;
33
+ }
5
34
  function base64ToUint8Array(base64) {
6
35
  const binaryString = atob(base64);
7
36
  const bytes = new Uint8Array(binaryString.length);
@@ -11,7 +40,9 @@ function base64ToUint8Array(base64) {
11
40
  return bytes;
12
41
  }
13
42
  function convertBuffer(dataSrc, numberOfChannels, numberOfSamples, bits, type) {
14
- const data = new Uint8Array(dataSrc);
43
+ const data = ArrayBuffer.isView(dataSrc)
44
+ ? new Uint8Array(dataSrc.buffer, dataSrc.byteOffset, dataSrc.byteLength)
45
+ : new Uint8Array(dataSrc);
15
46
  if (bits === 16 && type === 'SS') {
16
47
  const ret = [];
17
48
  const bytesPerSample = 2;
@@ -34,52 +65,10 @@ function convertBuffer(dataSrc, numberOfChannels, numberOfSamples, bits, type) {
34
65
  }
35
66
  return [];
36
67
  }
37
- function multipartDecode(response) {
38
- const message = new Uint8Array(response);
39
- const separator = new TextEncoder().encode('\r\n\r\n');
40
- let offset = 0;
41
- const maxHeader = 1000;
42
- let headerEnd = -1;
43
- for (let i = 0; i < Math.min(message.length - separator.length, offset + maxHeader); i++) {
44
- let found = true;
45
- for (let j = 0; j < separator.length; j++) {
46
- if (message[i + j] !== separator[j]) {
47
- found = false;
48
- break;
49
- }
50
- }
51
- if (found) {
52
- headerEnd = i;
53
- break;
54
- }
55
- }
56
- if (headerEnd === -1)
57
- return [response];
58
- const headerStr = new TextDecoder().decode(message.slice(0, headerEnd));
59
- const boundaryMatch = headerStr.match(/boundary=([^\s;]+)/i);
60
- const boundary = boundaryMatch
61
- ? new TextEncoder().encode(`--${boundaryMatch[1].replace(/"/g, '').trim()}`)
62
- : null;
63
- if (!boundary)
64
- return [response];
65
- const dataStart = headerEnd + separator.length;
66
- const components = [];
67
- let idx = message.indexOf(boundary[0], dataStart);
68
- while (idx !== -1) {
69
- const nextSep = message.indexOf(separator[0], idx);
70
- if (nextSep === -1)
71
- break;
72
- const partStart = nextSep + separator.length;
73
- const nextBound = message.indexOf(boundary[0], partStart);
74
- const partEnd = nextBound === -1 ? message.length : nextBound - 2;
75
- if (partEnd > partStart) {
76
- components.push(response.slice(partStart, partEnd));
77
- }
78
- if (nextBound === -1)
79
- break;
80
- idx = message.indexOf(boundary[0], nextBound + boundary.length);
81
- }
82
- return components.length > 0 ? components : [response];
68
+ function isDecodedChannels(value, numberOfChannels, numberOfSamples) {
69
+ return (Array.isArray(value) &&
70
+ value.length === numberOfChannels &&
71
+ value.every((channel) => channel instanceof Int16Array && channel.length === numberOfSamples));
83
72
  }
84
73
  function parseWadoRsImageId(imageId) {
85
74
  const uri = imageId.replace(/^wadors:/i, '');
@@ -102,12 +91,162 @@ function toArray(seq) {
102
91
  }
103
92
  return [seq];
104
93
  }
105
- export function buildEcgModuleFromInstance(instance, imageId) {
94
+ const ECG_CODE_MAP = new Map([
95
+ ['8867-4', 'ventricularHeartRate'],
96
+ ['2:1', 'ventricularHeartRate'],
97
+ ['8625-6', 'prInterval'],
98
+ ['8633-0', 'qrsDuration'],
99
+ ['8634-8', 'qtInterval'],
100
+ ['8636-3', 'qtcInterval'],
101
+ ['8637-1', 'rrInterval'],
102
+ ['8626-4', 'pAxis'],
103
+ ['8632-2', 'qrsAxis'],
104
+ ['8638-9', 'tAxis'],
105
+ ]);
106
+ const ECG_MEANING_PATTERNS = [
107
+ [
108
+ /^(?!.*\batrial\b).*\b((ventricular\s+)?heart|ventricular)\s+rate\b|^hr$/i,
109
+ 'ventricularHeartRate',
110
+ ],
111
+ [/(^|\b)p[- ]?r\s*(interval|int)\b|^p[- ]?r$/i, 'prInterval'],
112
+ [/(^|\b)qrs\s*(duration|width|interval)\b|^qrs$/i, 'qrsDuration'],
113
+ [/(^|\b)qtc(\s*(interval|calc|int))?\b|^qtc$/i, 'qtcInterval'],
114
+ [/(^|\b)qt\s*(interval|int)\b|^qt$/i, 'qtInterval'],
115
+ [/(^|\b)r[- ]?r\s*(interval|int)\b|^r[- ]?r$/i, 'rrInterval'],
116
+ [/(^|\b)p(\s+wave)?\s+axis\b|axis\s+p\b/i, 'pAxis'],
117
+ [/(^|\b)qrs\s+axis\b|axis\s+qrs\b/i, 'qrsAxis'],
118
+ [/(^|\b)t(\s+wave)?\s+axis\b|axis\s+t\b/i, 'tAxis'],
119
+ ];
120
+ function matchEcgMetric(codeValue, codeMeaning) {
121
+ if (codeValue && ECG_CODE_MAP.has(codeValue)) {
122
+ return ECG_CODE_MAP.get(codeValue);
123
+ }
124
+ return codeMeaning
125
+ ? ECG_MEANING_PATTERNS.find(([pattern]) => pattern.test(codeMeaning))?.[1]
126
+ : undefined;
127
+ }
128
+ function firstText(raw) {
129
+ const text = asArrayFirst(raw)
130
+ ?.toString()
131
+ .trim();
132
+ return text || undefined;
133
+ }
134
+ function codeText(item) {
135
+ return firstText(item?.CodeMeaning) || firstText(item?.CodeValue);
136
+ }
137
+ export function parseEcgAnnotations(instance) {
138
+ const seq = toArray(instance.WaveformAnnotationSequence);
139
+ if (!seq.length) {
140
+ return undefined;
141
+ }
142
+ const annotations = {
143
+ units: {},
144
+ interpretations: [],
145
+ all: [],
146
+ };
147
+ for (const item of seq) {
148
+ if (!item) {
149
+ continue;
150
+ }
151
+ const concept = toArray(item.ConceptNameCodeSequence)[0];
152
+ const codeMeaning = firstText(concept?.CodeMeaning) ?? '';
153
+ const codeValue = firstText(concept?.CodeValue) ?? '';
154
+ const numericVal = toFiniteFirst(item.NumericValue);
155
+ const unitItem = toArray(item.MeasurementUnitsCodeSequence)[0];
156
+ const unit = codeText(unitItem);
157
+ const codedValue = codeText(toArray(item.ConceptCodeSequence)[0]);
158
+ const textVal = firstText(item.UnformattedTextValue ?? item.TextValue) ?? codedValue;
159
+ const channelPairs = [item.ReferencedWaveformChannels ?? []].flat();
160
+ const isGlobal = item.TemporalRangeType === undefined &&
161
+ item.ReferencedSamplePositions === undefined &&
162
+ item.ReferencedTimeOffsets === undefined &&
163
+ item.ReferencedDateTime === undefined &&
164
+ channelPairs.every((value, index) => index % 2 === 0 || value === 0);
165
+ if (codeMeaning || codeValue || numericVal !== undefined || textVal) {
166
+ annotations.all.push({
167
+ concept: codeMeaning || codeValue
168
+ ? {
169
+ codeMeaning,
170
+ codeValue,
171
+ codingSchemeDesignator: firstText(concept?.CodingSchemeDesignator) ?? '',
172
+ }
173
+ : undefined,
174
+ numericValue: numericVal,
175
+ unit,
176
+ textValue: textVal,
177
+ });
178
+ }
179
+ if (!isGlobal) {
180
+ continue;
181
+ }
182
+ const metric = numericVal !== undefined
183
+ ? matchEcgMetric(codeValue, codeMeaning)
184
+ : undefined;
185
+ if (metric) {
186
+ if (annotations[metric] === undefined) {
187
+ annotations[metric] = numericVal;
188
+ annotations.units[metric] = firstText(unitItem?.CodeValue) || unit;
189
+ }
190
+ continue;
191
+ }
192
+ const finding = textVal || (numericVal === undefined ? codeMeaning : undefined);
193
+ if (finding && !annotations.interpretations.includes(finding)) {
194
+ annotations.interpretations.push(finding);
195
+ }
196
+ }
197
+ if (annotations.ventricularHeartRate === undefined &&
198
+ annotations.rrInterval !== undefined &&
199
+ annotations.rrInterval > 0) {
200
+ const rrUnit = annotations.units.rrInterval;
201
+ const inSeconds = rrUnit === 's' || (rrUnit !== 'ms' && annotations.rrInterval < 5);
202
+ const rrMs = annotations.rrInterval * (inSeconds ? 1000 : 1);
203
+ annotations.ventricularHeartRate = Math.round(60000 / rrMs);
204
+ annotations.units.ventricularHeartRate = '/min';
205
+ }
206
+ return annotations;
207
+ }
208
+ export function getEcgMultiplexGroupIndex(options) {
209
+ const index = options
210
+ ?.multiplexGroupIndex;
211
+ return Number.isInteger(index) && index >= 0
212
+ ? index
213
+ : undefined;
214
+ }
215
+ export function findPrimaryWaveformGroup(groups, preferredIndex) {
216
+ if (!groups.length) {
217
+ throw new Error('[ecgFromInstance] WaveformSequence has no groups');
218
+ }
219
+ if (typeof preferredIndex === 'number' &&
220
+ preferredIndex >= 0 &&
221
+ preferredIndex < groups.length) {
222
+ return { group: groups[preferredIndex], index: preferredIndex };
223
+ }
224
+ const rhythmIndex = groups.findIndex((g) => {
225
+ const label = String(g.MultiplexGroupLabel || '').toUpperCase();
226
+ return label.includes('RHYTHM') || label.includes('CONTINUOUS');
227
+ });
228
+ if (rhythmIndex >= 0) {
229
+ return { group: groups[rhythmIndex], index: rhythmIndex };
230
+ }
231
+ let bestIndex = 0;
232
+ let maxDuration = -1;
233
+ groups.forEach((g, idx) => {
234
+ const samples = Number(g.NumberOfWaveformSamples) || 0;
235
+ const freq = Number(g.SamplingFrequency) || 1;
236
+ const duration = freq > 0 ? samples / freq : samples;
237
+ if (duration > maxDuration) {
238
+ maxDuration = duration;
239
+ bestIndex = idx;
240
+ }
241
+ });
242
+ return { group: groups[bestIndex], index: bestIndex };
243
+ }
244
+ export function buildEcgModuleFromInstance(instance, imageId, options = {}) {
106
245
  const raw = instance.WaveformSequence;
107
246
  const groups = toArray(raw);
108
247
  if (!groups.length)
109
248
  return null;
110
- const group = groups[0];
249
+ const { group, index: multiplexGroupIndex } = findPrimaryWaveformGroup(groups, options.multiplexGroupIndex);
111
250
  const numberOfChannels = group.NumberOfWaveformChannels ?? 0;
112
251
  const numberOfSamples = group.NumberOfWaveformSamples ?? 0;
113
252
  const samplingFrequency = group.SamplingFrequency ?? 1;
@@ -119,36 +258,44 @@ export function buildEcgModuleFromInstance(instance, imageId) {
119
258
  const srcSeqArr = toArray(ch.ChannelSourceSequence);
120
259
  const srcSeq = srcSeqArr[0];
121
260
  const codeMeaning = srcSeq?.CodeMeaning ?? '';
261
+ const codeValue = srcSeq?.CodeValue;
262
+ const codingSchemeDesignator = srcSeq?.CodingSchemeDesignator;
122
263
  return {
123
- channelSourceSequence: { codeMeaning },
264
+ channelSourceSequence: { codeMeaning, codeValue, codingSchemeDesignator },
265
+ mvPerUnit: getChannelMvPerUnit(ch),
124
266
  };
125
267
  });
126
- let waveformDataRaw = (group.WaveformData ?? group.waveformData);
127
- if (waveformDataRaw &&
128
- typeof waveformDataRaw.length === 'number' &&
129
- waveformDataRaw.length > 0) {
268
+ let waveformDataRaw = group.WaveformData ?? group.waveformData;
269
+ if (Array.isArray(waveformDataRaw) && waveformDataRaw.length > 0) {
130
270
  waveformDataRaw = waveformDataRaw[0];
131
271
  }
132
272
  const waveformData = waveformDataRaw ?? {};
133
273
  const { wadoRsRoot = undefined, studyUID = undefined } = imageId
134
274
  ? parseWadoRsImageId(imageId)
135
275
  : {};
276
+ const toChannels = (value) => {
277
+ if (isDecodedChannels(value, numberOfChannels, numberOfSamples)) {
278
+ return value;
279
+ }
280
+ const bytes = getSingleBufferFromArray(value);
281
+ return bytes
282
+ ? convertBuffer(bytes, numberOfChannels, numberOfSamples, bitsAllocated, sampleInterpretation)
283
+ : undefined;
284
+ };
136
285
  const retrieveBulkData = async () => {
137
- const wd = waveformData;
138
- if (wd instanceof ArrayBuffer ||
139
- (typeof ArrayBuffer !== 'undefined' &&
140
- ArrayBuffer.isView &&
141
- ArrayBuffer.isView(wd))) {
142
- return convertBuffer(wd, numberOfChannels, numberOfSamples, bitsAllocated, sampleInterpretation);
286
+ const channels = toChannels(waveformData) ?? toChannels(waveformData.Value);
287
+ if (channels) {
288
+ return channels;
143
289
  }
144
- if (waveformData.Value)
145
- return waveformData.Value;
146
290
  if (waveformData.InlineBinary) {
147
291
  const raw = base64ToUint8Array(waveformData.InlineBinary);
148
292
  return convertBuffer(raw, numberOfChannels, numberOfSamples, bitsAllocated, sampleInterpretation);
149
293
  }
150
294
  if (typeof waveformData.retrieveBulkData === 'function') {
151
- return waveformData.retrieveBulkData();
295
+ const retrieved = toChannels(await waveformData.retrieveBulkData());
296
+ if (retrieved) {
297
+ return retrieved;
298
+ }
152
299
  }
153
300
  if (waveformData.BulkDataURI) {
154
301
  let url = waveformData.BulkDataURI;
@@ -157,17 +304,23 @@ export function buildEcgModuleFromInstance(instance, imageId) {
157
304
  ? `${wadoRsRoot}/studies/${studyUID}/${url}`
158
305
  : `${wadoRsRoot}/${url}`;
159
306
  }
160
- const response = await fetch(url);
161
- const buffer = await response.arrayBuffer();
162
- const contentType = response.headers.get('content-type') || '';
163
- const decoded = contentType.includes('multipart')
164
- ? multipartDecode(buffer)[0]
165
- : buffer;
166
- return convertBuffer(decoded, numberOfChannels, numberOfSamples, bitsAllocated, sampleInterpretation);
307
+ const response = await fetch(url, {
308
+ headers: {
309
+ Accept: 'multipart/related; type="application/octet-stream"',
310
+ ...options.getHeaders?.(),
311
+ },
312
+ });
313
+ if (!response.ok) {
314
+ throw new Error(`[ecgFromInstance] Waveform bulkdata fetch failed: ${response.status}`);
315
+ }
316
+ const [part] = decodeMultipartRelated(await response.arrayBuffer(), response.headers.get('content-type') ?? undefined);
317
+ return convertBuffer(part, numberOfChannels, numberOfSamples, bitsAllocated, sampleInterpretation);
167
318
  }
168
- cs3dLogger.warn('[ecgFromInstance] No waveform data source found. group keys:', Object.keys(group), 'waveformData keys:', Object.keys(waveformData));
319
+ const dataKeys = Object.keys(waveformData);
320
+ cs3dLogger.warn('[ecgFromInstance] No waveform data source found. waveformData keys:', dataKeys.slice(0, 10), `(${dataKeys.length} total)`);
169
321
  throw new Error('[ecgFromInstance] No waveform data source found');
170
322
  };
323
+ const annotations = parseEcgAnnotations(instance);
171
324
  return {
172
325
  numberOfWaveformChannels: numberOfChannels,
173
326
  numberOfWaveformSamples: numberOfSamples,
@@ -175,8 +328,11 @@ export function buildEcgModuleFromInstance(instance, imageId) {
175
328
  waveformBitsAllocated: bitsAllocated,
176
329
  waveformSampleInterpretation: sampleInterpretation,
177
330
  multiplexGroupLabel,
331
+ multiplexGroupIndex,
332
+ totalMultiplexGroups: groups.length,
178
333
  channelDefinitionSequence,
179
334
  waveformData: { retrieveBulkData },
335
+ annotations,
180
336
  };
181
337
  }
182
338
  const ECG_FROM_INSTANCE_PRIORITY = 4_000;
@@ -186,22 +342,26 @@ const ecgFromInstanceProvider = (next, query, data, options) => {
186
342
  if (!hasWaveform) {
187
343
  return next(query, data, options);
188
344
  }
189
- const result = buildEcgModuleFromInstance(instance, query);
345
+ const result = buildEcgModuleFromInstance(instance, query, {
346
+ multiplexGroupIndex: getEcgMultiplexGroupIndex(options),
347
+ });
190
348
  return result ?? next(query, data, options);
191
349
  };
192
- const ECG_AMPLITUDE_INDEX_SIZE = 65536;
193
- const ECG_AMPLITUDE_OFFSET = 32768;
194
- const ecgCalibrationProvider = (next, query, data, options) => {
195
- const instance = data;
350
+ export const ECG_AMPLITUDE_INDEX_SIZE = 65536;
351
+ const ECG_AMPLITUDE_OFFSET = ECG_AMPLITUDE_INDEX_SIZE / 2;
352
+ export function buildEcgCalibrationFromInstance(instance, options) {
196
353
  const raw = instance?.WaveformSequence;
197
354
  const groups = toArray(raw);
198
355
  if (!groups.length)
199
- return next(query, data, options);
200
- const group = groups[0];
356
+ return undefined;
357
+ const { group } = findPrimaryWaveformGroup(groups, getEcgMultiplexGroupIndex(options));
201
358
  const numberOfWaveformSamples = group.NumberOfWaveformSamples ?? 0;
202
359
  const samplingFrequency = group.SamplingFrequency ?? 1;
203
360
  const physicalDeltaX = 1 / (samplingFrequency || 1);
204
- const physicalDeltaY = 0.001;
361
+ const firstChannel = toArray(group.ChannelDefinitionSequence)[0];
362
+ const physicalDeltaY = firstChannel
363
+ ? getChannelMvPerUnit(firstChannel)
364
+ : ECG_DEFAULT_MV_PER_UNIT;
205
365
  return {
206
366
  sequenceOfUltrasoundRegions: [
207
367
  {
@@ -219,7 +379,8 @@ const ecgCalibrationProvider = (next, query, data, options) => {
219
379
  },
220
380
  ],
221
381
  };
222
- };
382
+ }
383
+ const ecgCalibrationProvider = (next, query, data, options) => buildEcgCalibrationFromInstance(data, options) ?? next(query, data, options);
223
384
  export function registerEcgFromInstanceProvider() {
224
385
  addTypedProvider(MetadataModules.ECG, ecgFromInstanceProvider, {
225
386
  priority: ECG_FROM_INSTANCE_PRIORITY,
@@ -8,4 +8,5 @@ export const tags = [
8
8
  'Modality',
9
9
  'SOPInstanceUID',
10
10
  'SOPClassUID',
11
+ 'WaveformAnnotationSequence',
11
12
  ];
@@ -1 +1 @@
1
- export declare const version = "5.15.2";
1
+ export declare const version = "5.16.0";
@@ -1 +1 @@
1
- export const version = '5.15.2';
1
+ export const version = '5.16.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cornerstonejs/metadata",
3
- "version": "5.15.2",
3
+ "version": "5.16.0",
4
4
  "description": "Cornerstone3D Metadata Foundation - Provider chain, DICOM stream parsing, tag modules, and series splitting",
5
5
  "module": "./dist/esm/index.js",
6
6
  "types": "./dist/esm/index.d.ts",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@cornerstonejs/calculate-suv": "1.0.3",
84
- "@cornerstonejs/utils": "5.15.2",
84
+ "@cornerstonejs/utils": "5.16.0",
85
85
  "dcmjs": "0.52.0",
86
86
  "gl-matrix": "3.4.3"
87
87
  },