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); + } + }); +});