fix: agar bisa MPR di OHIF #5
@@ -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": {
|
||||
|
||||
125
report/01-dicom-web-proxy-recommendation.md
Normal file
125
report/01-dicom-web-proxy-recommendation.md
Normal 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
286
src/dimse/dicomJson.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import dicomParser, { DataSet, Element, readFixedString } from 'dicom-parser';
|
||||
import { get_element } from '@iwharris/dicom-data-dictionary';
|
||||
|
||||
/**
|
||||
* Minimal DICOM JSON model as defined by DICOM PS3.18 F.
|
||||
* See https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_F.2.html
|
||||
*/
|
||||
export interface DicomJsonElement {
|
||||
vr: string;
|
||||
Value?: unknown[];
|
||||
InlineBinary?: string;
|
||||
BulkDataURI?: string;
|
||||
}
|
||||
|
||||
export type DicomJsonDataSet = Record<string, DicomJsonElement>;
|
||||
|
||||
export interface DicomJsonOptions {
|
||||
/**
|
||||
* Include private (odd group) data elements. Defaults to false to keep
|
||||
* responses small and to avoid leaking site-specific private data.
|
||||
*/
|
||||
includePrivate?: boolean;
|
||||
}
|
||||
|
||||
/** String VRs that map to a JSON array of strings. */
|
||||
const STRING_VRS = new Set([
|
||||
'AE', 'AS', 'CS', 'DA', 'DT', 'LO', 'LT', 'SH', 'ST', 'TM', 'UC', 'UI', 'UR', 'UT',
|
||||
]);
|
||||
|
||||
/** Numeric VRs and the byte-array parser reader used for each. */
|
||||
const NUMBER_VRS: Record<string, 'readUint16' | 'readInt16' | 'readUint32' | 'readInt32' | 'readFloat' | 'readDouble'> = {
|
||||
US: 'readUint16',
|
||||
SS: 'readInt16',
|
||||
UL: 'readUint32',
|
||||
SL: 'readInt32',
|
||||
FL: 'readFloat',
|
||||
FD: 'readDouble',
|
||||
};
|
||||
|
||||
/** VRs whose payload is binary and must be encoded as InlineBinary/BulkDataURI. */
|
||||
const BINARY_VRS = new Set(['OB', 'OW', 'OF', 'OD', 'OL', 'OV', 'UN']);
|
||||
|
||||
/** Pixel data and float pixel data are never part of the metadata response. */
|
||||
const EXCLUDED_TAGS = new Set(['7FE00010', '7FE00008', '7FE00009']);
|
||||
|
||||
function formatKey(key: string): string {
|
||||
return key.replace(/^x/i, '').toUpperCase();
|
||||
}
|
||||
|
||||
function groupOf(tag: string): number {
|
||||
return parseInt(tag.substring(0, 4), 16);
|
||||
}
|
||||
|
||||
function isPrivateTag(tag: string): boolean {
|
||||
return (groupOf(tag) & 0x0001) === 1;
|
||||
}
|
||||
|
||||
function isExcludedTag(tag: string): boolean {
|
||||
if (groupOf(tag) === 0x0002) {
|
||||
return true;
|
||||
}
|
||||
if (EXCLUDED_TAGS.has(tag)) {
|
||||
return true;
|
||||
}
|
||||
// Overlay data lives in groups 6000-60FF, element 3000.
|
||||
const group = groupOf(tag);
|
||||
if (group >= 0x6000 && group <= 0x60ff && tag.substring(4) === '3000') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveAmbiguousVr(vr: string, tag: string, dataSet: DataSet): string {
|
||||
const options = vr.split(/\s+or\s+/i).map((v) => v.trim().toUpperCase());
|
||||
if (options.length === 1) {
|
||||
return options[0];
|
||||
}
|
||||
// Pixel Padding Value/Range Limit are US or SS depending on PixelRepresentation.
|
||||
if ((tag === '00280120' || tag === '00280121') && options.includes('US') && options.includes('SS')) {
|
||||
const pixelRepresentation = dataSet.uint16('x00280103');
|
||||
return pixelRepresentation === 1 ? 'SS' : 'US';
|
||||
}
|
||||
return options[0];
|
||||
}
|
||||
|
||||
function resolveVr(element: Element, tag: string, dataSet: DataSet): string {
|
||||
let vr = element.vr;
|
||||
if (!vr) {
|
||||
const dictionaryEntry = get_element(tag);
|
||||
if (dictionaryEntry) {
|
||||
vr = dictionaryEntry.vr;
|
||||
}
|
||||
}
|
||||
vr = (vr || 'UN').toUpperCase();
|
||||
if (/\bor\b/i.test(vr)) {
|
||||
vr = resolveAmbiguousVr(vr, tag, dataSet);
|
||||
}
|
||||
return vr;
|
||||
}
|
||||
|
||||
function byteArrayParserFor(element: Element, dataSet: DataSet) {
|
||||
return element.parser ?? dataSet.byteArrayParser;
|
||||
}
|
||||
|
||||
function readBinary(dataSet: DataSet, element: Element): Buffer {
|
||||
const byteArray = dataSet.byteArray;
|
||||
if (Buffer.isBuffer(byteArray)) {
|
||||
return byteArray.subarray(element.dataOffset, element.dataOffset + element.length);
|
||||
}
|
||||
return Buffer.from(byteArray.buffer, byteArray.byteOffset + element.dataOffset, element.length);
|
||||
}
|
||||
|
||||
function stripTrailingNul(value: string): string {
|
||||
let end = value.length;
|
||||
while (end > 0 && value.charCodeAt(end - 1) === 0) {
|
||||
end -= 1;
|
||||
}
|
||||
return value.slice(0, end);
|
||||
}
|
||||
|
||||
function readStringValues(dataSet: DataSet, element: Element): string[] {
|
||||
const raw = readFixedString(dataSet.byteArray, element.dataOffset, element.length);
|
||||
const padded = stripTrailingNul(raw).replace(/ +$/g, '');
|
||||
if (padded.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return padded.split('\\').map((value) => value.replace(/ +$/g, ''));
|
||||
}
|
||||
|
||||
function parsePersonName(value: string): Record<string, string> {
|
||||
const [alphabetic, ideographic, phonetic] = value.split('=');
|
||||
const personName: Record<string, string> = {};
|
||||
if (alphabetic) {
|
||||
personName.Alphabetic = alphabetic;
|
||||
}
|
||||
if (ideographic) {
|
||||
personName.Ideographic = ideographic;
|
||||
}
|
||||
if (phonetic) {
|
||||
personName.Phonetic = phonetic;
|
||||
}
|
||||
return personName;
|
||||
}
|
||||
|
||||
function readNumericValues(dataSet: DataSet, element: Element, vr: string): number[] {
|
||||
const readerName = NUMBER_VRS[vr];
|
||||
const reader = byteArrayParserFor(element, dataSet)[readerName];
|
||||
const width = readerName === 'readFloat' || readerName === 'readInt32' || readerName === 'readUint32'
|
||||
? 4
|
||||
: readerName === 'readDouble'
|
||||
? 8
|
||||
: 2;
|
||||
const count = Math.floor(element.length / width);
|
||||
const values: number[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
values.push(reader(dataSet.byteArray, element.dataOffset + i * width));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function readAttributeTags(dataSet: DataSet, element: Element): string[] {
|
||||
const parser = byteArrayParserFor(element, dataSet);
|
||||
const count = Math.floor(element.length / 4);
|
||||
const values: string[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const offset = element.dataOffset + i * 4;
|
||||
const group = parser.readUint16(dataSet.byteArray, offset);
|
||||
const elementNumber = parser.readUint16(dataSet.byteArray, offset + 2);
|
||||
values.push(
|
||||
`${
|
||||
group.toString(16).padStart(4, '0')
|
||||
}${
|
||||
elementNumber.toString(16).padStart(4, '0')}`.toUpperCase(),
|
||||
);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function readDecimalStrings(dataSet: DataSet, element: Element): unknown[] {
|
||||
return readStringValues(dataSet, element).map((value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
});
|
||||
}
|
||||
|
||||
function readIntegerStrings(dataSet: DataSet, element: Element): unknown[] {
|
||||
return readStringValues(dataSet, element).map((value) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
});
|
||||
}
|
||||
|
||||
function convertSequence(dataSet: DataSet, element: Element, options: DicomJsonOptions): DicomJsonElement {
|
||||
const items: DicomJsonDataSet[] = [];
|
||||
if (element.items) {
|
||||
for (const item of element.items) {
|
||||
if (item.dataSet) {
|
||||
items.push(convertDataSetToJson(item.dataSet, options));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { vr: 'SQ', Value: items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a parsed DICOM dataset into DICOM JSON (PS3.18 F).
|
||||
*
|
||||
* The conversion is VR aware: it uses the explicit VR stored on the element
|
||||
* when available and falls back to the DICOM data dictionary otherwise. Absent
|
||||
* attributes are never synthesized, so a source file without Window Center or
|
||||
* Rescale tags will not gain fabricated values.
|
||||
*/
|
||||
export function convertElementToJson(
|
||||
dataSet: DataSet,
|
||||
element: Element,
|
||||
options: DicomJsonOptions = {},
|
||||
): DicomJsonElement {
|
||||
const tag = formatKey(element.tag);
|
||||
const vr = resolveVr(element, tag, dataSet);
|
||||
|
||||
if (vr === 'SQ') {
|
||||
return convertSequence(dataSet, element, options);
|
||||
}
|
||||
|
||||
if (element.length === 0) {
|
||||
return { vr, Value: [] };
|
||||
}
|
||||
|
||||
if (vr === 'PN') {
|
||||
return { vr, Value: readStringValues(dataSet, element).map(parsePersonName) };
|
||||
}
|
||||
|
||||
if (vr === 'DS') {
|
||||
return { vr, Value: readDecimalStrings(dataSet, element) };
|
||||
}
|
||||
|
||||
if (vr === 'IS') {
|
||||
return { vr, Value: readIntegerStrings(dataSet, element) };
|
||||
}
|
||||
|
||||
if (vr === 'AT') {
|
||||
return { vr, Value: readAttributeTags(dataSet, element) };
|
||||
}
|
||||
|
||||
if (NUMBER_VRS[vr]) {
|
||||
return { vr, Value: readNumericValues(dataSet, element, vr) };
|
||||
}
|
||||
|
||||
if (STRING_VRS.has(vr)) {
|
||||
return { vr, Value: readStringValues(dataSet, element) };
|
||||
}
|
||||
|
||||
if (BINARY_VRS.has(vr)) {
|
||||
return { vr, InlineBinary: readBinary(dataSet, element).toString('base64') };
|
||||
}
|
||||
|
||||
// Unknown VR: fall back to an inline binary payload rather than guessing.
|
||||
return { vr, InlineBinary: readBinary(dataSet, element).toString('base64') };
|
||||
}
|
||||
|
||||
export function convertDataSetToJson(
|
||||
dataSet: DataSet,
|
||||
options: DicomJsonOptions = {},
|
||||
): DicomJsonDataSet {
|
||||
const result: DicomJsonDataSet = {};
|
||||
for (const key of Object.keys(dataSet.elements)) {
|
||||
const element = dataSet.elements[key];
|
||||
const tag = formatKey(key);
|
||||
if (isExcludedTag(tag)) {
|
||||
continue;
|
||||
}
|
||||
if (!options.includePrivate && isPrivateTag(tag)) {
|
||||
continue;
|
||||
}
|
||||
result[tag] = convertElementToJson(dataSet, element, options);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseDicomToJson(
|
||||
buffer: Buffer | Uint8Array,
|
||||
options: DicomJsonOptions = {},
|
||||
): DicomJsonDataSet {
|
||||
const dataSet = dicomParser.parseDicom(buffer);
|
||||
return convertDataSetToJson(dataSet, options);
|
||||
}
|
||||
43
src/dimse/errors.ts
Normal file
43
src/dimse/errors.ts
Normal 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
153
src/dimse/frameExtractor.ts
Normal 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);
|
||||
}
|
||||
@@ -1,144 +1,33 @@
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { fileExists } from '../utils/fileHelper';
|
||||
import dicomParser from 'dicom-parser';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { DicomJsonDataSet, parseDicomToJson } from './dicomJson';
|
||||
|
||||
interface ValueType {
|
||||
Value: string[] | number[] | unknown[];
|
||||
vr: string;
|
||||
}
|
||||
type ElementType = Record<string, ValueType>;
|
||||
|
||||
function parseFile(filename: string): Promise<ElementType> {
|
||||
function parseFile(filename: string): Promise<DicomJsonDataSet> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
return new Promise<ElementType>((resolve, reject) => {
|
||||
return new Promise<DicomJsonDataSet>((resolve, reject) => {
|
||||
fileExists(filename).then((success: boolean) => {
|
||||
if (!success) {
|
||||
logger.error(`file does not exist: ${filename}`);
|
||||
return reject();
|
||||
return reject(new Error(`file does not exist: ${filename}`));
|
||||
}
|
||||
|
||||
fs.promises.readFile(filename).then((data: Uint8Array) => {
|
||||
const dataset = dicomParser.parseDicom(data);
|
||||
|
||||
// parse additional needed attributes
|
||||
const patientName = dataset.string('x00100010');
|
||||
const patentID = dataset.string('x00100020');
|
||||
const studyInstanceUID = dataset.string('x0020000d');
|
||||
const studyDate = dataset.string('x00080020');
|
||||
const studyTime = dataset.string('x00080030');
|
||||
const seriesInstanceUID = dataset.string('x0020000e');
|
||||
const seriesNumber = dataset.string('x00200011');
|
||||
const sopInstanceUID = dataset.string('x00080018');
|
||||
const sopClassUID = dataset.string('x00080016');
|
||||
const bitsAllocated = dataset.uint16('x00280100');
|
||||
const bitsStored = dataset.uint16('x00280101');
|
||||
const highBit = dataset.uint16('x00280102');
|
||||
const rows = dataset.uint16('x00280010');
|
||||
const cols = dataset.uint16('x00280011');
|
||||
const pixelSpacingString = dataset.string('x00280030');
|
||||
const pixelSpacing = pixelSpacingString ? pixelSpacingString.split('\\').map((e: string) => parseFloat(e)) : [1, 1];
|
||||
const modality = dataset.string('x00080060');
|
||||
const samplesPerPixel = dataset.uint16('x00280002');
|
||||
const photometricInterpretation = dataset.string('x00280004');
|
||||
const pixelRepresentation = dataset.uint16('x00280103');
|
||||
const windowCenter = dataset.string('x00281050');
|
||||
const wc = windowCenter ? parseFloat(windowCenter.split('\\')[0]) : 40;
|
||||
const windowWidth = dataset.string('x00281051');
|
||||
const ww = windowWidth ? parseFloat(windowWidth.split('\\')[0]) : 80;
|
||||
const rescaleIntercept = parseFloat(dataset.string('x00281052') || '1');
|
||||
const rescaleSlope = parseFloat(dataset.string('x00281053') || '1');
|
||||
const iopString = dataset.string('x00200037');
|
||||
const iop = iopString ? iopString.split('\\').map((e: string) => parseFloat(e)) : null;
|
||||
const ippString = dataset.string('x00200032');
|
||||
const ipp = ippString ? ippString.split('\\').map((e: string) => parseFloat(e)) : null;
|
||||
const instanceNumber = dataset.string('x00200013');
|
||||
const sliceThickness = dataset.string('x00180050');
|
||||
const sliceLocation = dataset.string('x00201041');
|
||||
|
||||
// TODO: buat ini dynamic berdasarkan includefields atau modality
|
||||
// TODO: hapus tambahan di bawah ini yang tidak diperlukan
|
||||
|
||||
// MR-specific tags for overlay
|
||||
const spacingBetweenSlices = dataset.string('x00180088');
|
||||
const percentPhaseFieldOfView = dataset.string('x00180094');
|
||||
const fovDimensionElement = dataset.elements.x00181149;
|
||||
const fovDimension = fovDimensionElement ? [dataset.uint16('x00181149', 0), dataset.uint16('x00181149', 1)] : null;
|
||||
const acquisitionMatrixElement = dataset.elements.x00181310;
|
||||
const acquisitionMatrix = acquisitionMatrixElement
|
||||
? [dataset.uint16('x00181310', 0), dataset.uint16('x00181310', 1), dataset.uint16('x00181310', 2), dataset.uint16('x00181310', 3)]
|
||||
: null;
|
||||
const scanningSequence = dataset.string('x00180020');
|
||||
const repetitionTime = dataset.string('x00180080');
|
||||
const echoTime = dataset.string('x00180081');
|
||||
const inversionTime = dataset.string('x00180082');
|
||||
const receiveCoilName = dataset.string('x00181250');
|
||||
const mrAcquisitionType = dataset.string('x00180023');
|
||||
const phaseEncodingDirection = dataset.string('x00181312');
|
||||
const numOfAverages = dataset.string('x00180083');
|
||||
const echoTrainLength = dataset.string('x00180091');
|
||||
const flipAngle = dataset.string('x00181314');
|
||||
const pixelBandwidth = dataset.string('x00180095');
|
||||
const acquisitionTime = dataset.string('x00080032');
|
||||
const acquistionDurationTotal = dataset.string('x00189073'); // in seconds
|
||||
const acquistionDurationPerFrame = dataset.string('x00189220'); // in ms
|
||||
const parallelAcquisitionTechnique = dataset.string('x00189078');
|
||||
|
||||
// append to all results
|
||||
const result: ElementType = {
|
||||
'00100010': { Value: [{ Alphabetic: patientName }], vr: 'PN' },
|
||||
'00100020': { Value: [patentID], vr: 'LO' },
|
||||
'0020000D': { Value: [studyInstanceUID], vr: 'UI' },
|
||||
'00080020': { Value: [studyDate], vr: 'DA' },
|
||||
'00080030': { Value: [studyTime], vr: 'TM' },
|
||||
'0020000E': { Value: [seriesInstanceUID], vr: 'UI' },
|
||||
'00200011': { Value: [seriesNumber], vr: 'IS' },
|
||||
'00080018': { Value: [sopInstanceUID], vr: 'UI' },
|
||||
'00080016': { Value: [sopClassUID], vr: 'UI' },
|
||||
'00080060': { Value: [modality], vr: 'CS' },
|
||||
'00280002': { Value: [samplesPerPixel], vr: 'US' },
|
||||
'00280004': { Value: [photometricInterpretation], vr: 'CS' },
|
||||
'00280010': { Value: [rows], vr: 'US' },
|
||||
'00280011': { Value: [cols], vr: 'US' },
|
||||
'00280030': { Value: pixelSpacing, vr: 'DS' },
|
||||
'00280100': { Value: [bitsAllocated], vr: 'US' },
|
||||
'00280101': { Value: [bitsStored], vr: 'US' },
|
||||
'00280102': { Value: [highBit], vr: 'US' },
|
||||
'00280103': { Value: [pixelRepresentation], vr: 'US' },
|
||||
'00281050': { Value: [wc], vr: 'DS' },
|
||||
'00281051': { Value: [ww], vr: 'DS' },
|
||||
'00281052': { Value: [rescaleIntercept], vr: 'DS' },
|
||||
'00281053': { Value: [rescaleSlope], vr: 'DS' },
|
||||
...(iop && { '00200037': { Value: iop, vr: 'DS' } }),
|
||||
...(ipp && { '00200032': { Value: ipp, vr: 'DS' } }),
|
||||
'00200013': { Value: [instanceNumber], vr: 'IS' },
|
||||
'00180050': { Value: [sliceThickness], vr: 'DS' },
|
||||
'00201041': { Value: [sliceLocation], vr: 'DS' },
|
||||
'00180088': { Value: [spacingBetweenSlices], vr: 'DS' },
|
||||
'00180094': { Value: [percentPhaseFieldOfView], vr: 'DS' },
|
||||
...(fovDimension && { '00181149': { Value: fovDimension, vr: 'IS' } }),
|
||||
...(acquisitionMatrix && { '00181310': { Value: acquisitionMatrix, vr: 'US' } }),
|
||||
'00180020': { Value: [scanningSequence], vr: 'CS' },
|
||||
'00180080': { Value: [repetitionTime], vr: 'DS' },
|
||||
'00180081': { Value: [echoTime], vr: 'DS' },
|
||||
'00180082': { Value: [inversionTime], vr: 'DS' },
|
||||
'00181250': { Value: [receiveCoilName], vr: 'SH' },
|
||||
'00180023': { Value: [mrAcquisitionType], vr: 'CS' },
|
||||
'00181312': { Value: [phaseEncodingDirection], vr: 'CS' },
|
||||
'00180083': { Value: [numOfAverages], vr: 'DS' },
|
||||
'00180091': { Value: [echoTrainLength], vr: 'IS' },
|
||||
'00181314': { Value: [flipAngle], vr: 'DS' },
|
||||
'00180095': { Value: [pixelBandwidth], vr: 'DS' },
|
||||
'00080032': { Value: [acquisitionTime], vr: 'TM' },
|
||||
'00189073': { Value: [acquistionDurationTotal], vr: 'DS' },
|
||||
'00189220': { Value: [acquistionDurationPerFrame], vr: 'FD' },
|
||||
'00189078': { Value: [parallelAcquisitionTechnique], vr: 'CS' },
|
||||
};
|
||||
|
||||
resolve(result);
|
||||
});
|
||||
fs.promises.readFile(filename)
|
||||
.then((data: Uint8Array) => {
|
||||
try {
|
||||
resolve(parseDicomToJson(data));
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`failed to parse metadata from ${filename}: ${error}`);
|
||||
reject(error);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error(`failed to read ${filename}: ${error}`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -147,7 +36,7 @@ export function parseMeta(json: object, studyInstanceUID: string, seriesInstance
|
||||
const logger = LoggerSingleton.Instance;
|
||||
logger.info(`parsing series ${seriesInstanceUID}`);
|
||||
|
||||
const parsing = new Array<Promise<ElementType>>();
|
||||
const parsing = new Array<Promise<DicomJsonDataSet>>();
|
||||
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
|
||||
for (const [key] of Object.entries(json)) {
|
||||
const sopInstanceUid = json[key]['00080018'].Value[0];
|
||||
|
||||
@@ -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<Buffer> {
|
||||
async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo, frame }: AddFileToBuffer): Promise<Buffer> {
|
||||
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<WadoRsResponse> {
|
||||
export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat, frame }: WadoRsArgs): Promise<WadoRsResponse> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
114
tests/dicomJson.test.ts
Normal file
114
tests/dicomJson.test.ts
Normal 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]);
|
||||
});
|
||||
107
tests/frameExtractor.test.ts
Normal file
107
tests/frameExtractor.test.ts
Normal 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);
|
||||
});
|
||||
174
tests/helpers/dicomWriter.ts
Normal file
174
tests/helpers/dicomWriter.ts
Normal 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 });
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user