项目背景和需求

在Hololens 2手术项目中,医生需要通过查看切面图来更好的观察穿刺针位置,因此需要将MPR功能在Hololens 2上实现。
样例图片

开发环境

项目 版本
操作系统 Windows 10 家庭中文版 21H2
Unity版本 Unity2021.3.4f1c1
Mixed Reality Toolkit Foundation 2.8.3
Mixed Reality Toolkit Standard Assets 2.8.3
Mixed Reality OpenXR Plugin 1.6.0
fo-dicom(Unity Assets) 3.5.0

涉及的相关技术

Dicom标准与Dicom文件

DICOM(Digital Imaging and Communications in Medicine)即医学数字成像和通信,是医学图像和相关信息的国际标准(ISO 12052)。它定义了质量能满足临床需要的可用于数据交换的医学图像格式。Dicom官方文档.
本项目主要处理的是CT图像的dicom文件。Dicom文件的结构如图所示(Dicom文件简介),其中数据集(DataSet)和像素数据是我们涉及到的部分。
dicom文件结构
在数据集中包含着各类图片信息,具体图片信息的解释可以查看以下网页:DICOM Standard Browser.
在像素数据中,存放的是每个像素的数据,存放顺序是从左到右、从上到下。该数据并非灰度值,对于CT图像,该数据可以通过计算变成CT的HU值。

HU(Hounsfiled Unit)值:反映了组织对X射线吸收程度。以水的吸收程度作为参考,即水的HU=0,衰减系数大于水的为正直,小于水的为负值。并以骨皮质和空气的HU值为上限和下限。空气为-1000,致密骨为+1000。在实际图像中可能是负几千到几千不等。

HU值的计算公式: 输出值 = 像素数据 ∗ 斜率 + 截距。 输出值 = 像素数据 * 斜率 + 截距。 输出值=像素数据斜率+截距。

其中截距是Rescale Intercept Attribute(Tag(0028,1052)),斜率是Rescale Slope Attribute(Tag (0028,1053))。当ImageType(0008,0008)的值1为ORIGINAL且值3不是LOCALIZER、且Multienergy CT Acquistion(0018,9361)是不存在或者NO的时候,输出值为HU值。

调窗操作:HU值的范围过大,不便于输出和观察,因此要通过调窗操作才能变为图片显示所需的灰度值。
以下是调窗操作的伪代码:

		/*x是输入值,y是范围从ymin到ymax的输出值,
		c是窗口中心(0028,1050),w是窗口宽度(0028,1051)*/
		float ChangeWindow( float x, float c, float w, float yMin, float yMax)
		{
			float y = 0;
			if(x <= c- 0.5f - (w - 1) / 2)
				y=yMin;
			elseif(x > c - 0.5f + (w - 1) / 2)
				y=yMax;
			else
				y = ((x - (c - 0.5f)) / (w - 1) + 0.5f) * (yMax- yMin) + yMin;
			return y;
		}

多平面三维重建

如图所示,多平面三维重建即根据影像数据重建出影像横断面、矢状位、冠状位图像,且切面可以进行角度、位置调整。
MPR示例

fo-dicom库

一个C# dicom库,可用于.NET Framework、.NET Core、Universal Windows、Android、iOS、Mono和Unity,提供了方法进行读取、修改dicom文件。
文档链接
github链接
Unity Store链接

Unity的Texture3d

主要用来存储显示图像所需数据
Unity Texture3d 文档

实现流程详解

基本思路

受制于Hololens设备的运算性能以及opengl无法在Hololens上运行等问题,采用了如下方法模拟MPR:
1)从dicom文件中读取原始数据和文件信息(窗位、窗宽等),原始数据处理成HU值用Texture3d保存(受限于Color格式需要除以10000并用Alpha标志正负),文件信息用对应Json文件保存。
2)修改Shader以用于显示Texture3d,首先是Texture3d中的坐标对应,之后是将Color内数值还原为HU值,并根据外部给定的窗位窗宽计算实际灰度
3)给平面赋予对应Material,用来让Shader根据位置渲染出画面,之后用摄像机拍摄画面,使用RenderTexture在对应位置显示。

读取Dicom文件获取所需信息

