Skip to content

Commit 3775479

Browse files
committed
ultrasound: report region count and warn on multi-region images
VTK image data has a single global spacing, so a multi-region ultrasound (e.g. dual-pane B-mode + Doppler) cannot be fully represented. Surface the total region count alongside the first region's spacing and warn in the console when more than one region exists, so the partial-coverage limitation is visible rather than silent.
1 parent 2096f8c commit 3775479

5 files changed

Lines changed: 113 additions & 63 deletions

File tree

src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -84,44 +84,58 @@ const wellFormedItem: Item[] = [
8484

8585
describe('decodeUltrasoundRegion', () => {
8686
it('decodes the first item of the sequence', () => {
87-
const region = decodeUltrasoundRegion(fakeSequenceData([wellFormedItem]));
88-
expect(region).toEqual({
89-
physicalDeltaX: 0.05,
90-
physicalDeltaY: 0.1,
91-
physicalUnitsXDirection: US_UNIT_CENTIMETERS,
92-
physicalUnitsYDirection: US_UNIT_CENTIMETERS,
87+
const result = decodeUltrasoundRegion(fakeSequenceData([wellFormedItem]));
88+
expect(result).toEqual({
89+
region: {
90+
physicalDeltaX: 0.05,
91+
physicalDeltaY: 0.1,
92+
physicalUnitsXDirection: US_UNIT_CENTIMETERS,
93+
physicalUnitsYDirection: US_UNIT_CENTIMETERS,
94+
},
95+
regionCount: 1,
9396
});
9497
});
9598

96-
it('returns null when the sequence is empty', () => {
97-
expect(decodeUltrasoundRegion([])).toBeNull();
99+
it('returns a null region with zero count when the sequence is empty', () => {
100+
expect(decodeUltrasoundRegion([])).toEqual({
101+
region: null,
102+
regionCount: 0,
103+
});
98104
});
99105

100-
it('returns null when the data is not a sequence', () => {
101-
expect(decodeUltrasoundRegion(undefined)).toBeNull();
102-
expect(decodeUltrasoundRegion(new Uint8Array(4))).toBeNull();
106+
it('returns a null region with zero count when the data is not a sequence', () => {
107+
expect(decodeUltrasoundRegion(undefined)).toEqual({
108+
region: null,
109+
regionCount: 0,
110+
});
111+
expect(decodeUltrasoundRegion(new Uint8Array(4))).toEqual({
112+
region: null,
113+
regionCount: 0,
114+
});
103115
});
104116

105-
it('returns null when a required field is missing', () => {
117+
it('returns a null region but reports the count when a required field is missing', () => {
106118
const missingDeltaY = wellFormedItem.filter(
107119
(e) => !(e.group === 0x0018 && e.element === 0x602e)
108120
);
109-
expect(
110-
decodeUltrasoundRegion(fakeSequenceData([missingDeltaY]))
111-
).toBeNull();
121+
expect(decodeUltrasoundRegion(fakeSequenceData([missingDeltaY]))).toEqual({
122+
region: null,
123+
regionCount: 1,
124+
});
112125
});
113126

114-
it('ignores items beyond the first', () => {
127+
it('decodes only the first item but reports the total count', () => {
115128
const second: Item[] = [
116129
{ group: 0x0018, element: 0x6024, vr: 'US', value: u16LE(0) },
117130
{ group: 0x0018, element: 0x6026, vr: 'US', value: u16LE(0) },
118131
{ group: 0x0018, element: 0x602c, vr: 'FD', value: f64LE(999) },
119132
{ group: 0x0018, element: 0x602e, vr: 'FD', value: f64LE(999) },
120133
];
121-
const region = decodeUltrasoundRegion(
134+
const result = decodeUltrasoundRegion(
122135
fakeSequenceData([wellFormedItem, second])
123136
);
124-
expect(region?.physicalDeltaX).toBe(0.05);
137+
expect(result.region?.physicalDeltaX).toBe(0.05);
138+
expect(result.regionCount).toBe(2);
125139
});
126140
});
127141

@@ -141,21 +155,24 @@ describe('unitToMm', () => {
141155

142156
describe('encodeUltrasoundRegionMeta / getUltrasoundRegionFromMetadata', () => {
143157
it('round-trips through the metadata tag array', () => {
144-
const region = {
145-
physicalDeltaX: 0.05,
146-
physicalDeltaY: 0.1,
147-
physicalUnitsXDirection: US_UNIT_CENTIMETERS,
148-
physicalUnitsYDirection: US_UNIT_CENTIMETERS,
158+
const regions = {
159+
region: {
160+
physicalDeltaX: 0.05,
161+
physicalDeltaY: 0.1,
162+
physicalUnitsXDirection: US_UNIT_CENTIMETERS,
163+
physicalUnitsYDirection: US_UNIT_CENTIMETERS,
164+
},
165+
regionCount: 2,
149166
};
150-
const entry = encodeUltrasoundRegionMeta(region);
167+
const entry = encodeUltrasoundRegionMeta(regions);
151168
expect(entry[0]).toBe(US_REGION_META_KEY);
152169

153170
const meta: Array<[string, string]> = [
154171
['0008|0060', 'US'],
155172
entry,
156173
['0010|0010', 'PATIENT^NAME'],
157174
];
158-
expect(getUltrasoundRegionFromMetadata(meta)).toEqual(region);
175+
expect(getUltrasoundRegionFromMetadata(meta)).toEqual(regions);
159176
});
160177

161178
it('returns null when the entry is absent', () => {
@@ -217,14 +234,17 @@ const buildDicomBlob = (item: Item[]) => {
217234
};
218235

219236
describe('parseUltrasoundRegionFromBlob', () => {
220-
it('extracts the region from a synthetic DICOM blob', async () => {
237+
it('extracts the region and count from a synthetic DICOM blob', async () => {
221238
const blob = buildDicomBlob(wellFormedItem);
222-
const region = await parseUltrasoundRegionFromBlob(blob);
223-
expect(region).toEqual({
224-
physicalDeltaX: 0.05,
225-
physicalDeltaY: 0.1,
226-
physicalUnitsXDirection: US_UNIT_CENTIMETERS,
227-
physicalUnitsYDirection: US_UNIT_CENTIMETERS,
239+
const result = await parseUltrasoundRegionFromBlob(blob);
240+
expect(result).toEqual({
241+
region: {
242+
physicalDeltaX: 0.05,
243+
physicalDeltaY: 0.1,
244+
physicalUnitsXDirection: US_UNIT_CENTIMETERS,
245+
physicalUnitsYDirection: US_UNIT_CENTIMETERS,
246+
},
247+
regionCount: 1,
228248
});
229249
});
230250

src/core/streaming/dicom/dicomFileMetaLoader.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ export class DicomFileMetaLoader implements MetaLoader {
3232

3333
const modality = new Map(this.tags).get(Tags.Modality)?.trim();
3434
if (modality === 'US') {
35-
const region = await parseUltrasoundRegionFromBlob(this.file);
36-
if (region) {
37-
this.tags.push(encodeUltrasoundRegionMeta(region));
35+
const regions = await parseUltrasoundRegionFromBlob(this.file);
36+
if (regions) {
37+
this.tags.push(encodeUltrasoundRegionMeta(regions));
3838
}
3939
}
4040
}

src/core/streaming/dicom/dicomMetaLoader.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
decodeUltrasoundRegion,
1414
encodeUltrasoundRegionMeta,
1515
SEQUENCE_OF_ULTRASOUND_REGIONS,
16-
UltrasoundRegion,
16+
UltrasoundRegions,
1717
} from '@/src/core/streaming/dicom/ultrasoundRegion';
1818

1919
export type ReadDicomTagsFunction = (
@@ -57,7 +57,7 @@ export class DicomMetaLoader implements MetaLoader {
5757
let explicitVr = true;
5858
let dicomUpToPixelDataIdx = -1;
5959
let modality: string | undefined;
60-
let ultrasoundRegion: UltrasoundRegion | null = null;
60+
let ultrasoundRegions: UltrasoundRegions | null = null;
6161

6262
const parse = createDicomParser({
6363
stopAtElement(group, element) {
@@ -76,9 +76,9 @@ export class DicomMetaLoader implements MetaLoader {
7676
if (
7777
el.group === SEQUENCE_OF_ULTRASOUND_REGIONS[0] &&
7878
el.element === SEQUENCE_OF_ULTRASOUND_REGIONS[1] &&
79-
!ultrasoundRegion
79+
!ultrasoundRegions
8080
) {
81-
ultrasoundRegion = decodeUltrasoundRegion(el.data);
81+
ultrasoundRegions = decodeUltrasoundRegion(el.data);
8282
}
8383
},
8484
});
@@ -130,8 +130,8 @@ export class DicomMetaLoader implements MetaLoader {
130130
const metadataFile = new File([validPixelDataBlob], 'file.dcm');
131131
this.tags = await this.readDicomTags(metadataFile);
132132

133-
if (modality === 'US' && ultrasoundRegion) {
134-
this.tags.push(encodeUltrasoundRegionMeta(ultrasoundRegion));
133+
if (modality === 'US' && ultrasoundRegions) {
134+
this.tags.push(encodeUltrasoundRegionMeta(ultrasoundRegions));
135135
}
136136
}
137137

src/core/streaming/dicom/ultrasoundRegion.ts

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ export type UltrasoundRegion = {
2828
physicalUnitsYDirection: number;
2929
};
3030

31+
// First-region spacing plus the total number of regions in the source
32+
// SequenceOfUltrasoundRegions. Multi-region images (e.g. dual-pane B-mode +
33+
// Doppler) cannot be fully represented with a single VTK image spacing, so
34+
// we expose the count to let callers warn about partial support.
35+
export type UltrasoundRegions = {
36+
region: UltrasoundRegion | null;
37+
regionCount: number;
38+
};
39+
3140
export const SEQUENCE_OF_ULTRASOUND_REGIONS = tagToGroupElement(
3241
Tags.SequenceOfUltrasoundRegions
3342
);
@@ -56,13 +65,18 @@ const readUint16LE = (bytes: Uint8Array) =>
5665
);
5766

5867
/**
59-
* Decodes the first item of a SequenceOfUltrasoundRegions element.
60-
* Returns null if required fields are missing.
68+
* Decodes the first item of a SequenceOfUltrasoundRegions element and
69+
* reports the total number of regions found. The first region's spacing
70+
* is what gets applied to the VTK image; the count lets callers warn when
71+
* additional regions exist (multi-region images are only partially
72+
* supported because VTK image data has a single global spacing).
6173
*/
6274
export function decodeUltrasoundRegion(
6375
sequenceData: DataElement['data']
64-
): UltrasoundRegion | null {
65-
if (!Array.isArray(sequenceData) || sequenceData.length === 0) return null;
76+
): UltrasoundRegions {
77+
if (!Array.isArray(sequenceData) || sequenceData.length === 0) {
78+
return { region: null, regionCount: 0 };
79+
}
6680
const [firstItem] = sequenceData;
6781

6882
const findBytes = (target: [number, number]) => {
@@ -76,42 +90,47 @@ export function decodeUltrasoundRegion(
7690
const unitsXBytes = findBytes(PHYSICAL_UNITS_X_DIRECTION);
7791
const unitsYBytes = findBytes(PHYSICAL_UNITS_Y_DIRECTION);
7892

93+
const regionCount = sequenceData.length;
7994
if (!deltaXBytes || !deltaYBytes || !unitsXBytes || !unitsYBytes) {
80-
return null;
95+
return { region: null, regionCount };
8196
}
8297

8398
return {
84-
physicalDeltaX: readFloat64LE(deltaXBytes),
85-
physicalDeltaY: readFloat64LE(deltaYBytes),
86-
physicalUnitsXDirection: readUint16LE(unitsXBytes),
87-
physicalUnitsYDirection: readUint16LE(unitsYBytes),
99+
region: {
100+
physicalDeltaX: readFloat64LE(deltaXBytes),
101+
physicalDeltaY: readFloat64LE(deltaYBytes),
102+
physicalUnitsXDirection: readUint16LE(unitsXBytes),
103+
physicalUnitsYDirection: readUint16LE(unitsYBytes),
104+
},
105+
regionCount,
88106
};
89107
}
90108

91109
/**
92-
* Parses a DICOM blob and returns the first ultrasound region, if present.
110+
* Parses a DICOM blob and returns the first ultrasound region plus the
111+
* total region count, if a SequenceOfUltrasoundRegions is present.
93112
*/
94113
export async function parseUltrasoundRegionFromBlob(
95114
blob: Blob
96-
): Promise<UltrasoundRegion | null> {
97-
let region: UltrasoundRegion | null = null;
115+
): Promise<UltrasoundRegions | null> {
116+
let regions: UltrasoundRegions | null = null;
98117

99118
const parse = createDicomParser({
100119
stopAtElement(group, element) {
101120
return group === 0x7fe0 && element === 0x0010;
102121
},
103122
onDataElement(el) {
104-
if (region) return;
123+
if (regions) return;
105124
if (isTag(el, SEQUENCE_OF_ULTRASOUND_REGIONS)) {
106-
region = decodeUltrasoundRegion(el.data);
125+
regions = decodeUltrasoundRegion(el.data);
107126
}
108127
},
109128
});
110129

111130
const stream = blob.stream();
112131
const reader = stream.getReader();
113132
try {
114-
while (!region) {
133+
while (!regions) {
115134
const { value, done } = await reader.read();
116135
if (done) break;
117136
const result = parse(value);
@@ -123,23 +142,23 @@ export async function parseUltrasoundRegionFromBlob(
123142
reader.releaseLock();
124143
}
125144

126-
return region;
145+
return regions;
127146
}
128147

129148
export function encodeUltrasoundRegionMeta(
130-
region: UltrasoundRegion
149+
regions: UltrasoundRegions
131150
): [string, string] {
132-
return [US_REGION_META_KEY, JSON.stringify(region)];
151+
return [US_REGION_META_KEY, JSON.stringify(regions)];
133152
}
134153

135154
export function getUltrasoundRegionFromMetadata(
136155
meta: ReadonlyArray<readonly [string, string]> | null | undefined
137-
): UltrasoundRegion | null {
156+
): UltrasoundRegions | null {
138157
if (!meta) return null;
139158
const entry = meta.find(([key]) => key === US_REGION_META_KEY);
140159
if (!entry) return null;
141160
try {
142-
return JSON.parse(entry[1]) as UltrasoundRegion;
161+
return JSON.parse(entry[1]) as UltrasoundRegions;
143162
} catch {
144163
return null;
145164
}

src/core/streaming/dicomChunkImage.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,20 @@ export default class DicomChunkImage
289289
private applyUltrasoundSpacing() {
290290
if (this.getModality() !== 'US') return;
291291

292-
const region = getUltrasoundRegionFromMetadata(this.getDicomMetadata());
293-
if (!region) return;
292+
const regions = getUltrasoundRegionFromMetadata(this.getDicomMetadata());
293+
if (!regions?.region) return;
294+
295+
// VTK image data has a single global spacing, so multi-region images
296+
// (e.g. dual-pane B-mode + Doppler) cannot be fully represented. The
297+
// first region's spacing is applied to the whole image; warn so the
298+
// mismatch on additional panes is at least visible in the console.
299+
if (regions.regionCount > 1) {
300+
console.warn(
301+
`Ultrasound image has ${regions.regionCount} regions; only the first region's physical spacing is applied. Multi-region (e.g. dual-pane B-mode + Doppler) ultrasound is not fully supported.`
302+
);
303+
}
294304

305+
const { region } = regions;
295306
const xFactor = unitToMm(region.physicalUnitsXDirection);
296307
const yFactor = unitToMm(region.physicalUnitsYDirection);
297308
if (xFactor === null || yFactor === null) return;

0 commit comments

Comments
 (0)