Files
dicom-proxy/src/dimse/frameExtractor.ts
padmanto 3ac04f9147 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
2026-09-18 14:16:16 +07:00

154 lines
5.3 KiB
TypeScript

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