我们使用fo-dicom进行了dicom文件的读取。首先使用DicomImage从文件夹所有图片中的第一张获取默认窗宽窗位,之后使用dcmFile.Dataset.Get<>()的方式获取其他图片信息、使用自建结构体PixelDataFactory存储像素信息,用三维数组存储HU值数据。代码主要参考了使用fo-dicom读取Dicom文件的PixelData信息及像素信息(C# / fo-dicom)

//path:文件夹位置,personalDicomData:自建的存储信息结构体
static float[][][] ReadDicomPixel(string path, PersonalDicomData personalDicomData)
{
    string folderPath = path; 
    //如果文件夹存在
    if (Directory.Exists(folderPath))
    {
        //打开第一个dcm文件,以获取所有图片应该共同不变的信息:窗位、窗宽
        string tempFilePath = path + "/IM0"; 
        var tempDcmFile = DicomFile.Open(tempFilePath); 
		//通过第一张图片地址创建DicomImage对象
        DicomImage dicomImage = new DicomImage(tempFilePath);
        //将默认的窗位、窗宽存入自己写的结构体中,也可以用后续的dcmFile.Dataset.Get<>()的方式获取
        personalDicomData.defaultWindowLocation = dicomImage.WindowCenter;
        personalDicomData.defaultWindowWidth = dicomImage.WindowWidth;

        //开始文件夹中所有图片的读取
        DirectoryInfo direction = new DirectoryInfo(folderPath);
        FileInfo[] files = direction.GetFiles("*", SearchOption.AllDirectories);
        //我这里用一个三维数组存储读取到的HU值信息,因为有Meta文件所以除以2
        float[][][] pixel_data_3d = new float[files.Length / 2][][];
        personalDicomData.textureWidth = 0;
        for (int i = 0; i < files.Length / 2; ++i)
        {
        	//我这里文件的地址格式为:文件夹地址/IM0、文件夹地址/IM1...
            string fName = path + "/IM" + string.Format("{0}", i);
            var dcmFile = DicomFile.Open(fName); // 打开dcm文件
            //获取第0帧的pixeldata,想获取其他帧修改即可,返回IPixelData类型
            var pixelData = PixelDataFactory .Create(DicomPixelData.Create(dcmFile.Dataset), 0); 

            //获取单个Diocm数据中,(w,h)处的像素信息
            if (pixelData is Dicom.Imaging.Render.GrayscalePixelDataU16)
            {
            	//存储本dcm文件所有点的像素
                float[][] pixel_data_2d = new float[pixelData.Height][]; 
                //读取图像的截距
                float intercept = dcmFile.Dataset.Get<float>(DicomTag.RescaleIntercept);
                //读取图像的斜率
                float slope = dcmFile.Dataset.Get<float>(DicomTag.RescaleSlope);
                //读取像素间距离,以便后续计算Texture3D和实际模型之间的比例
                personalDicomData.pixelSpacing = dcmFile.Dataset.Get<float[]>(DicomTag.PixelSpacing);
                //这里实际上应该用前后两张图片的位置信息相减获得图片见的间距
                personalDicomData.imageSpacing = 1.0f;
                personalDicomData.textureHeight = pixelData.Height;
                personalDicomData.textureWidth = pixelData.Width;
                personalDicomData.textureLength++;
                for (int h = 0; h < pixelData.Height; h++)
                {
                    float[] pixel_data = new float[pixelData.Height];
                    for (int w = 0; w < pixelData.Width; w++)
                    {
                    	//依据公式将原始数据转为HU值,并且为了存储在Unity的Color类型里除以了10000
                        float hu = Convert.ToSingle((pixelData.GetPixel(w, h) * slope + intercept) / 10000);
                        pixel_data[w] = hu;
                    }
                    pixel_data_2d[h] = pixel_data;
                }
                pixel_data_3d[i] = pixel_data_2d;
            }
        }
        return pixel_data_3d;
    }
    else
        return null;
}

存储所需信息及生成Texture3d

将获取到的PerdonalDicomData使用Unity的JsonUtility.ToJson()来存储为Json文件,将读取到的HU值存入Color类型。创建Texture3d并将颜色值复制到纹理存储。代码主要参考了Unity的演示文档。

[MenuItem("Assets/CreateTextureAndJson/CreateFromFolder")]
static void CreateDicomPixel()
{
    var select = Selection.activeObject;
    var path = AssetDatabase.GetAssetPath(select);
    PersonalDicomData personalDicomData = new PersonalDicomData();
    float[][][] dicomImages = ReadDicomPixel(path, personalDicomData);
    
    // 配置纹理
    int size = dicomImages.Length;
    TextureFormat format = TextureFormat.RGBA32;
    TextureWrapMode wrapMode = TextureWrapMode.Clamp;

    // 创建纹理并应用配置
    Texture3D texture = new Texture3D(dicomImages[0][0].Length, dicomImages[0].Length, size, format, false);
    texture.wrapMode = wrapMode;

    // 创建 3 维数组以存储颜色数据
    Color[] colors = new Color[size * dicomImages[0][0].Length * dicomImages[0].Length];

    // 填充数组,使纹理的 x、y 和 z 值映射为红色、蓝色和绿色
    for (int z = 0; z < size; z++)
    {
        int zOffset = z * dicomImages[0][0].Length * dicomImages[0].Length;
        for (int y = 0; y < dicomImages[0].Length; y++)
        {
            int yOffset = y * dicomImages[0].Length;
            for (int x = 0; x < dicomImages[0][0].Length; x++)
            {
            	//由于Color类型无法存储负值,所以使用alpha来作为标志位,0为负,1为正
                if (dicomImages[z][y][x] < 0)
                {
                    colors[x + yOffset + zOffset] = 
                    	new Color(-dicomImages[z][y][x], -dicomImages[z][y][x], -dicomImages[z][y][x], 0);
                }
                else
                {
                    colors[x + yOffset + zOffset] = 
                    	new Color(dicomImages[z][y][x], dicomImages[z][y][x], dicomImages[z][y][x], 1);
                }
            }
        }
    }

    // 将颜色值复制到纹理
    texture.SetPixels(colors);

    // 将更改应用到纹理,然后将更新的纹理上传到 GPU
    texture.Apply();

    //保存Json信息
    SaveJson(personalDicomData);

    // 将纹理保存到 Unity 项目
    AssetDatabase.CreateAsset(texture, "Assets/MPR/Texture3ds/3DPixel.asset");
}

编辑Shader以在平面上显示切片

Shader主要是在git项目unity-valume-rendering上修改过来的,将原来需要循环计算的部分去掉只进行对应位置的渲染,将原来Color中的HU值还原并依据公式进行调窗运算。以下是修改部分的代码。
sample_volume方法的作用是根据位置p得出该点对应的颜色值。_WindowWidth是要设置的窗宽减去1获取到的值,_WindowLocation是要设置的窗位减去0.5获取到的值,之所以没有放在Shader中进行该运算是因为shader里的运算能省则省,不然会拖慢运行速度。_BasicLight是为了防止画面过暗加上的一个基础值,_Intensity是光强。
在frag方法中的_CubePosX、_CubeScaleX等指期望texture3d所在的坐标和大小。
从Shader中创建Material并赋给平面物体,当平面物体上的点和期望Texture3d所在位置重合时,平面上对应点会显示对应灰度值,这样就形成了切片。后续即可使用摄像机和RenderTexture组合用于获取对应切片图像。

float sample_volume(float3 uv, float3 p)
  {
      //计算颜色值(0~1)
      float origin = (tex3D(_Volume, uv).a * 2 + -1) * tex3D(_Volume, uv).r * 10000;
      origin = (origin - _WindowLocation) / _WindowWidth + 0.5; 
      if (origin < 0)
          origin = 0;
      else if (origin > 1)
          origin = 1;
      float v = origin == 0 ? 0 : (_BasicLight + origin * _Intensity);
      
      //从unity-valume-rendering项目中复制来的,个人见解是限制显示范围
      float3 axis = mul(_AxisRotationMatrix, float4(p, 0)).xyz;
      axis = get_uv(axis);
      float min = step(0, axis.x) * step(0, axis.y) * step(0, axis.z);
      float max = step(axis.x, 1) * step(axis.y, 1) * step(axis.z, 1);

      return v * min * max;
  }

  fixed4 frag(v2f i) : SV_Target
  {
    UNITY_SETUP_INSTANCE_ID(i);

    float4 dst = float4(0, 0, 0, 0);
    //根据点i的世界坐标,按照当前Cube坐标和大小换算回1*1*1的Texture3d中的点坐标
    float3 p = float3((i.world.x - _CubePosX) / _CubeScaleX, (i.world.y - _CubePosY) / _CubeScaleY, (i.world.z - _CubePosZ) / _CubeScaleZ);
    float3 uv = get_uv(p);
    float v = sample_volume(uv, p);
    float4 src = float4(v, v, v, v);
    src.a *= 0.5;
    src.rgb *= src.a;
    dst = (1.0 - dst.a) * src + dst;

    return saturate(dst) * _Color;
  }

将穿刺针相对模型的位置和切片位置对应

大小对齐可以使用之前获取到的像素间隔和图片间隔,根据图片宽高像素数和图片数来计算实际上texture3d的长宽高,并以此对Shader中的显示的Cube进行大小位置调整,是的模型和切片的大小对齐。
位置对齐

实现效果

MPR实现效果
项目实际在Hololens2上运行较为卡顿,约30帧左右,且波动明显。个人感觉Hololens的性能实在有限,如果相关运算可以放到服务器上,Hololens只做显示可能时更好的方案。

写在最后

这篇文章本来写于2023年,因为各种各样的原因一直躺在草稿箱里,今天清理草稿箱时看到。我已经不再从事相关工作内容,也无法知道自己这篇文章的技术水平如何,今天发布出来权当是记录过往的时光,或许也能算作一点微小的贡献。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