fix: agar bisa MPR di OHIF #5
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