nodeJs对DICOM医学影像文件解析并保存为图片

最近入职一家医疗硬件和软件开发的公司,负责一套医疗软件中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图片的方法,已经运用到我当前的项目中,欢迎大家讨论学习,有问题请联系我修复。

你可能感兴趣的:(JS,nodeJs,typescript,javascript,node.js,node-canvas,dicomParser)