美文网首页
CPR曲面重建的Web实现

CPR曲面重建的Web实现

作者: huk | 来源:发表于2022-08-20 12:41 被阅读0次

    需求

    仅实现肋骨、脊柱上的效果,血管太复杂了一般要自动计算勾勒出沿着血管的路径才行,不然在大几百张图层上选择路径不是用户期望的。

    项目背景

    react + cornerstone的一个web影像渲染引擎

    实现大体是以下几个步骤:

    1.通过vtk实现mpr重建图像,操作面为横断位
    2.自定义cornerstone-tools工具CPRTool,实现路径点/线的绘制
    3.根据路径点将每一段路径截取对应的切面的pixeldata
    4.拼接所有切面
    5.将拼接后的pixel数据构造出一个CornerStoneImage,供cornerstone渲染canvas

    step1

    通过vtkjs获得多平面重建图像这个不赘述了,官网有相关的example[https://kitware.github.io/vtk-js/examples/ResliceCursorWidget.html]
    首先,操作面是重建后的横断面,我们可以想象一下,相当于一把刀从横断的第一层切到最后一层(如图),所以曲面重建后图像的高度是固定了的

    视角(原谅我不会作图)
    step2

    不用cornerstone及其tools这套框架的跳过这段就成,这边的目的是获得canvas上绘制的路径点

    自定以路径绘制的cornerstone-tools工具CRPTools,可以参考官网提供的Probe工具[https://tools.cornerstonejs.org/examples/tools/probe.html] Probe工具落下的点其实就类比我们的路径点,只要使用drawLines多绘制一下中间的线段就行。

    这边有一个问题,用户期望的是所有层上都可以看到完整路径,切换层只是为了方便找到准确位置选点,但是cornerstone-tools这儿toolState的机制默认是以imageId为key的,表现就是这些点只能在绘制的那层看到。解决办法就是自定义toolStateManager,cornerstone-tools提供setElementToolStateManager方法,去源码里把imageId的manager复制过来重写一个seriesInstanceUidStateManager.js,add和get方法都需要修改。

    function addElementToolState(element, toolName, data) {
            const enabledElement = external.cornerstone.getEnabledElement(element);
            // 原先这边是imageId,现在改成和series关联的id
            //这样一整个序列每张图的这个id都是一致的,路径就可以在所有image上完整显示了
            if (!enabledElement.uuid) {
                return;
            }
            addImageIdToolState(enabledElement.uuid, toolName, data);
        }
    

    得到每个点的image坐标之后,需要转成三维坐标才能给vtk使用,cornerstone-tools提供方法:imagePointToPatientPoint

     const v3 = imagePointToPatientPoint(eventData.currentPoints.image, imagePlane);
    
    step3

    接下来就是通过路径点来抽取每一spacing的像素值拼接起来。首先将得到的空间坐标转换成vtk world坐标系:

     // Image Volume图像坐标系转换到VTK Volume 图像坐标系
     projectToVtkVolumePosition(imagePosition) {
           const pos = [imagePosition[0] - this.firstImageMatrix[0][3], imagePosition[1] - this.firstImageMatrix[1][3], imagePosition[2] - this.firstImageMatrix[2][3]];
           let result = [0, 0, 0];
           for (let i = 0; i < 3; i++) {
                let vectorA = i === 2 ? this.zVector : [this.firstImageMatrix[0][i], this.firstImageMatrix[1][i], this.firstImageMatrix[2][i]];
                let a = pos[0] * vectorA[0] + pos[1] * vectorA[1] + pos[2] * vectorA[2];
                let b = Math.sqrt(vectorA[0] * vectorA[0] + vectorA[1] * vectorA[1] + vectorA[2] * vectorA[2]);
                result[i] = a / (Math.abs(b) < 0.0001 ? 1 : b);
           }
           let t = !result ? imagePosition : result;
           return [t[0] / this.pixelSpacingX, t[1] / this.pixelSpacingY, t[2] / this.spaceBetweenSlice];
     }
     //转换到world坐标系 这边的xyz就是step2中的v3
     this.imageData.indexToWorldVec3(this.projectToVtkVolumePosition([x, y, z]), vout);
    

    假设我们现在设置了两个点AB,连成第一条路径,得到空间坐标(xa,ya,za)和(xb,yb,zb),结合上面横断面的图我们可以想象,其实要做的就是这段路径经过了多少“层”,|xb-xa|和|yb-ya|这两个方向的差值比较一下,x方向的差值比较大的话就以x来取切割点,反之用y取切割点,按该方向的pixelSpacing来分割:


    取点

    其实最好是做成拟合曲线来取点(贝塞尔曲线),会更加准确。这边我们就先用直线来取一下(代码如下),返回的数组就是AB上所有我们需要截取pixeldata数据的点的集合_points,这些点其实就是需要重建的center,在这边z是不敏感的,因为取的是冠状面或矢状面。

    // 伪代码
    const _points  = linePointCalc(...)
    // 每段路径直线上取点 begin和end就是A B
    linePointCalc(beginVolume, endVolume, pixelSpacing, direction) {
            let res = []
            if (direction === 'y') {
                let stepLength = Math.abs(endVolume.y - beginVolume.y);
                //end点取不准 但是如果接近一个spacing的话 就当成一个点计算
                const sign = (endVolume.y > beginVolume.y ? 1 : -1);
                if ((stepLength % pixelSpacing !== 0)) {
                    if ((stepLength % pixelSpacing) > (pixelSpacing / 2)) {
                        stepLength = (lodash.ceil(stepLength / pixelSpacing)) * pixelSpacing;
                    } else {
                        stepLength = (lodash.floor(stepLength / pixelSpacing)) * pixelSpacing;
                    }
                }
                for (let i = 0; i <= stepLength; i += pixelSpacing) {
                    const yPos = beginVolume.y + i * sign;
                    const xPos = beginVolume.x + (yPos - beginVolume.y) * (endVolume.x - beginVolume.x) / (endVolume.y - beginVolume.y);
                    res.push({ x: xPos, y: yPos})
                }
            } else {
                // x方向同理
            }
            return res;
        }
    

    遍历_points,我们要截取的就是每个point作为center的重建面,这个点从z为0到imageHeight(前面说过一刀下去就是整个横断的高度)的所有pixeldata,每个像素值都通过双三次插值计算,基于4*4的矩阵,所以要取这个点的前后两层重建面,具体看下面代码,getCoronalData做的是设置mpr center,获取mpr重建后冠状面的imagedata,不细说了。同时要记录一下minPixelValue和maxPixelValue,为了后续cornerstoneImage的构造。

    ps: 写这个文章的时候突然意识到直接用point作为center重建的面,将点做双线性插值应该也是一样的,没必要写的这么复杂,但是写都写了,这边的代码还是先按三次卷积来写吧

    // 这边按 y方向diff较大来做例子
    // 曲面重建图像的宽度
    let width = 0;
    let resultValues = [];
    for (let i = 0; i < _points.length; i++) {
          const currentX = _points[i].x;
          const currentY = _points[i].y;
          const positionX = currentX / pixelSpacingX + 1;
          // y前后推移两层  每行取值x前后推移2个
          const prevY = lodash.floor(currentY / pixelSpacingY) * pixelSpacingY;
          const layer1 = getCoronalData(currentX, (prevY - pixelSpacingY) < 0 ? 0 : prevY - 1);
          const layer2 = getCoronalData(currentX, prevY);
          const layer3 = getCoronalData(currentX, prevY + pixelSpacingY);
          const layer4 = getCoronalData(currentX, prevY + 2 * pixelSpacingY);
          // 按高度遍历 截取那一行的pixel
          for (let j = 0; j < imageHeight; j++) {
               // 双三次插值
               const matrix = this.getCubicMatrix(j, positionX, layer1, layer2, layer3, layer4, imageWidth)
               const pointPixelData = lodash.round(this.cubicInterpolation(coordinateX, coordinateY, matrix));
               resultValues[j].push(pointPixelData);
               // 同时要记录minPixelValue maxPixelValue
               ..........
          }
    }
    

    同时把双三次插值这块实现的代码也贴上来:

    // 组装双三次插值的矩阵 currentX是实际空间坐标的值 coordinateY是按pixeldata arr中的坐标
        getCubicMatrix(z, coordinateIndex, layer1, layer2, layer3, layer4, imageWidth) {
            const start = z * imageWidth;
            const end = (z + 1) * imageWidth;
            const line1 = layer1.slice(start, end);
            const line2 = layer2.slice(start, end);
            const line3 = layer3.slice(start, end);
            const line4 = layer4.slice(start, end);
            const index = lodash.floor(coordinateIndex);
            const pos1 = index - 1;
            const pos2 = index;
            const pos3 = index + 1;
            const pos4 = index + 2;
            let matrix = [[], [], [], []];
            matrix[0][0] = line1[pos1];
            matrix[0][1] = line2[pos1];
            matrix[0][2] = line3[pos1];
            matrix[0][3] = line4[pos1];
    
            matrix[1][0] = line1[pos2];
            matrix[1][1] = line2[pos2];
            matrix[1][2] = line3[pos2];
            matrix[1][3] = line4[pos2];
    
            matrix[2][0] = line1[pos3];
            matrix[2][1] = line2[pos3];
            matrix[2][2] = line3[pos3];
            matrix[2][3] = line4[pos3];
    
            matrix[3][0] = line1[pos4];
            matrix[3][1] = line2[pos4];
            matrix[3][2] = line3[pos4];
            matrix[3][3] = line4[pos4];
    
            return matrix;
        }
    
        /**
         * 双三次插值
         * @param x 当前点在矩阵中的x坐标
         * @param y 当前点在矩阵中的y坐标
         * @param data 4*4矩阵数据
         * @returns {number}
         */
        cubicInterpolation(x, y, data) {
            let w = 4;
            let h = 4;
            // 计算x和y方向的最近的4*4的坐标和权重
            let wcx = this.getCubicWeight(x);
            let wcy = this.getCubicWeight(y);
            let wx = wcx.weight;
            let wy = wcy.weight;
            // 坐标
            let xs = wcx.coordinate;
            let ys = wcy.coordinate;
            let val = 0;
            // 遍历4*4的点,根据权重相加
            for (let j = 0; j < 4; j++) {
                let py = ys[j];
                py = py < 0 ? 0 : py;
                py = py > h - 1 ? h - 1 : py;
                for (let i = 0; i < 4; i++) {
                    let px = xs[i];
                    px = px < 0 ? 0 : px;
                    px = px > w - 1 ? w - 1 : px;
                    // 该点的值
                    let dv = data[py][px];
                    // 该点的权重
                    let w_x = wx[i];
                    let w_y = wy[j];
                    // 根据加权加起来
                    val += (dv * w_x * w_y);
                }
            }
            return val;
        }
    
    step4

    拼接其实在上一步的代码里面已经体现了,就是resultValues这个字段的处理,比如一段路径按spacing可以切分出10个点,那这段路径最终呈现的重建图像就是10 * Height,我们图像最终显示的pixelData的数组是按行排列下去的,比如一个4宽度、3高度的图像的pixelData其实是这样的:
    [
    1,2,3,4,
    1,2,3,4,
    1,2,3,4
    ]
    那对应上面的resultValues[j]其实是获得了这样一个数组:
    [
    [1,2,3,4],
    [1,2,3,4],
    [1,2,3,4]
    ]
    实际的多段路径就是height * point数量的数组:
    [
    [1,2,3,4.....], 长度为point数量
    [1,2,3,4.....],
    [1,2,3,4.....]
    ....
    长度为height
    ]
    然后lodash.flattenDeep(resultValues)把数据展开,就是我们期望的pixeldata了

    step5

    最后拼装一个cornerstoneImage给cornerstone渲染,需要把上面得到的pixeldata再Int16Array一下,然后min/maxPixelValue不能搞错,不然会影响到generateLut那边。因为实现了一下路径点回退的功能,所以发现退到width为0的时候,cornerstone那边的render不知道是哪步有问题,一旦为0之后,再drawImage或者updateImage都不会显示图像了,所以这边hack了一下最小也给1

    const cornerStoneImage = {
                height: coronalData.imageHeight,
                rows: coronalData.imageHeight,
                /* hack: width为0的时候cornerstone那边的render机制会出问题 后续都显示不出来 所以默认给1*/
                width: width || 1,
                columns: width,
                pixelData: __resultValues,
                getPixelData() {
                    return __resultValues;
                },
                imageId: 'cprgenerate',
                columnPixelSpacing: sagittalData.pixelSpacingX,
                rowPixelSpacing: sagittalData.pixelSpacingY,
                slope: 1,
                windowCenter: coronalData.windowCenter,
                windowWidth: coronalData.windowLevel,
                sizeInBytes: coronalData.imageHeight * width,
                color: false,
                invert: false,
                intercept: coronalData.intercept,
                minPixelValue: minPixelValue || 0,
                maxPixelValue: maxPixelValue || 0
            }
    
    最后

    以上是我的实现思路,下图是之前截的大体效果,左侧是横断的薄层,滚动才能看到完整胸骨走向,没仔细选点所以前面有几个比较大的断层可以无视,但后面那些马赛克纹路比较明显的,这是比较困惑我的地方,想要得到更平滑的重建图或许还是得做拟合曲线,去拟合左侧选点贴合在骨骼实际路径中心点的位置,如果大家有好的办法或者例子,麻烦告诉我,感谢~

    效果图

    参考:
    CT后处理技巧-CPR https://mp.weixin.qq.com/s/nwRsx3nSowDvBAfoCYAvhw
    js双三次插值 http://events.jianshu.io/p/3eec04ef39a1

    相关文章

      网友评论

          本文标题:CPR曲面重建的Web实现

          本文链接:https://www.haomeiwen.com/subject/xolvwrtx.html