4 Commits
rsab ... phkg

Author SHA1 Message Date
AlfandiMario
ac162cd2a3 startDate h-7 2026-08-20 15:52:12 +07:00
AlfandiMario
bfb55055a2 additional field untuk overlay MRI OHIF 2026-08-20 15:52:12 +07:00
AlfandiMario
af9ec01c19 additional field untuk overlay MRI OHIF 2025-11-04 11:47:12 +07:00
AlfandiMario
ae53d7faa3 support MRI custom overlay data for OHIF through fetchMeta because it couldn't retrieved from C-FIND / FINDSCU 2025-11-03 16:05:34 +07:00
5 changed files with 83 additions and 51 deletions

View File

@@ -2,7 +2,7 @@ version: '3'
services: services:
dicomweb-proxy: dicomweb-proxy:
image: dicomweb-proxy:2.0 image: dicomweb-proxy:3.0.1
container_name: dicomweb-proxy container_name: dicomweb-proxy
ports: ports:
- "5000:5000" - "5000:5000"

View File

@@ -57,10 +57,34 @@ function parseFile(filename: string): Promise<ElementType> {
const instanceNumber = dataset.string('x00200013'); const instanceNumber = dataset.string('x00200013');
const sliceThickness = dataset.string('x00180050'); const sliceThickness = dataset.string('x00180050');
const sliceLocation = dataset.string('x00201041'); const sliceLocation = dataset.string('x00201041');
const numberOfFrames = dataset.string('x00280008');
const recommendedDisplayFrameRate = dataset.string('x00082144'); // TODO: buat ini dynamic berdasarkan includefields atau modality
const cineRate = dataset.string('x00180040'); // TODO: hapus tambahan di bawah ini yang tidak diperlukan
const frameTime = dataset.string('x00181063');
// MR-specific tags for overlay
const spacingBetweenSlices = dataset.string('x00180088');
const percentPhaseFieldOfView = dataset.string('x00180094');
const fovDimensionElement = dataset.elements.x00181149;
const fovDimension = fovDimensionElement ? [dataset.uint16('x00181149', 0), dataset.uint16('x00181149', 1)] : null;
const acquisitionMatrixElement = dataset.elements.x00181310;
const acquisitionMatrix = acquisitionMatrixElement
? [dataset.uint16('x00181310', 0), dataset.uint16('x00181310', 1), dataset.uint16('x00181310', 2), dataset.uint16('x00181310', 3)]
: null;
const scanningSequence = dataset.string('x00180020');
const repetitionTime = dataset.string('x00180080');
const echoTime = dataset.string('x00180081');
const inversionTime = dataset.string('x00180082');
const receiveCoilName = dataset.string('x00181250');
const mrAcquisitionType = dataset.string('x00180023');
const phaseEncodingDirection = dataset.string('x00181312');
const numOfAverages = dataset.string('x00180083');
const echoTrainLength = dataset.string('x00180091');
const flipAngle = dataset.string('x00181314');
const pixelBandwidth = dataset.string('x00180095');
const acquisitionTime = dataset.string('x00080032');
const acquistionDurationTotal = dataset.string('x00189073'); // in seconds
const acquistionDurationPerFrame = dataset.string('x00189220'); // in ms
const parallelAcquisitionTechnique = dataset.string('x00189078');
// append to all results // append to all results
const result: ElementType = { const result: ElementType = {
@@ -90,13 +114,29 @@ function parseFile(filename: string): Promise<ElementType> {
...(iop && { '00200037': { Value: iop, vr: 'DS' } }), ...(iop && { '00200037': { Value: iop, vr: 'DS' } }),
...(ipp && { '00200032': { Value: ipp, vr: 'DS' } }), ...(ipp && { '00200032': { Value: ipp, vr: 'DS' } }),
'00200013': { Value: [instanceNumber], vr: 'IS' }, '00200013': { Value: [instanceNumber], vr: 'IS' },
'00280008': { Value: [numberOfFrames], vr: 'IS' },
'00082144': { Value: [recommendedDisplayFrameRate], vr: 'IS' },
'00180040': { Value: [cineRate], vr: 'IS' },
'00181063': { Value: [frameTime], vr: 'DS' },
'00180050': { Value: [sliceThickness], vr: 'DS' }, '00180050': { Value: [sliceThickness], vr: 'DS' },
'00201041': { Value: [sliceLocation], vr: 'DS' }, '00201041': { Value: [sliceLocation], vr: 'DS' },
'00180088': { Value: [spacingBetweenSlices], vr: 'DS' },
'00180094': { Value: [percentPhaseFieldOfView], vr: 'DS' },
...(fovDimension && { '00181149': { Value: fovDimension, vr: 'IS' } }),
...(acquisitionMatrix && { '00181310': { Value: acquisitionMatrix, vr: 'US' } }),
'00180020': { Value: [scanningSequence], vr: 'CS' },
'00180080': { Value: [repetitionTime], vr: 'DS' },
'00180081': { Value: [echoTime], vr: 'DS' },
'00180082': { Value: [inversionTime], vr: 'DS' },
'00181250': { Value: [receiveCoilName], vr: 'SH' },
'00180023': { Value: [mrAcquisitionType], vr: 'CS' },
'00181312': { Value: [phaseEncodingDirection], vr: 'CS' },
'00180083': { Value: [numOfAverages], vr: 'DS' },
'00180091': { Value: [echoTrainLength], vr: 'IS' },
'00181314': { Value: [flipAngle], vr: 'DS' },
'00180095': { Value: [pixelBandwidth], vr: 'DS' },
'00080032': { Value: [acquisitionTime], vr: 'TM' },
'00189073': { Value: [acquistionDurationTotal], vr: 'DS' },
'00189220': { Value: [acquistionDurationPerFrame], vr: 'FD' },
'00189078': { Value: [parallelAcquisitionTechnique], vr: 'CS' },
}; };
resolve(result); resolve(result);
}); });
}); });
@@ -113,7 +153,6 @@ export function parseMeta(json: object, studyInstanceUID: string, seriesInstance
const sopInstanceUid = json[key]['00080018'].Value[0]; const sopInstanceUid = json[key]['00080018'].Value[0];
const pathname = path.join(storagePath, studyInstanceUID, sopInstanceUid); const pathname = path.join(storagePath, studyInstanceUID, sopInstanceUid);
parsing.push(parseFile(pathname)); parsing.push(parseFile(pathname));
} }
return Promise.all(parsing); return Promise.all(parsing);
} }

