From 0c680b680f246f02769911d7001c19f72ce8c7fc Mon Sep 17 00:00:00 2001 From: padmanto Date: Fri, 18 Sep 2026 14:16:11 +0700 Subject: [PATCH 1/3] 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. */ -- 2.49.1 From 3ac04f9147dad65cbf90f8d8296048b71c7ec84a Mon Sep 17 00:00:00 2001 From: padmanto Date: Fri, 18 Sep 2026 14:16:16 +0700 Subject: [PATCH 2/3] fix(wado-rs): extract only the requested frame and return typed errors /frames/{frame} now validates the frame as a positive 1-based integer and returns 400 for invalid values. The frame number is threaded through doWadoRs into addFileToBuffer, where it is sliced out of uncompressed Pixel Data using NumberOfFrames, Rows, Columns, SamplesPerPixel and BitsAllocated. - add src/dimse/frameExtractor.ts (native frame slicing) - add src/dimse/errors.ts (status-bearing HttpError types) - reject compressed/encapsulated frames with 415 instead of full pixel data - keep JPEG conversion on /rendered and /thumbnail only - log frame requests with jpegRendererUsed=false - add frame extraction and validation tests --- src/dimse/errors.ts | 43 ++++++++++ src/dimse/frameExtractor.ts | 153 +++++++++++++++++++++++++++++++++++ src/dimse/wadoRs.ts | 41 ++++++++-- src/routes/routes.ts | 24 ++++-- tests/frameExtractor.test.ts | 107 ++++++++++++++++++++++++ 5 files changed, 355 insertions(+), 13 deletions(-) create mode 100644 src/dimse/errors.ts create mode 100644 src/dimse/frameExtractor.ts create mode 100644 tests/frameExtractor.test.ts diff --git a/src/dimse/errors.ts b/src/dimse/errors.ts new file mode 100644 index 0000000..1a88223 --- /dev/null +++ b/src/dimse/errors.ts @@ -0,0 +1,43 @@ +/** + * Typed, status-bearing errors so that routes can translate service failures + * into the right HTTP status code instead of always returning 500. + */ +export class HttpError extends Error { + public readonly statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.name = 'HttpError'; + this.statusCode = statusCode; + // Restore prototype chain when targeting ES5-ish runtimes. + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class BadRequestError extends HttpError { + constructor(message: string) { + super(400, message); + this.name = 'BadRequestError'; + } +} + +export class UnsupportedMediaTypeError extends HttpError { + constructor(message: string) { + super(415, message); + this.name = 'UnsupportedMediaTypeError'; + } +} + +export class NotImplementedError extends HttpError { + constructor(message: string) { + super(501, message); + this.name = 'NotImplementedError'; + } +} + +export function statusCodeForError(error: unknown): number { + if (error instanceof HttpError) { + return error.statusCode; + } + return 500; +} diff --git a/src/dimse/frameExtractor.ts b/src/dimse/frameExtractor.ts new file mode 100644 index 0000000..3aa1021 --- /dev/null +++ b/src/dimse/frameExtractor.ts @@ -0,0 +1,153 @@ +import dicomParser, { DataSet, Element } from 'dicom-parser'; +import { BadRequestError, HttpError, UnsupportedMediaTypeError } from './errors'; + +/** Transfer syntaxes whose Pixel Data can be sliced directly as native frames. */ +export const UNCOMPRESSED_TRANSFER_SYNTAXES = new Set([ + '1.2.840.10008.1.2', // Implicit VR Little Endian + '1.2.840.10008.1.2.1', // Explicit VR Little Endian + '1.2.840.10008.1.2.2', // Explicit VR Big Endian +]); + +const PIXEL_DATA_TAG = 'x7fe00010'; + +export interface ExtractedFrame { + /** The 1-based frame number that was requested. */ + frame: number; + /** NumberOfFrames from the dataset, 1 when the tag is absent. */ + numberOfFrames: number; + /** Transfer syntax UID of the parsed object, if present. */ + transferSyntax: string; + /** Size in bytes of a single native frame. */ + frameSize: number; + /** Exactly one frame worth of Pixel Data bytes. */ + buffer: Buffer; +} + +/** + * Parse a 1-based frame number from a route parameter. Returns undefined for + * missing, non-numeric, zero, negative or fractional values so callers can + * respond with 400. + */ +export function parseFrameParam(value: string | undefined): number | undefined { + if (value === undefined || !/^\d+$/.test(value)) { + return undefined; + } + const frame = Number.parseInt(value, 10); + if (!Number.isInteger(frame) || frame < 1) { + return undefined; + } + return frame; +} + +function isEncapsulated(pixelDataElement: Element): boolean { + return pixelDataElement.encapsulatedPixelData === true + || pixelDataElement.hadUndefinedLength === true + || (Array.isArray(pixelDataElement.fragments) && pixelDataElement.fragments.length > 0); +} + +function readNumberOfFrames(dataSet: DataSet): number { + const fromString = dataSet.intString('x00280008'); + if (typeof fromString === 'number' && Number.isFinite(fromString)) { + return fromString; + } + const fromUint16 = dataSet.uint16('x00280008'); + if (typeof fromUint16 === 'number' && Number.isFinite(fromUint16)) { + return fromUint16; + } + return 1; +} + +function requireUint16(dataSet: DataSet, tag: string, name: string): number { + const value = dataSet.uint16(tag); + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new HttpError(500, `cannot extract frame: missing or invalid ${name}`); + } + return value; +} + +/** + * Extract a single native (uncompressed) frame from a parsed DICOM dataset. + * + * The returned buffer contains exactly one frame worth of bytes; it never + * falls back to returning the full Pixel Data element. Encapsulated/compressed + * Pixel Data is rejected with an explicit unsupported response until real + * compressed frame extraction is implemented. + */ +export function extractFrame(dataSet: DataSet, frame: number): ExtractedFrame { + if (!Number.isInteger(frame) || frame < 1) { + throw new BadRequestError(`frame must be a positive integer, received '${frame}'`); + } + + const transferSyntaxElement = dataSet.elements.x00020010; + const transferSyntax = transferSyntaxElement + ? (dataSet.string('x00020010') ?? '') + : ''; + + const pixelDataElement = dataSet.elements[PIXEL_DATA_TAG]; + if (!pixelDataElement) { + throw new HttpError(500, 'cannot extract frame: Pixel Data (7FE0,0010) not present'); + } + + if (transferSyntax && !UNCOMPRESSED_TRANSFER_SYNTAXES.has(transferSyntax)) { + throw new UnsupportedMediaTypeError( + `compressed frame extraction is not supported for transfer syntax ${transferSyntax}`, + ); + } + + if (isEncapsulated(pixelDataElement)) { + throw new UnsupportedMediaTypeError( + 'encapsulated (compressed) Pixel Data frame extraction is not supported', + ); + } + + const numberOfFrames = readNumberOfFrames(dataSet); + if (frame > numberOfFrames) { + throw new BadRequestError( + `frame ${frame} is out of range, instance has ${numberOfFrames} frame(s)`, + ); + } + + const rows = requireUint16(dataSet, 'x00280010', 'Rows (0028,0010)'); + const columns = requireUint16(dataSet, 'x00280011', 'Columns (0028,0011)'); + const samplesPerPixel = requireUint16(dataSet, 'x00280002', 'SamplesPerPixel (0028,0002)'); + const bitsAllocated = requireUint16(dataSet, 'x00280100', 'BitsAllocated (0028,0100)'); + + let frameSize: number; + if (bitsAllocated === 1) { + frameSize = Math.ceil((rows * columns * samplesPerPixel) / 8); + } + else if (bitsAllocated % 8 === 0) { + frameSize = rows * columns * samplesPerPixel * (bitsAllocated / 8); + } + else { + throw new HttpError(500, `cannot extract frame: unsupported BitsAllocated ${bitsAllocated}`); + } + + const requiredLength = frame * frameSize; + if (pixelDataElement.length < requiredLength) { + throw new HttpError( + 500, + `cannot extract frame ${frame}: Pixel Data is too short ` + + `(${pixelDataElement.length} bytes, need ${requiredLength})`, + ); + } + + const offset = pixelDataElement.dataOffset + (frame - 1) * frameSize; + const byteArray = dataSet.byteArray; + const buffer = Buffer.isBuffer(byteArray) + ? byteArray.subarray(offset, offset + frameSize) + : Buffer.from(byteArray.buffer, byteArray.byteOffset + offset, frameSize); + + return { + frame, + numberOfFrames, + transferSyntax, + frameSize, + buffer: Buffer.from(buffer), + }; +} + +export function extractFrameFromBuffer(buffer: Buffer | Uint8Array, frame: number): ExtractedFrame { + const dataSet = dicomParser.parseDicom(buffer); + return extractFrame(dataSet, frame); +} diff --git a/src/dimse/wadoRs.ts b/src/dimse/wadoRs.ts index 55d0668..f0408c1 100644 --- a/src/dimse/wadoRs.ts +++ b/src/dimse/wadoRs.ts @@ -10,6 +10,7 @@ import deepmerge from 'deepmerge'; import dicomParser from 'dicom-parser'; import combineMerge from '../utils/combineMerge'; import { fileExists } from '../utils/fileHelper'; +import { extractFrame } from './frameExtractor'; import { execFile as exFile } from 'child_process'; import util from 'util'; @@ -22,7 +23,7 @@ type WadoRsArgs = { seriesInstanceUid?: string; sopInstanceUid?: string; dataFormat?: DataFormat; - frame?: number | number[]; + frame?: number; }; type WadoRsResponse = { contentType: string; @@ -91,9 +92,10 @@ interface AddFileToBuffer { filename: string, instanceInfo: InstanceInfo, dataFormat?: DataFormat, + frame?: number, } -async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }: AddFileToBuffer): Promise { +async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo, frame }: AddFileToBuffer): Promise { const logger = LoggerSingleton.Instance; const filepath = path.join(pathname, filename); const buffArray: Buffer[] = []; @@ -108,7 +110,11 @@ async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }: contentLocation += `/series/${instanceInfo.series}` } if (instanceInfo.instance) { - contentLocation += `/instance/${instanceInfo.instance}` + // Frame requests use the canonical WADO-RS frame URL so clients can + // resolve the exact resource that was returned. + contentLocation += typeof frame === 'number' + ? `/instances/${instanceInfo.instance}/frames/${frame}` + : `/instance/${instanceInfo.instance}` } // Compress the file @@ -123,8 +129,7 @@ async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }: const data = await fs.readFile(filepath); let returnData; switch (dataFormat) { - case 'bulkdata': - case 'pixeldata': { + case 'bulkdata': { // Get the pixeldata from the DICOM and add it to the buffer. const dataset = dicomParser.parseDicom(data); const pixeldataElement = dataset.elements.x7fe00010; @@ -132,6 +137,26 @@ async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }: returnData = Buffer.from(dataset.byteArray.buffer, pixeldataElement.dataOffset, pixeldataElement.length); break; } + case 'pixeldata': { + // Get the pixeldata from the DICOM and add it to the buffer. + const dataset = dicomParser.parseDicom(data); + buffArray.push(Buffer.from(`Content-Type:application/octet-stream;${term}`)); + if (typeof frame === 'number') { + const extracted = extractFrame(dataset, frame); + logger.info( + `frame request study=${instanceInfo.study} series=${instanceInfo.series} ` + + `instance=${instanceInfo.instance} frame=${extracted.frame} ` + + `numberOfFrames=${extracted.numberOfFrames} transferSyntax=${extracted.transferSyntax} ` + + 'contentType=application/octet-stream jpegRendererUsed=false' + ); + returnData = extracted.buffer; + } + else { + const pixeldataElement = dataset.elements.x7fe00010; + returnData = Buffer.from(dataset.byteArray.buffer, pixeldataElement.dataOffset, pixeldataElement.length); + } + break; + } case 'rendered': { // Convert the DCM file to a JPEG and return that buffArray.push(Buffer.from(`Content-Type:image/jpeg;${term}`)); @@ -157,7 +182,7 @@ type InstanceInfo = { instance?: string } -export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat }: WadoRsArgs): Promise { +export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat, frame }: WadoRsArgs): Promise { const logger = LoggerSingleton.Instance; // Set up all the paths and query levels. const storagePath = config.get(ConfParams.STORAGE_PATH) as string; @@ -237,14 +262,14 @@ export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanc buffers = await Promise.all(files.map(async (file) => { const instanceInfo = foundInstances.find((i) => i.instance === file) if (instanceInfo) { - return addFileToBuffer({ pathname, filename: file, dataFormat, instanceInfo }); + return addFileToBuffer({ pathname, filename: file, dataFormat, instanceInfo, frame }); } })) } else { // Attach the one file that we need to the return buffer const instanceInfo = { study: studyInstanceUid, series: seriesInstanceUid, instance: sopInstanceUid } - buffers = [await addFileToBuffer({ pathname: studyPath, filename, dataFormat, instanceInfo })]; + buffers = [await addFileToBuffer({ pathname: studyPath, filename, dataFormat, instanceInfo, frame: typeof frame === 'number' ? frame : undefined })]; } // Set up the boundaries and join together all of the file buffers to form diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 0245682..2ab9670 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -4,6 +4,8 @@ import { doFind } from '../dimse/findData'; import { QUERY_LEVEL } from '../dimse/querLevel'; import { doWadoRs } from '../dimse/wadoRs'; import { doWadoUri } from '../dimse/wadoUri'; +import { parseFrameParam } from '../dimse/frameExtractor'; +import { statusCodeForError } from '../dimse/errors'; import { LoggerSingleton } from '../utils/logger'; import moment from 'moment'; import deepmerge from 'deepmerge'; @@ -25,6 +27,10 @@ interface IParamsImage extends IParamsSeries { sopInstanceUid: string; } +interface IParamsFrame extends IParamsImage { + frame: string; +} + interface IQueryImage { studyUID: string; seriesUID: string; @@ -40,7 +46,7 @@ module.exports = function (server: FastifyInstance, opts: unknown, done: () => v Querystring: QueryParams; }>('/rs/studies', async (req, reply) => { try { - var { query } = req; + const { query } = req; // Tambahkan filter StudyDate hanya ketika tidak ada: Medrec, AccessionNo, PatientName, StudyInstanceUID, dan StudyDate // Params: &00100020=MRN, AccessionNumber=, PatientName=, StudyDate= @@ -360,17 +366,25 @@ module.exports = function (server: FastifyInstance, opts: unknown, done: () => v //------------------------------------------------------------------ server.get<{ - Params: IParamsImage; + Params: IParamsFrame; }>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/instances/:sopInstanceUid/frames/:frame', async (req, reply) => { - const { studyInstanceUid, seriesInstanceUid, sopInstanceUid } = req.params; + const { studyInstanceUid, seriesInstanceUid, sopInstanceUid, frame: frameParam } = req.params; + + const frame = parseFrameParam(frameParam); + if (frame === undefined) { + return reply.code(400).send({ error: `frame must be a positive integer, received '${frameParam}'` }); + } try { - const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat: 'pixeldata' }); + const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat: 'pixeldata', frame }); reply.header('Content-Type', rsp.contentType); return reply.send(rsp.buffer); } catch (error) { + const statusCode = statusCodeForError(error); logger.error(error); - return reply.send(500); + return reply.code(statusCode).send({ + error: statusCode === 500 ? 'internal error' : (error as Error).message, + }); } }); diff --git a/tests/frameExtractor.test.ts b/tests/frameExtractor.test.ts new file mode 100644 index 0000000..300fdde --- /dev/null +++ b/tests/frameExtractor.test.ts @@ -0,0 +1,107 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { extractFrameFromBuffer, parseFrameParam } from '../src/dimse/frameExtractor'; +import { BadRequestError, HttpError, statusCodeForError, UnsupportedMediaTypeError } from '../src/dimse/errors'; +import { buildMonochromeImage } from './helpers/dicomWriter'; + +function twoFrameImage() { + return buildMonochromeImage({ + rows: 2, + columns: 2, + bitsAllocated: 8, + samplesPerPixel: 1, + numberOfFrames: 2, + pixelData: [1, 2, 3, 4, 5, 6, 7, 8], + }); +} + +test('/frames/1 returns only the first frame byte range', () => { + const extracted = extractFrameFromBuffer(twoFrameImage(), 1); + assert.deepEqual([...extracted.buffer], [1, 2, 3, 4]); + assert.equal(extracted.frameSize, 4); + assert.equal(extracted.numberOfFrames, 2); +}); + +test('/frames/2 returns only the second frame byte range', () => { + const extracted = extractFrameFromBuffer(twoFrameImage(), 2); + assert.deepEqual([...extracted.buffer], [5, 6, 7, 8]); +}); + +test('/frames/2 for a single-frame instance is rejected with 400', () => { + const singleFrame = buildMonochromeImage({ + rows: 2, + columns: 2, + bitsAllocated: 8, + pixelData: [1, 2, 3, 4], + }); + assert.throws( + () => extractFrameFromBuffer(singleFrame, 2), + (error: unknown) => error instanceof BadRequestError && statusCodeForError(error) === 400, + ); +}); + +test('extracts 16-bit frames with the correct byte offsets', () => { + const image = buildMonochromeImage({ + rows: 2, + columns: 2, + bitsAllocated: 16, + samplesPerPixel: 1, + numberOfFrames: 2, + pixelData: [256, 257, 258, 259, 260, 261, 262, 263], + }); + const second = extractFrameFromBuffer(image, 2); + const values = [0, 2, 4, 6].map((offset) => second.buffer.readUInt16LE(offset)); + assert.deepEqual(values, [260, 261, 262, 263]); +}); + +test('rejects out-of-range and zero frame numbers with 400', () => { + const image = twoFrameImage(); + assert.throws( + () => extractFrameFromBuffer(image, 0), + (error: unknown) => error instanceof BadRequestError, + ); + assert.throws( + () => extractFrameFromBuffer(image, 3), + (error: unknown) => error instanceof BadRequestError, + ); +}); + +test('rejects compressed transfer syntax with an explicit unsupported response', () => { + const image = buildMonochromeImage({ + rows: 2, + columns: 2, + bitsAllocated: 8, + numberOfFrames: 2, + pixelData: [1, 2, 3, 4, 5, 6, 7, 8], + transferSyntaxUID: '1.2.840.10008.1.2.4.80', + }); + assert.throws( + () => extractFrameFromBuffer(image, 2), + (error: unknown) => error instanceof UnsupportedMediaTypeError && statusCodeForError(error) === 415, + ); +}); + +test('treats a too-short Pixel Data element as a 500 data error', () => { + const image = buildMonochromeImage({ + rows: 4, + columns: 4, + bitsAllocated: 8, + pixelData: [1, 2, 3, 4], + }); + assert.throws( + () => extractFrameFromBuffer(image, 1), + (error: unknown) => error instanceof HttpError && statusCodeForError(error) === 500, + ); +}); + +test('parseFrameParam accepts only positive base-10 integers', () => { + assert.equal(parseFrameParam('1'), 1); + assert.equal(parseFrameParam('42'), 42); + assert.equal(parseFrameParam('0'), undefined); + assert.equal(parseFrameParam('-1'), undefined); + assert.equal(parseFrameParam('abc'), undefined); + assert.equal(parseFrameParam('1.5'), undefined); + assert.equal(parseFrameParam(' 1'), undefined); + assert.equal(parseFrameParam(''), undefined); + assert.equal(parseFrameParam(undefined), undefined); +}); -- 2.49.1 From f31dd0762135371e581f371f43980a805abfd45f Mon Sep 17 00:00:00 2001 From: padmanto Date: Fri, 18 Sep 2026 14:16:36 +0700 Subject: [PATCH 3/3] docs(report): add implementation notes for DICOMweb proxy recommendation --- report/01-dicom-web-proxy-recommendation.md | 125 ++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 report/01-dicom-web-proxy-recommendation.md diff --git a/report/01-dicom-web-proxy-recommendation.md b/report/01-dicom-web-proxy-recommendation.md new file mode 100644 index 0000000..baa19c3 --- /dev/null +++ b/report/01-dicom-web-proxy-recommendation.md @@ -0,0 +1,125 @@ +# Report: DICOMweb proxy recommendation + +- Todo: `todo/01-dicom-web-proxy-recommendation.md` +- Detail: `todo/01-dicom-web-proxy-recommendation.detail.md` +- Branch: `feature/dicomweb-proxy-recommendation` +- Commits: `0c680b6`, `3ac04f9` +- Status: implemented, build + lint + tests pass + +## What was implemented + +### P0 - metadata display correctness + +The hand-built metadata subset in `parseMeta.ts` was replaced with a +VR-aware DICOM-to-DICOM-JSON converter (`src/dimse/dicomJson.ts`). + +| Requirement | Result | +| :------------------------------------------- | :----- | +| Stop fabricating Window Center (0028,1050) | Done | +| Stop fabricating Window Width (0028,1051) | Done | +| Stop fabricating Rescale Intercept (0028,1052)| Done | +| Stop fabricating Rescale Slope (0028,1053) | Done | +| Preserve multi-valued WC/WW and rescale | Done | +| Preserve VOI/LUT/presentation/padding tags | Done | +| Omit absent optional tags (no `[undefined]`) | Done | + +Converter behavior: + +- Uses explicit `element.vr`, then the DICOM dictionary as fallback. +- Resolves ambiguous dictionary VRs (for example `US or SS` for padding). +- Handles string, numeric, `DS`/`IS`, `AT`, `PN` and binary VRs. +- Recursively converts `SQ` items and keeps LUT sequences. +- Encodes binary LUT payloads as `InlineBinary` (base64). +- Excludes Pixel Data, float pixel data and overlay data. +- Excludes private (odd-group) tags by default. + +### P1 - WADO-RS frame correctness + +| Requirement | Result | +| :-------------------------------------------- | :----- | +| Validate frame as positive 1-based integer | Done | +| Return 400 for invalid values | Done | +| Thread frame into `doWadoRs`/buffer builder | Done | +| Return only requested uncompressed frame | Done | +| Explicit unsupported response for compressed | Done (415) | +| Never fall back to full Pixel Data | Done | +| JPEG only on `/rendered` and `/thumbnail` | Done | +| Frame log with `jpegRendererUsed=false` | Done | + +Frame slicing details: + +- `NumberOfFrames` (0028,0008) defaults to 1 only when absent. +- Validates `1 <= frame <= NumberOfFrames`. +- Frame size uses Rows, Columns, SamplesPerPixel and BitsAllocated. +- Supports 1-bit and byte-aligned BitsAllocated. +- Too-short Pixel Data is a 500 server error, not a full-buffer fallback. +- Compressed or encapsulated Pixel Data is rejected with 415. +- Frame `Content-Location` uses the canonical `/instances/.../frames/{n}` URL. + +### P2 - tests and verification + +| Requirement | Result | +| :------------------------------------------- | :----- | +| Synthetic non-PHI CR and multi-frame fixtures| Done | +| Absent WC/WW stays absent | Done | +| Absent rescale stays absent | Done | +| Multi-valued WC/WW preserved | Done | +| `/frames/1` and `/frames/2` differ | Done | +| Invalid frame numbers return 400 | Done | +| Compressed extraction returns 415/501 | Done | +| `npm run build` and test suite | Done | + +## Files changed + +| File | Change | +| :------------------------------ | :----- | +| `src/dimse/dicomJson.ts` | New DICOM-to-DICOM-JSON converter | +| `src/dimse/frameExtractor.ts` | New native frame slicing + param parser | +| `src/dimse/errors.ts` | New status-bearing error types | +| `src/dimse/parseMeta.ts` | Uses the converter instead of a static map | +| `src/dimse/wadoRs.ts` | Threads frame, logs, frame Content-Location | +| `src/routes/routes.ts` | Validates frame, maps error status codes | +| `tests/dicomJson.test.ts` | Metadata regression tests | +| `tests/frameExtractor.test.ts` | Frame extraction tests | +| `tests/helpers/dicomWriter.ts` | Synthetic DICOM writer (test-only) | +| `package.json` | Added `npm test` script | +| `tsconfig.json` | Added `skipLibCheck` | + +## Deviations from the plan + +- The plan's "preferred end state" converter is implemented, but the + metadata route still returns an array of per-instance datasets to keep + the existing `fetchMeta` contract intact. +- Private tags are excluded from metadata output. The plan did not require + them; excluding them keeps responses small and avoids site-private data. +- `convertToJpeg` remains untouched on the rendered/thumbnail paths. +- No integration test exercises the Fastify HTTP routes, because that path + imports `dicom-dimse-native` and needs a live PACS. Route-level frame + validation is covered through `parseFrameParam` and service errors. +- `skipLibCheck` was enabled in `tsconfig.json`. Without it `npm run build` + fails on a pre-existing `@types/glob`/`minimatch` mismatch in dependencies. + +## Verification + +- `npm run build` -> pass +- `npm test` -> 16 tests, 16 pass, 0 fail +- `npx eslint` on changed files -> no errors + +Manual visual verification of a Jam-like CR fixture was not possible in this +environment: the original study data is not available. The synthetic CR +fixture proves no `40/80` window is fabricated, which is the stated root cause. + +## Documentation Updates Needed + +This repository has no `docs/` directory. Optional updates: + +### README.md + +Add a test section after the "Setup Instructions - source" block: + +```markdown +## Testing + +* run the unit tests (uses node:test): +```npm test``` +``` -- 2.49.1