最近入职一家医疗硬件和软件开发的公司,负责一套医疗软件中DICOM部分的功能开发,因为对这个行业完全陌生,对DICOM也一无所知,所以很头疼,查了很多国内外的资料,才有了一定了解。
软件需求是模仿国外知名的DICOM解析软件orthanc,实现他的功能。因为我们的软件后端使用nodejs实现,所以我需要用nodejs实现对dicom文件的解析及转换成png/jpg图片以供前后端使用。
关于对DICOM文件的解析,网上有很多资料可查,我这里不详细复述了,我碰到的问题是dicom转成图片的问题。如果是前端,可以直接用开源的医疗影像软件 cornerstonejs 下的众多开源软件包例如dicomParser解析DICOM获取TAG和位图数据,cornerstoneWADOImageLoader前端显示DICOM图像和TAG参数,cornerstoneTools使用各种预定义工具操作影像。但是nodejs就没有直接可操作转换DICOM文件为图片的相关开源包了。为此我只能查找了解DICOM的原理机制,并尝试读取位图信息并转换为图片。
经过两周不懈努力尝试了各种方案,终于开发并完成了一个可解决的方案,即利用dicomParser解析DICOM文件后,使用node-canvas将图片保存为PNG图片(此时保存的图片和DICOM文件基本差不多大,均在几兆到三十兆之间),最后使用imagemin压缩并转换成更小的PNG和JPG图片
接下来贴出部分代码以供参考学习,如有错误请指出讨论。
1、首先安装一些必要的包:
#安装dicom-parser
npm install dicom-parser
#安装node-canvas
npm install canvas
#安装imagemin
npm i [email protected]
npm i --save-dev @types/[email protected]
npm i imagemin-jpegtran
npm i --save-dev @types/imagemin-jpegtran
npm i imagemin-pngquant
npm i --save-dev @types/imagemin-pngquant
2、接下来是上传或导入DICOM文件并解析保存其TAGS到数据库的代码,这里就不列出了。需要导入一些必要的包,包括前面安装的
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as path from 'path';
import * as dicomParser from 'dicom-parser'; //DICOM解析
import * as canvas from 'canvas';
import { getVOILUT } from './getVOILut.js';
import imagemin = require('imagemin');
import imageminJpegtran = require('imagemin-jpegtran');
import imageminPngquant from 'imagemin-pngquant';
3、导入DICOM成功后,下面是转换DICOM图片的主方法:
/**
* 生成刚导入的DICOM文件的IMAGES
* @param fileList DICOM文件列表
* @returns
*/
async createImages(fileList: any) {
const savePath = this.objDir;//默认存放根目录
try {
for (let i = 0,len = fileList.length; i < len; i++) {
let uuid = fileList[i];
let filePath = path.join(savePath, uuid.substring(0, 2), uuid.substring(2, 4), uuid);
//读取文件
let dicomFileAsBuffer = fs.readFileSync(filePath);
let dataSet = dicomParser.parseDicom(dicomFileAsBuffer);
let tags = dicomParser.explicitDataSetToJS(dataSet); //所有TAG
//生成PNG和JPG图片
this.createImage(uuid, dataSet, tags, dicomFileAsBuffer);
}
return true;
} catch (e) {
console.log(e);
return false;
}
}
4、生成PNG和JPG图片的方法
/**
* 生成PNG和JPG图片
* @param uuid
* @param dataSet
* @param tags
* @param dicomFileAsBuffer
*/
async createImage(uuid: string, dataSet:dicomParser.DataSet, tags: any, dicomFileAsBuffer:Buffer ){
const savePath = this.objDir;//默认存放根目录
let pngFileName = uuid + ".png";
let jpegFileName = uuid + ".jpg";
let nextDir = uuid.substring(0, 2) + "/" + uuid.substring(2, 4);
let pngFilePath = path.join(savePath, nextDir, pngFileName);
let jpegFilePath = path.join(savePath, nextDir, jpegFileName);
let w = parseInt(tags['x00280011']); //图片宽度
let h = parseInt(tags['x00280010']); //图片高度
let invert = tags['x00280004'] === 'MONOCHROME1' ? true : false; //图像是否被反转显示
let windowCenter = parseInt(tags['x00281050']); //窗口中心
let windowWidth = parseInt(tags['x00281051']); //窗口宽度
let pixelData = dataSet.elements.x7fe00010;
let pixelDataBuffer = dicomParser.sharedCopy(dicomFileAsBuffer, pixelData.dataOffset, pixelData.length);
//生成PNG
let cv = canvas.createCanvas(w, h); //创建画布
this.createPngAsync(cv, pngFilePath, pixelDataBuffer, w, h, windowWidth, windowCenter, invert, jpegFilePath);
}
/**
* 生成PNG
* @param cv
* @param filePath
* @param pixelDataBuffer
* @param w
* @param h
* @param windowWidth
* @param windowCenter
* @param invert
* @param jpegFilePath
*/
async createPngAsync(cv: canvas.Canvas, filePath: string, pixelDataBuffer:any, w:number, h:number, windowWidth:number, windowCenter:number, invert:boolean, jpegFilePath? : string){
let stream: canvas.PNGStream;
let ctx = cv.getContext('2d', { pixelFormat: 'A8' }) //灰度图
let uint16 = new Uint16Array(pixelDataBuffer.buffer, pixelDataBuffer.byteOffset, pixelDataBuffer.byteLength / Uint16Array.BYTES_PER_ELEMENT); //获取uint16的像素数组
let voiLUT;
let lut = this.getLut(uint16, windowWidth, windowCenter, invert, voiLUT); //获取灰度数组
let uint8 = new Uint8ClampedArray(uint16.length); //八位灰度像素数组
//替换对应像素点为灰度
for (let i = 0,len = uint16.length; i < len; i++) {
uint8[i] = lut.lutArray[uint16[i]];
}
let image = canvas.createImageData(uint8, w, h);
ctx.putImageData(image, 0, 0);
stream = cv.createPNGStream({compressionLevel: 9, filters: cv.PNG_FILTER_NONE });
//stream.pipe(fs.createWriteStream(filePath));
fs.writeFileSync(filePath, stream.read());
this.saveMinImage(filePath);
if(jpegFilePath){ //生成JPG
this.createJpegAsync(cv, jpegFilePath);
}
}
/**
* PNG转JPG
* @param cv
* @param filePath
* @returns
*/
async createJpegAsync(cv: canvas.Canvas, filePath: string){
let stream;
let u = cv.toDataURL()
const Image = canvas.Image;
const img = new Image();
img.onload = () => {
let ca = canvas.createCanvas(img.width, img.height);
let ctx = ca.getContext('2d')
ctx.drawImage(img, 0, 0)
stream = ca.createJPEGStream();
//stream.pipe(fs.createWriteStream(filePath));
fs.writeFileSync(filePath, stream.read());
this.saveMinImage(filePath);
};
img.onerror = err => {throw err};
img.src = u;
}
/**
* 保存优化的图片
* @param filePath
*/
async saveMinImage(filePath: string){
filePath = filePath.replace(/\\/g,"/");
let newPath = this.getMinPath(filePath);
imagemin([filePath], {
destination: newPath,
plugins: [
imageminJpegtran(),
imageminPngquant({
speed: 11,
quality: [0.1, 0.1] //压缩质量(0,1)
})
]
}).then(() => {
console.log("压缩成功====",filePath);
}).catch(err => {
console.log("压缩失败:"+err)
});
}
5、这里有一点注意,因为DICOM影像显示的是灰度图,所以必须转换下
/**
* 获取灰度数组
* @param data
* @param windowWidth
* @param windowCenter
* @param invert
* @param voiLUT
* @returns
*/
getLut(data: Uint16Array, windowWidth: number, windowCenter: number, invert: boolean, voiLUT: any) {
let minPixelValue = 0;
let maxPixelValue = 0;
for (let i = 0,len = data.length;i < len; i++) {
if (minPixelValue > data[i]) {
minPixelValue = data[i];
}
if (maxPixelValue < data[i]) {
maxPixelValue = data[i];
}
}
let offset = Math.min(minPixelValue, 0);
let lutArray = new Uint8ClampedArray(maxPixelValue - offset + 1);
const vlutfn = getVOILUT(windowWidth, windowCenter, voiLUT, true);
if (invert === true) {
for (let storedValue = minPixelValue; storedValue <= maxPixelValue; storedValue++) {
lutArray[storedValue + (-offset)] = 255 - vlutfn(storedValue);
}
} else {
for (let storedValue = minPixelValue; storedValue <= maxPixelValue; storedValue++) {
lutArray[storedValue + (-offset)] = vlutfn(storedValue);
}
}
return {
minPixelValue: minPixelValue,
maxPixelValue: maxPixelValue,
lutArray: lutArray,
};
}
6、getVOILUT 文件里的代码
/* eslint no-bitwise: 0 */
/**
* Volume of Interest Lookup Table Function
*
* @typedef {Function} VOILUTFunction
*
* @param {Number} modalityLutValue
* @returns {Number} transformed value
* @memberof Objects
*/
/**
* @module: VOILUT
*/
/**
*
* @param {Number} windowWidth Window Width
* @param {Number} windowCenter Window Center
* @returns {VOILUTFunction} VOI LUT mapping function
* @memberof VOILUT
*/
function generateLinearVOILUT (windowWidth, windowCenter) {
return function (modalityLutValue) {
return ((modalityLutValue - windowCenter) / windowWidth + 0.5) * 255.0;
};
}
/**
* Generate a non-linear volume of interest lookup table
*
* @param {LUT} voiLUT Volume of Interest Lookup Table Object
* @param {Boolean} roundModalityLUTValues Do a Math.round of modality lut to compute non linear voilut
*
* @returns {VOILUTFunction} VOI LUT mapping function
* @memberof VOILUT
*/
function generateNonLinearVOILUT (voiLUT, roundModalityLUTValues) {
// We don't trust the voiLUT.numBitsPerEntry, mainly thanks to Agfa!
const bitsPerEntry = Math.max(...voiLUT.lut).toString(2).length;
const shift = bitsPerEntry - 8;
const minValue = voiLUT.lut[0] >> shift;
const maxValue = voiLUT.lut[voiLUT.lut.length - 1] >> shift;
const maxValueMapped = voiLUT.firstValueMapped + voiLUT.lut.length - 1;
return function (modalityLutValue) {
if (modalityLutValue < voiLUT.firstValueMapped) {
return minValue;
} else if (modalityLutValue >= maxValueMapped) {
return maxValue;
}
if (roundModalityLUTValues) {
return voiLUT.lut[Math.round(modalityLutValue) - voiLUT.firstValueMapped] >> shift;
}
return voiLUT.lut[modalityLutValue - voiLUT.firstValueMapped] >> shift;
};
}
/**
* Retrieve a VOI LUT mapping function given the current windowing settings
* and the VOI LUT for the image
*
* @param {Number} windowWidth Window Width
* @param {Number} windowCenter Window Center
* @param {LUT} [voiLUT] Volume of Interest Lookup Table Object
* @param {Boolean} roundModalityLUTValues Do a Math.round of modality lut to compute non linear voilut
*
* @return {VOILUTFunction} VOI LUT mapping function
* @memberof VOILUT
*/
//export default function (windowWidth, windowCenter, voiLUT, roundModalityLUTValues) {
function getVOILUT (windowWidth, windowCenter, voiLUT, roundModalityLUTValues) {
if (voiLUT) {
return generateNonLinearVOILUT(voiLUT, roundModalityLUTValues);
}
return generateLinearVOILUT(windowWidth, windowCenter);
}
module.exports = {
getVOILUT,
}
以上就是使用nodeJs将医疗影像文件DICOM文件转成PNG或JPG图片的方法,已经运用到我当前的项目中,欢迎大家讨论学习,有问题请联系我修复。