跳到主要内容

体素统计与斜切视图

问题所在

工具在视口上画出一个形状,而这个形状代表了一组体素。面积标注是一个棱柱: 轮廓沿视图法向扫出,该棱柱包含轮廓内部、且处在该标注自身厚度范围内的每个体素。 有些工具画出的是一个实体,例如球体或立方体,该实体则包含它内部的每个体素。 无论哪种情形,工具需要的都是同一件事:那组体素的全部,且每个恰好一次

产出这组体素比看起来更难,而 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:体素归属

一个体素属于某个面积标注,当且仅当它同时满足两个条件:

  1. 该体素中心沿法向到标注平面的距离在 (T + T_v) / 2 之内。
  2. 该中心沿法向投影到该平面上的投影点落在二维形状内部。

T 是标注的厚度。T_v 是体素沿法向的厚度。

视口板层厚度 t 不出现在规则 M 中。这个「不出现」正是该规则的目的所在: 统计量不会因为用户缩放了视口、改变了画布尺寸或加大了板层而变化。

T_v 这一项把板层在两侧各加宽半个体素,因此某个体素合格的条件恰好是 「该体素本身与板层有重叠」。在 T = T_v 且锚定在体素中心这个默认情形下, 这一项没有影响——两种算法都给出一层。它的意义体现在两处: 一是平面没落在体素中心上时——若不加宽就会一个都选不中; 二是板层更厚时——不加宽的测试在要求两个体素厚度时只会选中一个。

备注

恰好位于两个体素中心正中间的平面会选中两层。两个体素与板层的重叠量相等, 因此不存在任何有原则的取舍方式,而强行取舍会让计数取决于一次四舍五入的平局。 两层上的均值与一层上的均值不是同一个数,而 MPR 停在半层位置上 正是达到这种状态的常规途径。

规则 F:笔刷填充

规则 M 回答的是一个测量问题。笔刷填充问的是另一个问题, 而规则 M 对它给出的答案是错的。

笔刷填充写入一个体素,当且仅当它同时满足两个条件:

  1. 该体素中心沿法向、相对填充平面的深度落在半开区间 [-max(F, T_v) / 2, +max(F, T_v) / 2) 内。F 是该次填充覆盖的深度, 默认为 T_v
  2. 该中心沿法向投影到该平面上的投影点落在二维形状内部。

这个区间是半开的,而规则 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 换成 createEllipseShapecreateRectangleShape,其余一行都不用改。

使用方要注意两个细节:

  • ijkcenter各次迭代之间是复用的。若要留存它们,请先复制。
  • 形状是与板层取交集,而不是取并集。除非你有意让板层去裁切形状, 否则请把 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,
});

有两个参数承载了工具必须做对的全部要点:

  • pointsboundsMargin 给出索引边界。多段线的轮廓、以及矩形的四个角 已经把形状围住了,所以那些工具把 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 会把它裁掉。

createEllipseShapecreateRectangleShape 各自带有深度, 并各自应用自己的深度。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

gc0 都是常量,所以沿任一单个轴,满足 |depth(p)| < halfWidth 的那些体素构成一个闭式区间。因此迭代器发出的是精确的整数区间, 而不是对每个体素做一次测试——这一点在任意方位下都成立,斜切也包括在内。

g有意不做归一化的。它的各分量表示「每个索引步进一个单位时 世界深度的变化量」,这正是区间算术所需要的。在采集方位下, g 会平行于 (0, 0, 1),因为此时法向就是 k 轴, 于是 d0 . nd1 . n 都为零,只剩 s2 * (d2 . n)

各轴的角色

迭代按 outer → row → column 嵌套。

  • outerAxisargmax |g|,即索引步进对深度影响最大的那个轴。 把该轴放在最外层扫描,可使每一步外层迭代覆盖该体数据的一个薄带, 同时也把最接近标注平面的那两个轴留了出来。
  • rowAxiscolumnAxis 就是余下那两个轴。二维形状很自然地会把它的跨度 表达为「对每个 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 长度才回答「这个体素沿法向伸展多远」, 而这正是「体素与板层是否重叠」的测试所需要的。

函数公式回答什么
getSpacingInNormalDirectionL2,sqrt(Σ (d·aᵢ·sᵢ)²)相机推进多远才会看到新的体素
getVoxelThicknessAlongNormalL1,Σ |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 下导出。

导出项用途
iterateVoxelsInShapecollectVoxelsInShape遍历
sampleVoxelsInShape遍历,并读取取值
createEllipseShapecreateCircleShape平面内为椭圆,出平面为椭球
createRectangleShape平面内为矩形,出平面为盒子
createPolylineShape平面多段线,支持内部孔洞
getVoxelThicknessAlongNormalT_v
isPlaneDepthViewable规则 D 中深度那一半
buildIndexSpaceSlabgetDepthRungetSlabAxisBound索引空间的区间算术
isVoxelCenterInSlabgetMembershipHalfWidthgetDisplayHalfWidth规则 M 与规则 D 的谓词
getFillHalfWidth规则 F 的半宽

另有四个导出项位于该命名空间之外:

导出项用途
utilities.getEffectiveSpacingAlongDirection(core 中)沿直线跨过一个体素所需的步长
utilities.getViewSlabDepth(core 中)板层深度,「无板层」表示为 undefined
utilities.sampleAreaAnnotationVoxels@cornerstonejs/tools 中)每个面积标注工具都走的那条唯一路径
utilities.getViewSlabDepthOfViewport@cornerstonejs/tools 中)按渲染路径得出某个视口的板层深度