Skip to content
12 changes: 12 additions & 0 deletions src/core/dicomTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
};
4 changes: 4 additions & 0 deletions src/core/streaming/chunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export class Chunk {
return this.metaLoader.metaBlob;
}

get ultrasoundRegions() {
return this.metaLoader.ultrasoundRegions;
}

get dataBlob() {
return this.dataLoader.data;
}
Expand Down
219 changes: 219 additions & 0 deletions src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { describe, it, expect } from 'vitest';
import type { DataElement } from '@/src/core/streaming/dicom/dicomParser';
import {
decodeUltrasoundRegion,
parseUltrasoundRegionFromBlob,
unitToMm,
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 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 no region with zero count when the sequence is empty', () => {
expect(decodeUltrasoundRegion([])).toEqual({ regionCount: 0 });
});

it('returns no region with zero count when the data is not a sequence', () => {
expect(decodeUltrasoundRegion(undefined)).toEqual({ regionCount: 0 });
expect(decodeUltrasoundRegion(new Uint8Array(4))).toEqual({
regionCount: 0,
});
});

it('returns no 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]))).toEqual({
regionCount: 1,
});
});

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 result = decodeUltrasoundRegion(
fakeSequenceData([wellFormedItem, second])
);
expect(result.region?.physicalDeltaX).toBe(0.05);
expect(result.regionCount).toBe(2);
});
});

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=cm³,
// 11=cm³/sec, 12=degrees.
[0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12].forEach((code) => {
expect(unitToMm(code)).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 and count from a synthetic DICOM blob', async () => {
const blob = buildDicomBlob(wellFormedItem);
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,
});
});

it('returns undefined 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)).toBeUndefined();
});
});
11 changes: 11 additions & 0 deletions src/core/streaming/dicom/dicomFileMetaLoader.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
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 {
parseUltrasoundRegionFromBlob,
UltrasoundRegions,
} from '@/src/core/streaming/dicom/ultrasoundRegion';

export class DicomFileMetaLoader implements MetaLoader {
public tags: Maybe<Array<[string, string]>>;
public ultrasoundRegions: UltrasoundRegions | undefined;
private file: File;

constructor(
Expand All @@ -24,6 +30,11 @@ 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') {
this.ultrasoundRegions = await parseUltrasoundRegionFromBlob(this.file);
}
}

stop() {
Expand Down
24 changes: 24 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,
SEQUENCE_OF_ULTRASOUND_REGIONS,
UltrasoundRegions,
} from '@/src/core/streaming/dicom/ultrasoundRegion';

export type ReadDicomTagsFunction = (
file: File
Expand All @@ -28,6 +33,7 @@ export class DicomMetaLoader implements MetaLoader {
private fetcher: Fetcher;
private readDicomTags: ReadDicomTagsFunction;
private blob: Blob | null;
public ultrasoundRegions: UltrasoundRegions | undefined;

constructor(fetcher: Fetcher, readDicomTags: ReadDicomTagsFunction) {
this.fetcher = fetcher;
Expand All @@ -51,6 +57,7 @@ export class DicomMetaLoader implements MetaLoader {
let explicitVr = true;
let dicomUpToPixelDataIdx = -1;
let modality: string | undefined;
let ultrasoundRegions: UltrasoundRegions | undefined;

const parse = createDicomParser({
stopAtElement(group, element) {
Expand All @@ -66,6 +73,19 @@ 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] &&
!ultrasoundRegions
) {
// Decoding can throw if a malformed FD/US value has an unexpected
// length; swallow rather than abort the whole metadata load.
try {
ultrasoundRegions = decodeUltrasoundRegion(el.data);
} catch (err) {
console.warn('Failed to decode SequenceOfUltrasoundRegions:', err);
}
}
},
});

Expand Down Expand Up @@ -115,6 +135,10 @@ export class DicomMetaLoader implements MetaLoader {

const metadataFile = new File([validPixelDataBlob], 'file.dcm');
this.tags = await this.readDicomTags(metadataFile);

if (modality === 'US' && ultrasoundRegions) {
this.ultrasoundRegions = ultrasoundRegions;
}
}

stop() {
Expand Down
Loading
Loading