5 Commits

Author SHA1 Message Date
padmanto
f31dd07621 docs(report): add implementation notes for DICOMweb proxy recommendation 2026-09-18 14:16:36 +07:00
padmanto
3ac04f9147 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
2026-09-18 14:16:16 +07:00
padmanto
0c680b680f 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
2026-09-18 14:16:11 +07:00
AlfandiMario
ac162cd2a3 startDate h-7 2026-08-20 15:52:12 +07:00
AlfandiMario
bfb55055a2 additional field untuk overlay MRI OHIF 2026-08-20 15:52:12 +07:00
13 changed files with 1077 additions and 157 deletions

View File

@@ -2,7 +2,7 @@ version: '3'
services: services:
dicomweb-proxy: dicomweb-proxy:
image: dicomweb-proxy:2.0 image: dicomweb-proxy:3.0.1
container_name: dicomweb-proxy container_name: dicomweb-proxy
ports: ports:
- "5000:5000" - "5000:5000"

View File

@@ -7,6 +7,7 @@
"start": "ts-node src/app.ts", "start": "ts-node src/app.ts",
"format": "npx prettier ./src", "format": "npx prettier ./src",
"build": "tsc --p ./tsconfig.json", "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" "pkg:win": "npm run build && npx pkg --targets node16-win-x64 package.json"
}, },
"repository": { "repository": {

View File

@@ -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```
```

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

@@ -0,0 +1,286 @@
import dicomParser, { DataSet, Element, readFixedString } from 'dicom-parser';
import { get_element } from '@iwharris/dicom-data-dictionary';
/**
* Minimal DICOM JSON model as defined by DICOM PS3.18 F.
* See https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_F.2.html
*/
export interface DicomJsonElement {
vr: string;
Value?: unknown[];
InlineBinary?: string;
BulkDataURI?: string;
}
export type DicomJsonDataSet = Record<string, DicomJsonElement>;
export interface DicomJsonOptions {
/**
* Include private (odd group) data elements. Defaults to false to keep
* responses small and to avoid leaking site-specific private data.
*/
includePrivate?: boolean;
}
/** String VRs that map to a JSON array of strings. */
const STRING_VRS = new Set([
'AE', 'AS', 'CS', 'DA', 'DT', 'LO', 'LT', 'SH', 'ST', 'TM', 'UC', 'UI', 'UR', 'UT',
]);
/** Numeric VRs and the byte-array parser reader used for each. */
const NUMBER_VRS: Record<string, 'readUint16' | 'readInt16' | 'readUint32' | 'readInt32' | 'readFloat' | 'readDouble'> = {
US: 'readUint16',
SS: 'readInt16',
UL: 'readUint32',
SL: 'readInt32',
FL: 'readFloat',
FD: 'readDouble',
};
/** VRs whose payload is binary and must be encoded as InlineBinary/BulkDataURI. */
const BINARY_VRS = new Set(['OB', 'OW', 'OF', 'OD', 'OL', 'OV', 'UN']);
/** Pixel data and float pixel data are never part of the metadata response. */
const EXCLUDED_TAGS = new Set(['7FE00010', '7FE00008', '7FE00009']);
function formatKey(key: string): string {
return key.replace(/^x/i, '').toUpperCase();
}
function groupOf(tag: string): number {
return parseInt(tag.substring(0, 4), 16);
}
function isPrivateTag(tag: string): boolean {
return (groupOf(tag) & 0x0001) === 1;
}
function isExcludedTag(tag: string): boolean {
if (groupOf(tag) === 0x0002) {
return true;
}
if (EXCLUDED_TAGS.has(tag)) {
return true;
}
// Overlay data lives in groups 6000-60FF, element 3000.
const group = groupOf(tag);
if (group >= 0x6000 && group <= 0x60ff && tag.substring(4) === '3000') {
return true;
}
return false;
}
function resolveAmbiguousVr(vr: string, tag: string, dataSet: DataSet): string {
const options = vr.split(/\s+or\s+/i).map((v) => v.trim().toUpperCase());
if (options.length === 1) {
return options[0];
}
// Pixel Padding Value/Range Limit are US or SS depending on PixelRepresentation.
if ((tag === '00280120' || tag === '00280121') && options.includes('US') && options.includes('SS')) {
const pixelRepresentation = dataSet.uint16('x00280103');
return pixelRepresentation === 1 ? 'SS' : 'US';
}
return options[0];
}
function resolveVr(element: Element, tag: string, dataSet: DataSet): string {
let vr = element.vr;
if (!vr) {
const dictionaryEntry = get_element(tag);
if (dictionaryEntry) {
vr = dictionaryEntry.vr;
}
}
vr = (vr || 'UN').toUpperCase();
if (/\bor\b/i.test(vr)) {
vr = resolveAmbiguousVr(vr, tag, dataSet);
}
return vr;
}
function byteArrayParserFor(element: Element, dataSet: DataSet) {
return element.parser ?? dataSet.byteArrayParser;
}
function readBinary(dataSet: DataSet, element: Element): Buffer {
const byteArray = dataSet.byteArray;
if (Buffer.isBuffer(byteArray)) {
return byteArray.subarray(element.dataOffset, element.dataOffset + element.length);
}
return Buffer.from(byteArray.buffer, byteArray.byteOffset + element.dataOffset, element.length);
}
function stripTrailingNul(value: string): string {
let end = value.length;
while (end > 0 && value.charCodeAt(end - 1) === 0) {
end -= 1;
}
return value.slice(0, end);
}
function readStringValues(dataSet: DataSet, element: Element): string[] {
const raw = readFixedString(dataSet.byteArray, element.dataOffset, element.length);
const padded = stripTrailingNul(raw).replace(/ +$/g, '');
if (padded.length === 0) {
return [];
}
return padded.split('\\').map((value) => value.replace(/ +$/g, ''));
}
function parsePersonName(value: string): Record<string, string> {
const [alphabetic, ideographic, phonetic] = value.split('=');
const personName: Record<string, string> = {};
if (alphabetic) {
personName.Alphabetic = alphabetic;
}
if (ideographic) {
personName.Ideographic = ideographic;
}
if (phonetic) {
personName.Phonetic = phonetic;
}
return personName;
}
function readNumericValues(dataSet: DataSet, element: Element, vr: string): number[] {
const readerName = NUMBER_VRS[vr];
const reader = byteArrayParserFor(element, dataSet)[readerName];
const width = readerName === 'readFloat' || readerName === 'readInt32' || readerName === 'readUint32'
? 4
: readerName === 'readDouble'
? 8
: 2;
const count = Math.floor(element.length / width);
const values: number[] = [];
for (let i = 0; i < count; i += 1) {
values.push(reader(dataSet.byteArray, element.dataOffset + i * width));
}
return values;
}
function readAttributeTags(dataSet: DataSet, element: Element): string[] {
const parser = byteArrayParserFor(element, dataSet);
const count = Math.floor(element.length / 4);
const values: string[] = [];
for (let i = 0; i < count; i += 1) {
const offset = element.dataOffset + i * 4;
const group = parser.readUint16(dataSet.byteArray, offset);
const elementNumber = parser.readUint16(dataSet.byteArray, offset + 2);
values.push(
`${
group.toString(16).padStart(4, '0')
}${
elementNumber.toString(16).padStart(4, '0')}`.toUpperCase(),
);
}
return values;
}
function readDecimalStrings(dataSet: DataSet, element: Element): unknown[] {
return readStringValues(dataSet, element).map((value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
});
}
function readIntegerStrings(dataSet: DataSet, element: Element): unknown[] {
return readStringValues(dataSet, element).map((value) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : value;
});
}
function convertSequence(dataSet: DataSet, element: Element, options: DicomJsonOptions): DicomJsonElement {
const items: DicomJsonDataSet[] = [];
if (element.items) {
for (const item of element.items) {
if (item.dataSet) {
items.push(convertDataSetToJson(item.dataSet, options));
}
}
}
return { vr: 'SQ', Value: items };
}
/**
* Convert a parsed DICOM dataset into DICOM JSON (PS3.18 F).
*
* The conversion is VR aware: it uses the explicit VR stored on the element
* when available and falls back to the DICOM data dictionary otherwise. Absent
* attributes are never synthesized, so a source file without Window Center or
* Rescale tags will not gain fabricated values.
*/
export function convertElementToJson(
dataSet: DataSet,
element: Element,
options: DicomJsonOptions = {},
): DicomJsonElement {
const tag = formatKey(element.tag);
const vr = resolveVr(element, tag, dataSet);
if (vr === 'SQ') {
return convertSequence(dataSet, element, options);
}
if (element.length === 0) {
return { vr, Value: [] };
}
if (vr === 'PN') {
return { vr, Value: readStringValues(dataSet, element).map(parsePersonName) };
}
if (vr === 'DS') {
return { vr, Value: readDecimalStrings(dataSet, element) };
}
if (vr === 'IS') {
return { vr, Value: readIntegerStrings(dataSet, element) };
}
if (vr === 'AT') {
return { vr, Value: readAttributeTags(dataSet, element) };
}
if (NUMBER_VRS[vr]) {
return { vr, Value: readNumericValues(dataSet, element, vr) };
}
if (STRING_VRS.has(vr)) {
return { vr, Value: readStringValues(dataSet, element) };
}
if (BINARY_VRS.has(vr)) {
return { vr, InlineBinary: readBinary(dataSet, element).toString('base64') };
}
// Unknown VR: fall back to an inline binary payload rather than guessing.
return { vr, InlineBinary: readBinary(dataSet, element).toString('base64') };
}
export function convertDataSetToJson(
dataSet: DataSet,
options: DicomJsonOptions = {},
): DicomJsonDataSet {
const result: DicomJsonDataSet = {};
for (const key of Object.keys(dataSet.elements)) {
const element = dataSet.elements[key];
const tag = formatKey(key);
if (isExcludedTag(tag)) {
continue;
}
if (!options.includePrivate && isPrivateTag(tag)) {
continue;
}
result[tag] = convertElementToJson(dataSet, element, options);
}
return result;
}
export function parseDicomToJson(
buffer: Buffer | Uint8Array,
options: DicomJsonOptions = {},
): DicomJsonDataSet {
const dataSet = dicomParser.parseDicom(buffer);
return convertDataSetToJson(dataSet, options);
}

43
src/dimse/errors.ts Normal file
View File

@@ -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;
}

