From 09e4713727756fbdf0b3bb5b44d0150b75f74641 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 16 Apr 2026 18:36:06 -0400 Subject: [PATCH] ultrasound: use SequenceOfUltrasoundRegions for spacing --- .../streaming/dicom/dicomFileMetaLoader.ts | 13 ++ src/core/streaming/dicom/dicomMetaLoader.ts | 14 ++ src/core/streaming/dicom/ultrasoundRegion.ts | 128 ++++++++++++++++++ src/core/streaming/dicomChunkImage.ts | 26 ++++ tests/specs/configTestUtils.ts | 8 ++ tests/specs/ultrasound-spacing.e2e.ts | 92 +++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 src/core/streaming/dicom/ultrasoundRegion.ts create mode 100644 tests/specs/ultrasound-spacing.e2e.ts diff --git a/src/core/streaming/dicom/dicomFileMetaLoader.ts b/src/core/streaming/dicom/dicomFileMetaLoader.ts index ec6adab43..581a67947 100644 --- a/src/core/streaming/dicom/dicomFileMetaLoader.ts +++ b/src/core/streaming/dicom/dicomFileMetaLoader.ts @@ -1,6 +1,11 @@ import { ReadDicomTagsFunction } from '@/src/core/streaming/dicom/dicomMetaLoader'; import { MetaLoader } from '@/src/core/streaming/types'; import { Maybe } from '@/src/types'; +import { Tags } from '@/src/core/dicomTags'; +import { + encodeUltrasoundRegionMeta, + parseUltrasoundRegionFromBlob, +} from '@/src/core/streaming/dicom/ultrasoundRegion'; export class DicomFileMetaLoader implements MetaLoader { public tags: Maybe>; @@ -24,6 +29,14 @@ export class DicomFileMetaLoader implements MetaLoader { async load() { if (this.tags) return; this.tags = await this.readDicomTags(this.file); + + const modality = new Map(this.tags).get(Tags.Modality)?.trim(); + if (modality === 'US') { + const region = await parseUltrasoundRegionFromBlob(this.file); + if (region) { + this.tags.push(encodeUltrasoundRegionMeta(region)); + } + } } stop() { diff --git a/src/core/streaming/dicom/dicomMetaLoader.ts b/src/core/streaming/dicom/dicomMetaLoader.ts index 2be42d2c1..440d79094 100644 --- a/src/core/streaming/dicom/dicomMetaLoader.ts +++ b/src/core/streaming/dicom/dicomMetaLoader.ts @@ -9,6 +9,11 @@ import { Awaitable } from '@vueuse/core'; import { toAscii } from '@/src/utils'; import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; import { Tags } from '@/src/core/dicomTags'; +import { + decodeUltrasoundRegion, + encodeUltrasoundRegionMeta, + UltrasoundRegion, +} from '@/src/core/streaming/dicom/ultrasoundRegion'; export type ReadDicomTagsFunction = ( file: File @@ -51,6 +56,7 @@ export class DicomMetaLoader implements MetaLoader { let explicitVr = true; let dicomUpToPixelDataIdx = -1; let modality: string | undefined; + let ultrasoundRegion: UltrasoundRegion | null = null; const parse = createDicomParser({ stopAtElement(group, element) { @@ -66,6 +72,10 @@ export class DicomMetaLoader implements MetaLoader { if (el.group === 0x0008 && el.element === 0x0060 && el.data) { modality = toAscii(el.data as Uint8Array).trim(); } + // Capture SequenceOfUltrasoundRegions (0018,6011) + if (el.group === 0x0018 && el.element === 0x6011 && !ultrasoundRegion) { + ultrasoundRegion = decodeUltrasoundRegion(el.data); + } }, }); @@ -115,6 +125,10 @@ export class DicomMetaLoader implements MetaLoader { const metadataFile = new File([validPixelDataBlob], 'file.dcm'); this.tags = await this.readDicomTags(metadataFile); + + if (modality === 'US' && ultrasoundRegion) { + this.tags.push(encodeUltrasoundRegionMeta(ultrasoundRegion)); + } } stop() { diff --git a/src/core/streaming/dicom/ultrasoundRegion.ts b/src/core/streaming/dicom/ultrasoundRegion.ts new file mode 100644 index 000000000..b37c766c4 --- /dev/null +++ b/src/core/streaming/dicom/ultrasoundRegion.ts @@ -0,0 +1,128 @@ +import { + createDicomParser, + DataElement, +} from '@/src/core/streaming/dicom/dicomParser'; + +export const US_REGION_META_KEY = '__volview_us_region'; + +// DICOM unit codes for PhysicalUnitsXDirection / YDirection +// 0x0003 = centimeters. See DICOM PS3.3 C.8.5.5.1.1. +export const US_UNIT_CENTIMETERS = 3; + +export type UltrasoundRegion = { + physicalDeltaX: number; + physicalDeltaY: number; + physicalUnitsXDirection: number; + physicalUnitsYDirection: number; +}; + +const SEQUENCE_OF_ULTRASOUND_REGIONS: [number, number] = [0x0018, 0x6011]; +const PHYSICAL_DELTA_X: [number, number] = [0x0018, 0x602c]; +const PHYSICAL_DELTA_Y: [number, number] = [0x0018, 0x602e]; +const PHYSICAL_UNITS_X_DIRECTION: [number, number] = [0x0018, 0x6024]; +const PHYSICAL_UNITS_Y_DIRECTION: [number, number] = [0x0018, 0x6026]; + +const isTag = (el: DataElement, [group, element]: [number, number]) => + el.group === group && el.element === element; + +const readFloat64LE = (bytes: Uint8Array) => + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getFloat64( + 0, + true + ); + +const readUint16LE = (bytes: Uint8Array) => + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16( + 0, + true + ); + +/** + * Decodes the first item of a SequenceOfUltrasoundRegions element. + * Returns null if required fields are missing. + */ +export function decodeUltrasoundRegion( + sequenceData: DataElement['data'] +): UltrasoundRegion | null { + if (!Array.isArray(sequenceData) || sequenceData.length === 0) return null; + const [firstItem] = sequenceData; + + const findBytes = (target: [number, number]) => { + const el = firstItem.find((inner) => isTag(inner, target)); + if (!el || !(el.data instanceof Uint8Array)) return null; + return el.data; + }; + + const deltaXBytes = findBytes(PHYSICAL_DELTA_X); + const deltaYBytes = findBytes(PHYSICAL_DELTA_Y); + const unitsXBytes = findBytes(PHYSICAL_UNITS_X_DIRECTION); + const unitsYBytes = findBytes(PHYSICAL_UNITS_Y_DIRECTION); + + if (!deltaXBytes || !deltaYBytes || !unitsXBytes || !unitsYBytes) { + return null; + } + + return { + physicalDeltaX: readFloat64LE(deltaXBytes), + physicalDeltaY: readFloat64LE(deltaYBytes), + physicalUnitsXDirection: readUint16LE(unitsXBytes), + physicalUnitsYDirection: readUint16LE(unitsYBytes), + }; +} + +/** + * Parses a DICOM blob and returns the first ultrasound region, if present. + */ +export async function parseUltrasoundRegionFromBlob( + blob: Blob +): Promise { + let region: UltrasoundRegion | null = null; + + const parse = createDicomParser({ + stopAtElement(group, element) { + return group === 0x7fe0 && element === 0x0010; + }, + onDataElement(el) { + if (region) return; + if (isTag(el, SEQUENCE_OF_ULTRASOUND_REGIONS)) { + region = decodeUltrasoundRegion(el.data); + } + }, + }); + + const stream = blob.stream(); + const reader = stream.getReader(); + try { + while (!region) { + const { value, done } = await reader.read(); + if (done) break; + const result = parse(value); + if (result.done) break; + } + } catch { + return null; + } finally { + reader.releaseLock(); + } + + return region; +} + +export function encodeUltrasoundRegionMeta( + region: UltrasoundRegion +): [string, string] { + return [US_REGION_META_KEY, JSON.stringify(region)]; +} + +export function getUltrasoundRegionFromMetadata( + meta: ReadonlyArray | null | undefined +): UltrasoundRegion | null { + if (!meta) return null; + const entry = meta.find(([key]) => key === US_REGION_META_KEY); + if (!entry) return null; + try { + return JSON.parse(entry[1]) as UltrasoundRegion; + } catch { + return null; + } +} diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 2e9904b65..d6e1feeeb 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -26,6 +26,10 @@ import { import { ensureError } from '@/src/utils'; import { computed } from 'vue'; import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; +import { + getUltrasoundRegionFromMetadata, + US_UNIT_CENTIMETERS, +} from '@/src/core/streaming/dicom/ultrasoundRegion'; const { fastComputeRange } = vtkDataArray; @@ -279,6 +283,28 @@ export default class DicomChunkImage private reallocateImage() { this.vtkImageData.value.delete(); this.vtkImageData.value = allocateImageFromChunks(this.chunks); + this.applyUltrasoundSpacing(); + } + + private applyUltrasoundSpacing() { + if (this.getModality() !== 'US') return; + + const region = getUltrasoundRegionFromMetadata(this.getDicomMetadata()); + if (!region) return; + if ( + region.physicalUnitsXDirection !== US_UNIT_CENTIMETERS || + region.physicalUnitsYDirection !== US_UNIT_CENTIMETERS + ) { + return; + } + + const CM_TO_MM = 10; + const [, , zSpacing] = this.vtkImageData.value.getSpacing(); + this.vtkImageData.value.setSpacing([ + region.physicalDeltaX * CM_TO_MM, + region.physicalDeltaY * CM_TO_MM, + zSpacing, + ]); } private updateDataRangeFromChunks() { diff --git a/tests/specs/configTestUtils.ts b/tests/specs/configTestUtils.ts index 0fff6a9cd..6e045dfb2 100644 --- a/tests/specs/configTestUtils.ts +++ b/tests/specs/configTestUtils.ts @@ -73,6 +73,14 @@ export const FETUS_DATASET = { name: 'fetus.zip', } as const; +// Multiframe ultrasound DICOM from pydicom public test data. +// SequenceOfUltrasoundRegions: PhysicalDeltaX/Y = 0.05104970559 cm/pixel +// (unit code 3 = cm), so with US spacing fix the VTK spacing is ~0.5105 mm. +export const US_MULTIFRAME_DICOM = { + url: 'https://data.kitware.com/api/v1/file/69e1630646ef98a20f563020/download', + name: 'US_multiframe_30frames.dcm', +} as const; + export type DatasetResource = { url: string; name?: string; diff --git a/tests/specs/ultrasound-spacing.e2e.ts b/tests/specs/ultrasound-spacing.e2e.ts new file mode 100644 index 000000000..e48f84b3a --- /dev/null +++ b/tests/specs/ultrasound-spacing.e2e.ts @@ -0,0 +1,92 @@ +import { US_MULTIFRAME_DICOM } from './configTestUtils'; +import { openUrls } from './utils'; +import { volViewPage } from '../pageobjects/volview.page'; + +const clickAt = async (x: number, y: number) => { + await browser + .action('pointer') + .move({ x: Math.round(x), y: Math.round(y) }) + .down() + .up() + .perform(); +}; + +// Offset between the two ruler clicks (in canvas pixels). +// The measured ruler length in mm depends on this offset, the canvas size, +// and the image spacing. With the US spacing fix the VTK spacing comes from +// SequenceOfUltrasoundRegions (~0.5105 mm); without the fix it falls back to +// 1.0 mm, which makes the measured length ~1.96x larger. +const CLICK_DX = 0; +const CLICK_DY = 100; + +// Calibrated length (mm) that the ruler reports when the US spacing fix is +// active. Obtained by running this test once with the fix enabled. +// Without the fix the VTK spacing falls back to 1.0 mm/pixel, which makes +// the measured length grow to ~97 mm (~1.96x) and this assertion fails. +const EXPECTED_LENGTH_MM = 49.35; +const LENGTH_TOLERANCE_MM = 1.5; + +describe('Ultrasound image spacing', () => { + it('ruler length reflects physical spacing from SequenceOfUltrasoundRegions', async () => { + await openUrls([US_MULTIFRAME_DICOM]); + + // Activate the ruler tool + const rulerBtn = await $('button span i[class~=mdi-ruler]'); + await rulerBtn.waitForClickable(); + await rulerBtn.click(); + + // Place the ruler on the first view's canvas + const views = await volViewPage.views; + const canvas = views[0]; + const loc = await canvas.getLocation(); + const size = await canvas.getSize(); + const cx = loc.x + size.width / 2; + const cy = loc.y + size.height / 2; + + await clickAt(cx - CLICK_DX / 2, cy - CLICK_DY / 2); + await clickAt(cx + CLICK_DX / 2, cy + CLICK_DY / 2); + + // Open Annotations > Measurements to read the ruler length + const annotationsTab = await $( + 'button[data-testid="module-tab-Annotations"]' + ); + await annotationsTab.click(); + + const measurementsTab = await $('button.v-tab*=Measurements'); + await measurementsTab.waitForClickable(); + await measurementsTab.click(); + + // The ruler details panel renders `{value}mm`; read the first length. + let lengthMm = 0; + await browser.waitUntil( + async () => { + const spans = await $$('.v-list-item .value'); + for (const span of spans) { + const text = await span.getText(); + const match = text.match(/([\d.]+)\s*mm/); + if (match) { + lengthMm = parseFloat(match[1]); + return lengthMm > 0; + } + } + return false; + }, + { + timeout: 10_000, + timeoutMsg: 'Ruler length (mm) not found in measurements sidebar', + } + ); + + console.log(`[ultrasound-spacing] measured ruler length: ${lengthMm} mm`); + + if (EXPECTED_LENGTH_MM > 0) { + expect(lengthMm).toBeGreaterThan( + EXPECTED_LENGTH_MM - LENGTH_TOLERANCE_MM + ); + expect(lengthMm).toBeLessThan(EXPECTED_LENGTH_MM + LENGTH_TOLERANCE_MM); + } else { + // Calibration mode: any positive value passes, actual number is logged. + expect(lengthMm).toBeGreaterThan(0); + } + }); +});