diff --git a/src/dimse/errors.ts b/src/dimse/errors.ts new file mode 100644 index 0000000..1a88223 --- /dev/null +++ b/src/dimse/errors.ts @@ -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; +} diff --git a/src/dimse/frameExtractor.ts b/src/dimse/frameExtractor.ts new file mode 100644 index 0000000..3aa1021 --- /dev/null +++ b/src/dimse/frameExtractor.ts @@ -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); +} diff --git a/src/dimse/wadoRs.ts b/src/dimse/wadoRs.ts index 55d0668..f0408c1 100644 --- a/src/dimse/wadoRs.ts +++ b/src/dimse/wadoRs.ts @@ -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 { +async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo, frame }: AddFileToBuffer): Promise { 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 { +export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat, frame }: WadoRsArgs): Promise { 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 diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 0245682..2ab9670 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -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, + }); } }); diff --git a/tests/frameExtractor.test.ts b/tests/frameExtractor.test.ts new file mode 100644 index 0000000..300fdde --- /dev/null +++ b/tests/frameExtractor.test.ts @@ -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); +});