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/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``` +``` 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/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/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/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/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/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); +}); 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. */