UNPKG

dicomweb-proxy

Version:

A proxy to translate between dicomweb and dimse

288 lines (266 loc) 9.93 kB
import { ConfParams, config } from '../utils/config'; import { LoggerSingleton } from '../utils/logger'; import { waitOrFetchData } from './fetchData'; import { compressFile } from './compressFile'; import { doFind } from '../dimse/findData'; import path from 'path'; import fs from 'fs/promises'; import { QUERY_LEVEL } from './querLevel'; import deepmerge from 'deepmerge'; import dicomParser from 'dicom-parser'; import combineMerge from '../utils/combineMerge'; import { fileExists } from '../utils/fileHelper'; import { execFile as exFile } from 'child_process'; import util from 'util'; const execFile = util.promisify(exFile); export type DataFormat = 'pixeldata' | 'bulkdata' | 'rendered' | 'thumbnail'; type WadoRsArgs = { studyInstanceUid: string; seriesInstanceUid?: string; sopInstanceUid?: string; dataFormat?: DataFormat; frame?: number | number[]; }; type WadoRsResponse = { contentType: string; buffer: Buffer; }; type QidoResponse = { [key: string]: { Value: string[]; vr: string; }; }; const term = '\r\n'; /** * This function uses DCMTK to convert the given DICOM file to JPEG format * It will try twice, once without frames, the other with all frames. * If both fail it will throw the error returned by DCMTK. * If no resulting JPEG can be found, then it will return undefined. * * @param filepath Path to the file to convert * @param [asThumbnail=false] Return as thumbnail */ async function convertToJpeg(filepath: string, asThumbnail = false) { try { await execFile('dcmj2pnm', ['+oj', '+Jq', asThumbnail ? '10' : '100', filepath, `${filepath}.jpg`]); } catch (e) { // Try again but with all frames - if this fails don't catch the error (fail!) await execFile('dcmj2pnm', ['+oj', '+Jq', asThumbnail ? '10' : '100', '+Fa', filepath, `${filepath}`]); } let exists = await fileExists(`${filepath}.jpg`); let filePath; if (exists) { filePath = `${filepath}.jpg`; } else { exists = await fileExists(`${filepath}.0.jpg`); if (exists) { filePath = `${filepath}.0.jpg`; } } if (exists && filePath) { return fs.readFile(filePath); } return undefined; } /** * Compresses (if needed) the DCM file and then adds the required data to the return buffer: * bulkdata and PixelData return the DCM pixeldata buffer * rendered returns a JPEG file buffer * otherwise returns a DICOM file buffer * * Attaches needed headers */ interface AddFileToBuffer { pathname: string; filename: string; instanceInfo: InstanceInfo; dataFormat?: DataFormat; } async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }: AddFileToBuffer): Promise<Buffer> { const logger = LoggerSingleton.Instance; const filepath = path.join(pathname, filename); const buffArray: Buffer[] = []; let transferSyntax; // If there is a data format, use default compression if (dataFormat) { transferSyntax = '1.2.840.10008.1.2'; } let contentLocation = `/studies/${instanceInfo.study}`; if (instanceInfo.series) { contentLocation += `/series/${instanceInfo.series}`; } if (instanceInfo.instance) { contentLocation += `/instance/${instanceInfo.instance}`; } // Compress the file try { await compressFile(filepath, pathname, transferSyntax); } catch (e) { logger.error('Failed to compress', filepath); } // This will throw out if the file doesn't OK (but that's what we want) const data = await fs.readFile(filepath); let returnData; switch (dataFormat) { case 'bulkdata': { // Get the value from the DICOM and add it to the buffer. const dataset = dicomParser.parseDicom(data); const dataElement = dataset.elements.x00420011; // for the moment restrict this to bulkdata from encapsulated pdfs buffArray.push(Buffer.from(`Content-Type:application/octet-stream;${term}`)); returnData = dataElement ? Buffer.from(dataset.byteArray.buffer, dataElement.dataOffset, dataElement.length) : Buffer.from([]); break; } case 'pixeldata': { // Get the pixeldata from the DICOM and add it to the buffer. const dataset = dicomParser.parseDicom(data); const pixeldataElement = dataset.elements.x7fe00010; buffArray.push(Buffer.from(`Content-Type:application/octet-stream;${term}`)); returnData = pixeldataElement ? Buffer.from(dataset.byteArray.buffer, pixeldataElement.dataOffset, pixeldataElement.length) : Buffer.from([]); break; } case 'rendered': { // Convert the DCM file to a JPEG and return that buffArray.push(Buffer.from(`Content-Type:image/jpeg;${term}`)); returnData = await convertToJpeg(filepath); break; } default: { // Just return the DCM file buffArray.push(Buffer.from(`Content-Type:${config.get(ConfParams.MIMETYPE)};transfer-syntax:${config.get(ConfParams.XTRANSFER)}${term}`)); returnData = data; } } buffArray.push(Buffer.from(`Content-Location:${contentLocation};${term}`)); buffArray.push(Buffer.from(term)); buffArray.push(returnData); buffArray.push(Buffer.from(term)); return Buffer.concat(buffArray); } type InstanceInfo = { study: string; series?: string; instance?: string; }; export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat }: WadoRsArgs): Promise<WadoRsResponse> { const logger = LoggerSingleton.Instance; // Set up all the paths and query levels. const storagePath = config.get(ConfParams.STORAGE_PATH) as string; let queryLevel = QUERY_LEVEL.STUDY; const studyPath = path.join(storagePath, studyInstanceUid); let pathname = studyPath; let filename = ''; if (seriesInstanceUid) { queryLevel = QUERY_LEVEL.SERIES; } if (sopInstanceUid) { filename = sopInstanceUid; pathname = path.join(pathname, sopInstanceUid); } // Is the path that we have a directory or a file? let isDir = true; if (await fileExists(pathname)) { const stat = await fs.stat(pathname); isDir = await stat.isDirectory(); } let useCache = false; const foundInstances: InstanceInfo[] = []; if (isDir) { // It's a directory, what things do we expect to find in this directory for this search? const json = deepmerge.all( await doFind(QUERY_LEVEL.IMAGE, { StudyInstanceUID: studyInstanceUid, SeriesInstanceUID: seriesInstanceUid ?? '', SOPInstanceUID: sopInstanceUid ?? '', }), { arrayMerge: combineMerge } ) as QidoResponse[]; const foundPromises = await Promise.all( json.map(async (instance) => { if (instance['00080018']) { const instanceUid = instance['00080018'].Value[0]; const seriesUid = instance['0020000E'].Value[0]; foundInstances.push({ study: studyInstanceUid, series: seriesUid, instance: instanceUid, }); return fileExists(path.join(studyPath, instanceUid)); } return true; }) ); // If all of the files for this search exist, then we're gonna use the cache! useCache = foundPromises.reduce((prev, curr) => prev && curr, true); } else { // If the file exists, use the cache useCache = await fileExists(pathname); } if (!useCache) { // We're not using the cache, so go and fetch the files. This will happen even if just one file is missing. // Could this be improved to just get the needed files? logger.info(`fetching ${pathname}`); await waitOrFetchData(studyInstanceUid, seriesInstanceUid ?? '', sopInstanceUid ?? '', queryLevel); } // We only need a thumbnail - get it and bail. if (dataFormat === 'thumbnail') { // Just use the first of the foundInstances for this search const filePath = isDir ? path.join(pathname, foundInstances[0].instance as string) : pathname; const buff = await convertToJpeg(filePath, true); if (buff) { return { contentType: 'image/jpeg', buffer: buff, }; } else { throw new Error('Failed to create thumbnail'); } } let buffers: (Buffer | undefined)[] = []; try { if (isDir) { // We're in a directory, loop through the files we want and attach them to the return buffer const files = await fs.readdir(pathname); 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 }); } }) ); } 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 })]; } // Set up the boundaries and join together all of the file buffers to form // the final buffer to return to the client. const boundary = studyInstanceUid; const buffArray: Buffer[] = []; buffers = buffers.filter((b: Buffer | undefined) => !!b); buffers.forEach(async (buff) => { if (buff) { buffArray.push(Buffer.from(`--${boundary}${term}`)); buffArray.push(buff); } }); buffArray.push(Buffer.from(`--${boundary}--${term}`)); // We need to set the correct contentType depending on what was asked for. let type = 'application/dicom'; if (dataFormat === 'rendered') { type = 'image/jpeg'; } if (dataFormat?.match(/bulkdata|pixeldata/gi)) { type = 'application/octet-stream'; } const contentType = `multipart/related;type='${type}';boundary=${boundary}`; return Promise.resolve({ contentType, buffer: Buffer.concat(buffArray), }); } catch (error) { logger.error(`failed to process ${pathname}`); throw error; } }