fix: agar bisa MPR di OHIF #5

Open
mario wants to merge 3 commits from feature/dicomweb-proxy-recommendation into phkg
6 changed files with 595 additions and 130 deletions
Showing only changes of commit 0c680b680f - Show all commits

View File

@@ -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": {

286
src/dimse/dicomJson.ts Normal file
View File

@@ -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<string, DicomJsonElement>;
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<string, 'readUint16' | 'readInt16' | 'readUint32' | 'readInt32' | 'readFloat' | 'readDouble'> = {
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<string, string> {
const [alphabetic, ideographic, phonetic] = value.split('=');
const personName: Record<string, string> = {};
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);
}

View File

@@ -1,143 +1,32 @@
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<string, ValueType>;
function parseFile(filename: string): Promise<ElementType> {
function parseFile(filename: string): Promise<DicomJsonDataSet> {
const logger = LoggerSingleton.Instance;
return new Promise<ElementType>((resolve, reject) => {
return new Promise<DicomJsonDataSet>((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<Promise<ElementType>>();
const parsing = new Array<Promise<DicomJsonDataSet>>();
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
for (const [key] of Object.entries(json)) {
const sopInstanceUid = json[key]['00080018'].Value[0];

114
tests/dicomJson.test.ts Normal file
View 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]);
});

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

View File

@@ -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. */