EN

混合现实手术导航

混合现实里的一个 CT 场景,每台头显共用同一份并配准到身体上;场景同步服务器,全部后端与配准系统都是我写的。

两台 HoloLens 头显各看同一份 CT 影像,但各自渲染的是独立的一份:一人转动模型,另一人并不会察觉。2017 到 2019 年,我在国内的 Visual3D 做混合现实(Mixed Reality,MR)手术导航系统,要解决的还是这个问题,只是房间里多了真实的身体和一台机器人。我最初是全栈工程师,写了整个后端,其中就包括这个故事里每台设备都要经过的那台服务器。医学场景以及场景上的各种操作也由我做好后发布到头显上;显示场景的 HoloLens 客户端是同事写的。第二年我转做算法工程师,从设计到代码完成了配准系统,也就是把场景放到身体上的那一部分;此外还训练了两个分割网络,渲染方面我也做了一部分。

整套系统的画:两台 HoloLens 头显,一台 iPad,一台显示录制画面的笔记本电脑,一个胸腔的 CT 渲染,一个贴满二维码的立方体标出世界坐标系,一个运动传感器,一条机械臂,以及带探针和工作站的导航跟踪器,每台设备都有自己的一组小坐标轴。蓝线从每台设备连到中间的一台塔式服务器。
2020 年我画的整个房间,每台设备到我的服务器的连线是蓝色的。

每台设备共用一个场景

下面按设备逐个说,每台设备都是为了解决一个问题才加进来的。共享本身是容易的:一个各面贴着二维码的立方体定义了世界坐标系,每台头显扫描它,CT 场景再对齐到这个立方体上,两台头显就把模型放在了同一个位置。但这只能维持到有人去动它为止。一台头显平移或旋转了场景,另一台并不知道发生了改动,于是就需要一套实时通信系统。每台设备都连到同一台服务器,一次改动上传一次,再下发给其余所有设备。我让服务器把收到的改动合并起来,按固定节拍广播出去,因此满屋子的客户端同时编辑也不会把它堵住。

两台头显和二维码立方体。CT 场景画了两次,一次转过了角度并淡化;第一台头显标着「视角已改变」,第二台标着「不知道有改动」。
一台头显改了视角,另一台毫不知情。
同样的两台头显,下方加了一台服务器。从服务器出发的箭头标着「同步」和「更新场景」,分别指向 CT 场景和第二台头显。
加上服务器以后,另一台头显也能收到更新。

接下来的问题是控制:在 HoloLens 里直接操作场景相当笨拙(有些时候),于是加进来一台 iPad 作为控制器。此时「控制 1 号头显」这样的消息必须准确送到那一台头显,这就是我给服务器加上账号系统的原因。再往后的问题是录制:头显自己录制很慢,画质也差,因此改由一台桌面电脑做录制器,它向服务器请求预览,头显的头部位姿也经服务器转给它。

这台服务器并不是我写的最后一台同类服务器。后来我在 SLAC 写的 Teeport 也是同样的结构:它同样是一个实时中枢,只是客户端从头显换成了优化器以及它们所求解的问题。

把场景放到身体上

要做导航,场景必须贴在真实的身体上,这就要求知道身体在世界坐标系里的位置,而头显给不出这个信息,于是需要一台导航设备。用带跟踪的探针触碰身体上的若干点,把它们与 CT 里的同一组点配成对,CT 就与跟踪器联系起来了;跟踪器上再贴一个二维码,跟踪器又与头显联系起来。配准系统建立在两种方法之上:若已知某个标记的位姿,这一个位姿就给出了整个变换;若手里只有一些点,就把它们配成对,用最小二乘拟合出变换,也即解正规方程。探针那一步属于第二种。下图里每一次配准是一个箭头,只有沿途每个箭头都对,场景才是对的。

然后是机器人:以穿刺为例,只要路径已知,机器人就比人手做得好,而路径是在 CT 坐标系里画出来的。机械臂上装一个可跟踪的标记,用位姿匹配把机器人与跟踪器联系起来,跟踪器已经与 CT 联系过了,机器人也就知道了路径。最后一个问题是身体会动。重新配准要花时间,因此用一个运动传感器监视身体,它靠自己的二维码与头显联系;位姿一变,传感器就通过同一台服务器发出场景更新,走的和头显自己的改动是同一条路。

六个坐标系之间的配准链:运动传感器通过扫描它的二维码与 HoloLens 联系,HoloLens 通过扫描二维码立方体与世界坐标系联系,CT 对齐到世界坐标系,CT 通过带跟踪探针的点对与导航设备联系,导航设备通过跟踪器上的二维码与 HoloLens 联系,机器人通过位姿匹配与导航设备联系;由此得到机器人到 CT 的变换,机器人便知道了路径 运动传感器HoloLens世界 机器人导航CT M · 监视身体H · 每台头显一个W · 二维码立方体 R · 机械臂上的标记N · 跟踪探针C · 影像与路径 扫它的二维码 扫立方体 点对 位姿匹配 扫跟踪器上的二维码 场景对齐到立方体 于是有了机器人到 CT,机器人知道了路径
虚线箭头不需要测量,它由位姿匹配和点对推出来。

