From fed8bf2d6873c1c3040072ca89e6eb4f8b968872 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 16 Apr 2026 18:36:06 -0400 Subject: [PATCH 1/5] ultrasound: use SequenceOfUltrasoundRegions for spacing --- src/core/dicomTags.ts | 12 + .../dicom/__tests__/ultrasoundRegion.spec.ts | 217 ++++++++++++++++++ .../streaming/dicom/dicomFileMetaLoader.ts | 13 ++ src/core/streaming/dicom/dicomMetaLoader.ts | 18 ++ src/core/streaming/dicom/ultrasoundRegion.ts | 135 +++++++++++ src/core/streaming/dicomChunkImage.ts | 26 +++ tests/specs/configTestUtils.ts | 8 + tests/specs/ultrasound-spacing.e2e.ts | 92 ++++++++ 8 files changed, 521 insertions(+) create mode 100644 src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts create mode 100644 src/core/streaming/dicom/ultrasoundRegion.ts create mode 100644 tests/specs/ultrasound-spacing.e2e.ts diff --git a/src/core/dicomTags.ts b/src/core/dicomTags.ts index 3a1ee6e0d..7f599f262 100644 --- a/src/core/dicomTags.ts +++ b/src/core/dicomTags.ts @@ -33,8 +33,20 @@ const tags: Tag[] = [ { name: 'RescaleIntercept', tag: '0028|1052' }, { name: 'RescaleSlope', tag: '0028|1053' }, { name: 'NumberOfFrames', tag: '0028|0008' }, + { name: 'SequenceOfUltrasoundRegions', tag: '0018|6011' }, + { name: 'PhysicalDeltaX', tag: '0018|602c' }, + { name: 'PhysicalDeltaY', tag: '0018|602e' }, + { name: 'PhysicalUnitsXDirection', tag: '0018|6024' }, + { name: 'PhysicalUnitsYDirection', tag: '0018|6026' }, ]; export const TAG_TO_NAME = new Map(tags.map((t) => [t.tag, t.name])); export const NAME_TO_TAG = new Map(tags.map((t) => [t.name, t.tag])); export const Tags = Object.fromEntries(tags.map((t) => [t.name, t.tag])); + +// Splits an itk-wasm-style "GGGG|EEEE" tag into the numeric [group, element] +// pair emitted by the streaming DICOM parser. +export const tagToGroupElement = (tag: string): [number, number] => { + const [group, element] = tag.split('|'); + return [parseInt(group, 16), parseInt(element, 16)]; +}; diff --git a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts new file mode 100644 index 000000000..7bc69bf9b --- /dev/null +++ b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import type { DataElement } from '@/src/core/streaming/dicom/dicomParser'; +import { + decodeUltrasoundRegion, + encodeUltrasoundRegionMeta, + getUltrasoundRegionFromMetadata, + parseUltrasoundRegionFromBlob, + US_REGION_META_KEY, + US_UNIT_CENTIMETERS, +} from '@/src/core/streaming/dicom/ultrasoundRegion'; + +const u16LE = (v: number) => { + const b = new Uint8Array(2); + new DataView(b.buffer).setUint16(0, v, true); + return b; +}; + +const f64LE = (v: number) => { + const b = new Uint8Array(8); + new DataView(b.buffer).setFloat64(0, v, true); + return b; +}; + +const concat = (parts: Uint8Array[]) => { + const total = parts.reduce((sum, p) => sum + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + parts.forEach((p) => { + out.set(p, offset); + offset += p.length; + }); + return out; +}; + +// Builds an explicit-VR-LE data element for a VR with 2-byte length (US/FD/UI/etc). +const shortVRElement = ( + group: number, + element: number, + vr: string, + value: Uint8Array +) => { + const header = new Uint8Array(8); + const dv = new DataView(header.buffer); + dv.setUint16(0, group, true); + dv.setUint16(2, element, true); + header[4] = vr.charCodeAt(0); + header[5] = vr.charCodeAt(1); + dv.setUint16(6, value.length, true); + return concat([header, value]); +}; + +type Item = { group: number; element: number; vr: string; value: Uint8Array }; + +// Fake parsed sequence data mirroring what readSequenceValue emits. +const fakeSequenceData = (items: Item[][]): DataElement['data'] => + items.map( + (elements) => + elements.map((e) => ({ + group: e.group, + element: e.element, + vr: e.vr, + length: e.value.length, + data: e.value, + })) as DataElement[] + ); + +const wellFormedItem: Item[] = [ + { group: 0x0018, element: 0x6024, vr: 'US', value: u16LE(US_UNIT_CENTIMETERS) }, + { group: 0x0018, element: 0x6026, vr: 'US', value: u16LE(US_UNIT_CENTIMETERS) }, + { group: 0x0018, element: 0x602c, vr: 'FD', value: f64LE(0.05) }, + { group: 0x0018, element: 0x602e, vr: 'FD', value: f64LE(0.1) }, +]; + +describe('decodeUltrasoundRegion', () => { + it('decodes the first item of the sequence', () => { + const region = decodeUltrasoundRegion(fakeSequenceData([wellFormedItem])); + expect(region).toEqual({ + physicalDeltaX: 0.05, + physicalDeltaY: 0.1, + physicalUnitsXDirection: US_UNIT_CENTIMETERS, + physicalUnitsYDirection: US_UNIT_CENTIMETERS, + }); + }); + + it('returns null when the sequence is empty', () => { + expect(decodeUltrasoundRegion([])).toBeNull(); + }); + + it('returns null when the data is not a sequence', () => { + expect(decodeUltrasoundRegion(undefined)).toBeNull(); + expect(decodeUltrasoundRegion(new Uint8Array(4))).toBeNull(); + }); + + it('returns null when a required field is missing', () => { + const missingDeltaY = wellFormedItem.filter( + (e) => !(e.group === 0x0018 && e.element === 0x602e) + ); + expect(decodeUltrasoundRegion(fakeSequenceData([missingDeltaY]))).toBeNull(); + }); + + it('ignores items beyond the first', () => { + const second: Item[] = [ + { group: 0x0018, element: 0x6024, vr: 'US', value: u16LE(0) }, + { group: 0x0018, element: 0x6026, vr: 'US', value: u16LE(0) }, + { group: 0x0018, element: 0x602c, vr: 'FD', value: f64LE(999) }, + { group: 0x0018, element: 0x602e, vr: 'FD', value: f64LE(999) }, + ]; + const region = decodeUltrasoundRegion( + fakeSequenceData([wellFormedItem, second]) + ); + expect(region?.physicalDeltaX).toBe(0.05); + }); +}); + +describe('encodeUltrasoundRegionMeta / getUltrasoundRegionFromMetadata', () => { + it('round-trips through the metadata tag array', () => { + const region = { + physicalDeltaX: 0.05, + physicalDeltaY: 0.1, + physicalUnitsXDirection: US_UNIT_CENTIMETERS, + physicalUnitsYDirection: US_UNIT_CENTIMETERS, + }; + const entry = encodeUltrasoundRegionMeta(region); + expect(entry[0]).toBe(US_REGION_META_KEY); + + const meta: Array<[string, string]> = [ + ['0008|0060', 'US'], + entry, + ['0010|0010', 'PATIENT^NAME'], + ]; + expect(getUltrasoundRegionFromMetadata(meta)).toEqual(region); + }); + + it('returns null when the entry is absent', () => { + expect(getUltrasoundRegionFromMetadata([])).toBeNull(); + expect(getUltrasoundRegionFromMetadata(null)).toBeNull(); + expect(getUltrasoundRegionFromMetadata(undefined)).toBeNull(); + }); + + it('returns null when the entry value is unparseable', () => { + expect( + getUltrasoundRegionFromMetadata([[US_REGION_META_KEY, 'not-json']]) + ).toBeNull(); + }); +}); + +// Builds a minimal DICOM P10 byte stream containing only what the ultrasound +// parser needs: preamble, DICM magic, a TransferSyntaxUID (Explicit VR LE), +// and a SequenceOfUltrasoundRegions with one populated item. +const buildDicomBlob = (item: Item[]) => { + const preamble = new Uint8Array(128); + const magic = new TextEncoder().encode('DICM'); + + // File Meta Info: just TransferSyntaxUID. The parser exits the meta block + // as soon as it peeks a non-0x0002 group, so FileMetaInformationGroupLength + // is not required here. + const tsxValue = new TextEncoder().encode('1.2.840.10008.1.2.1\0'); + const tsx = shortVRElement(0x0002, 0x0010, 'UI', tsxValue); + + const itemBody = concat( + item.map((e) => shortVRElement(e.group, e.element, e.vr, e.value)) + ); + + // Item header: (fffe,e000) tag + 4-byte length. + const itemHeader = new Uint8Array(8); + const ivh = new DataView(itemHeader.buffer); + ivh.setUint16(0, 0xfffe, true); + ivh.setUint16(2, 0xe000, true); + ivh.setUint32(4, itemBody.length, true); + + const sequenceBody = concat([itemHeader, itemBody]); + + // SQ header: tag + "SQ" + 2 reserved + 4-byte length. + const sqHeader = new Uint8Array(12); + const sqh = new DataView(sqHeader.buffer); + sqh.setUint16(0, 0x0018, true); + sqh.setUint16(2, 0x6011, true); + sqHeader[4] = 'S'.charCodeAt(0); + sqHeader[5] = 'Q'.charCodeAt(0); + sqh.setUint32(8, sequenceBody.length, true); + + // Pixel data tag so the parser reaches its stop condition cleanly. + const pixelDataTag = new Uint8Array(4); + new DataView(pixelDataTag.buffer).setUint16(0, 0x7fe0, true); + new DataView(pixelDataTag.buffer).setUint16(2, 0x0010, true); + + return new Blob([ + concat([preamble, magic, tsx, sqHeader, sequenceBody, pixelDataTag]), + ]); +}; + +describe('parseUltrasoundRegionFromBlob', () => { + it('extracts the region from a synthetic DICOM blob', async () => { + const blob = buildDicomBlob(wellFormedItem); + const region = await parseUltrasoundRegionFromBlob(blob); + expect(region).toEqual({ + physicalDeltaX: 0.05, + physicalDeltaY: 0.1, + physicalUnitsXDirection: US_UNIT_CENTIMETERS, + physicalUnitsYDirection: US_UNIT_CENTIMETERS, + }); + }); + + it('returns null when the blob has no SequenceOfUltrasoundRegions', async () => { + // Build a blob with only the TransferSyntaxUID + pixel data tag. + const preamble = new Uint8Array(128); + const magic = new TextEncoder().encode('DICM'); + const tsxValue = new TextEncoder().encode('1.2.840.10008.1.2.1\0'); + const tsx = shortVRElement(0x0002, 0x0010, 'UI', tsxValue); + const pixelDataTag = new Uint8Array(4); + new DataView(pixelDataTag.buffer).setUint16(0, 0x7fe0, true); + new DataView(pixelDataTag.buffer).setUint16(2, 0x0010, true); + const blob = new Blob([concat([preamble, magic, tsx, pixelDataTag])]); + + expect(await parseUltrasoundRegionFromBlob(blob)).toBeNull(); + }); +}); 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..81727f11c 100644 --- a/src/core/streaming/dicom/dicomMetaLoader.ts +++ b/src/core/streaming/dicom/dicomMetaLoader.ts @@ -9,6 +9,12 @@ 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, + SEQUENCE_OF_ULTRASOUND_REGIONS, + UltrasoundRegion, +} from '@/src/core/streaming/dicom/ultrasoundRegion'; export type ReadDicomTagsFunction = ( file: File @@ -51,6 +57,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 +73,13 @@ export class DicomMetaLoader implements MetaLoader { if (el.group === 0x0008 && el.element === 0x0060 && el.data) { modality = toAscii(el.data as Uint8Array).trim(); } + if ( + el.group === SEQUENCE_OF_ULTRASOUND_REGIONS[0] && + el.element === SEQUENCE_OF_ULTRASOUND_REGIONS[1] && + !ultrasoundRegion + ) { + ultrasoundRegion = decodeUltrasoundRegion(el.data); + } }, }); @@ -115,6 +129,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..d57bf1b4b --- /dev/null +++ b/src/core/streaming/dicom/ultrasoundRegion.ts @@ -0,0 +1,135 @@ +import { + createDicomParser, + DataElement, +} from '@/src/core/streaming/dicom/dicomParser'; +import { Tags, tagToGroupElement } from '@/src/core/dicomTags'; + +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; +}; + +export const SEQUENCE_OF_ULTRASOUND_REGIONS = tagToGroupElement( + Tags.SequenceOfUltrasoundRegions +); +const PHYSICAL_DELTA_X = tagToGroupElement(Tags.PhysicalDeltaX); +const PHYSICAL_DELTA_Y = tagToGroupElement(Tags.PhysicalDeltaY); +const PHYSICAL_UNITS_X_DIRECTION = tagToGroupElement( + Tags.PhysicalUnitsXDirection +); +const PHYSICAL_UNITS_Y_DIRECTION = tagToGroupElement( + Tags.PhysicalUnitsYDirection +); + +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); + } + }); +}); From 55eb9bb6d0cab5b808f4586ab1353da252f524ff Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 4 May 2026 10:06:57 -0400 Subject: [PATCH 2/5] ultrasound: prettier format spec --- .../dicom/__tests__/ultrasoundRegion.spec.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts index 7bc69bf9b..5d8ecf5ff 100644 --- a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts +++ b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts @@ -65,8 +65,18 @@ const fakeSequenceData = (items: Item[][]): DataElement['data'] => ); const wellFormedItem: Item[] = [ - { group: 0x0018, element: 0x6024, vr: 'US', value: u16LE(US_UNIT_CENTIMETERS) }, - { group: 0x0018, element: 0x6026, vr: 'US', value: u16LE(US_UNIT_CENTIMETERS) }, + { + group: 0x0018, + element: 0x6024, + vr: 'US', + value: u16LE(US_UNIT_CENTIMETERS), + }, + { + group: 0x0018, + element: 0x6026, + vr: 'US', + value: u16LE(US_UNIT_CENTIMETERS), + }, { group: 0x0018, element: 0x602c, vr: 'FD', value: f64LE(0.05) }, { group: 0x0018, element: 0x602e, vr: 'FD', value: f64LE(0.1) }, ]; @@ -95,7 +105,9 @@ describe('decodeUltrasoundRegion', () => { const missingDeltaY = wellFormedItem.filter( (e) => !(e.group === 0x0018 && e.element === 0x602e) ); - expect(decodeUltrasoundRegion(fakeSequenceData([missingDeltaY]))).toBeNull(); + expect( + decodeUltrasoundRegion(fakeSequenceData([missingDeltaY])) + ).toBeNull(); }); it('ignores items beyond the first', () => { From 2096f8c30621faa3679bee09533d27fcb00a06a6 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 4 May 2026 11:37:03 -0400 Subject: [PATCH 3/5] ultrasound: extract unitToMm helper, document unit codes per spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per DICOM PS3.3 C.8.5.5.1.15, code 3 (cm) is the only spatial spacing unit defined for PhysicalUnitsXDirection / YDirection — codes 0, 1, 2, 4–10 cover none / percent / dB / seconds / hertz / dB/sec / cm/sec / cm² / cm²/sec / degrees. Replace the strict cm equality check with a unitToMm helper that returns 10 for cm and null for everything else, applied per axis. Functionally equivalent to the previous strict check, but isolates the unit-to-spacing mapping in one tested place and adds a clear extension point if a vendor exception ever needs to be honoured. --- .../dicom/__tests__/ultrasoundRegion.spec.ts | 15 +++++++++++++++ src/core/streaming/dicom/ultrasoundRegion.ts | 15 +++++++++++++-- src/core/streaming/dicomChunkImage.ts | 17 +++++++---------- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts index 5d8ecf5ff..6553cf0f5 100644 --- a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts +++ b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts @@ -5,6 +5,7 @@ import { encodeUltrasoundRegionMeta, getUltrasoundRegionFromMetadata, parseUltrasoundRegionFromBlob, + unitToMm, US_REGION_META_KEY, US_UNIT_CENTIMETERS, } from '@/src/core/streaming/dicom/ultrasoundRegion'; @@ -124,6 +125,20 @@ describe('decodeUltrasoundRegion', () => { }); }); +describe('unitToMm', () => { + it('returns 10 for centimetres (code 3)', () => { + expect(unitToMm(US_UNIT_CENTIMETERS)).toBe(10); + }); + + it('returns null for non-spatial unit codes', () => { + // Per DICOM PS3.3 C.8.5.5.1.15: 0=none, 1=percent, 2=dB, 4=seconds, + // 5=hertz, 6=dB/sec, 7=cm/sec, 8=cm², 9=cm²/sec, 10=degrees. + [0, 1, 2, 4, 5, 6, 7, 8, 9, 10].forEach((code) => { + expect(unitToMm(code)).toBeNull(); + }); + }); +}); + describe('encodeUltrasoundRegionMeta / getUltrasoundRegionFromMetadata', () => { it('round-trips through the metadata tag array', () => { const region = { diff --git a/src/core/streaming/dicom/ultrasoundRegion.ts b/src/core/streaming/dicom/ultrasoundRegion.ts index d57bf1b4b..e0fd4c335 100644 --- a/src/core/streaming/dicom/ultrasoundRegion.ts +++ b/src/core/streaming/dicom/ultrasoundRegion.ts @@ -6,10 +6,21 @@ import { Tags, tagToGroupElement } from '@/src/core/dicomTags'; 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. +// DICOM unit codes for PhysicalUnitsXDirection / YDirection. +// See DICOM PS3.3 C.8.5.5.1.15. The only spatial spacing code defined for +// this field is 3 (cm). Other codes (0=none, 1=percent, 2=dB, 4=seconds, +// 5=hertz, 6=dB/seconds, 7=cm/sec, 8=cm², 9=cm²/sec, A=degrees) are time, +// frequency, velocity, area, or angle, so they are not converted to a VTK +// image spacing. export const US_UNIT_CENTIMETERS = 3; +// Returns the multiplier that converts a physical-delta value in the given +// unit to millimetres, or null when the unit is not a spatial spacing. +export const unitToMm = (code: number): number | null => { + if (code === US_UNIT_CENTIMETERS) return 10; + return null; +}; + export type UltrasoundRegion = { physicalDeltaX: number; physicalDeltaY: number; diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index d6e1feeeb..dd74dbee7 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -28,7 +28,7 @@ import { computed } from 'vue'; import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; import { getUltrasoundRegionFromMetadata, - US_UNIT_CENTIMETERS, + unitToMm, } from '@/src/core/streaming/dicom/ultrasoundRegion'; const { fastComputeRange } = vtkDataArray; @@ -291,18 +291,15 @@ export default class DicomChunkImage 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 xFactor = unitToMm(region.physicalUnitsXDirection); + const yFactor = unitToMm(region.physicalUnitsYDirection); + if (xFactor === null || yFactor === null) return; + const [, , zSpacing] = this.vtkImageData.value.getSpacing(); this.vtkImageData.value.setSpacing([ - region.physicalDeltaX * CM_TO_MM, - region.physicalDeltaY * CM_TO_MM, + region.physicalDeltaX * xFactor, + region.physicalDeltaY * yFactor, zSpacing, ]); } From 3775479ff4b8304a9af82d0eaad0983947c68b6b Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 4 May 2026 10:58:33 -0400 Subject: [PATCH 4/5] 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. --- .../dicom/__tests__/ultrasoundRegion.spec.ts | 84 ++++++++++++------- .../streaming/dicom/dicomFileMetaLoader.ts | 6 +- src/core/streaming/dicom/dicomMetaLoader.ts | 12 +-- src/core/streaming/dicom/ultrasoundRegion.ts | 59 ++++++++----- src/core/streaming/dicomChunkImage.ts | 15 +++- 5 files changed, 113 insertions(+), 63 deletions(-) diff --git a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts index 6553cf0f5..fb2b999e7 100644 --- a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts +++ b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts @@ -84,44 +84,58 @@ const wellFormedItem: Item[] = [ describe('decodeUltrasoundRegion', () => { it('decodes the first item of the sequence', () => { - const region = decodeUltrasoundRegion(fakeSequenceData([wellFormedItem])); - expect(region).toEqual({ - physicalDeltaX: 0.05, - physicalDeltaY: 0.1, - physicalUnitsXDirection: US_UNIT_CENTIMETERS, - physicalUnitsYDirection: US_UNIT_CENTIMETERS, + const result = decodeUltrasoundRegion(fakeSequenceData([wellFormedItem])); + expect(result).toEqual({ + region: { + physicalDeltaX: 0.05, + physicalDeltaY: 0.1, + physicalUnitsXDirection: US_UNIT_CENTIMETERS, + physicalUnitsYDirection: US_UNIT_CENTIMETERS, + }, + regionCount: 1, }); }); - it('returns null when the sequence is empty', () => { - expect(decodeUltrasoundRegion([])).toBeNull(); + it('returns a null region with zero count when the sequence is empty', () => { + expect(decodeUltrasoundRegion([])).toEqual({ + region: null, + regionCount: 0, + }); }); - it('returns null when the data is not a sequence', () => { - expect(decodeUltrasoundRegion(undefined)).toBeNull(); - expect(decodeUltrasoundRegion(new Uint8Array(4))).toBeNull(); + it('returns a null region with zero count when the data is not a sequence', () => { + expect(decodeUltrasoundRegion(undefined)).toEqual({ + region: null, + regionCount: 0, + }); + expect(decodeUltrasoundRegion(new Uint8Array(4))).toEqual({ + region: null, + regionCount: 0, + }); }); - it('returns null when a required field is missing', () => { + it('returns a null region but reports the count when a required field is missing', () => { const missingDeltaY = wellFormedItem.filter( (e) => !(e.group === 0x0018 && e.element === 0x602e) ); - expect( - decodeUltrasoundRegion(fakeSequenceData([missingDeltaY])) - ).toBeNull(); + expect(decodeUltrasoundRegion(fakeSequenceData([missingDeltaY]))).toEqual({ + region: null, + regionCount: 1, + }); }); - it('ignores items beyond the first', () => { + it('decodes only the first item but reports the total count', () => { const second: Item[] = [ { group: 0x0018, element: 0x6024, vr: 'US', value: u16LE(0) }, { group: 0x0018, element: 0x6026, vr: 'US', value: u16LE(0) }, { group: 0x0018, element: 0x602c, vr: 'FD', value: f64LE(999) }, { group: 0x0018, element: 0x602e, vr: 'FD', value: f64LE(999) }, ]; - const region = decodeUltrasoundRegion( + const result = decodeUltrasoundRegion( fakeSequenceData([wellFormedItem, second]) ); - expect(region?.physicalDeltaX).toBe(0.05); + expect(result.region?.physicalDeltaX).toBe(0.05); + expect(result.regionCount).toBe(2); }); }); @@ -141,13 +155,16 @@ describe('unitToMm', () => { describe('encodeUltrasoundRegionMeta / getUltrasoundRegionFromMetadata', () => { it('round-trips through the metadata tag array', () => { - const region = { - physicalDeltaX: 0.05, - physicalDeltaY: 0.1, - physicalUnitsXDirection: US_UNIT_CENTIMETERS, - physicalUnitsYDirection: US_UNIT_CENTIMETERS, + const regions = { + region: { + physicalDeltaX: 0.05, + physicalDeltaY: 0.1, + physicalUnitsXDirection: US_UNIT_CENTIMETERS, + physicalUnitsYDirection: US_UNIT_CENTIMETERS, + }, + regionCount: 2, }; - const entry = encodeUltrasoundRegionMeta(region); + const entry = encodeUltrasoundRegionMeta(regions); expect(entry[0]).toBe(US_REGION_META_KEY); const meta: Array<[string, string]> = [ @@ -155,7 +172,7 @@ describe('encodeUltrasoundRegionMeta / getUltrasoundRegionFromMetadata', () => { entry, ['0010|0010', 'PATIENT^NAME'], ]; - expect(getUltrasoundRegionFromMetadata(meta)).toEqual(region); + expect(getUltrasoundRegionFromMetadata(meta)).toEqual(regions); }); it('returns null when the entry is absent', () => { @@ -217,14 +234,17 @@ const buildDicomBlob = (item: Item[]) => { }; describe('parseUltrasoundRegionFromBlob', () => { - it('extracts the region from a synthetic DICOM blob', async () => { + it('extracts the region and count from a synthetic DICOM blob', async () => { const blob = buildDicomBlob(wellFormedItem); - const region = await parseUltrasoundRegionFromBlob(blob); - expect(region).toEqual({ - physicalDeltaX: 0.05, - physicalDeltaY: 0.1, - physicalUnitsXDirection: US_UNIT_CENTIMETERS, - physicalUnitsYDirection: US_UNIT_CENTIMETERS, + const result = await parseUltrasoundRegionFromBlob(blob); + expect(result).toEqual({ + region: { + physicalDeltaX: 0.05, + physicalDeltaY: 0.1, + physicalUnitsXDirection: US_UNIT_CENTIMETERS, + physicalUnitsYDirection: US_UNIT_CENTIMETERS, + }, + regionCount: 1, }); }); diff --git a/src/core/streaming/dicom/dicomFileMetaLoader.ts b/src/core/streaming/dicom/dicomFileMetaLoader.ts index 581a67947..0f765b7ea 100644 --- a/src/core/streaming/dicom/dicomFileMetaLoader.ts +++ b/src/core/streaming/dicom/dicomFileMetaLoader.ts @@ -32,9 +32,9 @@ export class DicomFileMetaLoader implements MetaLoader { 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)); + const regions = await parseUltrasoundRegionFromBlob(this.file); + if (regions) { + this.tags.push(encodeUltrasoundRegionMeta(regions)); } } } diff --git a/src/core/streaming/dicom/dicomMetaLoader.ts b/src/core/streaming/dicom/dicomMetaLoader.ts index 81727f11c..38f8a007b 100644 --- a/src/core/streaming/dicom/dicomMetaLoader.ts +++ b/src/core/streaming/dicom/dicomMetaLoader.ts @@ -13,7 +13,7 @@ import { decodeUltrasoundRegion, encodeUltrasoundRegionMeta, SEQUENCE_OF_ULTRASOUND_REGIONS, - UltrasoundRegion, + UltrasoundRegions, } from '@/src/core/streaming/dicom/ultrasoundRegion'; export type ReadDicomTagsFunction = ( @@ -57,7 +57,7 @@ export class DicomMetaLoader implements MetaLoader { let explicitVr = true; let dicomUpToPixelDataIdx = -1; let modality: string | undefined; - let ultrasoundRegion: UltrasoundRegion | null = null; + let ultrasoundRegions: UltrasoundRegions | null = null; const parse = createDicomParser({ stopAtElement(group, element) { @@ -76,9 +76,9 @@ export class DicomMetaLoader implements MetaLoader { if ( el.group === SEQUENCE_OF_ULTRASOUND_REGIONS[0] && el.element === SEQUENCE_OF_ULTRASOUND_REGIONS[1] && - !ultrasoundRegion + !ultrasoundRegions ) { - ultrasoundRegion = decodeUltrasoundRegion(el.data); + ultrasoundRegions = decodeUltrasoundRegion(el.data); } }, }); @@ -130,8 +130,8 @@ 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)); + if (modality === 'US' && ultrasoundRegions) { + this.tags.push(encodeUltrasoundRegionMeta(ultrasoundRegions)); } } diff --git a/src/core/streaming/dicom/ultrasoundRegion.ts b/src/core/streaming/dicom/ultrasoundRegion.ts index e0fd4c335..06a2adbbd 100644 --- a/src/core/streaming/dicom/ultrasoundRegion.ts +++ b/src/core/streaming/dicom/ultrasoundRegion.ts @@ -28,6 +28,15 @@ export type UltrasoundRegion = { physicalUnitsYDirection: number; }; +// First-region spacing plus the total number of regions in the source +// SequenceOfUltrasoundRegions. Multi-region images (e.g. dual-pane B-mode + +// Doppler) cannot be fully represented with a single VTK image spacing, so +// we expose the count to let callers warn about partial support. +export type UltrasoundRegions = { + region: UltrasoundRegion | null; + regionCount: number; +}; + export const SEQUENCE_OF_ULTRASOUND_REGIONS = tagToGroupElement( Tags.SequenceOfUltrasoundRegions ); @@ -56,13 +65,18 @@ const readUint16LE = (bytes: Uint8Array) => ); /** - * Decodes the first item of a SequenceOfUltrasoundRegions element. - * Returns null if required fields are missing. + * Decodes the first item of a SequenceOfUltrasoundRegions element and + * reports the total number of regions found. The first region's spacing + * is what gets applied to the VTK image; the count lets callers warn when + * additional regions exist (multi-region images are only partially + * supported because VTK image data has a single global spacing). */ export function decodeUltrasoundRegion( sequenceData: DataElement['data'] -): UltrasoundRegion | null { - if (!Array.isArray(sequenceData) || sequenceData.length === 0) return null; +): UltrasoundRegions { + if (!Array.isArray(sequenceData) || sequenceData.length === 0) { + return { region: null, regionCount: 0 }; + } const [firstItem] = sequenceData; const findBytes = (target: [number, number]) => { @@ -76,34 +90,39 @@ export function decodeUltrasoundRegion( const unitsXBytes = findBytes(PHYSICAL_UNITS_X_DIRECTION); const unitsYBytes = findBytes(PHYSICAL_UNITS_Y_DIRECTION); + const regionCount = sequenceData.length; if (!deltaXBytes || !deltaYBytes || !unitsXBytes || !unitsYBytes) { - return null; + return { region: null, regionCount }; } return { - physicalDeltaX: readFloat64LE(deltaXBytes), - physicalDeltaY: readFloat64LE(deltaYBytes), - physicalUnitsXDirection: readUint16LE(unitsXBytes), - physicalUnitsYDirection: readUint16LE(unitsYBytes), + region: { + physicalDeltaX: readFloat64LE(deltaXBytes), + physicalDeltaY: readFloat64LE(deltaYBytes), + physicalUnitsXDirection: readUint16LE(unitsXBytes), + physicalUnitsYDirection: readUint16LE(unitsYBytes), + }, + regionCount, }; } /** - * Parses a DICOM blob and returns the first ultrasound region, if present. + * Parses a DICOM blob and returns the first ultrasound region plus the + * total region count, if a SequenceOfUltrasoundRegions is present. */ export async function parseUltrasoundRegionFromBlob( blob: Blob -): Promise { - let region: UltrasoundRegion | null = null; +): Promise { + let regions: UltrasoundRegions | null = null; const parse = createDicomParser({ stopAtElement(group, element) { return group === 0x7fe0 && element === 0x0010; }, onDataElement(el) { - if (region) return; + if (regions) return; if (isTag(el, SEQUENCE_OF_ULTRASOUND_REGIONS)) { - region = decodeUltrasoundRegion(el.data); + regions = decodeUltrasoundRegion(el.data); } }, }); @@ -111,7 +130,7 @@ export async function parseUltrasoundRegionFromBlob( const stream = blob.stream(); const reader = stream.getReader(); try { - while (!region) { + while (!regions) { const { value, done } = await reader.read(); if (done) break; const result = parse(value); @@ -123,23 +142,23 @@ export async function parseUltrasoundRegionFromBlob( reader.releaseLock(); } - return region; + return regions; } export function encodeUltrasoundRegionMeta( - region: UltrasoundRegion + regions: UltrasoundRegions ): [string, string] { - return [US_REGION_META_KEY, JSON.stringify(region)]; + return [US_REGION_META_KEY, JSON.stringify(regions)]; } export function getUltrasoundRegionFromMetadata( meta: ReadonlyArray | null | undefined -): UltrasoundRegion | null { +): UltrasoundRegions | 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; + return JSON.parse(entry[1]) as UltrasoundRegions; } catch { return null; } diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index dd74dbee7..192f8ebf1 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -289,9 +289,20 @@ export default class DicomChunkImage private applyUltrasoundSpacing() { if (this.getModality() !== 'US') return; - const region = getUltrasoundRegionFromMetadata(this.getDicomMetadata()); - if (!region) return; + const regions = getUltrasoundRegionFromMetadata(this.getDicomMetadata()); + if (!regions?.region) return; + + // VTK image data has a single global spacing, so multi-region images + // (e.g. dual-pane B-mode + Doppler) cannot be fully represented. The + // first region's spacing is applied to the whole image; warn so the + // mismatch on additional panes is at least visible in the console. + if (regions.regionCount > 1) { + console.warn( + `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.` + ); + } + const { region } = regions; const xFactor = unitToMm(region.physicalUnitsXDirection); const yFactor = unitToMm(region.physicalUnitsYDirection); if (xFactor === null || yFactor === null) return; From caa53396908cebe7260eb53cbab70f7e4636fcfc Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 4 May 2026 11:00:35 -0400 Subject: [PATCH 5/5] ultrasound: replace JSON-in-tags sidecar with typed channel Stop encoding UltrasoundRegions as a JSON string under a magic key on the DICOM tag pair array and decoding it back. Expose it as a typed property on MetaLoader, surface it on Chunk, and read it directly in DicomChunkImage. The tag pair array now only contains DICOM tag/value pairs. --- src/core/streaming/chunk.ts | 4 ++ .../dicom/__tests__/ultrasoundRegion.spec.ts | 38 ------------------- .../streaming/dicom/dicomFileMetaLoader.ts | 8 ++-- src/core/streaming/dicom/dicomMetaLoader.ts | 4 +- src/core/streaming/dicom/ultrasoundRegion.ts | 21 ---------- src/core/streaming/dicomChunkImage.ts | 7 +--- src/core/streaming/types.ts | 2 + 7 files changed, 13 insertions(+), 71 deletions(-) diff --git a/src/core/streaming/chunk.ts b/src/core/streaming/chunk.ts index 9be70033b..69126de39 100644 --- a/src/core/streaming/chunk.ts +++ b/src/core/streaming/chunk.ts @@ -86,6 +86,10 @@ export class Chunk { return this.metaLoader.metaBlob; } + get ultrasoundRegions() { + return this.metaLoader.ultrasoundRegions ?? null; + } + get dataBlob() { return this.dataLoader.data; } diff --git a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts index fb2b999e7..d9b2afb38 100644 --- a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts +++ b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts @@ -2,11 +2,8 @@ import { describe, it, expect } from 'vitest'; import type { DataElement } from '@/src/core/streaming/dicom/dicomParser'; import { decodeUltrasoundRegion, - encodeUltrasoundRegionMeta, - getUltrasoundRegionFromMetadata, parseUltrasoundRegionFromBlob, unitToMm, - US_REGION_META_KEY, US_UNIT_CENTIMETERS, } from '@/src/core/streaming/dicom/ultrasoundRegion'; @@ -153,41 +150,6 @@ describe('unitToMm', () => { }); }); -describe('encodeUltrasoundRegionMeta / getUltrasoundRegionFromMetadata', () => { - it('round-trips through the metadata tag array', () => { - const regions = { - region: { - physicalDeltaX: 0.05, - physicalDeltaY: 0.1, - physicalUnitsXDirection: US_UNIT_CENTIMETERS, - physicalUnitsYDirection: US_UNIT_CENTIMETERS, - }, - regionCount: 2, - }; - const entry = encodeUltrasoundRegionMeta(regions); - expect(entry[0]).toBe(US_REGION_META_KEY); - - const meta: Array<[string, string]> = [ - ['0008|0060', 'US'], - entry, - ['0010|0010', 'PATIENT^NAME'], - ]; - expect(getUltrasoundRegionFromMetadata(meta)).toEqual(regions); - }); - - it('returns null when the entry is absent', () => { - expect(getUltrasoundRegionFromMetadata([])).toBeNull(); - expect(getUltrasoundRegionFromMetadata(null)).toBeNull(); - expect(getUltrasoundRegionFromMetadata(undefined)).toBeNull(); - }); - - it('returns null when the entry value is unparseable', () => { - expect( - getUltrasoundRegionFromMetadata([[US_REGION_META_KEY, 'not-json']]) - ).toBeNull(); - }); -}); - // Builds a minimal DICOM P10 byte stream containing only what the ultrasound // parser needs: preamble, DICM magic, a TransferSyntaxUID (Explicit VR LE), // and a SequenceOfUltrasoundRegions with one populated item. diff --git a/src/core/streaming/dicom/dicomFileMetaLoader.ts b/src/core/streaming/dicom/dicomFileMetaLoader.ts index 0f765b7ea..ee387420a 100644 --- a/src/core/streaming/dicom/dicomFileMetaLoader.ts +++ b/src/core/streaming/dicom/dicomFileMetaLoader.ts @@ -3,12 +3,13 @@ import { MetaLoader } from '@/src/core/streaming/types'; import { Maybe } from '@/src/types'; import { Tags } from '@/src/core/dicomTags'; import { - encodeUltrasoundRegionMeta, parseUltrasoundRegionFromBlob, + UltrasoundRegions, } from '@/src/core/streaming/dicom/ultrasoundRegion'; export class DicomFileMetaLoader implements MetaLoader { public tags: Maybe>; + public ultrasoundRegions: UltrasoundRegions | null = null; private file: File; constructor( @@ -32,10 +33,7 @@ export class DicomFileMetaLoader implements MetaLoader { const modality = new Map(this.tags).get(Tags.Modality)?.trim(); if (modality === 'US') { - const regions = await parseUltrasoundRegionFromBlob(this.file); - if (regions) { - this.tags.push(encodeUltrasoundRegionMeta(regions)); - } + this.ultrasoundRegions = await parseUltrasoundRegionFromBlob(this.file); } } diff --git a/src/core/streaming/dicom/dicomMetaLoader.ts b/src/core/streaming/dicom/dicomMetaLoader.ts index 38f8a007b..feb10198c 100644 --- a/src/core/streaming/dicom/dicomMetaLoader.ts +++ b/src/core/streaming/dicom/dicomMetaLoader.ts @@ -11,7 +11,6 @@ import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; import { Tags } from '@/src/core/dicomTags'; import { decodeUltrasoundRegion, - encodeUltrasoundRegionMeta, SEQUENCE_OF_ULTRASOUND_REGIONS, UltrasoundRegions, } from '@/src/core/streaming/dicom/ultrasoundRegion'; @@ -34,6 +33,7 @@ export class DicomMetaLoader implements MetaLoader { private fetcher: Fetcher; private readDicomTags: ReadDicomTagsFunction; private blob: Blob | null; + public ultrasoundRegions: UltrasoundRegions | null = null; constructor(fetcher: Fetcher, readDicomTags: ReadDicomTagsFunction) { this.fetcher = fetcher; @@ -131,7 +131,7 @@ export class DicomMetaLoader implements MetaLoader { this.tags = await this.readDicomTags(metadataFile); if (modality === 'US' && ultrasoundRegions) { - this.tags.push(encodeUltrasoundRegionMeta(ultrasoundRegions)); + this.ultrasoundRegions = ultrasoundRegions; } } diff --git a/src/core/streaming/dicom/ultrasoundRegion.ts b/src/core/streaming/dicom/ultrasoundRegion.ts index 06a2adbbd..2b0f1ce45 100644 --- a/src/core/streaming/dicom/ultrasoundRegion.ts +++ b/src/core/streaming/dicom/ultrasoundRegion.ts @@ -4,8 +4,6 @@ import { } from '@/src/core/streaming/dicom/dicomParser'; import { Tags, tagToGroupElement } from '@/src/core/dicomTags'; -export const US_REGION_META_KEY = '__volview_us_region'; - // DICOM unit codes for PhysicalUnitsXDirection / YDirection. // See DICOM PS3.3 C.8.5.5.1.15. The only spatial spacing code defined for // this field is 3 (cm). Other codes (0=none, 1=percent, 2=dB, 4=seconds, @@ -144,22 +142,3 @@ export async function parseUltrasoundRegionFromBlob( return regions; } - -export function encodeUltrasoundRegionMeta( - regions: UltrasoundRegions -): [string, string] { - return [US_REGION_META_KEY, JSON.stringify(regions)]; -} - -export function getUltrasoundRegionFromMetadata( - meta: ReadonlyArray | null | undefined -): UltrasoundRegions | 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 UltrasoundRegions; - } catch { - return null; - } -} diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 192f8ebf1..f671b4f10 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -26,10 +26,7 @@ import { import { ensureError } from '@/src/utils'; import { computed } from 'vue'; import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; -import { - getUltrasoundRegionFromMetadata, - unitToMm, -} from '@/src/core/streaming/dicom/ultrasoundRegion'; +import { unitToMm } from '@/src/core/streaming/dicom/ultrasoundRegion'; const { fastComputeRange } = vtkDataArray; @@ -289,7 +286,7 @@ export default class DicomChunkImage private applyUltrasoundSpacing() { if (this.getModality() !== 'US') return; - const regions = getUltrasoundRegionFromMetadata(this.getDicomMetadata()); + const regions = this.chunks[0]?.ultrasoundRegions ?? null; if (!regions?.region) return; // VTK image data has a single global spacing, so multi-region images diff --git a/src/core/streaming/types.ts b/src/core/streaming/types.ts index d34d5ec3b..a79c26fc4 100644 --- a/src/core/streaming/types.ts +++ b/src/core/streaming/types.ts @@ -1,5 +1,6 @@ import { Maybe } from '@/src/types'; import { Awaitable } from '@vueuse/core'; +import type { UltrasoundRegions } from '@/src/core/streaming/dicom/ultrasoundRegion'; export type LoaderEvents = { error: any; @@ -17,6 +18,7 @@ interface Loader { export interface MetaLoader extends Loader { meta: Maybe>; metaBlob: Maybe; + ultrasoundRegions?: UltrasoundRegions | null; } /**