init
This commit is contained in:
38
src/dimse/compressFile.ts
Normal file
38
src/dimse/compressFile.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { recompress, recompressOptions } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
|
||||
export async function compressFile(inputFile: string, outputDirectory: string, transferSyntax: string | undefined = undefined) {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const options: recompressOptions = {
|
||||
sourcePath: inputFile,
|
||||
storagePath: outputDirectory,
|
||||
writeTransfer: transferSyntax || config.get(ConfParams.XTRANSFER),
|
||||
lossyQuality: config.get(ConfParams.LOSSY_QUALITY),
|
||||
verbose: config.get(ConfParams.VERBOSE),
|
||||
};
|
||||
|
||||
// run find scu and return json response
|
||||
return new Promise((resolve, reject) => {
|
||||
recompress(options, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(result);
|
||||
if (json.code === 0) {
|
||||
resolve(true);
|
||||
} else {
|
||||
logger.error(`recompression failure (${inputFile}): ${json.message}`);
|
||||
reject();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(result);
|
||||
reject();
|
||||
}
|
||||
} else {
|
||||
logger.error('invalid result received');
|
||||
reject();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
52
src/dimse/fetchData.ts
Normal file
52
src/dimse/fetchData.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import throat from 'throat';
|
||||
import { fetchGet } from './fetchGet';
|
||||
import { fetchMove } from './fetchMove';
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { getLockUid, QUERY_LEVEL } from './querLevel';
|
||||
import { Node as DicomNode } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
|
||||
const maxAssociations = config.get(ConfParams.MAX_ASSOCIATIONS) as number;
|
||||
const throatLock = throat(maxAssociations);
|
||||
const lock = new Map();
|
||||
|
||||
export async function waitOrFetchData(studyUid: string, seriesUid: string, imageUid: string, level: QUERY_LEVEL): Promise<unknown> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const peers = config.get(ConfParams.PEERS) as DicomNode[];
|
||||
|
||||
for (const peer of peers) {
|
||||
try {
|
||||
return await waitOrFetchDataOnAet(studyUid, seriesUid, imageUid, level, peer);
|
||||
} catch (error) {
|
||||
logger.warn(error);
|
||||
}
|
||||
}
|
||||
return Promise.reject('failed waitOrFetchData');
|
||||
}
|
||||
|
||||
export async function waitOrFetchDataOnAet(
|
||||
studyUid: string,
|
||||
seriesUid: string,
|
||||
imageUid: string,
|
||||
level: QUERY_LEVEL,
|
||||
target: DicomNode
|
||||
): Promise<unknown> {
|
||||
const scu = config.get(ConfParams.C_GET) ? fetchGet : fetchMove;
|
||||
const lockId = getLockUid(studyUid, seriesUid, imageUid, level);
|
||||
|
||||
return await throatLock(async () => {
|
||||
if (lock.has(lockId)) {
|
||||
return lock.get(lockId);
|
||||
}
|
||||
const promise = scu(studyUid, seriesUid, imageUid, level, target);
|
||||
promise
|
||||
.then(() => {
|
||||
lock.delete(lockId);
|
||||
})
|
||||
.catch(() => {
|
||||
lock.delete(lockId);
|
||||
});
|
||||
lock.set(lockId, promise);
|
||||
return promise;
|
||||
});
|
||||
}
|
||||
68
src/dimse/fetchGet.ts
Normal file
68
src/dimse/fetchGet.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { getScu, getScuOptions, Node as DicomNode } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { QUERY_LEVEL, queryLevelToPath, queryLevelToString } from './querLevel';
|
||||
|
||||
// request data from PACS via c-get or c-move
|
||||
export async function fetchGet(studyUid: string, seriesUid: string, imageUid: string, level: QUERY_LEVEL, target: DicomNode): Promise<unknown> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
|
||||
// add query retrieve level and fetch whole study
|
||||
const ts = config.get(ConfParams.XTRANSFER) as string;
|
||||
|
||||
const getOptions: getScuOptions = {
|
||||
tags: [
|
||||
{
|
||||
key: '00080052',
|
||||
value: queryLevelToString(level),
|
||||
},
|
||||
{
|
||||
key: '0020000D',
|
||||
value: studyUid,
|
||||
},
|
||||
],
|
||||
netTransferPrefer: ts,
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
target,
|
||||
verbose: config.get(ConfParams.VERBOSE) as boolean,
|
||||
storagePath: config.get(ConfParams.STORAGE_PATH),
|
||||
};
|
||||
|
||||
if (level >= QUERY_LEVEL.SERIES) {
|
||||
getOptions.tags.push({
|
||||
key: '0020000E',
|
||||
value: seriesUid,
|
||||
});
|
||||
}
|
||||
|
||||
if (level >= QUERY_LEVEL.IMAGE) {
|
||||
getOptions.tags.push({
|
||||
key: '00080018',
|
||||
value: imageUid,
|
||||
});
|
||||
}
|
||||
const uidPath = queryLevelToPath(studyUid, seriesUid, imageUid, level);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
logger.info(`fetch start: ${uidPath}`);
|
||||
getScu(getOptions, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(result);
|
||||
if (json.code === 0 || json.code === 2) {
|
||||
logger.info(`fetch finished: ${uidPath}`);
|
||||
resolve(result);
|
||||
} else {
|
||||
logger.info(JSON.parse(result));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
54
src/dimse/fetchMeta.ts
Normal file
54
src/dimse/fetchMeta.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { sendCFindRequest, IQueryParams } from './findData';
|
||||
import { config, ConfParams } from '../utils/config';
|
||||
import { fileExists } from '../utils/fileHelper';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { QUERY_LEVEL } from './querLevel';
|
||||
import { waitOrFetchDataOnAet } from './fetchData';
|
||||
import { parseMeta } from './parseMeta';
|
||||
import { Node as DicomNode } from 'dicom-dimse-native';
|
||||
|
||||
import path from 'path';
|
||||
|
||||
export async function fetchMeta(query: IQueryParams, studyInstanceUID: string, seriesInstanceUID: string): Promise<unknown> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const peers = config.get(ConfParams.PEERS) as DicomNode[];
|
||||
const fullMeta = config.get(ConfParams.FULL_META) as boolean;
|
||||
|
||||
for (let i = 0; i < peers.length; i++) {
|
||||
const peer = peers[i];
|
||||
logger.info(`checking peer for data: ${peer.aet}`);
|
||||
const json = (await sendCFindRequest(QUERY_LEVEL.IMAGE, peer, query)) as object;
|
||||
|
||||
// make sure c-find worked
|
||||
if (Object.entries(json).length === 0) {
|
||||
logger.info(`no data found on peer: ${peer.aet}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fullMeta) {
|
||||
return json;
|
||||
}
|
||||
|
||||
// check if fetch is needed
|
||||
for (const [key] of Object.entries(json)) {
|
||||
const sopInstanceUid = json[key]['00080018'].Value[0];
|
||||
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
|
||||
const pathname = path.join(storagePath, studyInstanceUID, sopInstanceUid);
|
||||
const exists = await fileExists(pathname);
|
||||
if (!exists) {
|
||||
logger.info(`fetching series ${seriesInstanceUID}`);
|
||||
await waitOrFetchDataOnAet(studyInstanceUID, seriesInstanceUID, '', QUERY_LEVEL.SERIES, peer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await parseMeta(json, studyInstanceUID, seriesInstanceUID);
|
||||
logger.info('parsing finished, resolving');
|
||||
return Promise.resolve(result);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return Promise.reject('failed fetching meta data');
|
||||
}
|
||||
67
src/dimse/fetchMove.ts
Normal file
67
src/dimse/fetchMove.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { moveScu, moveScuOptions, Node as DicomNode } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { QUERY_LEVEL, queryLevelToPath, queryLevelToString } from './querLevel';
|
||||
|
||||
// request data from PACS via c-get or c-move
|
||||
export async function fetchMove(studyUid: string, seriesUid: string, imageUid: string, level: QUERY_LEVEL, target: DicomNode): Promise<unknown> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
|
||||
// add query retrieve level and fetch whole study
|
||||
const ts = config.get(ConfParams.XTRANSFER) as string;
|
||||
const moveOptions: moveScuOptions = {
|
||||
tags: [
|
||||
{
|
||||
key: '00080052',
|
||||
value: queryLevelToString(level),
|
||||
},
|
||||
{
|
||||
key: '0020000D',
|
||||
value: studyUid,
|
||||
},
|
||||
],
|
||||
destination: config.get(ConfParams.SOURCE),
|
||||
netTransferPrefer: ts,
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
target,
|
||||
verbose: config.get(ConfParams.VERBOSE) as boolean,
|
||||
};
|
||||
|
||||
if (level >= QUERY_LEVEL.SERIES) {
|
||||
moveOptions.tags.push({
|
||||
key: '0020000E',
|
||||
value: seriesUid,
|
||||
});
|
||||
}
|
||||
|
||||
if (level >= QUERY_LEVEL.IMAGE) {
|
||||
moveOptions.tags.push({
|
||||
key: '00080018',
|
||||
value: imageUid,
|
||||
});
|
||||
}
|
||||
const uidPath = queryLevelToPath(studyUid, seriesUid, imageUid, level);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
logger.info(`fetch start: ${uidPath}`);
|
||||
moveScu(moveOptions, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(result);
|
||||
if (json.code === 0 || json.code === 2) {
|
||||
logger.info(`fetch finished: ${uidPath}`);
|
||||
resolve(result);
|
||||
} else {
|
||||
logger.info(JSON.parse(result));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
144
src/dimse/findData.ts
Normal file
144
src/dimse/findData.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { findScu, findScuOptions, Node as DicomNode } from 'dicom-dimse-native';
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { queryLevelToString, QUERY_LEVEL } from './querLevel';
|
||||
import { get_element } from '@iwharris/dicom-data-dictionary';
|
||||
import { tagsForLevel } from './tags';
|
||||
|
||||
const findDicomName = (name: string): string | undefined => {
|
||||
const dataElement = get_element(name);
|
||||
if (dataElement) {
|
||||
return dataElement.tag.replace('(', '').replace(',', '').replace(')', '');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const findVR = (name: string): string => {
|
||||
const dataElement = get_element(name);
|
||||
if (dataElement) {
|
||||
return dataElement.vr;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export interface IQueryParams {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export async function doFind(level: QUERY_LEVEL, query: IQueryParams): Promise<Record<string, string>[][]> {
|
||||
const peers = config.get(ConfParams.PEERS) as DicomNode[];
|
||||
|
||||
const promises: Array<Promise<Record<string, string>[]>> = [];
|
||||
|
||||
peers.forEach((peer) => {
|
||||
promises.push(sendCFindRequest(level, peer, query));
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
export async function sendCFindRequest(level: QUERY_LEVEL, target: DicomNode, query: IQueryParams): Promise<Record<string, string>[]> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
|
||||
// add query retrieve level
|
||||
const options: findScuOptions = {
|
||||
tags: [
|
||||
{
|
||||
key: '00080052',
|
||||
value: queryLevelToString(level),
|
||||
},
|
||||
],
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
target,
|
||||
verbose: config.get(ConfParams.VERBOSE),
|
||||
};
|
||||
|
||||
// parse all include fields
|
||||
const includes = query.includefield;
|
||||
|
||||
let tags = new Array<string>();
|
||||
if (includes) {
|
||||
tags = includes.split(',');
|
||||
}
|
||||
|
||||
const defaultTagsForLevel = tagsForLevel(level);
|
||||
tags.push(...defaultTagsForLevel);
|
||||
|
||||
// add parsed tags
|
||||
tags.forEach((element: string) => {
|
||||
const tagName = findDicomName(element) || element;
|
||||
|
||||
if (tagName == '00080030') {
|
||||
options.tags.push({ key: tagName, value: '0900-1000' });
|
||||
} else {
|
||||
if (tagName) {
|
||||
options.tags.push({ key: tagName, value: '' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// add search param
|
||||
let invalidInput = false;
|
||||
const minCharsQido = config.get(ConfParams.MIN_CHARS) as number;
|
||||
Object.keys(query).forEach((propName) => {
|
||||
const tag = findDicomName(propName);
|
||||
const vr = findVR(propName);
|
||||
if (tag) {
|
||||
let v = query[propName];
|
||||
// string vr types check
|
||||
if (['PN', 'LO', 'LT', 'SH', 'ST'].includes(vr)) {
|
||||
// just make sure to remove any wildcards from prefix and suffix
|
||||
v = v.replace(/^[*]/, '');
|
||||
v = v.replace(/[*]$/, '');
|
||||
|
||||
// check if minimum number of chars are reached from input
|
||||
if (minCharsQido > v.length) {
|
||||
invalidInput = true;
|
||||
}
|
||||
// auto append wildcard
|
||||
if (config.get(ConfParams.APPEND_WILDCARD)) {
|
||||
v += '*';
|
||||
}
|
||||
}
|
||||
options.tags.push({ key: tag, value: v });
|
||||
}
|
||||
});
|
||||
|
||||
const offset = query.offset ? parseInt(query.offset, 10) : 0;
|
||||
|
||||
// run find scu and return json response
|
||||
return new Promise((resolve) => {
|
||||
// return with empty results if invalid
|
||||
if (invalidInput) {
|
||||
resolve([]);
|
||||
}
|
||||
|
||||
findScu(options, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(result);
|
||||
if (json.code === 0) {
|
||||
const container = JSON.parse(json.container);
|
||||
if (container) {
|
||||
resolve(container.slice(offset));
|
||||
} else {
|
||||
resolve([]);
|
||||
}
|
||||
} else if (json.code === 1) {
|
||||
logger.info('query is pending...');
|
||||
} else {
|
||||
logger.error(`c-find failure: ${json.message}`);
|
||||
resolve([]);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(result);
|
||||
resolve([]);
|
||||
}
|
||||
} else {
|
||||
logger.error('invalid result received');
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
111
src/dimse/parseMeta.ts
Normal file
111
src/dimse/parseMeta.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { fileExists } from '../utils/fileHelper';
|
||||
import dicomParser from 'dicom-parser';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
interface ValueType {
|
||||
Value: string[] | number[] | unknown[];
|
||||
vr: string;
|
||||
}
|
||||
type ElementType = Record<string, ValueType>;
|
||||
|
||||
function parseFile(filename: string): Promise<ElementType> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
return new Promise<ElementType>((resolve, reject) => {
|
||||
fileExists(filename).then((success: boolean) => {
|
||||
if (!success) {
|
||||
logger.error(`file does not exist: ${filename}`);
|
||||
return reject();
|
||||
}
|
||||
|
||||
fs.promises.readFile(filename).then((data: Uint8Array) => {
|
||||
const dataset = dicomParser.parseDicom(data);
|
||||
|
||||
// parse additional needed attributes
|
||||
const patientName = dataset.string('x00100010');
|
||||
const patentID = dataset.string('x00100020');
|
||||
const studyInstanceUID = dataset.string('x0020000d');
|
||||
const studyDate = dataset.string('x00080020');
|
||||
const studyTime = dataset.string('x00080030');
|
||||
const seriesInstanceUID = dataset.string('x0020000e');
|
||||
const seriesNumber = dataset.string('x00200011');
|
||||
const sopInstanceUID = dataset.string('x00080018');
|
||||
const sopClassUID = dataset.string('x00080016');
|
||||
const bitsAllocated = dataset.uint16('x00280100');
|
||||
const bitsStored = dataset.uint16('x00280101');
|
||||
const highBit = dataset.uint16('x00280102');
|
||||
const rows = dataset.uint16('x00280010');
|
||||
const cols = dataset.uint16('x00280011');
|
||||
const pixelSpacingString = dataset.string('x00280030');
|
||||
const pixelSpacing = pixelSpacingString ? pixelSpacingString.split('\\').map((e: string) => parseFloat(e)) : [1, 1];
|
||||
const modality = dataset.string('x00080060');
|
||||
const samplesPerPixel = dataset.uint16('x00280002');
|
||||
const photometricInterpretation = dataset.string('x00280004');
|
||||
const pixelRepresentation = dataset.uint16('x00280103');
|
||||
const windowCenter = dataset.string('x00281050');
|
||||
const wc = windowCenter ? parseFloat(windowCenter.split('\\')[0]) : 40;
|
||||
const windowWidth = dataset.string('x00281051');
|
||||
const ww = windowWidth ? parseFloat(windowWidth.split('\\')[0]) : 80;
|
||||
const rescaleIntercept = parseFloat(dataset.string('x00281052') || '1');
|
||||
const rescaleSlope = parseFloat(dataset.string('x00281053') || '1');
|
||||
const iopString = dataset.string('x00200037');
|
||||
const iop = iopString ? iopString.split('\\').map((e: string) => parseFloat(e)) : null;
|
||||
const ippString = dataset.string('x00200032');
|
||||
const ipp = ippString ? ippString.split('\\').map((e: string) => parseFloat(e)) : null;
|
||||
const instanceNumber = dataset.string('x00200013');
|
||||
const sliceThickness = dataset.string('x00180050');
|
||||
const sliceLocation = dataset.string('x00201041');
|
||||
|
||||
// append to all results
|
||||
const result: ElementType = {
|
||||
'00100010': { Value: [{ Alphabetic: patientName }], vr: 'PN' },
|
||||
'00100020': { Value: [patentID], vr: 'LO' },
|
||||
'0020000D': { Value: [studyInstanceUID], vr: 'UI' },
|
||||
'00080020': { Value: [studyDate], vr: 'DA' },
|
||||
'00080030': { Value: [studyTime], vr: 'TM' },
|
||||
'0020000E': { Value: [seriesInstanceUID], vr: 'UI' },
|
||||
'00200011': { Value: [seriesNumber], vr: 'IS' },
|
||||
'00080018': { Value: [sopInstanceUID], vr: 'UI' },
|
||||
'00080016': { Value: [sopClassUID], vr: 'UI' },
|
||||
'00080060': { Value: [modality], vr: 'CS' },
|
||||
'00280002': { Value: [samplesPerPixel], vr: 'US' },
|
||||
'00280004': { Value: [photometricInterpretation], vr: 'CS' },
|
||||
'00280010': { Value: [rows], vr: 'US' },
|
||||
'00280011': { Value: [cols], vr: 'US' },
|
||||
'00280030': { Value: pixelSpacing, vr: 'DS' },
|
||||
'00280100': { Value: [bitsAllocated], vr: 'US' },
|
||||
'00280101': { Value: [bitsStored], vr: 'US' },
|
||||
'00280102': { Value: [highBit], vr: 'US' },
|
||||
'00280103': { Value: [pixelRepresentation], vr: 'US' },
|
||||
'00281050': { Value: [wc], vr: 'DS' },
|
||||
'00281051': { Value: [ww], vr: 'DS' },
|
||||
'00281052': { Value: [rescaleIntercept], vr: 'DS' },
|
||||
'00281053': { Value: [rescaleSlope], vr: 'DS' },
|
||||
...(iop && { '00200037': { Value: iop, vr: 'DS' } }),
|
||||
...(ipp && { '00200032': { Value: ipp, vr: 'DS' } }),
|
||||
'00200013': { Value: [instanceNumber], vr: 'IS' },
|
||||
'00180050': { Value: [sliceThickness], vr: 'DS' },
|
||||
'00201041': { Value: [sliceLocation], vr: 'DS' },
|
||||
};
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseMeta(json: object, studyInstanceUID: string, seriesInstanceUID: string): Promise<unknown> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
logger.info(`parsing series ${seriesInstanceUID}`);
|
||||
|
||||
const parsing = new Array<Promise<ElementType>>();
|
||||
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
|
||||
for (const [key] of Object.entries(json)) {
|
||||
const sopInstanceUid = json[key]['00080018'].Value[0];
|
||||
const pathname = path.join(storagePath, studyInstanceUID, sopInstanceUid);
|
||||
parsing.push(parseFile(pathname));
|
||||
|
||||
}
|
||||
return Promise.all(parsing);
|
||||
}
|
||||
61
src/dimse/querLevel.ts
Normal file
61
src/dimse/querLevel.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export enum QUERY_LEVEL {
|
||||
STUDY,
|
||||
SERIES,
|
||||
IMAGE,
|
||||
}
|
||||
|
||||
export const STUDY_LEVEL = 'STUDY';
|
||||
export const SERIES_LEVEL = 'SERIES';
|
||||
export const IMAGE_LEVEL = 'IMAGE';
|
||||
|
||||
export function queryLevelToPath(studyUid: string, seriesUid: string, imageUid: string, qlevel: QUERY_LEVEL): string {
|
||||
switch (qlevel) {
|
||||
case QUERY_LEVEL.STUDY:
|
||||
return studyUid;
|
||||
case QUERY_LEVEL.SERIES:
|
||||
return `${studyUid}/${seriesUid}`;
|
||||
case QUERY_LEVEL.IMAGE:
|
||||
return `${studyUid}/${seriesUid}/${imageUid}`;
|
||||
default:
|
||||
return `${studyUid}/${seriesUid}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function queryLevelToString(level: QUERY_LEVEL): string {
|
||||
switch (level) {
|
||||
case QUERY_LEVEL.STUDY:
|
||||
return STUDY_LEVEL;
|
||||
case QUERY_LEVEL.SERIES:
|
||||
return SERIES_LEVEL;
|
||||
case QUERY_LEVEL.IMAGE:
|
||||
return IMAGE_LEVEL;
|
||||
default:
|
||||
return STUDY_LEVEL;
|
||||
}
|
||||
}
|
||||
|
||||
export function stringToQueryLevel(level: string): QUERY_LEVEL {
|
||||
switch (level) {
|
||||
case STUDY_LEVEL:
|
||||
return QUERY_LEVEL.STUDY;
|
||||
case SERIES_LEVEL:
|
||||
return QUERY_LEVEL.SERIES;
|
||||
case IMAGE_LEVEL:
|
||||
return QUERY_LEVEL.IMAGE;
|
||||
default:
|
||||
return QUERY_LEVEL.STUDY;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLockUid(studyUid: string, seriesUid: string, imageUid: string, level: QUERY_LEVEL): string {
|
||||
switch (level) {
|
||||
case QUERY_LEVEL.STUDY:
|
||||
return studyUid;
|
||||
case QUERY_LEVEL.SERIES:
|
||||
return seriesUid;
|
||||
case QUERY_LEVEL.IMAGE:
|
||||
return imageUid;
|
||||
default:
|
||||
return studyUid;
|
||||
}
|
||||
}
|
||||
46
src/dimse/sendEcho.ts
Normal file
46
src/dimse/sendEcho.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { echoScu, echoScuOptions, Node as DicomNode } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
|
||||
export async function sendEcho() {
|
||||
const peers = config.get(ConfParams.PEERS) as DicomNode[];
|
||||
|
||||
const promises: Array<Promise<unknown>> = [];
|
||||
|
||||
peers.forEach((peer) => {
|
||||
promises.push(sendCEchoRequest(peer));
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
export async function sendCEchoRequest(target: DicomNode) {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const options: echoScuOptions = {
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
target,
|
||||
verbose: config.get(ConfParams.VERBOSE),
|
||||
};
|
||||
|
||||
logger.info(`sending C-ECHO to target: ${options.target?.aet}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
echoScu(options, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
const res = JSON.parse(result);
|
||||
if (res.code === 2) {
|
||||
logger.error(res.message);
|
||||
} else {
|
||||
logger.info(res.message);
|
||||
}
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
logger.error(result);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
51
src/dimse/store.ts
Normal file
51
src/dimse/store.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { startStoreScp, storeScpOptions, shutdownScu, shutdownScuOptions } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
|
||||
export async function startScp() {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const ts = config.get(ConfParams.XTRANSFER) as string;
|
||||
|
||||
const options: storeScpOptions = {
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
peers: config.get(ConfParams.PEERS),
|
||||
storagePath: config.get(ConfParams.STORAGE_PATH),
|
||||
netTransferPrefer: ts,
|
||||
netTransferPropose: ts,
|
||||
writeTransfer: ts,
|
||||
permissive: true,
|
||||
verbose: config.get(ConfParams.VERBOSE),
|
||||
};
|
||||
logger.info(`pacs-server listening on port: ${options.source?.port}`);
|
||||
|
||||
startStoreScp(options, (result: string) => {
|
||||
// currently this will never log
|
||||
logger.info(JSON.parse(result));
|
||||
});
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const options: shutdownScuOptions = {
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
target: config.get(ConfParams.SOURCE),
|
||||
verbose: config.get(ConfParams.VERBOSE),
|
||||
};
|
||||
|
||||
logger.info(`sending shutdown request to target: ${options.target?.aet}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
shutdownScu(options, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
logger.info(JSON.parse(result));
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
logger.error(result);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
48
src/dimse/tags.ts
Normal file
48
src/dimse/tags.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { QUERY_LEVEL } from './querLevel';
|
||||
|
||||
export const studyLevelTags: Array<string> = [
|
||||
'00080005',
|
||||
'00080020',
|
||||
'00080030',
|
||||
'00080050',
|
||||
'00080054',
|
||||
'00080056',
|
||||
'00080061',
|
||||
'00080090',
|
||||
'00081190',
|
||||
'00100010',
|
||||
'00100020',
|
||||
'00100030',
|
||||
'00100040',
|
||||
'0020000D',
|
||||
'00200010',
|
||||
'00201206',
|
||||
'00201208',
|
||||
];
|
||||
|
||||
export const seriesLevelTags: Array<string> = [
|
||||
'00080005',
|
||||
'00080054',
|
||||
'00080056',
|
||||
'00080060',
|
||||
'0008103E',
|
||||
'00081190',
|
||||
'0020000E',
|
||||
'00200011',
|
||||
'00201209',
|
||||
];
|
||||
|
||||
export const imageLevelTags: Array<string> = ['00080016', '00080018'];
|
||||
|
||||
export function tagsForLevel(level: QUERY_LEVEL) {
|
||||
switch (level) {
|
||||
case QUERY_LEVEL.STUDY:
|
||||
return studyLevelTags;
|
||||
case QUERY_LEVEL.SERIES:
|
||||
return seriesLevelTags;
|
||||
case QUERY_LEVEL.IMAGE:
|
||||
return imageLevelTags;
|
||||
default:
|
||||
return studyLevelTags;
|
||||
}
|
||||
}
|
||||
282
src/dimse/wadoRs.ts
Normal file
282
src/dimse/wadoRs.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
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':
|
||||
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 = 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}`));
|
||||
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/ig)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
66
src/dimse/wadoUri.ts
Normal file
66
src/dimse/wadoUri.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { fileExists } from '../utils/fileHelper';
|
||||
import { compressFile } from './compressFile';
|
||||
import { waitOrFetchData } from './fetchData';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { stringToQueryLevel } from './querLevel';
|
||||
|
||||
type WadoUriArgs = {
|
||||
studyInstanceUid: string;
|
||||
seriesInstanceUid: string;
|
||||
sopInstanceUid: string;
|
||||
};
|
||||
type WadoUriResponse = {
|
||||
contentType: string;
|
||||
buffer: Buffer;
|
||||
};
|
||||
export async function doWadoUri({ studyInstanceUid, seriesInstanceUid, sopInstanceUid }: WadoUriArgs): Promise<WadoUriResponse> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const fetchLevel = config.get(ConfParams.FETCH_LEVEL) as string;
|
||||
const level = stringToQueryLevel(fetchLevel);
|
||||
|
||||
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
|
||||
const studyPath = path.join(storagePath, studyInstanceUid);
|
||||
const pathname = path.join(studyPath, sopInstanceUid);
|
||||
|
||||
// fetch if needed
|
||||
const exists = await fileExists(pathname);
|
||||
if (!exists) {
|
||||
try {
|
||||
await waitOrFetchData(studyInstanceUid, seriesInstanceUid, sopInstanceUid, level);
|
||||
} catch (err) {
|
||||
logger.error(`fetch failed for study: ${studyInstanceUid}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const postExists = await fileExists(pathname);
|
||||
if (!postExists) {
|
||||
const msg = `file not found ${pathname}`;
|
||||
logger.error(msg);
|
||||
throw msg;
|
||||
}
|
||||
|
||||
try {
|
||||
await compressFile(pathname, studyPath);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
const msg = `failed to compress ${pathname}`;
|
||||
throw msg;
|
||||
}
|
||||
|
||||
// read file from file system
|
||||
const fsPromise = fs.promises;
|
||||
try {
|
||||
return {
|
||||
contentType: 'application/dicom',
|
||||
buffer: await fsPromise.readFile(pathname),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
const msg = `failed to read ${pathname}`;
|
||||
throw msg;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user