体素统计与斜切视图
问题所在
工具在视口上画出一个形状,而这个形状代表了一组体素。面积标注是一个棱柱: 轮廓沿视图法向扫出,该棱柱包含轮廓内部、且处在该标注自身厚度范围内的每个体素。 有些工具画出的是一个实体,例如球体或立方体,该实体则包含它内部的每个体素。 无论哪种情形,工具需要的都是同一件事:那组体素的全部,且每个恰好一次。
产出这组体素比看起来更难,而 Cornerstone3D 里已经存在四套各自独立的尝试:
| 工具 | 它如何遍历体素 |
|---|---|
RectangleROITool | 两个角控制点在索引空间的包围盒,完全没有形状测试 |
EllipticalROITool | 同样的包围盒,外加对盒内每个体素做 pointInEllipse |
CircleROITool | 同样的包围盒,外加对盒内每个体素做球体测试 |
PlanarFreehandROITool | 同样的包围盒,外加对每个体素做 worldToCanvas,以及一个跨行携带状态的穿越计数 |
这四套都是「包围盒 + 逐体素测试」,而且四套各自错得不一样。矩形省掉了测试, 所以它对轴对齐的矩形是精确的,但对旋转过的矩形会把四角算多。自由绘制工具在 画布坐标下工作,所以用户缩放时答案会变。四套都从单张切片构建包围盒, 所以四套返回的都是一张只有一个体素厚的薄片。由此产生三种失效模式, 而且无论把采样步长调成什么值,三者一个都解决不了。
最近邻采样在旋转下无法覆盖整数格点。 在平面内 45° 斜切角下,
沿 (0.707, 0.707) 在 IJK 中采样会取整到 (0,0), (1,1), (2,2)…,
永远访问不到 (1,0) 或 (0,1)。那大约是一半的体素。
相机的亚像素相位决定了代码跳过哪一半,于是半个像素的平移就会改变所报告的最大值。
从画布派生出来的采样集依赖显示状态。 这样一组体素是缩放、平移、
画布尺寸和 devicePixelRatio 的函数。于是同一个标注、同一份数据,
只因工具重算统计量时的显示状态不同,报告出来的均值就不同。
单平面遍历返回的是一张只有一个体素厚的薄片。 在层厚 1 mm 的 NM 序列上 画一个自由绘制标注,再把该序列与同方位下 0.5 mm 的 CT 序列融合。 正确的 CT 最大值必须对每个平面内位置检查两个 CT 体素。 增加平面内的采样数永远产不出第二个体素,因为每个采样点都落在同一个平面上。
这四套遍历还都很慢,而且各自带着自己的特例。一个圆盘的包围盒所含体素数是
圆盘的 4 / π 倍,所以还没等平面变斜,就已经有四分之一的逐体素测试是白做的。
把平面一倾斜,包围盒就变成围住倾斜棱柱的三维盒子,其体素数是棱柱本身的好几倍。
解法:一个迭代器
iterateVoxelsInShape 直接遍历那组体素,任何工具都不再需要自己的遍历实现。
它在索引空间中工作,而在索引空间里深度测试对整数体素索引恰好是线性的。
因此它对每一行以闭式解求出两个闭区间——一个来自板层,一个来自形状——
取交集,再发出其中的整数。它不会测试任何它不发出的体素,
也不会从显示状态中读取任何东西。
于是面积统计就变成「在该迭代器上做一次循环 + 一个累加器」。 工具提供形状和厚度,迭代器提供体素,均值、最大值和计数一趟就都出来了。 下面的规则 M、规则 F 和规则 D 定义了该迭代器产出的那组体素, 因此使用该迭代器的工具无需了解其中算术,就能得到已定义的答案。
规则 M、规则 F 和规则 D 是规范性的。issue #2889 也陈述了规则 M 和规则 D。而那套能快速求值规则 M 的索引空间算术只是实现细节, 任何人都可以改它,只要它选出的是同一组体素。
基础情形就是迭代器的基底
最普通的情形是单层的非斜切视图:平面落在采集轴上,标注厚度为一个体素。
迭代器并不为这种视图做特例处理。它本身就是迭代器的基底:
最外层轴变成切片轴,深度区间收敛为一层,内层循环按内存顺序为每个 j
沿 i 发出区间。一般的斜切情形就是同样的三层循环,只是深度区间会移动。
即便在这个基础情形下,迭代器通常也比那四套遍历更快, 因为一个能提供区间的形状不需要任何逐体素测试。旧代码测试包围盒中的每个体素、 再把大多数拒掉。迭代器把整行一次解完并发出一个区间, 于是形状测试的次数从包围盒的大小降为零。
几何越难,差距越大。在拉伸空间中(间距各向异性时), 世界坐标下的圆在索引空间里是一个偏心椭圆, 而在平面内旋转过的矩形在索引空间里是一个旋转过的盒子。 两者的包围盒增长都快于其内容,所以基于包围盒的遍历会浪费更多工作。 闭式解则不在乎:椭球每行解一个二次方程,盒子每轴解一个线性不等式—— 在任意角度、任意长宽比下都是如此。
规则 M:体素归属
一个体素属于某个面积标注,当且仅当它同时满足两个条件:
- 该体素中心沿法向到标注平面的距离在
(T + T_v) / 2之内。 - 该中心沿法向投影到该平面上的投影点落在二维形状内部。
T 是标注的厚度。T_v 是体素沿法向的厚度。
视口板层厚度 t 不出现在规则 M 中。这个「不出现」正是该规则的目的所在:
统计量不会因为用户缩放了视口、改变了画布尺寸或加大了板层而变化。
T_v 这一项把板层在两侧各加宽半个体素,因此某个体素合格的条件恰好是
「该体素本身与板层有重叠」。在 T = T_v 且锚定在体素中心这个默认情形下,
这一项没有影响——两种算法都给出一层。它的意义体现在两处:
一是平面没落在体素中心上时——若不加宽就会一个都选不中;
二是板层更厚时——不加宽的测试在要求两个体素厚度时只会选中一个。
恰好位于两个体素中心正中间的平面会选中两层。两个体素与板层的重叠量相等, 因此不存在任何有原则的取舍方式,而强行取舍会让计数取决于一次四舍五入的平局。 两层上的均值与一层上的均值不是同一个数,而 MPR 停在半层位置上 正是达到这种状态的常规途径。
规则 F:笔刷填充
规则 M 回答的是一个测量问题。笔刷填充问的是另一个问题, 而规则 M 对它给出的答案是错的。
笔刷填充写入一个体素,当且仅当它同时满足两个条件:
- 该体素中心沿法向、相对填充平面的深度落在半开区间
[-max(F, T_v) / 2, +max(F, T_v) / 2)内。F是该次填充覆盖的深度, 默认为T_v。 - 该中心沿法向投影到该平面上的投影点落在二维形状内部。
这个区间是半开的,而规则 M 用的是开区间。这个差异是规范性的, 并且不是算术上的偶然。开区间会把它的两个边界都丢掉, 于是恰好落在边界上的体素中心,在两个连续板层中哪一个都不属于—— 那么无论平面在什么位置,都没有任何一次填充会写入该体素。
只要各体素中心的深度与板层可公度,体素中心就会恰好落在边界上。
绕某个体素轴旋转 45 度就是常见情形:此时各深度都是 T_v / 2 的倍数,
开区间会丢掉一半的体积。而半开区间在任意方位下都不会丢东西。
半开区间还修正了厚层填充的层数。宽度为 F 的半开区间恰好包含
F / T_v 层,无论该平面落在两层之间的什么位置。同宽度的开区间则会
因该位置不同而多一层或少一层。
规则 F 的半宽是 max(F, T_v) / 2,规则 M 的半宽是 (T + T_v) / 2。
在默认的一个体素深度下,规则 M 给出 T_v,规则 F 给出 T_v / 2。
也就是说规则 M 深了一倍——用户画了一层,它会写两层。
规则 F 给出的 T_v 这个深度是精确的,不是折中。规则 F 的板层厚度为 T_v,
也就是索引空间法向 g 的 L1 长度,其中 gᵢ = sᵢ * (aᵢ · n)。
这个厚度的板层是一个标准数字平面。由此有两条性质,
并且这两条性质在任意方位下同时成立:
- 无空洞。 该板层包含连续平面所穿过的每一个体素。
- 无重叠。 当两个平面沿法向相距
T_v时,这两个板层不共享任何体素。
正是半开区间让第二条性质在任意方位下都成立。两个连续板层共享它们之间的边界, 而半开区间把那条边界恰好判给两者中的一个。
因此连续的填充会精确地铺满整个体积,就像连续的数字直线铺满二维网格那样。 板层更薄会破坏第一条性质,更厚会破坏第二条。
这套铺砌成立的前提是平面之间相距 T_v。若视口按别的距离步进,
就会在两张连续切片上显示同一次填充,看起来像是填充渗入了相邻切片。
这种情况下填充是正确的,错的是步进。三种度量方式见下文的间距一节。
规则 D:显示
当某个平面上的点沿法向到焦点的距离在 (t + T) / 2 之内时,视口显示该平面。
跨模态时的这些效果是有意为之的。画在一张厚 NM 切片上的标注, 正确地出现在两张薄 CT 切片上;而跨越两张 CT 切片的标注, 正确地出现在一张 NM 切片上。
与规则 M 一样,这里的比较是严格的,并由一个相对 epsilon 收紧——
因为常见情形恰好把相邻切片放在边界上,而它必须被排除。
视口显示的是在它自己那张切片上创建的标注,不是下一张上的。
这里的 epsilon 是相对于半宽的,而不是相对于规则 M 所用的体素厚度 T_v,
因为 T_v 需要一份体数据,而显示决策是在没有体数据的情况下做出的。
没有记录厚度的引用会回退为「精确平面匹配」,精度到 isEqual。
任何在 PlaneRestriction.referencePlaneThickness 出现之前创建的标注
都没有记录厚度,若把可见范围放宽,就会改变这些标注出现在哪些切片上。
T 从哪里来
新标注在创建时取一次 T,取自用户绘制时所在视口的板层厚度。
Viewport.getReferencePlaneThickness 提供该值,
BaseVolumeViewport 重写了这个方法。创建之后,T 就属于该标注,
存放在 PlaneRestriction.referencePlaneThickness 上。
当引用没有记录厚度时,T 默认取沿法向的一个体素。堆栈视口用的就是这个默认值,
早于该字段存在的每个标注也一样。
T 为 0 或更小同样算作「未记录」,走同一个默认值。平面形状的
getRequiredThickness 返回 0,而调用方可能会把这个值直接传给迭代器,
所以 0 必须表示「该形状不要求自己的深度」。
在创建时读一次板层,并不与「规则 M 独立于 t」相矛盾。
代码是在创建引用时读板层的,重算统计量时从不读。
体数据视口上的两处换算
BaseVolumeViewport.getReferencePlaneThickness 做了两处换算,
直接读原始板层值的人会漏掉它们。
它把值翻倍。 getSlabThickness 返回的是传给
setOrientationOfClippingPlanes 的那个数,而该方法把裁剪面放在
focalPoint ± slabThickness 处。因此在那条渲染路径上,
存下来的数是半厚度,几何厚度是它的两倍。通用平面路径使用
vtkImageResliceMapper,其中同一个字段本身就已经是全厚度了,
所以这次翻倍属于体数据视口,而不属于共享的引用代码。
它把渲染最小值映射为 undefined。 板层等于
RENDERING_DEFAULTS.MINIMUM_SLAB_THICKNESS 表示「没有请求板层」,
而不是「请求了一个 0.05 mm 的板层」。照字面记录下来会得到 T = 0.1 mm,
这比任何真实体素都薄,会破坏「标注始终覆盖至少一层」这条保证。
把它映射为 undefined,才能让 T 回退为沿法向的一个体素。
第二处换算由 utilities.getViewSlabDepth 承担,
每个需要深度的调用方都用它。调用方提供的是全深度,
所以体数据视口传 getSlabThickness() * 2,通用平面视口原样传入。
笔刷填充则通过 tools 包中的 getViewSlabDepthOfViewport 到达该函数,
由后者选择渲染路径。若某个调用方读了 getSlabThickness
并把它当作深度使用,填出来的深度就只有用户所见的一半。
使用迭代器
工具先构建形状,然后遍历体素:
import { utilities } from '@cornerstonejs/core';
const { createPolylineShape, iterateVoxelsInShape } = utilities.voxelSlab;
const shape = createPolylineShape({
volume, // { dimensions, direction, spacing, origin }
planePoint, // 标注平面的锚点
viewPlaneNormal, // 单位长度
polyline, // 世界坐标下的轮廓
});
for (const { ijk, center } of iterateVoxelsInShape({
volume,
planePoint,
viewPlaneNormal,
referencePlaneThickness:
shape.getRequiredThickness() || referencePlaneThickness,
getShapeRuns: shape.getRuns,
})) {
// 累加统计量
}
轮廓不同的工具只需把 createPolylineShape 换成 createEllipseShape
或 createRectangleShape,其余一行都不用改。
使用方要注意两个细节:
ijk和center在各次迭代之间是复用的。若要留存它们,请先复制。- 形状是与板层取交集,而不是取并集。除非你有意让板层去裁切形状,
否则请把
getRequiredThickness()作为referencePlaneThickness传入。
每个形状都在 getRuns(优化实现)之外暴露 containsPoint(定义)。
把 getShapeRuns: shape.getRuns 换成 isInShape: shape.containsPoint,
得到的体素集必须完全相同,只是更慢。这个替换是调试形状最省事的办法。
采样取值
迭代器产出的是索引和中心点,不含取值。做测量的工具还需要每个体素的值,
所以 sampleVoxelsInShape 在迭代器之上包了一层来读取它:
const { createPolylineShape, sampleVoxelsInShape } = utilities.voxelSlab;
const samples = sampleVoxelsInShape({
volume,
planePoint,
viewPlaneNormal,
referencePlaneThickness:
shape.getRequiredThickness() || referencePlaneThickness,
bounds, // 该标注可触及的索引盒
getShapeRuns: shape.getRuns,
voxelManager,
onSample: statsCallback,
storePointData,
});
onSample 会按迭代顺序、为每个有取值的体素收到
{ value, pointLPS, pointIJK },这正是统计计算器所消费的东西。
voxelManager 中没有取值的体素会被跳过。storePointData
还会把这些采样收集起来并返回,代价是每个体素多一个对象;
因此只做统计累加的调用方应当关掉它、改用 onSample。
bounds 必须为 referencePlaneThickness 最终解析出的那个厚度留出余量。
若某个调用方只按标注自身的厚度扩张了边界,却把一个实体形状更大的深度
交给了迭代器,那么第一层之后的每一层都会丢失。
各工具共用一条路径
@cornerstonejs/tools 中的每个面积标注工具都通过
utilities.sampleAreaAnnotationVoxels 到达采样器。
该函数接收标注和目标影像,并从中推导出平面、法向、厚度和索引边界。
各工具之间唯一不同的就是形状:
const pointsInShape = utilities.sampleAreaAnnotationVoxels({
annotation,
image, // 目标的 IImageData,而不是 image.imageData
voxelManager,
points, // 构建该形状所依据的那些世界坐标点
boundsMargin, // 形状相对那些点向外延伸多远
createShape: ({ volume, planePoint, viewPlaneNormal }) =>
createCircleShape({
volume,
planePoint,
viewPlaneNormal,
centerWorld,
radius,
}),
onSample: statsCallback,
storePointData,
});
有两个参数承载了工具必须做对的全部要点:
points和boundsMargin给出索引边界。多段线的轮廓、以及矩形的四个角 已经把形状围住了,所以那些工具把boundsMargin留作 0。 而圆形和椭圆只触及它们的控制点,所以要传入最大半径: 一旦控制点构成的盒子与索引轴不对齐,轮廓就会鼓出该盒子之外; 而用simplified控制点绘制的圆只保留一个控制点。- 对退化的标注(例如半径为零的圆),
createShape什么都不返回, 采样器于是不测量任何体素。形状工厂函数遇到退化情形会抛异常, 而渲染循环内部的异常会让整个视口停摆。
平面来自 annotation.metadata,绝不来自视口。每个工具都会在用户绘制标注时
记录 viewPlaneNormal。从 DICOM SR 来的标注没有记录法向——因为 SR 不存相机——
此时 updatePlaneRestriction 会改为记录两个平面内向量;
采样器随后对这两个向量做叉积,描述出同一个平面。两个点只能给出一个平面内向量、
给不出平面,所以不带法向、只有两个点的标注不会报告统计量。
形状契约
每个形状都实现 VoxelSlabShape,它有三个成员。
containsPoint(point) 是该形状的定义。它接收世界坐标下的一个体素中心,
回答该形状是否包含它。
getRuns(outer, row, depthRun, slab) 是优化实现。
它为某个 (outer, row) 位置沿板层的列轴产出闭区间 [min, max],
并且必须选出与 containsPoint 相同的那些体素。什么都不产出表示
该形状没有触及那一行。区间提供者的精度分三档:
| 档位 | 契约 | 例子 |
|---|---|---|
| 精确 | 一个区间,恰好就是被覆盖的那些体素 | 矩形,或椭圆的轴对齐行 |
| 精确多段 | 若干互不重叠的区间,用于一行多次进出该形状的情形 | 非凸的自由绘制多边形 |
| 近似 | 一个超集区间,并提供 isInShape 让迭代器逐个测试其中的体素 | 任何新形状在被优化之前 |
depthRun 是深度测试已经允许的那个区间。提供者可以裁到它上面、但不必如此,
因为迭代器无论如何都会对结果取交集。
getRequiredThickness() 返回「使板层能容纳整个形状」的最小 T。
平面形状返回 0,因为它沿法向没有延展,任何 T 都行。
带深度的形状返回那个深度,而更小的 referencePlaneThickness 会把它裁掉。
createEllipseShape 和 createRectangleShape 各自带有深度,
并各自应用自己的深度。createPolylineShape 永远是平面的、返回 0,
因此板层沿法向伸展多远完全由调用方的 referencePlaneThickness 决定。
想要一个多段线棱柱的调用方,就把棱柱深度作为该厚度传进去。
多段线的环与孔洞
createPolylineShape 接受单个环,也接受一个环数组。每个环都是闭合的,
所以不要在末尾重复第一个点。这些点会被投影到标注平面上,
从而处理那种带一点深度误差的轮廓——手绘出来的轮廓总是带误差的。
对这个形状来说 planePoint 是可选的,因为轮廓的每个点本来就落在该平面内,
默认取第一个环的第一个点。不过如果你手上有标注自己的锚点,请传它:
手绘的顶点带有取整误差,而锚点没有。无论用哪个锚点,
都要给形状和迭代器传同一个,否则两者描述的是不同的板层。
内部区域由「对所有环的所有边应用奇偶规则」确定,并且奇偶性是跨环累积的, 而不是逐环计算。这条单一规则带来了:
- 内部孔洞 —— 把孔洞作为它自己的一个环传入,它就被排除;
- 任意深度的嵌套 —— 孔洞里面的环又变成实心;
- 互不相连的区域 —— 分开的环描述分开的区域。
绕向不影响结果,所以孔洞环不必与其父环绕向相反。单个环既不必是凸的、 也不必是简单的,因为奇偶规则对自相交的环同样能给出结果。
不要把多个环摊平成一个数组。摊平会在每个环的末点到下个环的起点之间 插入一条边。这不会报错,只会悄悄地测量出一个不同的形状。
为什么区间求解是精确的
规则 M 中深度那一半,对整数体素索引恰好是线性的。索引为 p 的体素,
其中心在世界空间中位于 origin + M p,其中 M 是索引到世界的矩阵,
因此:
depth(p) = (centre - P0) . n = p . g + c0
g = Mᵀ n (索引空间法向)
c0 = (origin - P0) . n
g 和 c0 都是常量,所以沿任一单个轴,满足 |depth(p)| < halfWidth
的那些体素构成一个闭式区间。因此迭代器发出的是精确的整数区间,
而不是对每个体素做一次测试——这一点在任意方位下都成立,斜切也包括在内。
g 是有意不做归一化的。它的各分量表示「每个索引步进一个单位时
世界深度的变化量」,这正是区间算术所需要的。在采集方位下,
g 会平行于 (0, 0, 1),因为此时法向就是 k 轴,
于是 d0 . n 和 d1 . n 都为零,只剩 s2 * (d2 . n)。
各轴的角色
迭代按 outer → row → column 嵌套。
outerAxis取argmax |g|,即索引步进对深度影响最大的那个轴。 把该轴放在最外层扫描,可使每一步外层迭代覆盖该体数据的一个薄带, 同时也把最接近标注平面的那两个轴留了出来。rowAxis和columnAxis就是余下那两个轴。二维形状很自然地会把它的跨度 表达为「对每个rowAxis取值、沿columnAxis的若干区间」, 这也是形状约束为何应当放在最内层的原因。对每一对(outer, row), 深度约束给出沿columnAxis的一个区间,形状给出一个或多个区间, 迭代器发出它们的交集。
沿 columnAxis 的深度区间常常是无界的。在采集方位下 g[columnAxis] 为零,
于是深度沿该轴不变,形状成为唯一起作用的约束。
每种形状各有自己求得精确区间的路径:
| 形状 | 路径 |
|---|---|
| 椭圆、椭球 | 把直线代入二次形式,得到关于列索引的一个二次方程,其实根界定出一个区间 |
| 矩形、盒子 | 每个面都是一个线性约束,各给出一个区间;由于盒子是凸的,它们的交集仍是一个区间 |
| 多段线轮廓 | 该直线与所有环的所有边的交点,排序后由相邻成对的交点界定出内部区间 |
因此非凸的多段线会产出多个区间,这正是迭代器所支持的「精确多段」情形。
边界处理
落在形状轮廓上的体素中心算在形状内部。 有三个彼此独立的情形使这条规则成为必需:
- 整数网格上半径为 5 的圆,会把体素中心恰好放在它的轮廓上——
在
(5, 0)处,以及每个勾股点例如(3, 4)处。containsPoint做平方求和,可能得到略大于 1 的结果; 而getRuns解根,得到的恰好是 5。因此两者都要与一个被相对 epsilon 放宽过的边界作比较。 - 奇偶规则给出的是多段线的内部,但
containsPoint沿某个平面轴投射射线, 而getRuns沿列轴所投影的方向与直线求交。这两套平局规则在不同的几何下退化。 一个画在体素边界上的矩形多段线,曾出现过一端保留了一行、另一端却丢掉的情况。 - 穿越测试看不到那种沿着区间直线延伸的轮廓边, 因为该边的两个端点都落在这条直线的同一侧。这样的边、 以及任何触及该直线的顶点,都会直接提供它自己的延展范围, 并且代码会把每一份贡献合并起来,保证没有体素被发出两次。
深度测试的方向恰好相反。SLAB_RELATIVE_EPSILON 是收紧它、而不是放宽它,
因为在那里必须把相邻那一层排除掉。
为什么深度测试是严格的
板层测试用的是 < 而不是 <=,因为默认的 T = T_v
会把相邻体素中心恰好放在板层边界上,而采集方位下的标注必须恰好覆盖一层。
带符号距离来自世界坐标上的点积,所以一个数学上正好在边界上的值 会落到边界的某一侧。没有容差的话,整个系统里最常见的那种情形 就会随机多算两层。这个容差是相对于体素厚度的、而不是绝对值, 因为医学影像中的间距从微米到厘米都有。取值 1e-5 稳稳高于 float32 误差 (后者约为 1e-7 的相对量级),而大多数输入都带有 float32 误差, 因为 gl-matrix 的向量以及渲染几何的其余部分都是 float32。
这条严格规则有一个可见的后果。如果某个厚度超出体素整数倍的部分
小于 2 * SLAB_RELATIVE_EPSILON * T_v,它仍会选中较少的层数。
在 T_v = 1 mm 时这个死区只有 20 nm 宽,实践中不可能碰到;
但 T = T_v + 1e-6 的行为确实等同于 T = T_v,而不会把两侧邻层都拉进来。
本节描述的是规则 M 和规则 D。规则 F 改用半开区间,理由见规则 F 那一节; 此时 epsilon 是移动该区间,而不是收窄它。收窄区间会在两次连续填充之间 留下缝隙,而这些填充必须铺满。
体素沿法向的厚度
T_v 是体素盒沿法向的支撑宽度:
T_v = Σᵢ |dᵢ · n| * sᵢ
这是一个 L1 长度,并且有意不采用 getSpacingInNormalDirection
返回的那个 L2 长度。只有 L1 长度才回答「这个体素沿法向伸展多远」,
而这正是「体素与板层是否重叠」的测试所需要的。
| 函数 | 公式 | 回答什么 |
|---|---|---|
getSpacingInNormalDirection | L2,sqrt(Σ (d·aᵢ·sᵢ)²) | 相机推进多远才会看到新的体素 |
getVoxelThicknessAlongNormal | L1,Σ |d·aᵢ|·sᵢ | 一个体素沿该方向伸展多远 |
getEffectiveSpacingAlongDirection | 调和平均,1/sqrt(Σ (d·aᵢ/sᵢ)²) | 要步进多远才跨过一个体素 |
只要法向平行于某个体素轴,这三者就一致——这覆盖了任何采集方位的视图;
而对斜切法向它们就会分道扬镳。对 1×1×3 mm 的体素、
在平面内某个轴与切片轴之间成 45 度观察时,
L1 值为 2*sqrt(2) ≈ 2.83 mm,L2 为 sqrt(5) ≈ 2.24 mm,
调和形式约为 1.34 mm。
规则 M 只用 T_v,别的都不用。规则 F 也用 T_v,
因为正是这个 L1 长度使该板层成为一个标准数字平面。
调和形式属于那种沿直线行走的工具,例如自由绘制 ROI 的亚像素重采样器:
它需要的步长是「每步跨过一个体素」,而另外两种度量都回答不了这个问题。
切片步进本质上是一次重叠测试,所以切片步进同样需要 L1 值。
对斜切法向而言,按 L2 步进比 T_v 更短,
于是两个连续的切片位置会落在同一个数字平面内。
开销
开销与迭代器发出的体素数、加上它触及的行数成正比。它既不与包围盒的体积成正比, 也不与画布面积成正比。一个 ROI 在 8 倍放大下的开销, 与它在适配窗口时的开销是一样的。
当工具已经知道标注在索引空间的包围盒时,请传入 bounds。
边界只会收窄:每个轴都会与体数据的范围取交集,
所以即使某个盒子伸到了体数据之外,也不会产出体数据之外的任何索引。
API
这里提到的一切都在 utilities.voxelSlab 下导出。
| 导出项 | 用途 |
|---|---|
iterateVoxelsInShape、collectVoxelsInShape | 遍历 |
sampleVoxelsInShape | 遍历,并读取取值 |
createEllipseShape、createCircleShape | 平面内为椭圆,出平面为椭球 |
createRectangleShape | 平面内为矩形,出平面为盒子 |
createPolylineShape | 平面多段线,支持内部孔洞 |
getVoxelThicknessAlongNormal | T_v |
isPlaneDepthViewable | 规则 D 中深度那一半 |
buildIndexSpaceSlab、getDepthRun、getSlabAxisBound | 索引空间的区间算术 |
isVoxelCenterInSlab、getMembershipHalfWidth、getDisplayHalfWidth | 规则 M 与规则 D 的谓词 |
getFillHalfWidth | 规则 F 的半宽 |
另有四个导出项位于该命名空间之外:
| 导出项 | 用途 |
|---|---|
utilities.getEffectiveSpacingAlongDirection(core 中) | 沿直线跨过一个体素所需的步长 |
utilities.getViewSlabDepth(core 中) | 板层深度,「无板层」表示为 undefined |
utilities.sampleAreaAnnotationVoxels(@cornerstonejs/tools 中) | 每个面积标注工具都走的那条唯一路径 |
utilities.getViewSlabDepthOfViewport(@cornerstonejs/tools 中) | 按渲染路径得出某个视口的板层深度 |