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
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user