From 0c680b680f246f02769911d7001c19f72ce8c7fc Mon Sep 17 00:00:00 2001 From: padmanto Date: Fri, 18 Sep 2026 14:16:11 +0700 Subject: [PATCH] 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 --- package.json | 1 + src/dimse/dicomJson.ts | 286 +++++++++++++++++++++++++++++++++++ src/dimse/parseMeta.ts | 149 +++--------------- tests/dicomJson.test.ts | 114 ++++++++++++++ tests/helpers/dicomWriter.ts | 174 +++++++++++++++++++++ tsconfig.json | 1 + 6 files changed, 595 insertions(+), 130 deletions(-) create mode 100644 src/dimse/dicomJson.ts create mode 100644 tests/dicomJson.test.ts create mode 100644 tests/helpers/dicomWriter.ts diff --git a/package.json b/package.json index 8dd06d5..998e4f8 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "start": "ts-node src/app.ts", "format": "npx prettier ./src", "build": "tsc --p ./tsconfig.json", + "test": "node --test -r ts-node/register/transpile-only tests/*.test.ts", "pkg:win": "npm run build && npx pkg --targets node16-win-x64 package.json" }, "repository": { diff --git a/src/dimse/dicomJson.ts b/src/dimse/dicomJson.ts new file mode 100644 index 0000000..35556a2 --- /dev/null +++ b/src/dimse/dicomJson.ts @@ -0,0 +1,286 @@ +import dicomParser, { DataSet, Element, readFixedString } from 'dicom-parser'; +import { get_element } from '@iwharris/dicom-data-dictionary'; + +/** + * Minimal DICOM JSON model as defined by DICOM PS3.18 F. + * See https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_F.2.html + */ +export interface DicomJsonElement { + vr: string; + Value?: unknown[]; + InlineBinary?: string; + BulkDataURI?: string; +} + +export type DicomJsonDataSet = Record; + +export interface DicomJsonOptions { + /** + * Include private (odd group) data elements. Defaults to false to keep + * responses small and to avoid leaking site-specific private data. + */ + includePrivate?: boolean; +} + +/** String VRs that map to a JSON array of strings. */ +const STRING_VRS = new Set([ + 'AE', 'AS', 'CS', 'DA', 'DT', 'LO', 'LT', 'SH', 'ST', 'TM', 'UC', 'UI', 'UR', 'UT', +]); + +/** Numeric VRs and the byte-array parser reader used for each. */ +const NUMBER_VRS: Record = { + US: 'readUint16', + SS: 'readInt16', + UL: 'readUint32', + SL: 'readInt32', + FL: 'readFloat', + FD: 'readDouble', +}; + +/** VRs whose payload is binary and must be encoded as InlineBinary/BulkDataURI. */ +const BINARY_VRS = new Set(['OB', 'OW', 'OF', 'OD', 'OL', 'OV', 'UN']); + +/** Pixel data and float pixel data are never part of the metadata response. */ +const EXCLUDED_TAGS = new Set(['7FE00010', '7FE00008', '7FE00009']); + +function formatKey(key: string): string { + return key.replace(/^x/i, '').toUpperCase(); +} + +function groupOf(tag: string): number { + return parseInt(tag.substring(0, 4), 16); +} + +function isPrivateTag(tag: string): boolean { + return (groupOf(tag) & 0x0001) === 1; +} + +function isExcludedTag(tag: string): boolean { + if (groupOf(tag) === 0x0002) { + return true; + } + if (EXCLUDED_TAGS.has(tag)) { + return true; + } + // Overlay data lives in groups 6000-60FF, element 3000. + const group = groupOf(tag); + if (group >= 0x6000 && group <= 0x60ff && tag.substring(4) === '3000') { + return true; + } + return false; +} + +function resolveAmbiguousVr(vr: string, tag: string, dataSet: DataSet): string { + const options = vr.split(/\s+or\s+/i).map((v) => v.trim().toUpperCase()); + if (options.length === 1) { + return options[0]; + } + // Pixel Padding Value/Range Limit are US or SS depending on PixelRepresentation. + if ((tag === '00280120' || tag === '00280121') && options.includes('US') && options.includes('SS')) { + const pixelRepresentation = dataSet.uint16('x00280103'); + return pixelRepresentation === 1 ? 'SS' : 'US'; + } + return options[0]; +} + +function resolveVr(element: Element, tag: string, dataSet: DataSet): string { + let vr = element.vr; + if (!vr) { + const dictionaryEntry = get_element(tag); + if (dictionaryEntry) { + vr = dictionaryEntry.vr; + } + } + vr = (vr || 'UN').toUpperCase(); + if (/\bor\b/i.test(vr)) { + vr = resolveAmbiguousVr(vr, tag, dataSet); + } + return vr; +} + +function byteArrayParserFor(element: Element, dataSet: DataSet) { + return element.parser ?? dataSet.byteArrayParser; +} + +function readBinary(dataSet: DataSet, element: Element): Buffer { + const byteArray = dataSet.byteArray; + if (Buffer.isBuffer(byteArray)) { + return byteArray.subarray(element.dataOffset, element.dataOffset + element.length); + } + return Buffer.from(byteArray.buffer, byteArray.byteOffset + element.dataOffset, element.length); +} + +function stripTrailingNul(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 0) { + end -= 1; + } + return value.slice(0, end); +} + +function readStringValues(dataSet: DataSet, element: Element): string[] { + const raw = readFixedString(dataSet.byteArray, element.dataOffset, element.length); + const padded = stripTrailingNul(raw).replace(/ +$/g, ''); + if (padded.length === 0) { + return []; + } + return padded.split('\\').map((value) => value.replace(/ +$/g, '')); +} + +function parsePersonName(value: string): Record { + const [alphabetic, ideographic, phonetic] = value.split('='); + const personName: Record = {}; + if (alphabetic) { + personName.Alphabetic = alphabetic; + } + if (ideographic) { + personName.Ideographic = ideographic; + } + if (phonetic) { + personName.Phonetic = phonetic; + } + return personName; +} + +function readNumericValues(dataSet: DataSet, element: Element, vr: string): number[] { + const readerName = NUMBER_VRS[vr]; + const reader = byteArrayParserFor(element, dataSet)[readerName]; + const width = readerName === 'readFloat' || readerName === 'readInt32' || readerName === 'readUint32' + ? 4 + : readerName === 'readDouble' + ? 8 + : 2; + const count = Math.floor(element.length / width); + const values: number[] = []; + for (let i = 0; i < count; i += 1) { + values.push(reader(dataSet.byteArray, element.dataOffset + i * width)); + } + return values; +} + +function readAttributeTags(dataSet: DataSet, element: Element): string[] { + const parser = byteArrayParserFor(element, dataSet); + const count = Math.floor(element.length / 4); + const values: string[] = []; + for (let i = 0; i < count; i += 1) { + const offset = element.dataOffset + i * 4; + const group = parser.readUint16(dataSet.byteArray, offset); + const elementNumber = parser.readUint16(dataSet.byteArray, offset + 2); + values.push( + `${ + group.toString(16).padStart(4, '0') + }${ + elementNumber.toString(16).padStart(4, '0')}`.toUpperCase(), + ); + } + return values; +} + +function readDecimalStrings(dataSet: DataSet, element: Element): unknown[] { + return readStringValues(dataSet, element).map((value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; + }); +} + +function readIntegerStrings(dataSet: DataSet, element: Element): unknown[] { + return readStringValues(dataSet, element).map((value) => { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : value; + }); +} + +function convertSequence(dataSet: DataSet, element: Element, options: DicomJsonOptions): DicomJsonElement { + const items: DicomJsonDataSet[] = []; + if (element.items) { + for (const item of element.items) { + if (item.dataSet) { + items.push(convertDataSetToJson(item.dataSet, options)); + } + } + } + return { vr: 'SQ', Value: items }; +} + +/** + * Convert a parsed DICOM dataset into DICOM JSON (PS3.18 F). + * + * The conversion is VR aware: it uses the explicit VR stored on the element + * when available and falls back to the DICOM data dictionary otherwise. Absent + * attributes are never synthesized, so a source file without Window Center or + * Rescale tags will not gain fabricated values. + */ +export function convertElementToJson( + dataSet: DataSet, + element: Element, + options: DicomJsonOptions = {}, +): DicomJsonElement { + const tag = formatKey(element.tag); + const vr = resolveVr(element, tag, dataSet); + + if (vr === 'SQ') { + return convertSequence(dataSet, element, options); + } + + if (element.length === 0) { + return { vr, Value: [] }; + } + + if (vr === 'PN') { + return { vr, Value: readStringValues(dataSet, element).map(parsePersonName) }; + } + + if (vr === 'DS') { + return { vr, Value: readDecimalStrings(dataSet, element) }; + } + + if (vr === 'IS') { + return { vr, Value: readIntegerStrings(dataSet, element) }; + } + + if (vr === 'AT') { + return { vr, Value: readAttributeTags(dataSet, element) }; + } + + if (NUMBER_VRS[vr]) { + return { vr, Value: readNumericValues(dataSet, element, vr) }; + } + + if (STRING_VRS.has(vr)) { + return { vr, Value: readStringValues(dataSet, element) }; + } + + if (BINARY_VRS.has(vr)) { + return { vr, InlineBinary: readBinary(dataSet, element).toString('base64') }; + } + + // Unknown VR: fall back to an inline binary payload rather than guessing. + return { vr, InlineBinary: readBinary(dataSet, element).toString('base64') }; +} + +export function convertDataSetToJson( + dataSet: DataSet, + options: DicomJsonOptions = {}, +): DicomJsonDataSet { + const result: DicomJsonDataSet = {}; + for (const key of Object.keys(dataSet.elements)) { + const element = dataSet.elements[key]; + const tag = formatKey(key); + if (isExcludedTag(tag)) { + continue; + } + if (!options.includePrivate && isPrivateTag(tag)) { + continue; + } + result[tag] = convertElementToJson(dataSet, element, options); + } + return result; +} + +export function parseDicomToJson( + buffer: Buffer | Uint8Array, + options: DicomJsonOptions = {}, +): DicomJsonDataSet { + const dataSet = dicomParser.parseDicom(buffer); + return convertDataSetToJson(dataSet, options); +} diff --git a/src/dimse/parseMeta.ts b/src/dimse/parseMeta.ts index 8c87c53..744d70e 100644 --- a/src/dimse/parseMeta.ts +++ b/src/dimse/parseMeta.ts @@ -1,144 +1,33 @@ import { LoggerSingleton } from '../utils/logger'; import { ConfParams, config } from '../utils/config'; import { fileExists } from '../utils/fileHelper'; -import dicomParser from 'dicom-parser'; import fs from 'fs'; import path from 'path'; +import { DicomJsonDataSet, parseDicomToJson } from './dicomJson'; -interface ValueType { - Value: string[] | number[] | unknown[]; - vr: string; -} -type ElementType = Record; - -function parseFile(filename: string): Promise { +function parseFile(filename: string): Promise { const logger = LoggerSingleton.Instance; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { fileExists(filename).then((success: boolean) => { if (!success) { logger.error(`file does not exist: ${filename}`); - return reject(); + return reject(new Error(`file does not exist: ${filename}`)); } - fs.promises.readFile(filename).then((data: Uint8Array) => { - const dataset = dicomParser.parseDicom(data); - - // parse additional needed attributes - const patientName = dataset.string('x00100010'); - const patentID = dataset.string('x00100020'); - const studyInstanceUID = dataset.string('x0020000d'); - const studyDate = dataset.string('x00080020'); - const studyTime = dataset.string('x00080030'); - const seriesInstanceUID = dataset.string('x0020000e'); - const seriesNumber = dataset.string('x00200011'); - const sopInstanceUID = dataset.string('x00080018'); - const sopClassUID = dataset.string('x00080016'); - const bitsAllocated = dataset.uint16('x00280100'); - const bitsStored = dataset.uint16('x00280101'); - const highBit = dataset.uint16('x00280102'); - const rows = dataset.uint16('x00280010'); - const cols = dataset.uint16('x00280011'); - const pixelSpacingString = dataset.string('x00280030'); - const pixelSpacing = pixelSpacingString ? pixelSpacingString.split('\\').map((e: string) => parseFloat(e)) : [1, 1]; - const modality = dataset.string('x00080060'); - const samplesPerPixel = dataset.uint16('x00280002'); - const photometricInterpretation = dataset.string('x00280004'); - const pixelRepresentation = dataset.uint16('x00280103'); - const windowCenter = dataset.string('x00281050'); - const wc = windowCenter ? parseFloat(windowCenter.split('\\')[0]) : 40; - const windowWidth = dataset.string('x00281051'); - const ww = windowWidth ? parseFloat(windowWidth.split('\\')[0]) : 80; - const rescaleIntercept = parseFloat(dataset.string('x00281052') || '1'); - const rescaleSlope = parseFloat(dataset.string('x00281053') || '1'); - const iopString = dataset.string('x00200037'); - const iop = iopString ? iopString.split('\\').map((e: string) => parseFloat(e)) : null; - const ippString = dataset.string('x00200032'); - const ipp = ippString ? ippString.split('\\').map((e: string) => parseFloat(e)) : null; - const instanceNumber = dataset.string('x00200013'); - const sliceThickness = dataset.string('x00180050'); - const sliceLocation = dataset.string('x00201041'); - - // TODO: buat ini dynamic berdasarkan includefields atau modality - // TODO: hapus tambahan di bawah ini yang tidak diperlukan - - // MR-specific tags for overlay - const spacingBetweenSlices = dataset.string('x00180088'); - const percentPhaseFieldOfView = dataset.string('x00180094'); - const fovDimensionElement = dataset.elements.x00181149; - const fovDimension = fovDimensionElement ? [dataset.uint16('x00181149', 0), dataset.uint16('x00181149', 1)] : null; - const acquisitionMatrixElement = dataset.elements.x00181310; - const acquisitionMatrix = acquisitionMatrixElement - ? [dataset.uint16('x00181310', 0), dataset.uint16('x00181310', 1), dataset.uint16('x00181310', 2), dataset.uint16('x00181310', 3)] - : null; - const scanningSequence = dataset.string('x00180020'); - const repetitionTime = dataset.string('x00180080'); - const echoTime = dataset.string('x00180081'); - const inversionTime = dataset.string('x00180082'); - const receiveCoilName = dataset.string('x00181250'); - const mrAcquisitionType = dataset.string('x00180023'); - const phaseEncodingDirection = dataset.string('x00181312'); - const numOfAverages = dataset.string('x00180083'); - const echoTrainLength = dataset.string('x00180091'); - const flipAngle = dataset.string('x00181314'); - const pixelBandwidth = dataset.string('x00180095'); - const acquisitionTime = dataset.string('x00080032'); - const acquistionDurationTotal = dataset.string('x00189073'); // in seconds - const acquistionDurationPerFrame = dataset.string('x00189220'); // in ms - const parallelAcquisitionTechnique = dataset.string('x00189078'); - - // append to all results - const result: ElementType = { - '00100010': { Value: [{ Alphabetic: patientName }], vr: 'PN' }, - '00100020': { Value: [patentID], vr: 'LO' }, - '0020000D': { Value: [studyInstanceUID], vr: 'UI' }, - '00080020': { Value: [studyDate], vr: 'DA' }, - '00080030': { Value: [studyTime], vr: 'TM' }, - '0020000E': { Value: [seriesInstanceUID], vr: 'UI' }, - '00200011': { Value: [seriesNumber], vr: 'IS' }, - '00080018': { Value: [sopInstanceUID], vr: 'UI' }, - '00080016': { Value: [sopClassUID], vr: 'UI' }, - '00080060': { Value: [modality], vr: 'CS' }, - '00280002': { Value: [samplesPerPixel], vr: 'US' }, - '00280004': { Value: [photometricInterpretation], vr: 'CS' }, - '00280010': { Value: [rows], vr: 'US' }, - '00280011': { Value: [cols], vr: 'US' }, - '00280030': { Value: pixelSpacing, vr: 'DS' }, - '00280100': { Value: [bitsAllocated], vr: 'US' }, - '00280101': { Value: [bitsStored], vr: 'US' }, - '00280102': { Value: [highBit], vr: 'US' }, - '00280103': { Value: [pixelRepresentation], vr: 'US' }, - '00281050': { Value: [wc], vr: 'DS' }, - '00281051': { Value: [ww], vr: 'DS' }, - '00281052': { Value: [rescaleIntercept], vr: 'DS' }, - '00281053': { Value: [rescaleSlope], vr: 'DS' }, - ...(iop && { '00200037': { Value: iop, vr: 'DS' } }), - ...(ipp && { '00200032': { Value: ipp, vr: 'DS' } }), - '00200013': { Value: [instanceNumber], vr: 'IS' }, - '00180050': { Value: [sliceThickness], vr: 'DS' }, - '00201041': { Value: [sliceLocation], vr: 'DS' }, - '00180088': { Value: [spacingBetweenSlices], vr: 'DS' }, - '00180094': { Value: [percentPhaseFieldOfView], vr: 'DS' }, - ...(fovDimension && { '00181149': { Value: fovDimension, vr: 'IS' } }), - ...(acquisitionMatrix && { '00181310': { Value: acquisitionMatrix, vr: 'US' } }), - '00180020': { Value: [scanningSequence], vr: 'CS' }, - '00180080': { Value: [repetitionTime], vr: 'DS' }, - '00180081': { Value: [echoTime], vr: 'DS' }, - '00180082': { Value: [inversionTime], vr: 'DS' }, - '00181250': { Value: [receiveCoilName], vr: 'SH' }, - '00180023': { Value: [mrAcquisitionType], vr: 'CS' }, - '00181312': { Value: [phaseEncodingDirection], vr: 'CS' }, - '00180083': { Value: [numOfAverages], vr: 'DS' }, - '00180091': { Value: [echoTrainLength], vr: 'IS' }, - '00181314': { Value: [flipAngle], vr: 'DS' }, - '00180095': { Value: [pixelBandwidth], vr: 'DS' }, - '00080032': { Value: [acquisitionTime], vr: 'TM' }, - '00189073': { Value: [acquistionDurationTotal], vr: 'DS' }, - '00189220': { Value: [acquistionDurationPerFrame], vr: 'FD' }, - '00189078': { Value: [parallelAcquisitionTechnique], vr: 'CS' }, - }; - - resolve(result); - }); + fs.promises.readFile(filename) + .then((data: Uint8Array) => { + try { + resolve(parseDicomToJson(data)); + } + catch (error) { + logger.error(`failed to parse metadata from ${filename}: ${error}`); + reject(error); + } + }) + .catch((error: unknown) => { + logger.error(`failed to read ${filename}: ${error}`); + reject(error); + }); }); }); } @@ -147,7 +36,7 @@ export function parseMeta(json: object, studyInstanceUID: string, seriesInstance const logger = LoggerSingleton.Instance; logger.info(`parsing series ${seriesInstanceUID}`); - const parsing = new Array>(); + const parsing = new Array>(); const storagePath = config.get(ConfParams.STORAGE_PATH) as string; for (const [key] of Object.entries(json)) { const sopInstanceUid = json[key]['00080018'].Value[0]; diff --git a/tests/dicomJson.test.ts b/tests/dicomJson.test.ts new file mode 100644 index 0000000..fb6e1f4 --- /dev/null +++ b/tests/dicomJson.test.ts @@ -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[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]); +}); diff --git a/tests/helpers/dicomWriter.ts b/tests/helpers/dicomWriter.ts new file mode 100644 index 0000000..6ee373e --- /dev/null +++ b/tests/helpers/dicomWriter.ts @@ -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 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 }); +} diff --git a/tsconfig.json b/tsconfig.json index df8e01d..c5f48ea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -69,6 +69,7 @@ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ + "skipLibCheck": true, /* Skip type checking of declaration files from dependencies. */ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */