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:
286
src/dimse/dicomJson.ts
Normal file
286
src/dimse/dicomJson.ts
Normal 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);
|
||||
}
|
||||
@@ -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<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];
|
||||
|
||||
Reference in New Issue
Block a user