153
src/dimse/frameExtractor.ts Normal file
View File

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

View File

@@ -1,154 +1,33 @@
import { LoggerSingleton } from '../utils/logger'; import { LoggerSingleton } from '../utils/logger';
import { ConfParams, config } from '../utils/config'; import { ConfParams, config } from '../utils/config';
import { fileExists } from '../utils/fileHelper'; import { fileExists } from '../utils/fileHelper';
import dicomParser from 'dicom-parser';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { DicomJsonDataSet, parseDicomToJson } from './dicomJson';
interface ValueType { function parseFile(filename: string): Promise<DicomJsonDataSet> {
Value: string[] | number[] | unknown[];
vr: string;
}
type ElementType = Record<string, ValueType>;
function parseFile(filename: string): Promise<ElementType> {
const logger = LoggerSingleton.Instance; const logger = LoggerSingleton.Instance;
return new Promise<ElementType>((resolve, reject) => { return new Promise<DicomJsonDataSet>((resolve, reject) => {
fileExists(filename).then((success: boolean) => { fileExists(filename).then((success: boolean) => {
if (!success) { if (!success) {
logger.error(`file does not exist: ${filename}`); 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) => { fs.promises.readFile(filename)
const dataset = dicomParser.parseDicom(data); .then((data: Uint8Array) => {
try {
// parse additional needed attributes resolve(parseDicomToJson(data));
const patientName = dataset.string('x00100010'); }
const patentID = dataset.string('x00100020'); catch (error) {
const studyInstanceUID = dataset.string('x0020000d'); logger.error(`failed to parse metadata from ${filename}: ${error}`);
const studyDate = dataset.string('x00080020'); reject(error);
const studyTime = dataset.string('x00080030'); }
const seriesInstanceUID = dataset.string('x0020000e'); })
const seriesNumber = dataset.string('x00200011'); .catch((error: unknown) => {
const sopInstanceUID = dataset.string('x00080018'); logger.error(`failed to read ${filename}: ${error}`);
const sopClassUID = dataset.string('x00080016'); reject(error);
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('x00181130', 0),
dataset.uint16('x00181130', 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('x00181316');
// 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' },
'00181316': { Value: [parallelAcquisitionTechnique], vr: 'CS' },
};
resolve(result);
});
}); });
}); });
} }
@@ -157,13 +36,12 @@ export function parseMeta(json: object, studyInstanceUID: string, seriesInstance
const logger = LoggerSingleton.Instance; const logger = LoggerSingleton.Instance;
logger.info(`parsing series ${seriesInstanceUID}`); logger.info(`parsing series ${seriesInstanceUID}`);
const parsing = new Array<Promise<ElementType>>(); const parsing = new Array<Promise<DicomJsonDataSet>>();
const storagePath = config.get(ConfParams.STORAGE_PATH) as string; const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
for (const [key] of Object.entries(json)) { for (const [key] of Object.entries(json)) {
const sopInstanceUid = json[key]['00080018'].Value[0]; const sopInstanceUid = json[key]['00080018'].Value[0];
const pathname = path.join(storagePath, studyInstanceUID, sopInstanceUid); const pathname = path.join(storagePath, studyInstanceUID, sopInstanceUid);
parsing.push(parseFile(pathname)); parsing.push(parseFile(pathname));
} }
return Promise.all(parsing); return Promise.all(parsing);
} }

View File

@@ -10,6 +10,7 @@ import deepmerge from 'deepmerge';
import dicomParser from 'dicom-parser'; import dicomParser from 'dicom-parser';
import combineMerge from '../utils/combineMerge'; import combineMerge from '../utils/combineMerge';
import { fileExists } from '../utils/fileHelper'; import { fileExists } from '../utils/fileHelper';
import { extractFrame } from './frameExtractor';
import { execFile as exFile } from 'child_process'; import { execFile as exFile } from 'child_process';
import util from 'util'; import util from 'util';
@@ -22,7 +23,7 @@ type WadoRsArgs = {
seriesInstanceUid?: string; seriesInstanceUid?: string;
sopInstanceUid?: string; sopInstanceUid?: string;
dataFormat?: DataFormat; dataFormat?: DataFormat;
frame?: number | number[]; frame?: number;
}; };
type WadoRsResponse = { type WadoRsResponse = {
contentType: string; contentType: string;
@@ -91,9 +92,10 @@ interface AddFileToBuffer {
filename: string, filename: string,
instanceInfo: InstanceInfo, instanceInfo: InstanceInfo,
dataFormat?: DataFormat, dataFormat?: DataFormat,
frame?: number,
} }
async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }: AddFileToBuffer): Promise<Buffer> { async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo, frame }: AddFileToBuffer): Promise<Buffer> {
const logger = LoggerSingleton.Instance; const logger = LoggerSingleton.Instance;
const filepath = path.join(pathname, filename); const filepath = path.join(pathname, filename);
const buffArray: Buffer[] = []; const buffArray: Buffer[] = [];
@@ -108,7 +110,11 @@ async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }:
contentLocation += `/series/${instanceInfo.series}` contentLocation += `/series/${instanceInfo.series}`
} }
if (instanceInfo.instance) { 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 // Compress the file
@@ -123,8 +129,7 @@ async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }:
const data = await fs.readFile(filepath); const data = await fs.readFile(filepath);
let returnData; let returnData;
switch (dataFormat) { switch (dataFormat) {
case 'bulkdata': case 'bulkdata': {
case 'pixeldata': {
// Get the pixeldata from the DICOM and add it to the buffer. // Get the pixeldata from the DICOM and add it to the buffer.
const dataset = dicomParser.parseDicom(data); const dataset = dicomParser.parseDicom(data);
const pixeldataElement = dataset.elements.x7fe00010; 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); returnData = Buffer.from(dataset.byteArray.buffer, pixeldataElement.dataOffset, pixeldataElement.length);
break; 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': { case 'rendered': {
// Convert the DCM file to a JPEG and return that // Convert the DCM file to a JPEG and return that
buffArray.push(Buffer.from(`Content-Type:image/jpeg;${term}`)); buffArray.push(Buffer.from(`Content-Type:image/jpeg;${term}`));
@@ -157,7 +182,7 @@ type InstanceInfo = {
instance?: string instance?: string
} }
export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat }: WadoRsArgs): Promise<WadoRsResponse> { export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat, frame }: WadoRsArgs): Promise<WadoRsResponse> {
const logger = LoggerSingleton.Instance; const logger = LoggerSingleton.Instance;
// Set up all the paths and query levels. // Set up all the paths and query levels.
const storagePath = config.get(ConfParams.STORAGE_PATH) as string; 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) => { buffers = await Promise.all(files.map(async (file) => {
const instanceInfo = foundInstances.find((i) => i.instance === file) const instanceInfo = foundInstances.find((i) => i.instance === file)
if (instanceInfo) { if (instanceInfo) {
return addFileToBuffer({ pathname, filename: file, dataFormat, instanceInfo }); return addFileToBuffer({ pathname, filename: file, dataFormat, instanceInfo, frame });
} }
})) }))
} }
else { else {
// Attach the one file that we need to the return buffer // Attach the one file that we need to the return buffer
const instanceInfo = { study: studyInstanceUid, series: seriesInstanceUid, instance: sopInstanceUid } 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 // Set up the boundaries and join together all of the file buffers to form

View File

@@ -4,6 +4,8 @@ import { doFind } from '../dimse/findData';
import { QUERY_LEVEL } from '../dimse/querLevel'; import { QUERY_LEVEL } from '../dimse/querLevel';
import { doWadoRs } from '../dimse/wadoRs'; import { doWadoRs } from '../dimse/wadoRs';
import { doWadoUri } from '../dimse/wadoUri'; import { doWadoUri } from '../dimse/wadoUri';
import { parseFrameParam } from '../dimse/frameExtractor';
import { statusCodeForError } from '../dimse/errors';
import { LoggerSingleton } from '../utils/logger'; import { LoggerSingleton } from '../utils/logger';
import moment from 'moment'; import moment from 'moment';
import deepmerge from 'deepmerge'; import deepmerge from 'deepmerge';
@@ -25,6 +27,10 @@ interface IParamsImage extends IParamsSeries {
sopInstanceUid: string; sopInstanceUid: string;
} }
interface IParamsFrame extends IParamsImage {
frame: string;
}
interface IQueryImage { interface IQueryImage {
studyUID: string; studyUID: string;
seriesUID: string; seriesUID: string;
@@ -40,7 +46,7 @@ module.exports = function (server: FastifyInstance, opts: unknown, done: () => v
Querystring: QueryParams; Querystring: QueryParams;
}>('/rs/studies', async (req, reply) => { }>('/rs/studies', async (req, reply) => {
try { try {
var { query } = req; const { query } = req;
// Tambahkan filter StudyDate hanya ketika tidak ada: Medrec, AccessionNo, PatientName, StudyInstanceUID, dan StudyDate // Tambahkan filter StudyDate hanya ketika tidak ada: Medrec, AccessionNo, PatientName, StudyInstanceUID, dan StudyDate
// Params: &00100020=MRN, AccessionNumber=, PatientName=, StudyDate= // Params: &00100020=MRN, AccessionNumber=, PatientName=, StudyDate=
@@ -51,8 +57,7 @@ module.exports = function (server: FastifyInstance, opts: unknown, done: () => v
const hasStudyDate = query.StudyDate !== undefined; const hasStudyDate = query.StudyDate !== undefined;
if (!hasMedicalRecord && !hasAccessionNumber && !hasPatientName && !hasStudyInstanceUID && !hasStudyDate) { if (!hasMedicalRecord && !hasAccessionNumber && !hasPatientName && !hasStudyInstanceUID && !hasStudyDate) {
// TODO: buat startDate tgl H-1 karena terkadang kena Bug beda timezone +-7 di file DICOM nya const startDate = moment().subtract(7, 'days').format('YYYYMMDD');
const startDate = moment().format('YYYYMMDD');
const endDate = moment().format('YYYYMMDD'); const endDate = moment().format('YYYYMMDD');
query.StudyDate = `${startDate}-${endDate}`; query.StudyDate = `${startDate}-${endDate}`;
@@ -361,17 +366,25 @@ module.exports = function (server: FastifyInstance, opts: unknown, done: () => v
//------------------------------------------------------------------ //------------------------------------------------------------------
server.get<{ server.get<{
Params: IParamsImage; Params: IParamsFrame;
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/instances/:sopInstanceUid/frames/:frame', async (req, reply) => { }>('/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 { 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); reply.header('Content-Type', rsp.contentType);
return reply.send(rsp.buffer); return reply.send(rsp.buffer);
} catch (error) { } catch (error) {
const statusCode = statusCodeForError(error);
logger.error(error); logger.error(error);
return reply.send(500); return reply.code(statusCode).send({
error: statusCode === 500 ? 'internal error' : (error as Error).message,
});
} }
}); });

114
tests/dicomJson.test.ts Normal file
View File

@@ -0,0 +1,114 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import dicomParser, { Element } from 'dicom-parser';
import { convertElementToJson, DicomJsonDataSet, parseDicomToJson } from '../src/dimse/dicomJson';
import { buildMonochromeImage, Dataset } from './helpers/dicomWriter';
const BASE_PIXELS = [0, 1, 2, 3, 4, 5, 6, 7];
function crLike(extra: Dataset = [], bufferExtra: Partial<Parameters<typeof buildMonochromeImage>[0]> = {}) {
return buildMonochromeImage({
rows: 2,
columns: 4,
pixelData: BASE_PIXELS,
extra,
...bufferExtra,
});
}
test('omits Window Center/Width when the source tags are absent', () => {
const json = parseDicomToJson(crLike());
assert.equal(json['00281050'], undefined);
assert.equal(json['00281051'], undefined);
});
test('omits Rescale Intercept/Slope when the source tags are absent', () => {
const json = parseDicomToJson(crLike());
assert.equal(json['00281052'], undefined);
assert.equal(json['00281053'], undefined);
});
test('preserves multi-valued Window Center/Width in source order', () => {
const json = parseDicomToJson(crLike([
{ tag: '00281050', vr: 'DS', value: '40\\400' },
{ tag: '00281051', vr: 'DS', value: '80\\800' },
]));
assert.deepEqual(json['00281050'].Value, [40, 400]);
assert.deepEqual(json['00281051'].Value, [80, 800]);
});
test('preserves rescale values when present', () => {
const json = parseDicomToJson(crLike([
{ tag: '00281052', vr: 'DS', value: '-1024' },
{ tag: '00281053', vr: 'DS', value: '2' },
]));
assert.deepEqual(json['00281052'].Value, [-1024]);
assert.deepEqual(json['00281053'].Value, [2]);
});
test('preserves VOI, LUT, presentation LUT and pixel padding metadata', () => {
const json = parseDicomToJson(crLike([
{ tag: '00281055', vr: 'LO', value: 'WINDOW' },
{ tag: '00281056', vr: 'CS', value: 'LINEAR' },
{ tag: '20500020', vr: 'CS', value: 'IDENTITY' },
{ tag: '00280120', vr: 'US', value: 5 },
{ tag: '00280121', vr: 'US', value: 10 },
{
tag: '00283010',
vr: 'SQ',
items: [
[
{ tag: '00283002', vr: 'US', value: [256, 0, 8] },
{ tag: '00283006', vr: 'US', value: [1, 2, 3] },
],
],
},
]));
assert.deepEqual(json['00281055'].Value, ['WINDOW']);
assert.deepEqual(json['00281056'].Value, ['LINEAR']);
assert.deepEqual(json['20500020'].Value, ['IDENTITY']);
assert.deepEqual(json['00280120'].Value, [5]);
assert.deepEqual(json['00280121'].Value, [10]);
const sequence = json['00283010'];
assert.equal(sequence.vr, 'SQ');
const item = (sequence.Value as DicomJsonDataSet[])[0];
assert.deepEqual(item['00283002'].Value, [256, 0, 8]);
assert.deepEqual(item['00283006'].Value, [1, 2, 3]);
});
test('never emits Value: [undefined] for absent optional tags', () => {
const json = parseDicomToJson(crLike());
for (const [tag, element] of Object.entries(json)) {
if (Array.isArray(element.Value)) {
for (const value of element.Value) {
assert.notEqual(value, undefined, `${tag} contains an undefined value`);
}
}
}
});
test('excludes Pixel Data from metadata output', () => {
const json = parseDicomToJson(crLike());
assert.equal(json['7FE00010'], undefined);
});
test('falls back to the data dictionary when VR is not explicit', () => {
const buffer = crLike([
{ tag: '00281050', vr: 'DS', value: '40' },
]);
const dataSet = dicomParser.parseDicom(buffer);
const explicit = dataSet.elements.x00281050;
const implicitLike: Element = {
tag: explicit.tag,
length: explicit.length,
dataOffset: explicit.dataOffset,
};
const json = convertElementToJson(dataSet, implicitLike);
assert.equal(json.vr, 'DS');
assert.deepEqual(json.Value, [40]);
});

View File

@@ -0,0 +1,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);
});

View File

@@ -0,0 +1,174 @@
/**
* Minimal, dependency-free DICOM Part 10 writer used to build synthetic,
* non-PHI fixtures for unit tests. Only the explicit VR little endian
* transfer syntax is produced, which is enough to exercise the metadata
* converter and native frame extraction.
*/
export interface VrElement {
/** Tag in 'ggggeeee' hex form, e.g. '00281050'. */
tag: string;
vr: string;
value?: string | number | number[] | Buffer;
/** Sequence items, only used when vr === 'SQ'. Each item is itself a dataset. */
items?: Dataset[];
}
export type Dataset = VrElement[];
const LONG_VRS = new Set(['OB', 'OD', 'OF', 'OL', 'OV', 'OW', 'SQ', 'SV', 'UC', 'UN', 'UR', 'UT', 'UV']);
const NUMERIC_VRS: Record<string, { width: number; write: (buf: Buffer, value: number, offset: number) => void }> = {
US: { width: 2, write: (b, v, o) => b.writeUInt16LE(v, o) },
SS: { width: 2, write: (b, v, o) => b.writeInt16LE(v, o) },
UL: { width: 4, write: (b, v, o) => b.writeUInt32LE(v, o) },
SL: { width: 4, write: (b, v, o) => b.writeInt32LE(v, o) },
FL: { width: 4, write: (b, v, o) => b.writeFloatLE(v, o) },
FD: { width: 8, write: (b, v, o) => b.writeDoubleLE(v, o) },
};
function paddingByte(vr: string): number {
return vr === 'UI' ? 0x00 : 0x20;
}
function encodeValue(element: VrElement): Buffer {
const { vr, value } = element;
if (vr === 'SQ') {
const items = Array.isArray(element.items) ? element.items : [];
return Buffer.concat(items.map(encodeItem));
}
if (value === undefined) {
return Buffer.alloc(0);
}
if (Buffer.isBuffer(value)) {
return value;
}
if (NUMERIC_VRS[vr]) {
const numbers = Array.isArray(value) ? value : [value as number];
const { width, write } = NUMERIC_VRS[vr];
const buffer = Buffer.alloc(numbers.length * width);
numbers.forEach((number, index) => write(buffer, number, index * width));
return buffer;
}
const text = Array.isArray(value) ? value.join('\\') : String(value);
return Buffer.from(text, 'ascii');
}
function encodeItem(dataset: Dataset): Buffer {
const body = encodeDataset(dataset);
const header = Buffer.alloc(8);
header.writeUInt16LE(0xfffe, 0);
header.writeUInt16LE(0xe000, 2);
header.writeUInt32LE(body.length, 4);
return Buffer.concat([header, body]);
}
export function encodeElement(element: VrElement): Buffer {
const group = Number.parseInt(element.tag.substring(0, 4), 16);
const elementNumber = Number.parseInt(element.tag.substring(4, 8), 16);
let value = encodeValue(element);
if (value.length % 2 !== 0) {
value = Buffer.concat([value, Buffer.from([paddingByte(element.vr)])]);
}
const isLong = LONG_VRS.has(element.vr);
const header = Buffer.alloc(isLong ? 12 : 8);
header.writeUInt16LE(group, 0);
header.writeUInt16LE(elementNumber, 2);
header.write(element.vr, 4, 2, 'ascii');
if (isLong) {
header.writeUInt32LE(value.length, 8);
}
else {
header.writeUInt16LE(value.length, 6);
}
return Buffer.concat([header, value]);
}
export function encodeDataset(dataset: Dataset): Buffer {
return Buffer.concat(dataset.map(encodeElement));
}
export interface Part10Options {
transferSyntaxUID?: string;
sopClassUID?: string;
sopInstanceUID?: string;
}
export function buildPart10(dataset: Dataset, options: Part10Options = {}): Buffer {
const transferSyntaxUID = options.transferSyntaxUID ?? '1.2.840.10008.1.2.1';
const sopClassUID = options.sopClassUID ?? '1.2.840.10008.5.1.4.1.1.7';
const sopInstanceUID = options.sopInstanceUID ?? '1.2.826.0.1.3680043.8.498.1';
const metaElements: Dataset = [
{ tag: '00020001', vr: 'OB', value: Buffer.from([0x00, 0x01]) },
{ tag: '00020002', vr: 'UI', value: sopClassUID },
{ tag: '00020003', vr: 'UI', value: sopInstanceUID },
{ tag: '00020010', vr: 'UI', value: transferSyntaxUID },
{ tag: '00020012', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.1' },
];
const metaBody = encodeDataset(metaElements);
const groupLength = encodeElement({ tag: '00020000', vr: 'UL', value: metaBody.length });
const preamble = Buffer.alloc(128);
const magic = Buffer.from('DICM', 'ascii');
const datasetBytes = encodeDataset(dataset);
return Buffer.concat([preamble, magic, groupLength, metaBody, datasetBytes]);
}
/** Build a synthetic single or multi frame MONOCHROME2 image. */
export function buildMonochromeImage(options: {
rows: number;
columns: number;
bitsAllocated?: number;
samplesPerPixel?: number;
numberOfFrames?: number;
pixelData: number[];
extra?: Dataset;
transferSyntaxUID?: string;
photometricInterpretation?: string;
}): Buffer {
const bitsAllocated = options.bitsAllocated ?? 8;
const samplesPerPixel = options.samplesPerPixel ?? 1;
const dataset: Dataset = [
{ tag: '00080016', vr: 'UI', value: '1.2.840.10008.5.1.4.1.1.7' },
{ tag: '00080018', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.1' },
{ tag: '00080060', vr: 'CS', value: 'CR' },
{ tag: '0020000D', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.100' },
{ tag: '0020000E', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.200' },
{ tag: '00280002', vr: 'US', value: samplesPerPixel },
{ tag: '00280004', vr: 'CS', value: options.photometricInterpretation ?? 'MONOCHROME2' },
{ tag: '00280010', vr: 'US', value: options.rows },
{ tag: '00280011', vr: 'US', value: options.columns },
{ tag: '00280100', vr: 'US', value: bitsAllocated },
{ tag: '00280101', vr: 'US', value: bitsAllocated },
{ tag: '00280102', vr: 'US', value: bitsAllocated - 1 },
{ tag: '00280103', vr: 'US', value: 0 },
];
if (options.numberOfFrames !== undefined) {
dataset.push({ tag: '00280008', vr: 'IS', value: String(options.numberOfFrames) });
}
if (options.extra) {
dataset.push(...options.extra);
}
const pixelData = bitsAllocated <= 8
? Buffer.from(options.pixelData)
: (() => {
const buffer = Buffer.alloc(options.pixelData.length * 2);
options.pixelData.forEach((value, index) => buffer.writeUInt16LE(value, index * 2));
return buffer;
})();
dataset.push({ tag: '7FE00010', vr: bitsAllocated <= 8 ? 'OB' : 'OW', value: pixelData });
return buildPart10(dataset, { transferSyntaxUID: options.transferSyntaxUID });
}

View File

@@ -69,6 +69,7 @@
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "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. */ // "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. */ "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. */ // "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. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */