2D 与 MPR 渲染:从体数据切出可看的图像
复盘 DicomVision 中 2D Stack、窗宽窗位、仿射变换、MPR 三正交面和 scipy 重采样的实现思路。
问题引入
2D 显示看起来最简单:选一张切片,显示出来。但医学影像的 2D 渲染并不是把数组直接贴到页面上。它至少要处理 HU 转换、窗宽窗位、灰度方向、缩放、平移、旋转、翻转、测量叠加和视口尺寸。MPR 又在 2D 之上增加了一层复杂度:图像不再来自原始切片,而是从三维 volume 中沿任意平面重采样得到。
DicomVision 的后端渲染思路是把这两件事统一到视口模型里。Stack、MPR、3D 都有 ViewRecord,用户操作都变成 view operation。不同视图的差异体现在像素来源和渲染路径,而不是前端自己维护一套独立算法。
背景概念
Stack 是最传统的阅读方式:当前 index 指向某个 Instance,滚轮切换 index,窗宽窗位决定显示范围。图像坐标是二维像素网格,屏幕坐标是视口画布。缩放、平移、旋转和翻转可以用一个 image-to-canvas 的仿射矩阵表示。
MPR 是 Multi-Planar Reconstruction。它把连续切片看作一个三维体,然后从体里切出 axial、coronal、sagittal 三个正交面。Axial 通常接近原始横断面,coronal 是冠状面,sagittal 是矢状面。MPR 的关键不是“换个方向显示数组”,而是按真实 spacing 重采样。如果 z 方向切片厚度是 2.5 mm,而 x/y 像素间距是 0.7 mm,直接按数组索引切面会导致比例失真。
项目实现
2D Stack 的渲染链路是:根据 view.current_index 找到 Instance,读取 dicom_cache 中的 source_pixels,选择视口窗宽窗位,使用 np.clip 把目标值域裁剪到显示范围,归一化为 uint8,再把图像按 ViewportTransformer 的仿射矩阵映射到 canvas 大小。测量层和方向标识等 overlay 也要使用同一套变换,避免图像和标注错位。
ViewportTransformer.build_image_to_canvas_transform 会构造 3x3 矩阵:先把图像中心平移到原点,再缩放和翻转,再旋转,最后平移到 canvas 中心并加上用户拖拽偏移。应用到 NumPy 数组时,项目使用 scipy.ndimage.affine_transform。这个函数接受的是输出像素到输入像素的反向映射,所以代码会先求逆矩阵。它还使用 row/col,也就是 y/x 顺序,因此需要把矩阵从 x/y 坐标转为数组坐标。
MPR 的路径更重一些。后端先构造 volume 和几何信息,然后每个 MPR 视口根据自己的平面定义取样。三视图通过共享的 ViewGroupRecord 保持联动:一个视口里的十字线移动,会改变共享 cursor,再广播到另外两个视口。这样 axial、coronal、sagittal 不是三个孤立图像,而是同一个三维位置的三个观察方向。
关键函数表
| 函数或模块 | 作用 | 关键注意点 |
|---|---|---|
ViewportTransformer.calculate_contain_zoom | 计算初始适配缩放 | 要考虑像素宽高比 |
build_image_to_canvas_transform | 构造 image-to-canvas 矩阵 | 平移、缩放、翻转、旋转的顺序不能乱 |
AffineTransform.inverse_components | 给 SciPy 提供反向映射 | affine_transform 使用 output-to-input |
scipy.ndimage.affine_transform | 重采样二维图像或切面 | 数组坐标是 row/col,不是 x/y |
Pillow.Image.fromarray | 把 uint8 数组转成可编码图像 | 输出前必须确认 dtype 和范围 |
services/mpr/* | MPR 几何、平面、光标、重采样 | 必须使用 spacing 和方向信息 |
自绘图表
小结
2D 和 MPR 的共同点是都要把医学空间的数据变成当前视口的二维图像。区别在于 Stack 的像素直接来自某个 Instance,MPR 的像素来自 volume 重采样。只要牢记“数组坐标、物理坐标、屏幕坐标是三件事”,渲染路径里的矩阵、spacing 和重采样就会变得容易理解。