Skip to content

Commit b505b0e

Browse files
committed
ultrasound: use SequenceOfUltrasoundRegions for spacing
1 parent b2c211c commit b505b0e

6 files changed

Lines changed: 281 additions & 0 deletions

File tree

src/core/streaming/dicom/dicomFileMetaLoader.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { ReadDicomTagsFunction } from '@/src/core/streaming/dicom/dicomMetaLoader';
22
import { MetaLoader } from '@/src/core/streaming/types';
33
import { Maybe } from '@/src/types';
4+
import { Tags } from '@/src/core/dicomTags';
5+
import {
6+
encodeUltrasoundRegionMeta,
7+
parseUltrasoundRegionFromBlob,
8+
} from '@/src/core/streaming/dicom/ultrasoundRegion';
49

510
export class DicomFileMetaLoader implements MetaLoader {
611
public tags: Maybe<Array<[string, string]>>;
@@ -24,6 +29,14 @@ export class DicomFileMetaLoader implements MetaLoader {
2429
async load() {
2530
if (this.tags) return;
2631
this.tags = await this.readDicomTags(this.file);
32+
33+
const modality = new Map(this.tags).get(Tags.Modality)?.trim();
34+
if (modality === 'US') {
35+
const region = await parseUltrasoundRegionFromBlob(this.file);
36+
if (region) {
37+
this.tags.push(encodeUltrasoundRegionMeta(region));
38+
}
39+
}
2740
}
2841

2942
stop() {

src/core/streaming/dicom/dicomMetaLoader.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { Awaitable } from '@vueuse/core';
99
import { toAscii } from '@/src/utils';
1010
import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes';
1111
import { Tags } from '@/src/core/dicomTags';
12+
import {
13+
decodeUltrasoundRegion,
14+
encodeUltrasoundRegionMeta,
15+
UltrasoundRegion,
16+
} from '@/src/core/streaming/dicom/ultrasoundRegion';
1217

1318
export type ReadDicomTagsFunction = (
1419
file: File
@@ -51,6 +56,7 @@ export class DicomMetaLoader implements MetaLoader {
5156
let explicitVr = true;
5257
let dicomUpToPixelDataIdx = -1;
5358
let modality: string | undefined;
59+
let ultrasoundRegion: UltrasoundRegion | null = null;
5460

5561
const parse = createDicomParser({
5662
stopAtElement(group, element) {
@@ -66,6 +72,10 @@ export class DicomMetaLoader implements MetaLoader {
6672
if (el.group === 0x0008 && el.element === 0x0060 && el.data) {
6773
modality = toAscii(el.data as Uint8Array).trim();
6874
}
75+
// Capture SequenceOfUltrasoundRegions (0018,6011)
76+
if (el.group === 0x0018 && el.element === 0x6011 && !ultrasoundRegion) {
77+
ultrasoundRegion = decodeUltrasoundRegion(el.data);
78+
}
6979
},
7080
});
7181

@@ -115,6 +125,10 @@ export class DicomMetaLoader implements MetaLoader {
115125

116126
const metadataFile = new File([validPixelDataBlob], 'file.dcm');
117127
this.tags = await this.readDicomTags(metadataFile);
128+
129+
if (modality === 'US' && ultrasoundRegion) {
130+
this.tags.push(encodeUltrasoundRegionMeta(ultrasoundRegion));
131+
}
118132
}
119133

120134
stop() {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {
2+
createDicomParser,
3+
DataElement,
4+
} from '@/src/core/streaming/dicom/dicomParser';
5+
6+
export const US_REGION_META_KEY = '__volview_us_region';
7+
8+
// DICOM unit codes for PhysicalUnitsXDirection / YDirection
9+
// 0x0003 = centimeters. See DICOM PS3.3 C.8.5.5.1.1.
10+
export const US_UNIT_CENTIMETERS = 3;
11+
12+
export type UltrasoundRegion = {
13+
physicalDeltaX: number;
14+
physicalDeltaY: number;
15+
physicalUnitsXDirection: number;
16+
physicalUnitsYDirection: number;
17+
};
18+
19+
const SEQUENCE_OF_ULTRASOUND_REGIONS: [number, number] = [0x0018, 0x6011];
20+
const PHYSICAL_DELTA_X: [number, number] = [0x0018, 0x602c];
21+
const PHYSICAL_DELTA_Y: [number, number] = [0x0018, 0x602e];
22+
const PHYSICAL_UNITS_X_DIRECTION: [number, number] = [0x0018, 0x6024];
23+
const PHYSICAL_UNITS_Y_DIRECTION: [number, number] = [0x0018, 0x6026];
24+
25+
const isTag = (el: DataElement, [group, element]: [number, number]) =>
26+
el.group === group && el.element === element;
27+
28+
const readFloat64LE = (bytes: Uint8Array) =>
29+
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getFloat64(
30+
0,
31+
true
32+
);
33+
34+
const readUint16LE = (bytes: Uint8Array) =>
35+
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(
36+
0,
37+
true
38+
);
39+
40+
/**
41+
* Decodes the first item of a SequenceOfUltrasoundRegions element.
42+
* Returns null if required fields are missing.
43+
*/
44+
export function decodeUltrasoundRegion(
45+
sequenceData: DataElement['data']
46+
): UltrasoundRegion | null {
47+
if (!Array.isArray(sequenceData) || sequenceData.length === 0) return null;
48+
const [firstItem] = sequenceData;
49+
50+
const findBytes = (target: [number, number]) => {
51+
const el = firstItem.find((inner) => isTag(inner, target));
52+
if (!el || !(el.data instanceof Uint8Array)) return null;
53+
return el.data;
54+
};
55+
56+
const deltaXBytes = findBytes(PHYSICAL_DELTA_X);
57+
const deltaYBytes = findBytes(PHYSICAL_DELTA_Y);
58+
const unitsXBytes = findBytes(PHYSICAL_UNITS_X_DIRECTION);
59+
const unitsYBytes = findBytes(PHYSICAL_UNITS_Y_DIRECTION);
60+
61+
if (!deltaXBytes || !deltaYBytes || !unitsXBytes || !unitsYBytes) {
62+
return null;
63+
}
64+
65+
return {
66+
physicalDeltaX: readFloat64LE(deltaXBytes),
67+
physicalDeltaY: readFloat64LE(deltaYBytes),
68+
physicalUnitsXDirection: readUint16LE(unitsXBytes),
69+
physicalUnitsYDirection: readUint16LE(unitsYBytes),
70+
};
71+
}
72+
73+
/**
74+
* Parses a DICOM blob and returns the first ultrasound region, if present.
75+
*/
76+
export async function parseUltrasoundRegionFromBlob(
77+
blob: Blob
78+
): Promise<UltrasoundRegion | null> {
79+
let region: UltrasoundRegion | null = null;
80+
81+
const parse = createDicomParser({
82+
stopAtElement(group, element) {
83+
return group === 0x7fe0 && element === 0x0010;
84+
},
85+
onDataElement(el) {
86+
if (region) return;
87+
if (isTag(el, SEQUENCE_OF_ULTRASOUND_REGIONS)) {
88+
region = decodeUltrasoundRegion(el.data);
89+
}
90+
},
91+
});
92+
93+
const stream = blob.stream();
94+
const reader = stream.getReader();
95+
try {
96+
while (!region) {
97+
const { value, done } = await reader.read();
98+
if (done) break;
99+
const result = parse(value);
100+
if (result.done) break;
101+
}
102+
} catch {
103+
return null;
104+
} finally {
105+
reader.releaseLock();
106+
}
107+
108+
return region;
109+
}
110+
111+
export function encodeUltrasoundRegionMeta(
112+
region: UltrasoundRegion
113+
): [string, string] {
114+
return [US_REGION_META_KEY, JSON.stringify(region)];
115+
}
116+
117+
export function getUltrasoundRegionFromMetadata(
118+
meta: ReadonlyArray<readonly [string, string]> | null | undefined
119+
): UltrasoundRegion | null {
120+
if (!meta) return null;
121+
const entry = meta.find(([key]) => key === US_REGION_META_KEY);
122+
if (!entry) return null;
123+
try {
124+
return JSON.parse(entry[1]) as UltrasoundRegion;
125+
} catch {
126+
return null;
127+
}
128+
}

src/core/streaming/dicomChunkImage.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import {
2626
import { ensureError } from '@/src/utils';
2727
import { computed } from 'vue';
2828
import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper';
29+
import {
30+
getUltrasoundRegionFromMetadata,
31+
US_UNIT_CENTIMETERS,
32+
} from '@/src/core/streaming/dicom/ultrasoundRegion';
2933

3034
const { fastComputeRange } = vtkDataArray;
3135

@@ -279,6 +283,28 @@ export default class DicomChunkImage
279283
private reallocateImage() {
280284
this.vtkImageData.value.delete();
281285
this.vtkImageData.value = allocateImageFromChunks(this.chunks);
286+
this.applyUltrasoundSpacing();
287+
}
288+
289+
private applyUltrasoundSpacing() {
290+
if (this.getModality() !== 'US') return;
291+
292+
const region = getUltrasoundRegionFromMetadata(this.getDicomMetadata());
293+
if (!region) return;
294+
if (
295+
region.physicalUnitsXDirection !== US_UNIT_CENTIMETERS ||
296+
region.physicalUnitsYDirection !== US_UNIT_CENTIMETERS
297+
) {
298+
return;
299+
}
300+
301+
const CM_TO_MM = 10;
302+
const [, , zSpacing] = this.vtkImageData.value.getSpacing();
303+
this.vtkImageData.value.setSpacing([
304+
region.physicalDeltaX * CM_TO_MM,
305+
region.physicalDeltaY * CM_TO_MM,
306+
zSpacing,
307+
]);
282308
}
283309

284310
private updateDataRangeFromChunks() {

tests/specs/configTestUtils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ export const FETUS_DATASET = {
7373
name: 'fetus.zip',
7474
} as const;
7575

76+
// Multiframe ultrasound DICOM from pydicom public test data.
77+
// SequenceOfUltrasoundRegions: PhysicalDeltaX/Y = 0.05104970559 cm/pixel
78+
// (unit code 3 = cm), so with US spacing fix the VTK spacing is ~0.5105 mm.
79+
export const US_MULTIFRAME_DICOM = {
80+
url: 'https://data.kitware.com/api/v1/file/69e1630646ef98a20f563020/download',
81+
name: 'US_multiframe_30frames.dcm',
82+
} as const;
83+
7684
export type DatasetResource = {
7785
url: string;
7886
name?: string;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { US_MULTIFRAME_DICOM } from './configTestUtils';
2+
import { openUrls } from './utils';
3+
import { volViewPage } from '../pageobjects/volview.page';
4+
5+
const clickAt = async (x: number, y: number) => {
6+
await browser
7+
.action('pointer')
8+
.move({ x: Math.round(x), y: Math.round(y) })
9+
.down()
10+
.up()
11+
.perform();
12+
};
13+
14+
// Offset between the two ruler clicks (in canvas pixels).
15+
// The measured ruler length in mm depends on this offset, the canvas size,
16+
// and the image spacing. With the US spacing fix the VTK spacing comes from
17+
// SequenceOfUltrasoundRegions (~0.5105 mm); without the fix it falls back to
18+
// 1.0 mm, which makes the measured length ~1.96x larger.
19+
const CLICK_DX = 0;
20+
const CLICK_DY = 100;
21+
22+
// Calibrated length (mm) that the ruler reports when the US spacing fix is
23+
// active. Obtained by running this test once with the fix enabled.
24+
// Without the fix the VTK spacing falls back to 1.0 mm/pixel, which makes
25+
// the measured length grow to ~97 mm (~1.96x) and this assertion fails.
26+
const EXPECTED_LENGTH_MM = 49.35;
27+
const LENGTH_TOLERANCE_MM = 1.5;
28+
29+
describe('Ultrasound image spacing', () => {
30+
it('ruler length reflects physical spacing from SequenceOfUltrasoundRegions', async () => {
31+
await openUrls([US_MULTIFRAME_DICOM]);
32+
33+
// Activate the ruler tool
34+
const rulerBtn = await $('button span i[class~=mdi-ruler]');
35+
await rulerBtn.waitForClickable();
36+
await rulerBtn.click();
37+
38+
// Place the ruler on the first view's canvas
39+
const views = await volViewPage.views;
40+
const canvas = views[0];
41+
const loc = await canvas.getLocation();
42+
const size = await canvas.getSize();
43+
const cx = loc.x + size.width / 2;
44+
const cy = loc.y + size.height / 2;
45+
46+
await clickAt(cx - CLICK_DX / 2, cy - CLICK_DY / 2);
47+
await clickAt(cx + CLICK_DX / 2, cy + CLICK_DY / 2);
48+
49+
// Open Annotations > Measurements to read the ruler length
50+
const annotationsTab = await $(
51+
'button[data-testid="module-tab-Annotations"]'
52+
);
53+
await annotationsTab.click();
54+
55+
const measurementsTab = await $('button.v-tab*=Measurements');
56+
await measurementsTab.waitForClickable();
57+
await measurementsTab.click();
58+
59+
// The ruler details panel renders `{value}mm`; read the first length.
60+
let lengthMm = 0;
61+
await browser.waitUntil(
62+
async () => {
63+
const spans = await $$('.v-list-item .value');
64+
for (const span of spans) {
65+
const text = await span.getText();
66+
const match = text.match(/([\d.]+)\s*mm/);
67+
if (match) {
68+
lengthMm = parseFloat(match[1]);
69+
return lengthMm > 0;
70+
}
71+
}
72+
return false;
73+
},
74+
{
75+
timeout: 10_000,
76+
timeoutMsg: 'Ruler length (mm) not found in measurements sidebar',
77+
}
78+
);
79+
80+
console.log(`[ultrasound-spacing] measured ruler length: ${lengthMm} mm`);
81+
82+
if (EXPECTED_LENGTH_MM > 0) {
83+
expect(lengthMm).toBeGreaterThan(
84+
EXPECTED_LENGTH_MM - LENGTH_TOLERANCE_MM
85+
);
86+
expect(lengthMm).toBeLessThan(EXPECTED_LENGTH_MM + LENGTH_TOLERANCE_MM);
87+
} else {
88+
// Calibration mode: any positive value passes, actual number is logged.
89+
expect(lengthMm).toBeGreaterThan(0);
90+
}
91+
});
92+
});

0 commit comments

Comments
 (0)