Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/core/streaming/dicom/dicomFileMetaLoader.ts
Original file line number Diff line number Diff line change
@@ -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<Array<[string, string]>>;
Expand All @@ -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() {
Expand Down
14 changes: 14 additions & 0 deletions src/core/streaming/dicom/dicomMetaLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
},
});

Expand Down Expand Up @@ -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() {
Expand Down
128 changes: 128 additions & 0 deletions src/core/streaming/dicom/ultrasoundRegion.ts
Original file line number Diff line number Diff line change
@@ -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<UltrasoundRegion | null> {
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<readonly [string, string]> | 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;
}
}
26 changes: 26 additions & 0 deletions src/core/streaming/dicomChunkImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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() {
Expand Down
8 changes: 8 additions & 0 deletions tests/specs/configTestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
92 changes: 92 additions & 0 deletions tests/specs/ultrasound-spacing.e2e.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading