fix(metadata): stop fabricating display metadata and add DICOM JSON converter
Replace the hand-built metadata subset in parseMeta with a VR-aware DICOM-to-DICOM-JSON converter. Window Center/Width, Rescale Intercept/Slope are only emitted when the source element exists; multi-values are preserved in source order. VOI/LUT, Presentation LUT and Pixel Padding tags are preserved and Pixel Data is excluded. - add src/dimse/dicomJson.ts (dictionary-aware converter) - rewrite src/dimse/parseMeta.ts to use the converter - add synthetic non-PHI DICOM writer and metadata tests - add npm test script (node:test + ts-node) and skipLibCheck
This commit is contained in:
114
tests/dicomJson.test.ts
Normal file
114
tests/dicomJson.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import dicomParser, { Element } from 'dicom-parser';
|
||||
import { convertElementToJson, DicomJsonDataSet, parseDicomToJson } from '../src/dimse/dicomJson';
|
||||
import { buildMonochromeImage, Dataset } from './helpers/dicomWriter';
|
||||
|
||||
const BASE_PIXELS = [0, 1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
function crLike(extra: Dataset = [], bufferExtra: Partial<Parameters<typeof buildMonochromeImage>[0]> = {}) {
|
||||
return buildMonochromeImage({
|
||||
rows: 2,
|
||||
columns: 4,
|
||||
pixelData: BASE_PIXELS,
|
||||
extra,
|
||||
...bufferExtra,
|
||||
});
|
||||
}
|
||||
|
||||
test('omits Window Center/Width when the source tags are absent', () => {
|
||||
const json = parseDicomToJson(crLike());
|
||||
assert.equal(json['00281050'], undefined);
|
||||
assert.equal(json['00281051'], undefined);
|
||||
});
|
||||
|
||||
test('omits Rescale Intercept/Slope when the source tags are absent', () => {
|
||||
const json = parseDicomToJson(crLike());
|
||||
assert.equal(json['00281052'], undefined);
|
||||
assert.equal(json['00281053'], undefined);
|
||||
});
|
||||
|
||||
test('preserves multi-valued Window Center/Width in source order', () => {
|
||||
const json = parseDicomToJson(crLike([
|
||||
{ tag: '00281050', vr: 'DS', value: '40\\400' },
|
||||
{ tag: '00281051', vr: 'DS', value: '80\\800' },
|
||||
]));
|
||||
|
||||
assert.deepEqual(json['00281050'].Value, [40, 400]);
|
||||
assert.deepEqual(json['00281051'].Value, [80, 800]);
|
||||
});
|
||||
|
||||
test('preserves rescale values when present', () => {
|
||||
const json = parseDicomToJson(crLike([
|
||||
{ tag: '00281052', vr: 'DS', value: '-1024' },
|
||||
{ tag: '00281053', vr: 'DS', value: '2' },
|
||||
]));
|
||||
|
||||
assert.deepEqual(json['00281052'].Value, [-1024]);
|
||||
assert.deepEqual(json['00281053'].Value, [2]);
|
||||
});
|
||||
|
||||
test('preserves VOI, LUT, presentation LUT and pixel padding metadata', () => {
|
||||
const json = parseDicomToJson(crLike([
|
||||
{ tag: '00281055', vr: 'LO', value: 'WINDOW' },
|
||||
{ tag: '00281056', vr: 'CS', value: 'LINEAR' },
|
||||
{ tag: '20500020', vr: 'CS', value: 'IDENTITY' },
|
||||
{ tag: '00280120', vr: 'US', value: 5 },
|
||||
{ tag: '00280121', vr: 'US', value: 10 },
|
||||
{
|
||||
tag: '00283010',
|
||||
vr: 'SQ',
|
||||
items: [
|
||||
[
|
||||
{ tag: '00283002', vr: 'US', value: [256, 0, 8] },
|
||||
{ tag: '00283006', vr: 'US', value: [1, 2, 3] },
|
||||
],
|
||||
],
|
||||
},
|
||||
]));
|
||||
|
||||
assert.deepEqual(json['00281055'].Value, ['WINDOW']);
|
||||
assert.deepEqual(json['00281056'].Value, ['LINEAR']);
|
||||
assert.deepEqual(json['20500020'].Value, ['IDENTITY']);
|
||||
assert.deepEqual(json['00280120'].Value, [5]);
|
||||
assert.deepEqual(json['00280121'].Value, [10]);
|
||||
|
||||
const sequence = json['00283010'];
|
||||
assert.equal(sequence.vr, 'SQ');
|
||||
const item = (sequence.Value as DicomJsonDataSet[])[0];
|
||||
assert.deepEqual(item['00283002'].Value, [256, 0, 8]);
|
||||
assert.deepEqual(item['00283006'].Value, [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('never emits Value: [undefined] for absent optional tags', () => {
|
||||
const json = parseDicomToJson(crLike());
|
||||
for (const [tag, element] of Object.entries(json)) {
|
||||
if (Array.isArray(element.Value)) {
|
||||
for (const value of element.Value) {
|
||||
assert.notEqual(value, undefined, `${tag} contains an undefined value`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('excludes Pixel Data from metadata output', () => {
|
||||
const json = parseDicomToJson(crLike());
|
||||
assert.equal(json['7FE00010'], undefined);
|
||||
});
|
||||
|
||||
test('falls back to the data dictionary when VR is not explicit', () => {
|
||||
const buffer = crLike([
|
||||
{ tag: '00281050', vr: 'DS', value: '40' },
|
||||
]);
|
||||
const dataSet = dicomParser.parseDicom(buffer);
|
||||
const explicit = dataSet.elements.x00281050;
|
||||
const implicitLike: Element = {
|
||||
tag: explicit.tag,
|
||||
length: explicit.length,
|
||||
dataOffset: explicit.dataOffset,
|
||||
};
|
||||
|
||||
const json = convertElementToJson(dataSet, implicitLike);
|
||||
assert.equal(json.vr, 'DS');
|
||||
assert.deepEqual(json.Value, [40]);
|
||||
});
|
||||
174
tests/helpers/dicomWriter.ts
Normal file
174
tests/helpers/dicomWriter.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Minimal, dependency-free DICOM Part 10 writer used to build synthetic,
|
||||
* non-PHI fixtures for unit tests. Only the explicit VR little endian
|
||||
* transfer syntax is produced, which is enough to exercise the metadata
|
||||
* converter and native frame extraction.
|
||||
*/
|
||||
|
||||
export interface VrElement {
|
||||
/** Tag in 'ggggeeee' hex form, e.g. '00281050'. */
|
||||
tag: string;
|
||||
vr: string;
|
||||
value?: string | number | number[] | Buffer;
|
||||
/** Sequence items, only used when vr === 'SQ'. Each item is itself a dataset. */
|
||||
items?: Dataset[];
|
||||
}
|
||||
|
||||
export type Dataset = VrElement[];
|
||||
|
||||
const LONG_VRS = new Set(['OB', 'OD', 'OF', 'OL', 'OV', 'OW', 'SQ', 'SV', 'UC', 'UN', 'UR', 'UT', 'UV']);
|
||||
|
||||
const NUMERIC_VRS: Record<string, { width: number; write: (buf: Buffer, value: number, offset: number) => void }> = {
|
||||
US: { width: 2, write: (b, v, o) => b.writeUInt16LE(v, o) },
|
||||
SS: { width: 2, write: (b, v, o) => b.writeInt16LE(v, o) },
|
||||
UL: { width: 4, write: (b, v, o) => b.writeUInt32LE(v, o) },
|
||||
SL: { width: 4, write: (b, v, o) => b.writeInt32LE(v, o) },
|
||||
FL: { width: 4, write: (b, v, o) => b.writeFloatLE(v, o) },
|
||||
FD: { width: 8, write: (b, v, o) => b.writeDoubleLE(v, o) },
|
||||
};
|
||||
|
||||
function paddingByte(vr: string): number {
|
||||
return vr === 'UI' ? 0x00 : 0x20;
|
||||
}
|
||||
|
||||
function encodeValue(element: VrElement): Buffer {
|
||||
const { vr, value } = element;
|
||||
|
||||
if (vr === 'SQ') {
|
||||
const items = Array.isArray(element.items) ? element.items : [];
|
||||
return Buffer.concat(items.map(encodeItem));
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (NUMERIC_VRS[vr]) {
|
||||
const numbers = Array.isArray(value) ? value : [value as number];
|
||||
const { width, write } = NUMERIC_VRS[vr];
|
||||
const buffer = Buffer.alloc(numbers.length * width);
|
||||
numbers.forEach((number, index) => write(buffer, number, index * width));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const text = Array.isArray(value) ? value.join('\\') : String(value);
|
||||
return Buffer.from(text, 'ascii');
|
||||
}
|
||||
|
||||
function encodeItem(dataset: Dataset): Buffer {
|
||||
const body = encodeDataset(dataset);
|
||||
const header = Buffer.alloc(8);
|
||||
header.writeUInt16LE(0xfffe, 0);
|
||||
header.writeUInt16LE(0xe000, 2);
|
||||
header.writeUInt32LE(body.length, 4);
|
||||
return Buffer.concat([header, body]);
|
||||
}
|
||||
|
||||
export function encodeElement(element: VrElement): Buffer {
|
||||
const group = Number.parseInt(element.tag.substring(0, 4), 16);
|
||||
const elementNumber = Number.parseInt(element.tag.substring(4, 8), 16);
|
||||
|
||||
let value = encodeValue(element);
|
||||
if (value.length % 2 !== 0) {
|
||||
value = Buffer.concat([value, Buffer.from([paddingByte(element.vr)])]);
|
||||
}
|
||||
|
||||
const isLong = LONG_VRS.has(element.vr);
|
||||
const header = Buffer.alloc(isLong ? 12 : 8);
|
||||
header.writeUInt16LE(group, 0);
|
||||
header.writeUInt16LE(elementNumber, 2);
|
||||
header.write(element.vr, 4, 2, 'ascii');
|
||||
if (isLong) {
|
||||
header.writeUInt32LE(value.length, 8);
|
||||
}
|
||||
else {
|
||||
header.writeUInt16LE(value.length, 6);
|
||||
}
|
||||
return Buffer.concat([header, value]);
|
||||
}
|
||||
|
||||
export function encodeDataset(dataset: Dataset): Buffer {
|
||||
return Buffer.concat(dataset.map(encodeElement));
|
||||
}
|
||||
|
||||
export interface Part10Options {
|
||||
transferSyntaxUID?: string;
|
||||
sopClassUID?: string;
|
||||
sopInstanceUID?: string;
|
||||
}
|
||||
|
||||
export function buildPart10(dataset: Dataset, options: Part10Options = {}): Buffer {
|
||||
const transferSyntaxUID = options.transferSyntaxUID ?? '1.2.840.10008.1.2.1';
|
||||
const sopClassUID = options.sopClassUID ?? '1.2.840.10008.5.1.4.1.1.7';
|
||||
const sopInstanceUID = options.sopInstanceUID ?? '1.2.826.0.1.3680043.8.498.1';
|
||||
|
||||
const metaElements: Dataset = [
|
||||
{ tag: '00020001', vr: 'OB', value: Buffer.from([0x00, 0x01]) },
|
||||
{ tag: '00020002', vr: 'UI', value: sopClassUID },
|
||||
{ tag: '00020003', vr: 'UI', value: sopInstanceUID },
|
||||
{ tag: '00020010', vr: 'UI', value: transferSyntaxUID },
|
||||
{ tag: '00020012', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.1' },
|
||||
];
|
||||
const metaBody = encodeDataset(metaElements);
|
||||
const groupLength = encodeElement({ tag: '00020000', vr: 'UL', value: metaBody.length });
|
||||
|
||||
const preamble = Buffer.alloc(128);
|
||||
const magic = Buffer.from('DICM', 'ascii');
|
||||
const datasetBytes = encodeDataset(dataset);
|
||||
|
||||
return Buffer.concat([preamble, magic, groupLength, metaBody, datasetBytes]);
|
||||
}
|
||||
|
||||
/** Build a synthetic single or multi frame MONOCHROME2 image. */
|
||||
export function buildMonochromeImage(options: {
|
||||
rows: number;
|
||||
columns: number;
|
||||
bitsAllocated?: number;
|
||||
samplesPerPixel?: number;
|
||||
numberOfFrames?: number;
|
||||
pixelData: number[];
|
||||
extra?: Dataset;
|
||||
transferSyntaxUID?: string;
|
||||
photometricInterpretation?: string;
|
||||
}): Buffer {
|
||||
const bitsAllocated = options.bitsAllocated ?? 8;
|
||||
const samplesPerPixel = options.samplesPerPixel ?? 1;
|
||||
const dataset: Dataset = [
|
||||
{ tag: '00080016', vr: 'UI', value: '1.2.840.10008.5.1.4.1.1.7' },
|
||||
{ tag: '00080018', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.1' },
|
||||
{ tag: '00080060', vr: 'CS', value: 'CR' },
|
||||
{ tag: '0020000D', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.100' },
|
||||
{ tag: '0020000E', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.200' },
|
||||
{ tag: '00280002', vr: 'US', value: samplesPerPixel },
|
||||
{ tag: '00280004', vr: 'CS', value: options.photometricInterpretation ?? 'MONOCHROME2' },
|
||||
{ tag: '00280010', vr: 'US', value: options.rows },
|
||||
{ tag: '00280011', vr: 'US', value: options.columns },
|
||||
{ tag: '00280100', vr: 'US', value: bitsAllocated },
|
||||
{ tag: '00280101', vr: 'US', value: bitsAllocated },
|
||||
{ tag: '00280102', vr: 'US', value: bitsAllocated - 1 },
|
||||
{ tag: '00280103', vr: 'US', value: 0 },
|
||||
];
|
||||
|
||||
if (options.numberOfFrames !== undefined) {
|
||||
dataset.push({ tag: '00280008', vr: 'IS', value: String(options.numberOfFrames) });
|
||||
}
|
||||
|
||||
if (options.extra) {
|
||||
dataset.push(...options.extra);
|
||||
}
|
||||
|
||||
const pixelData = bitsAllocated <= 8
|
||||
? Buffer.from(options.pixelData)
|
||||
: (() => {
|
||||
const buffer = Buffer.alloc(options.pixelData.length * 2);
|
||||
options.pixelData.forEach((value, index) => buffer.writeUInt16LE(value, index * 2));
|
||||
return buffer;
|
||||
})();
|
||||
dataset.push({ tag: '7FE00010', vr: bitsAllocated <= 8 ? 'OB' : 'OW', value: pixelData });
|
||||
|
||||
return buildPart10(dataset, { transferSyntaxUID: options.transferSyntaxUID });
|
||||
}
|
||||
Reference in New Issue
Block a user