View File

@@ -32,16 +32,7 @@ export const seriesLevelTags: Array<string> = [
'00201209', '00201209',
]; ];
export const imageLevelTags: Array<string> = [ export const imageLevelTags: Array<string> = ['00080016', '00080018'];
'00080016', // SOPClassUID
'00080018', // SOPInstanceUID
'00200013', // InstanceNumber
'00280008', // NumberOfFrames
'00280010', // Rows
'00280011', // Columns
'00082144', // RecommendedDisplayFrameRate
'00180040', // CineRate
];
export function tagsForLevel(level: QUERY_LEVEL) { export function tagsForLevel(level: QUERY_LEVEL) {
switch (level) { switch (level) {

View File

@@ -22,7 +22,7 @@ type WadoRsArgs = {
seriesInstanceUid?: string; seriesInstanceUid?: string;
sopInstanceUid?: string; sopInstanceUid?: string;
dataFormat?: DataFormat; dataFormat?: DataFormat;
frame?: number; frame?: number | number[];
}; };
type WadoRsResponse = { type WadoRsResponse = {
contentType: string; contentType: string;
@@ -91,10 +91,9 @@ interface AddFileToBuffer {
filename: string, filename: string,
instanceInfo: InstanceInfo, instanceInfo: InstanceInfo,
dataFormat?: DataFormat, dataFormat?: DataFormat,
frame?: number,
} }
async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo, frame }: AddFileToBuffer): Promise<Buffer> { async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }: 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[] = [];
@@ -129,24 +128,8 @@ async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo, f
// 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;
let dataOffset = pixeldataElement.dataOffset;
let dataLength = pixeldataElement.length;
// WADO-RS frame request: return only the requested frame's bytes.
// The file is recompressed to uncompressed (Implicit VR LE) above, so
// frames are contiguous and slicing by frame size is safe.
if (dataFormat === 'pixeldata' && typeof frame === 'number' && frame > 0) {
const rows = dataset.uint16('x00280010') ?? 0;
const cols = dataset.uint16('x00280011') ?? 0;
const samplesPerPixel = dataset.uint16('x00280002') ?? 1;
const bitsAllocated = dataset.uint16('x00280100') ?? 8;
const frameSize = rows * cols * samplesPerPixel * (bitsAllocated / 8);
dataOffset += (frame - 1) * frameSize;
dataLength = frameSize;
}
buffArray.push(Buffer.from(`Content-Type:application/octet-stream;${term}`)); buffArray.push(Buffer.from(`Content-Type:application/octet-stream;${term}`));
returnData = Buffer.from(dataset.byteArray.buffer, dataOffset, dataLength); returnData = Buffer.from(dataset.byteArray.buffer, pixeldataElement.dataOffset, pixeldataElement.length);
break; break;
} }
case 'rendered': { case 'rendered': {
@@ -174,7 +157,7 @@ type InstanceInfo = {
instance?: string instance?: string
} }
export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat, frame }: WadoRsArgs): Promise<WadoRsResponse> { export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat }: 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;
@@ -254,14 +237,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, frame }); return addFileToBuffer({ pathname, filename: file, dataFormat, instanceInfo });
} }
})) }))
} }
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, frame })]; buffers = [await addFileToBuffer({ pathname: studyPath, filename, dataFormat, instanceInfo })];
} }
// 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

