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