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 dicomParser from 'dicom-parser';
|
||||||
import combineMerge from '../utils/combineMerge';
|
import combineMerge from '../utils/combineMerge';
|
||||||
import { fileExists } from '../utils/fileHelper';
|
import { fileExists } from '../utils/fileHelper';
|
||||||
|
import { extractFrame } from './frameExtractor';
|
||||||
import { execFile as exFile } from 'child_process';
|
import { execFile as exFile } from 'child_process';
|
||||||
import util from 'util';
|
import util from 'util';
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ type WadoRsArgs = {
|
|||||||
seriesInstanceUid?: string;
|
seriesInstanceUid?: string;
|
||||||
sopInstanceUid?: string;
|
sopInstanceUid?: string;
|
||||||
dataFormat?: DataFormat;
|
dataFormat?: DataFormat;
|
||||||
frame?: number | number[];
|
frame?: number;
|
||||||
};
|
};
|
||||||
type WadoRsResponse = {
|
type WadoRsResponse = {
|
||||||
contentType: string;
|
contentType: string;
|
||||||
@@ -91,9 +92,10 @@ interface AddFileToBuffer {
|
|||||||
filename: string,
|
filename: string,
|
||||||
instanceInfo: InstanceInfo,
|
instanceInfo: InstanceInfo,
|
||||||
dataFormat?: DataFormat,
|
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 logger = LoggerSingleton.Instance;
|
||||||
const filepath = path.join(pathname, filename);
|
const filepath = path.join(pathname, filename);
|
||||||
const buffArray: Buffer[] = [];
|
const buffArray: Buffer[] = [];
|
||||||
@@ -108,7 +110,11 @@ async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }:
|
|||||||
contentLocation += `/series/${instanceInfo.series}`
|
contentLocation += `/series/${instanceInfo.series}`
|
||||||
}
|
}
|
||||||
if (instanceInfo.instance) {
|
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
|
// Compress the file
|
||||||
@@ -123,8 +129,7 @@ async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }:
|
|||||||
const data = await fs.readFile(filepath);
|
const data = await fs.readFile(filepath);
|
||||||
let returnData;
|
let returnData;
|
||||||
switch (dataFormat) {
|
switch (dataFormat) {
|
||||||
case 'bulkdata':
|
case 'bulkdata': {
|
||||||
case 'pixeldata': {
|
|
||||||
// Get the pixeldata from the DICOM and add it to the buffer.
|
// Get the pixeldata from the DICOM and add it to the buffer.
|
||||||
const dataset = dicomParser.parseDicom(data);
|
const dataset = dicomParser.parseDicom(data);
|
||||||
const pixeldataElement = dataset.elements.x7fe00010;
|
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);
|
returnData = Buffer.from(dataset.byteArray.buffer, pixeldataElement.dataOffset, pixeldataElement.length);
|
||||||
break;
|
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': {
|
case 'rendered': {
|
||||||
// Convert the DCM file to a JPEG and return that
|
// Convert the DCM file to a JPEG and return that
|
||||||
buffArray.push(Buffer.from(`Content-Type:image/jpeg;${term}`));
|
buffArray.push(Buffer.from(`Content-Type:image/jpeg;${term}`));
|
||||||
@@ -157,7 +182,7 @@ type InstanceInfo = {
|
|||||||
instance?: string
|
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;
|
const logger = LoggerSingleton.Instance;
|
||||||
// Set up all the paths and query levels.
|
// Set up all the paths and query levels.
|
||||||
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
|
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) => {
|
buffers = await Promise.all(files.map(async (file) => {
|
||||||
const instanceInfo = foundInstances.find((i) => i.instance === file)
|
const instanceInfo = foundInstances.find((i) => i.instance === file)
|
||||||
if (instanceInfo) {
|
if (instanceInfo) {
|
||||||
return addFileToBuffer({ pathname, filename: file, dataFormat, instanceInfo });
|
return addFileToBuffer({ pathname, filename: file, dataFormat, instanceInfo, frame });
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Attach the one file that we need to the return buffer
|
// Attach the one file that we need to the return buffer
|
||||||
const instanceInfo = { study: studyInstanceUid, series: seriesInstanceUid, instance: sopInstanceUid }
|
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
|
// 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 { QUERY_LEVEL } from '../dimse/querLevel';
|
||||||
import { doWadoRs } from '../dimse/wadoRs';
|
import { doWadoRs } from '../dimse/wadoRs';
|
||||||
import { doWadoUri } from '../dimse/wadoUri';
|
import { doWadoUri } from '../dimse/wadoUri';
|
||||||
|
import { parseFrameParam } from '../dimse/frameExtractor';
|
||||||
|
import { statusCodeForError } from '../dimse/errors';
|
||||||
import { LoggerSingleton } from '../utils/logger';
|
import { LoggerSingleton } from '../utils/logger';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import deepmerge from 'deepmerge';
|
import deepmerge from 'deepmerge';
|
||||||
@@ -25,6 +27,10 @@ interface IParamsImage extends IParamsSeries {
|
|||||||
sopInstanceUid: string;
|
sopInstanceUid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IParamsFrame extends IParamsImage {
|
||||||
|
frame: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface IQueryImage {
|
interface IQueryImage {
|
||||||
studyUID: string;
|
studyUID: string;
|
||||||
seriesUID: string;
|
seriesUID: string;
|
||||||
@@ -40,7 +46,7 @@ module.exports = function (server: FastifyInstance, opts: unknown, done: () => v
|
|||||||
Querystring: QueryParams;
|
Querystring: QueryParams;
|
||||||
}>('/rs/studies', async (req, reply) => {
|
}>('/rs/studies', async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
var { query } = req;
|
const { query } = req;
|
||||||
|
|
||||||
// Tambahkan filter StudyDate hanya ketika tidak ada: Medrec, AccessionNo, PatientName, StudyInstanceUID, dan StudyDate
|
// Tambahkan filter StudyDate hanya ketika tidak ada: Medrec, AccessionNo, PatientName, StudyInstanceUID, dan StudyDate
|
||||||
// Params: &00100020=MRN, AccessionNumber=, PatientName=, StudyDate=
|
// Params: &00100020=MRN, AccessionNumber=, PatientName=, StudyDate=
|
||||||
@@ -360,17 +366,25 @@ module.exports = function (server: FastifyInstance, opts: unknown, done: () => v
|
|||||||
//------------------------------------------------------------------
|
//------------------------------------------------------------------
|
||||||
|
|
||||||
server.get<{
|
server.get<{
|
||||||
Params: IParamsImage;
|
Params: IParamsFrame;
|
||||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/instances/:sopInstanceUid/frames/:frame', async (req, reply) => {
|
}>('/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 {
|
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);
|
reply.header('Content-Type', rsp.contentType);
|
||||||
return reply.send(rsp.buffer);
|
return reply.send(rsp.buffer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const statusCode = statusCodeForError(error);
|
||||||
logger.error(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