View File

@@ -51,7 +51,7 @@ module.exports = function (server: FastifyInstance, opts: unknown, done: () => v
const hasStudyDate = query.StudyDate !== undefined; const hasStudyDate = query.StudyDate !== undefined;
if (!hasMedicalRecord && !hasAccessionNumber && !hasPatientName && !hasStudyInstanceUID && !hasStudyDate) { if (!hasMedicalRecord && !hasAccessionNumber && !hasPatientName && !hasStudyInstanceUID && !hasStudyDate) {
const startDate = moment().format('YYYYMMDD'); const startDate = moment().subtract(7, 'days').format('YYYYMMDD');
const endDate = moment().format('YYYYMMDD'); const endDate = moment().format('YYYYMMDD');
query.StudyDate = `${startDate}-${endDate}`; query.StudyDate = `${startDate}-${endDate}`;
@@ -360,12 +360,12 @@ module.exports = function (server: FastifyInstance, opts: unknown, done: () => v
//------------------------------------------------------------------ //------------------------------------------------------------------
server.get<{ server.get<{
Params: IParamsImage & { frame: string }; Params: IParamsImage;
}>('/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, frame } = req.params; const { studyInstanceUid, seriesInstanceUid, sopInstanceUid } = req.params;
try { try {
const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat: 'pixeldata', frame: parseInt(frame, 10) }); const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat: 'pixeldata' });
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) {
@@ -410,6 +410,25 @@ module.exports = function (server: FastifyInstance, opts: unknown, done: () => v
} }
}); });
server.get<{
Params: IParamsImage;
Querystring: QueryParams;
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/instances/:sopInstanceUid/metadata', async (req, reply) => {
const { studyInstanceUid, seriesInstanceUid, sopInstanceUid } = req.params;
const { query } = req;
query.StudyInstanceUID = studyInstanceUid;
query.SeriesInstanceUID = seriesInstanceUid;
query.SOPInstanceUID = sopInstanceUid;
try {
const rsp = await fetchMeta(query, studyInstanceUid, seriesInstanceUid);
return reply.send(rsp);
} catch (error) {
logger.error(error);
return reply.send(500);
}
});
done(); done();
}; };