fix: agar bisa MPR di OHIF #5

Open
mario wants to merge 3 commits from feature/dicomweb-proxy-recommendation into phkg
5 changed files with 355 additions and 13 deletions
Showing only changes of commit 3ac04f9147 - Show all commits

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

@@ -0,0 +1,43 @@
/**
* Typed, status-bearing errors so that routes can translate service failures
* into the right HTTP status code instead of always returning 500.
*/
export class HttpError extends Error {
public readonly statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.name = 'HttpError';
this.statusCode = statusCode;
// Restore prototype chain when targeting ES5-ish runtimes.
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class BadRequestError extends HttpError {
constructor(message: string) {
super(400, message);
this.name = 'BadRequestError';
}
}
export class UnsupportedMediaTypeError extends HttpError {
constructor(message: string) {
super(415, message);
this.name = 'UnsupportedMediaTypeError';
}
}
export class NotImplementedError extends HttpError {
constructor(message: string) {
super(501, message);
this.name = 'NotImplementedError';
}
}
export function statusCodeForError(error: unknown): number {
if (error instanceof HttpError) {
return error.statusCode;
}
return 500;
}

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

@@ -0,0 +1,153 @@
import dicomParser, { DataSet, Element } from 'dicom-parser';
import { BadRequestError, HttpError, UnsupportedMediaTypeError } from './errors';
/** Transfer syntaxes whose Pixel Data can be sliced directly as native frames. */
export const UNCOMPRESSED_TRANSFER_SYNTAXES = new Set([
'1.2.840.10008.1.2', // Implicit VR Little Endian
'1.2.840.10008.1.2.1', // Explicit VR Little Endian
'1.2.840.10008.1.2.2', // Explicit VR Big Endian
]);
const PIXEL_DATA_TAG = 'x7fe00010';
export interface ExtractedFrame {
/** The 1-based frame number that was requested. */
frame: number;
/** NumberOfFrames from the dataset, 1 when the tag is absent. */
numberOfFrames: number;
/** Transfer syntax UID of the parsed object, if present. */
transferSyntax: string;
/** Size in bytes of a single native frame. */
frameSize: number;
/** Exactly one frame worth of Pixel Data bytes. */
buffer: Buffer;
}
/**
* Parse a 1-based frame number from a route parameter. Returns undefined for
* missing, non-numeric, zero, negative or fractional values so callers can
* respond with 400.
*/
export function parseFrameParam(value: string | undefined): number | undefined {
if (value === undefined || !/^\d+$/.test(value)) {
return undefined;
}
const frame = Number.parseInt(value, 10);
if (!Number.isInteger(frame) || frame < 1) {
return undefined;
}
return frame;
}
function isEncapsulated(pixelDataElement: Element): boolean {
return pixelDataElement.encapsulatedPixelData === true
|| pixelDataElement.hadUndefinedLength === true
|| (Array.isArray(pixelDataElement.fragments) && pixelDataElement.fragments.length > 0);
}
function readNumberOfFrames(dataSet: DataSet): number {
const fromString = dataSet.intString('x00280008');
if (typeof fromString === 'number' && Number.isFinite(fromString)) {
return fromString;
}
const fromUint16 = dataSet.uint16('x00280008');
if (typeof fromUint16 === 'number' && Number.isFinite(fromUint16)) {
return fromUint16;
}
return 1;
}
function requireUint16(dataSet: DataSet, tag: string, name: string): number {
const value = dataSet.uint16(tag);
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new HttpError(500, `cannot extract frame: missing or invalid ${name}`);
}
return value;
}
/**
* Extract a single native (uncompressed) frame from a parsed DICOM dataset.
*
* The returned buffer contains exactly one frame worth of bytes; it never
* falls back to returning the full Pixel Data element. Encapsulated/compressed
* Pixel Data is rejected with an explicit unsupported response until real
* compressed frame extraction is implemented.
*/
export function extractFrame(dataSet: DataSet, frame: number): ExtractedFrame {
if (!Number.isInteger(frame) || frame < 1) {
throw new BadRequestError(`frame must be a positive integer, received '${frame}'`);
}
const transferSyntaxElement = dataSet.elements.x00020010;
const transferSyntax = transferSyntaxElement
? (dataSet.string('x00020010') ?? '')
: '';
const pixelDataElement = dataSet.elements[PIXEL_DATA_TAG];
if (!pixelDataElement) {
throw new HttpError(500, 'cannot extract frame: Pixel Data (7FE0,0010) not present');
}
if (transferSyntax && !UNCOMPRESSED_TRANSFER_SYNTAXES.has(transferSyntax)) {
throw new UnsupportedMediaTypeError(
`compressed frame extraction is not supported for transfer syntax ${transferSyntax}`,
);
}
if (isEncapsulated(pixelDataElement)) {
throw new UnsupportedMediaTypeError(
'encapsulated (compressed) Pixel Data frame extraction is not supported',
);
}
const numberOfFrames = readNumberOfFrames(dataSet);
if (frame > numberOfFrames) {
throw new BadRequestError(
`frame ${frame} is out of range, instance has ${numberOfFrames} frame(s)`,
);
}
const rows = requireUint16(dataSet, 'x00280010', 'Rows (0028,0010)');
const columns = requireUint16(dataSet, 'x00280011', 'Columns (0028,0011)');
const samplesPerPixel = requireUint16(dataSet, 'x00280002', 'SamplesPerPixel (0028,0002)');
const bitsAllocated = requireUint16(dataSet, 'x00280100', 'BitsAllocated (0028,0100)');
let frameSize: number;
if (bitsAllocated === 1) {
frameSize = Math.ceil((rows * columns * samplesPerPixel) / 8);
}
else if (bitsAllocated % 8 === 0) {
frameSize = rows * columns * samplesPerPixel * (bitsAllocated / 8);
}
else {
throw new HttpError(500, `cannot extract frame: unsupported BitsAllocated ${bitsAllocated}`);
}
const requiredLength = frame * frameSize;
if (pixelDataElement.length < requiredLength) {
throw new HttpError(
500,
`cannot extract frame ${frame}: Pixel Data is too short `
+ `(${pixelDataElement.length} bytes, need ${requiredLength})`,
);
}
const offset = pixelDataElement.dataOffset + (frame - 1) * frameSize;
const byteArray = dataSet.byteArray;
const buffer = Buffer.isBuffer(byteArray)
? byteArray.subarray(offset, offset + frameSize)
: Buffer.from(byteArray.buffer, byteArray.byteOffset + offset, frameSize);
return {
frame,
numberOfFrames,
transferSyntax,
frameSize,
buffer: Buffer.from(buffer),
};
}
export function extractFrameFromBuffer(buffer: Buffer | Uint8Array, frame: number): ExtractedFrame {
const dataSet = dicomParser.parseDicom(buffer);
return extractFrame(dataSet, frame);
}

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { extractFrameFromBuffer, parseFrameParam } from '../src/dimse/frameExtractor';
import { BadRequestError, HttpError, statusCodeForError, UnsupportedMediaTypeError } from '../src/dimse/errors';
import { buildMonochromeImage } from './helpers/dicomWriter';
function twoFrameImage() {
return buildMonochromeImage({
rows: 2,
columns: 2,
bitsAllocated: 8,
samplesPerPixel: 1,
numberOfFrames: 2,
pixelData: [1, 2, 3, 4, 5, 6, 7, 8],
});
}
test('/frames/1 returns only the first frame byte range', () => {
const extracted = extractFrameFromBuffer(twoFrameImage(), 1);
assert.deepEqual([...extracted.buffer], [1, 2, 3, 4]);
assert.equal(extracted.frameSize, 4);
assert.equal(extracted.numberOfFrames, 2);
});
test('/frames/2 returns only the second frame byte range', () => {
const extracted = extractFrameFromBuffer(twoFrameImage(), 2);
assert.deepEqual([...extracted.buffer], [5, 6, 7, 8]);
});
test('/frames/2 for a single-frame instance is rejected with 400', () => {
const singleFrame = buildMonochromeImage({
rows: 2,
columns: 2,
bitsAllocated: 8,
pixelData: [1, 2, 3, 4],
});
assert.throws(
() => extractFrameFromBuffer(singleFrame, 2),
(error: unknown) => error instanceof BadRequestError && statusCodeForError(error) === 400,
);
});
test('extracts 16-bit frames with the correct byte offsets', () => {
const image = buildMonochromeImage({
rows: 2,
columns: 2,
bitsAllocated: 16,
samplesPerPixel: 1,
numberOfFrames: 2,
pixelData: [256, 257, 258, 259, 260, 261, 262, 263],
});
const second = extractFrameFromBuffer(image, 2);
const values = [0, 2, 4, 6].map((offset) => second.buffer.readUInt16LE(offset));
assert.deepEqual(values, [260, 261, 262, 263]);
});
test('rejects out-of-range and zero frame numbers with 400', () => {
const image = twoFrameImage();
assert.throws(
() => extractFrameFromBuffer(image, 0),
(error: unknown) => error instanceof BadRequestError,
);
assert.throws(
() => extractFrameFromBuffer(image, 3),
(error: unknown) => error instanceof BadRequestError,
);
});
test('rejects compressed transfer syntax with an explicit unsupported response', () => {
const image = buildMonochromeImage({
rows: 2,
columns: 2,
bitsAllocated: 8,
numberOfFrames: 2,
pixelData: [1, 2, 3, 4, 5, 6, 7, 8],
transferSyntaxUID: '1.2.840.10008.1.2.4.80',
});
assert.throws(
() => extractFrameFromBuffer(image, 2),
(error: unknown) => error instanceof UnsupportedMediaTypeError && statusCodeForError(error) === 415,
);
});
test('treats a too-short Pixel Data element as a 500 data error', () => {
const image = buildMonochromeImage({
rows: 4,
columns: 4,
bitsAllocated: 8,
pixelData: [1, 2, 3, 4],
});
assert.throws(
() => extractFrameFromBuffer(image, 1),
(error: unknown) => error instanceof HttpError && statusCodeForError(error) === 500,
);
});
test('parseFrameParam accepts only positive base-10 integers', () => {
assert.equal(parseFrameParam('1'), 1);
assert.equal(parseFrameParam('42'), 42);
assert.equal(parseFrameParam('0'), undefined);
assert.equal(parseFrameParam('-1'), undefined);
assert.equal(parseFrameParam('abc'), undefined);
assert.equal(parseFrameParam('1.5'), undefined);
assert.equal(parseFrameParam(' 1'), undefined);
assert.equal(parseFrameParam(''), undefined);
assert.equal(parseFrameParam(undefined), undefined);
});