@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.
- package/dist/esm/displayset/registerDisplaySetMetadata.js +1 -1
- package/dist/esm/types/MetadataModuleTypes.d.ts +2 -0
- package/dist/esm/types/index.d.ts +1 -0
- package/dist/esm/utilities/decodeMultipartRelated.d.ts +1 -0
- package/dist/esm/utilities/decodeMultipartRelated.js +55 -0
- package/dist/esm/utilities/index.d.ts +1 -0
- package/dist/esm/utilities/index.js +1 -0
- package/dist/esm/utilities/metadataProvider/ecgFromInstance.d.ts +110 -1
- package/dist/esm/utilities/metadataProvider/ecgFromInstance.js +242 -81
- package/dist/esm/utilities/modules/ecg.js +1 -0
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
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 =
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
138
|
-
if (
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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 =
|
|
194
|
-
|
|
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
|
|
200
|
-
const group = groups
|
|
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
|
|
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,
|
package/dist/esm/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const version = "5.
|
|
1
|
+
export declare const version = "5.16.0";
|
package/dist/esm/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '5.
|
|
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.
|
|
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.
|
|
84
|
+
"@cornerstonejs/utils": "5.16.0",
|
|
85
85
|
"dcmjs": "0.52.0",
|
|
86
86
|
"gl-matrix": "3.4.3"
|
|
87
87
|
},
|