上线的后端

画里的服务器是一台塔式服务器,我最初写的也就是这样一台服务器。它对每个客户端开一条 WebSocket,大家编辑时场景放在 Redis 的内存里,编辑完由 PostgreSQL 保存下来,模型文件就放在本地磁盘上。后来它不断扩展,每扩展一次我就把规划重画一遍,从最初那一个方框到最后一版共画了六张。场景方面的工作和数据库方面的工作分到了两台服务器上,它们与存储之间加了一层 API,账号也有了单独的登录服务器。接着是一台服务发现服务器,客户端通过 UDP 向它询问其余服务在哪里;再往后加了 PACS 服务器,使场景可以引用 PACS 里的影像(DICOM 序列),又加了许可证服务器,登录时要向它核验。

最后一张图就是上线的版本,其中每一个服务都是我写的。它也更扁平了:每件事各有一个服务,客户端先向服务发现(仍然通过 UDP)询问它们都在哪里。让所有人的场景保持一致本来是那一台服务器的任务,现在改由消息队列承担:一次编辑先到达通道服务器,由它放进 NATS 队列,再由场景状态更新服务器取出并写进 Redis。

上线的后端。客户端通过 UDP 向服务发现询问各服务的地址,后面是九个服务。一次场景编辑到达通道服务器,由它放进 NATS 消息队列;NATS 转发给场景状态更新服务器,后者把场景写进 Redis。场景状态更新服务器通过场景状态 API 服务器加载和保存场景,API 服务器同样写 Redis,并经过场景服务器。场景服务器使用账号,文件和 PACS 服务器。账号与文件记录在 PostgreSQL 里;文件服务器把模型放在磁盘上,许可证服务器把许可证文件也放在那里;PACS 服务器从 PACS 存储读取 DICOM 序列。账号,文件和 PACS 都向许可证服务器核验。 微服务 客户端客户端客户端服务发现通道场景NATS场景状态场景状态账号文件许可证PACSRedisPostgreSQL磁盘PACS 加入,离开,编辑管理场景消息队列更新服务器API 服务器注册,登录上传,下载许可证核验读 DICOM内存里的场景账号与文件记录模型与许可证文件DICOM 序列 通过 UDP 询问各服务的地址 进队列转发写入场景 加载,保存分配归属关系场景文件DICOM 信息 账号,文件与 PACS都向它核验
最后一张图,也就是上线的那一版,橙色是一次场景编辑走过的路径。

服务器是用 JavaScript 写的,客户端则各用各的平台所要求的语言,头显上是 C#,导航与机器人工作站上是 C++。代码量很大:2020 年我数了一下,我参与过的全部仓库(包括 SLAC 的和个人的)里,大约四分之三是 Visual3D 的。2018 年能拿出来展示的是三个演示,其中两个用的是骨骼模型演示。

我训练的两个网络

第二年我还训练了分割模型,网络本身不是我的。做纤维束追踪的 TractSeg 和做脑肿瘤的 nnU-Net 都出自 MIC-DKFZ 组,底下都是 U-Net(Ronneberger 等人)。nnU-Net 大体上就是一个带有数据集自适配的 U-Net,省去了自行做数据归一化的工作。我做的是训练和运行。两个模型里肿瘤模型的训练集更大,它在一块 V100 上训练了两周多,之后分割一份新的影像大约要一分钟。

脑的一张轴位 MRI 切片,上面用绿,红,蓝三色画出参考纤维束,两束在脑室两侧前后走向。
一张轴位切片上的参考纤维束。
同一张轴位切片,上面是 TractSeg 预测的纤维束,同样的两束,同样的颜色,略平滑一些。
TractSeg 对同一张切片的预测,略平滑一些。
脑的一张轴位 MRI 切片,一侧有一个大肿瘤,叠加了参考标注:白环里一个青色的核心,后方一片橙色区域。
另一个脑的参考肿瘤标注。
同一张切片,叠加了 nnU-Net 的预测,青色核心,白环和橙色区域与参考几乎一致。
nnU-Net 对这张切片的预测。

资料来源:数字出自我 2020 年 9 月做的 Work Overview 幻灯片,Dice 系数出自我的简历;服务器的广播方式和两种配准方法出自我 2018 年在 SLAC 的求职报告。房间的图(其中的设备图是产品照片)和 MRI 切片出自 2020 年的幻灯片,配准链的图按它们重画;后端的图按我留在那些幻灯片旁边的最后一张架构图重画。