空间坐标系 · 交互讲解
从"一个点有两套坐标"讲起,一直讲到 C 臂(DiffDRR / GeoReg)和 R2-Gaussian 的相机。全部用简单形状,没有真实数据;每章都有能拖的东西和一道小题。
页面自检:计算中… 每章右上角的"自检"是打开页面时自动跑的数值断言(比如 RᵀR = I、两条代码路径落点一致),点开能看到每一条。
- 颜色:x 红、y 绿、z 蓝;相机或局部坐标轴用同样颜色的浅色版。
- 世界坐标:z 朝上,原点 = 体积中心 = C 臂旋转中心(DiffDRR 和 R2-Gaussian 碰巧都是这样)。
- 矩阵默认是"列向量"写法:新点 = M · 旧点。只有第 10 章会专门讲"存成转置、行向量"的写法。
- 3D 视图:左键拖动旋转视角,右键拖动平移,滚轮缩放。视角怎么转都不改变任何坐标数值。
整条链一张图
一个体素的值最后变成探测器上一个像素,中间要换四次坐标。点方框跳到对应章节。
第 5 章
第 6·9·10 章
第 7 章
第 7 章
反方向(像素 → 射线 → 沿射线采样体素)就是 DRR 的做法,在第 8 章。
目录
- 坐标系是什么
- 旋转矩阵:列就是新的坐标轴
- 齐次坐标与 4×4:补一个 1、顺序、逆
- 欧拉角:用 3 个数描述一个旋转
- 体素下标 ↔ 世界坐标
- 相机外参:一步步把局部坐标系变成相机
- 投影:相机坐标 → 像素(含 c2w → 像素完整数例)
- 反过来:像素 → 射线 → 体素
- C 臂(DiffDRR / GeoReg)
- R2-Gaussian 的相机与投影
- 对照表与常见坑
坐标系是什么
自检…
坐标系 = 一个原点 + 几根有方向的轴 + 单位长度。坐标不是点本身的属性,而是"从某个原点出发,沿这几根轴各走几步能到这个点"。换一个坐标系,同一个点的数字就变了,点本身一动没动。
下图有两个坐标系:固定的世界坐标系(深色 x、y),和一个可以拖动、旋转的局部坐标系(浅色 x′、y′)。蓝色的 F 是"钉"在局部坐标系上的,跟着它走。
- R 的每一列 = 一根局部轴在世界里的方向(第 1 列是 x′,第 2 列是 y′)。
- t = 局部原点 O′ 在世界里的位置。
- 反过来 p′ = Rᵀ · (p − t):先减掉原点,再用转置"转回去"。
旋转矩阵:列就是新的坐标轴
自检…
到 3D 还是同一件事。一个 3×3 旋转矩阵 R 不用背公式,只要会读它的三列:第 1 列是物体自己的 x′ 轴转完以后在世界里指向哪,第 2、3 列是 y′、z′。
所以"矩阵乘一个点"其实就是:R · (a, b, c) = a × 第 1 列 + b × 第 2 列 + c × 第 3 列——沿着三根新轴依次走 a、b、c 步。右边打开"分解",3D 里会画出这三段路。
- R 的列 = 旋转后的坐标轴。三列长度都是 1、两两垂直,所以 RᵀR = I,R⁻¹ = Rᵀ:求逆只要转置。
- det R = +1 是纯旋转;把某一列取反,det 变成 −1,物体就被镜像了(F 会变成反的 F)。第 5 章 affine 的行列式带负号,说的就是这件事。
齐次坐标与 4×4:补一个 1,就冒出了"顺序"
自检…
第 1 章的 p = R·p′ + t 里有两种运算:旋转是乘,平移是加。只做一次变换没问题;可一旦要连着做好几次(体素 → 世界 → 配准 → 相机……),乘法和加法交替出现,公式很快就乱了。这一章按顺序讲三件事:
- 补一个 1:把点 (x, y) 写成 (x, y, 1),平移也就能写成矩阵,变成乘法。→ 下面 ①
- 好处:旋转、平移全是矩阵了,连着做几次 = 几个矩阵相乘,可以先乘成一个矩阵。
- 代价:矩阵乘法不能交换(A·B ≠ B·A)。"先做哪个"只体现在谁在左、谁在右,写反了照样能算、不报错。→ 下面 ②
③④ 是由此而来的两个常见错误。2D 用 3×3,3D 用 4×4,道理完全一样;为了看得清楚,这一章用 2D。
- 补上 1 以后,平移矩阵第三列的 (tₓ, t_y) 乘的正是点的那个 1,结果里就多出 +tₓ、+t_y:加法被写成了乘法。
- 从右往左读:p′ = B·A·p 里 A 离 p 最近,先做 A。顺序不同,合起来矩阵的平移列就不同:B·A 的平移列还是 B 的 t;A·B 的平移列是 R·t(平移向量自己也被转了)。
- 对到 C 臂:DiffDRR 的 E = [R | R·tra] 拆开就是 R·T(tra),先平移 tra,再旋转 R,所以源在 R·tra 而不在 tra(第 9 章)。
- 自己手写合成公式时,平移列是 R₂·t₁ + t₂,不是 t₁ + t₂(③);刚体变换的逆是 [Rᵀ, −Rᵀ·t],不是 [Rᵀ, −t](④)。第 6 章 w2c 的第 4 列就是 −Rᵀ·t。
欧拉角:用 3 个数描述一个旋转
自检…
从前两章接过来
- 第 2 章:3D 旋转矩阵有 9 个数,但不能随便填,三列必须长度为 1、两两垂直。真正能自由调的只有 3 个。
- 第 3 章:连着做几次变换 = 几个矩阵相乘,顺序不同结果不同。
于是有个实际问题:怎么用 3 个数说清楚一个旋转?C 臂的主角度、副角度,DiffDRR 的 rot,优化器要调的参数,都需要这 3 个数,不可能让人去填 9 个互相牵制的矩阵元素。
欧拉角的办法:把任意旋转拆成"绕坐标轴转 3 次"。绕某一根坐标轴转,矩阵特别简单,只有一个角度:
三个乘起来就是 R。既然是连乘,第 3 章的教训马上用上:先绕哪根轴,结果不一样。所以欧拉角一定要带顺序名,比如 ZYX 表示 R = Rz(α)·Ry(β)·Rx(γ)。只说"三个角是 30°、20°、10°"而不说顺序,是还原不出旋转的。
再多一个坑:绕的是"哪根"轴?
转第二次时,物体已经转过一次了。"绕 y 轴转"指的是世界的 y 轴,还是物体自己转过之后的 y′ 轴?两种说法都有人用,而且同一个乘积 R = Rz(α)·Ry(β)·Rx(γ) 两种都能读:
- 外旋(绕世界的固定轴):从右往左读,和第 3 章完全一样。先绕世界 X 转 γ,再绕世界 Y 转 β,最后绕世界 Z 转 α。每新做一步,矩阵乘在左边。
- 内旋(绕物体自己的轴):从左往右读。先绕 Z 转 α,再绕物体转过之后的 y′ 转 β,最后绕再转过之后的 x″ 转 γ。每新做一步,矩阵乘在右边。
两种读法的终点完全一样。最简单的例子:α = 90°、β = 90°、γ = 0,也就是 R = Rz(90°)·Ry(90°):
| 读法 | 第一步 | 第二步 | 终点(积木三根轴指向) |
|---|---|---|---|
| 外旋,从右往左 | 绕世界 Y 转 90° | 绕世界 Z 转 90° | x′ → 世界 −z y′ → 世界 −x z′ → 世界 +y |
| 内旋,从左往右 | 绕 Z 转 90° 此时积木的 y′ 指向世界 −x | 绕积木自己的 y′(= 世界 −x)转 90° |
下面先点"用这个例子",再分别点两个播放按钮:黄色粗箭头标出每一步绕的那根轴,两次播放停在同一个姿态。
- DiffDRR:
convert(rot, tra, parameterization="euler_angles", convention="ZYX")算的正是 R = Rz(rot[0])·Ry(rot[1])·Rx(rot[2])。GeoReg 用 rot = [主角度, 0, 副角度],所以 R = Rz(主)·Rx(副)。 - R2-Gaussian:
angle2pose里写着 "rotate … (fixed axis)",是外旋:rot = R3·R2·R1,先 R1 后 R3。第 10 章会分步播放它。 - 万向锁:ZYX 且 β = ±90° 时,α 和 γ 绕的变成同一根轴,只剩它们的差有意义,少了一个自由度。优化器在这附近会很难受。
体素下标 ↔ 世界坐标
自检…
CT 体数据在文件里只是一个三维数组,下标 (i, j, k) 是整数,没有单位、也不知道头朝哪。把它放进世界里的,是 4×4 的 affine:
和第 1 章一模一样:前三列 = 下标每加 1 在世界里往哪走、走几毫米;第 4 列 = 体素 (0, 0, 0) 的中心在世界里的位置。下面的格子是一个 5×6×3 的小体积,蓝色体素拼成一个 F(i 向右、j 向上时读起来是正的)。
- det(A 的前 3×3) 的绝对值 = 一个体素的体积(mm³);负号 = 下标坐标系相对世界是镜像的。
- 方向码(nibabel 的
aff2axcodes)说的是下标增大那一端朝哪:第 1 列是 (−20, 0, 0) → i 增大时往 −x 走 → 在 RAS 世界里 −x 是病人左侧 → 码是 L。 - canonicalize(DiffDRR
read()默认做)只改第 4 列,把体积中心挪到世界原点;数组一个字节不动,前三列也不动。中心 = A·[(n−1)/2, 1]。 - 下标顺序:nibabel 读出来是
arr[i, j, k];SimpleITK 和很多 CT 代码是arr[k, j, i]。不少重建代码里的volume_shape也写成 (Z, Y, X)。同一个体素,两种写法下标顺序正好反过来。
相机外参:一步步把局部坐标系变成相机(c2w、w2c)
自检…
回顾:世界坐标系和"物体自己的"坐标系
世界坐标系:从这一章开始,所有东西都放进第 5 章 canonicalize 之后的那个世界里。原点 = 体积(头)的中心,x 向病人右、y 向前、z 向上,单位 mm。下面 3D 图里的"头"就摆在原点,三个小球标出方向:R 在 +x、A 在 +y、S 在 +z。头一直不动。
物体自己的(局部)坐标系:第 1–3 章讲过,一个局部坐标系一开始和世界系重合;把它转一下(R)、挪一下(t),它里面的点换到世界就是 p = R·p′ + t。R 的三列是它三根轴在世界里的方向(第 2 章),t 是它的原点在世界里的位置,补一个 1 拼成 4×4 就是 [R | t](第 3 章)。
相机就是这样一个局部坐标系,没有任何特殊。拍片时要回答的问题是"站在相机那里看,这个点偏右多少、偏下多少、离我多远",这三个数就是点在这个局部坐标系里的坐标。下面不直接给定义,而是从世界原点出发,一步步把一个局部坐标系转、移成相机。
六步:从世界原点到相机
| 步骤 | 做什么 | 这一步的矩阵 | 做完以后 |
|---|---|---|---|
| ① 起点 | 局部系和世界系重合,放在原点 | I | M = I |
| ② 转 | 绕世界 x 转 −90° | Rx(−90°) | z′ 指向 +y,y′ 指向 −z |
| ③ 转 | 绕世界 z 转 +90° | Rz(+90°) | z′ 指向 −x、x′ 指向 +y、y′ 朝下:得到基准朝向 R₀,给三根轴起名 x_c / y_c / z_c |
| ④ 转 | 绕世界 y 转 −el(仰角) | Ry(−el) | 视线往下压 el |
| ⑤ 转 | 绕世界 z 转 az(方位角) | Rz(az) | 旋转定下来:R = Rz(az)·Ry(−el)·R₀ |
| ⑥ 移 | 沿自己的视线往后退 d | T(C),C = R·(0, 0, −d) | M = [R | C],这就是 c2w |
②–⑤ 每一步都只转不移,而且都绕世界的固定轴转,所以新的一步都乘在左边:M ← S·M(第 4 章外旋读法)。先抬仰角、再转方位角,是为了让每一步都绕世界的固定轴;反过来先转方位角也行,但那时仰角要绕"跟着转过去的水平轴"(第 4 章内旋读法),结果一样。
下面按 ①–⑥ 一步步点,或者点"▶ 从头播放"。3D 里浅色箭头是这个局部坐标系,半透明的是它上一步的位置,黄色粗箭头是这一步绕的世界轴。
做完第 ⑥ 步:M 就叫 c2w
这个 M 做的事和第 1 章的 [R | t] 一样:把局部系里的点送到世界。局部系现在就是相机系,所以它叫 c2w(camera-to-world)。两个最直观的检验:
- c2w · (0, 0, 0, 1) = C:相机坐标的原点,在世界里就是相机的位置(第 4 列)。
- c2w · (0, 0, 300, 1):相机正前方 300 mm 的点在世界里的位置 = C + 300 × z_c。
手算一个例子(仰角 0、方位角 0、d = 500,下面点"代入手算例子"):第 ③ 步得到 R₀,第 ④⑤ 步角度都是 0,什么都不转;第 ⑥ 步沿视线 z_c = (−1, 0, 0) 往后退 500,到 C = (500, 0, 0)。所以 x_c = (0, 1, 0)、y_c = (0, 0, −1)、z_c = (−1, 0, 0)。相机正前方 300 mm 的点:C + 300 × z_c = (200, 0, 0),正好在相机和原点之间。
w2c:反过来,世界 → 相机(投影真正要用的方向)
拍片时方向反过来:已知世界里的点,要算它在相机坐标里是多少。所以要 c2w 的逆,叫 w2c = [Rᵀ, −Rᵀ·C](第 3 章 ④ 的逆矩阵)。它做的事很直观:先减去相机位置,再分别点乘相机的三根轴。
接着上面的例子:小球 R 在世界 (52, 0, 8)。p − C = (−448, 0, 8);x_c = (0, 1, 0)·(−448, 0, 8) = 0;y_c = (0, 0, −1)·(−448, 0, 8) = −8;z_c = (−1, 0, 0)·(−448, 0, 8) = 448。意思是:R 在相机正前方 448 mm,水平居中,比画面中心高 8 mm(y_c 为负就是向上)。第 ⑥ 步之后右边会显示任意点的这套换算。
这台相机有几个自由度
- 一般的相机是 3 个旋转 + 3 个平移 = 6 个数(第 9 章 DiffDRR 的 rot、tra 就是)。这里 6 个都在:旋转是 R = Rz(az)·Ry(−el)·R₀,第三个角"滚转"(绕视线 z_c 转)固定为 0,所以画面不歪、x_c 永远水平;平移是 C = R·(0, 0, −d),被"视线对准原点"绑住了,由角度和 d 算出来。第 ⑥ 步之后可以打开"解开另外 3 个自由度"单独拖滚转和平移。
- 仰角为什么只到 ±70°:到 ±90° 时视线和 z 轴平行,"水平方向"没有定义,x_c 算不出来,和第 4 章的万向锁是同一类问题。
- 和 C 臂的关系:主角度绕世界 z 转,对应这里的方位角;副角度抬起,对应这里的仰角;GeoReg 把中间的 Y 角固定为 0。结构一样,只是基准朝向和正负号不同(第 9 章)。
- 和 R2-Gaussian 的关系:
angle2pose就是这里的 ①②③⑤⑥,没有第 ④ 步(仰角 = 0),方位角 = 扫描角,d = DSO(第 10 章)。
- 世界原点 = 头(体积)的中心,头不动;相机是一个局部坐标系,从原点出发先转(②–⑤)、再沿视线往后退(⑥)。
- 这几步乘起来就是 c2w:前三列是相机三根轴在世界里的方向,第 4 列是相机位置。w2c 的第 4 列 −Rᵀ·C 不是相机位置,直接当位置用是常见 bug。
- w2c = "先减相机位置,再点乘三根相机轴"。z_c 是深度,第 7 章的透视就是拿 x_c、y_c 除以它。
投影:相机坐标 → 像素
自检…
换到相机坐标以后,X 光(锥形束)成像只剩一件事:相似三角形。源在原点,探测器在视线方向距离 DSD 处,一个点 (x_c, y_c, z_c) 落在探测器上的位置是
除以 z_c 就是"近大远小"。这一步没法写成 3×3 乘法,所以才需要第 3 章说的齐次坐标:先乘矩阵,再除以最后一个分量。
① 侧面看:相似三角形
② 正面看:从 3D 点到像素
下面是模拟头的 DRR(每个像素 = 那条射线上密度的积分,算法在第 8 章),十字是用矩阵算出来的三个小球中心的投影。十字落在亮斑正中间,就说明矩阵算对了。右边对比两条算像素的路:相机内参矩阵 K,以及 R2-Gaussian 光栅化器用的投影矩阵 P → ndc → 像素。
③ 从 c2w 到像素:代入数字一步步算
把前面几章串成一条完整的计算:给定扫描角 θ(R2-Gaussian 的 angle2pose,仰角 0)、DSO、DSD 和世界里的一个点 p,算出它落在探测器的第几行、第几列。左边俯视图(从 +z 往下看)里可以拖光源(改扫描角)、拖点 p(改它的 x、y);右边按步骤列出每一步的数字,用"显示到第几步"一次只看前几步,俯视图也会跟着一步步把线画出来。
- 放大率 = DSD ⁄ DSO:等中心处 1 mm 的东西在探测器上是 DSD ⁄ DSO mm。离源越近的东西越大。
- mm → 像素:col = u ⁄ 像素间距 + (W − 1) ⁄ 2。把这两步合在一起就是内参矩阵 K = [[DSD⁄du, 0, cx], [0, DSD⁄dv, cy], [0, 0, 1]]。
- R2-Gaussian 的 P 矩阵把 x_c ⁄ z_c 先缩放到 ndc ∈ [−1, 1],再用
ndc2Pix(v, S) = ((v + 1)·S − 1) ⁄ 2变成像素;那个 −1 是半像素修正,让像素中心落在整数上。两条路结果逐位相同。 - 平行束是 DSO → ∞ 的极限:不除以 z_c,放大率恒为 1。R2-Gaussian 平行束模式里 P 直接是单位阵。
反过来:像素 → 射线 → 体素
自检…
第 7 章是"点 → 像素"。DRR(包括 DiffDRR)走的是反方向:对每个像素,从源画一条穿过它的射线,沿射线在体积里采样,把密度加起来。这一步会把前面几章的矩阵全部用一遍:
- 像素中心在相机坐标里是 ((col − (W−1)⁄2)·du, (row − (H−1)⁄2)·dv, DSD),乘 c2w 就到世界里;射线方向 = 它减去源的位置再归一化。
- 采样点的下标一般不是整数(A⁻¹ 算出来是 12.37 这种),所以要插值(DiffDRR 用三线性插值,也可以用 Siddon 算法精确求每个体素里的长度)。
- 求和 Σ ρ·Δt 是线积分的近似。采样越密越接近真值;太稀时细小结构会被整个跳过。
C 臂(DiffDRR / GeoReg)
自检…
这一章把 DiffDRR 0.6 里真正执行的几行代码逐步复现出来(对照 DiffDRR 0.6 源码写的)。世界坐标是 RAS(x 向病人右、y 向前、z 向上),read() 已经把体积中心挪到原点(第 5 章的 canonicalize)。
最后一行的两个负号来自 _initialize_carm:行下标 t 取反;列下标 s 在 reverse_x_axis=True(DRR 的默认值,注释写着"obey radiologic convention")时也取反。
- tra = [0, SOD, 0] 是"沿相机自己的 y 轴退 SOD",不是世界里的固定位置:源 = R·tra = SOD × R 的第 2 列。LAT 和 AP 的 R 不同,同一句 tra 把源放到了完全不同的地方。
- 不管角度怎么调:源到原点的距离恒为 SOD,中心射线恒穿过原点 → 世界原点就是 C 臂的旋转中心,头钉在那里不动。
- 图像方向:零位时源在 +y(前方)、探测器在后方;col 增大 = 世界 −R·x̂,row 增大 = 世界 −R·ẑ。所以零位图像的左边是病人右侧、上边是头顶(放射学看片习惯)。
- 两台 C 臂的相对位姿由 DICOM 定死(下面"两个视角"里的夹角和源间距),头相对整套系统只有 6 个自由度。
R2-Gaussian 的相机与投影
自检…
R2-Gaussian(用 3D 高斯做 CT 重建)用的是另一套写法:C 臂只绕世界 z 轴转,一个角度 θ 就定下相机。下面用一个简单的 R2-Gaussian 设置举例,坐标链是:
其中 c2w 的建法和第 6 章完全一样:从世界原点放一个局部坐标系,先转(R1、R2 得到基准朝向,R3 转到扫描角),再沿视线往后退 DSO。点下面的"▶ 分解 angle2pose"能看到同样的几步,标题里标着对应第 6 章的第几步。
- angle2pose 就是第 6 章那六步去掉第 ④ 步:R1、R2 是第 ②③ 步(得到基准朝向 R₀),R3 是第 ⑤ 步(方位角 = ang),trans 是第 ⑥ 步(沿视线往后退 d = DSO);没有仰角,所以光源只在 xy 平面的圆上转,画面上方永远是 +z。全是绕世界固定轴转,是外旋。
- 代码里的
Camera.R/Camera.T容易看错:dataset_readers存的是R = w2c[:3,:3].T,它其实就是 c2w 的旋转(列 = 相机三根轴);T = w2c[:3,3]却是 w2c 的平移 −Rᵀ·C,不是光源位置。getWorld2View2(R, T)再把两者拼回 w2c。 - "R stored transposed due to glm":CUDA 里用 glm,矩阵按列存,所以 Python 这边把 w2c 转置后传进去;平移就跑到了最后一行,
camera_center = view.inverse()[3, :3]取的也是最后一行。 - 两条投影路径逐位一致(本章自检用 500 个随机点验证)。三处"修正"缺一不可:渲染路径的
90° − θ、flip(-1),和手写投影的 z 取反。下面的开关可以一个个关掉看后果。 - R2-Gaussian 的数据读取会把整个场景缩放到 [−1, 1]³(scene_scale = 2 ⁄ max(sVoxel),DSO、DSD、探测器尺寸一起乘,投影值也乘);像素位置不变。也可以不缩放、直接用物理尺寸,本章的例子就是这样。
对照表与常见坑
自检…
三套代码逐项对照
| DiffDRR / GeoReg | R2-Gaussian(数据读取默认) | R2-Gaussian(第 10 章的例子) | |
|---|---|---|---|
| 世界原点 | 体积中心(read() 的 canonicalize) | 体积中心(offOrigin = 0 时) | 体积中心(voxelize 的 center = (0, 0, 0)) |
| 长度单位 | mm | 缩放到体积落在 [−1, 1]³(scene_scale) | 配置里的物理尺寸(xyz_norm × s_voxel) |
| 位姿怎么给 | rot(ZYX 欧拉角,弧度)+ tra,6 个数 | 一个角度 θ,只绕 z | 一个角度,先换成 90° − θ |
| 源的位置 | R·tra = SOD × R 的第 2 列 | DSO·(cos θ, sin θ, 0) | DSO·(sin θ, cos θ, 0) |
| 视线方向 | −R·ŷ(canonical z 经 reorient 变成 −y) | 相机 z_c,指向原点 | 同左 |
| 矩阵写法 | 列向量:E·p | w2c 转置后传给 CUDA,行向量 | 同左 |
| 像素方向 | row + = −R·ẑ;col + = −R·x̂(reverse_x_axis=True) | col + = x_c,row + = y_c(ndc2Pix) | 渲染完再 flip(-1),col 反向 |
| 体数据下标 | torchio / nibabel:(i, j, k) | 按 nVoxel、sVoxel 查询网格 | volume_shape = (Z, Y, X) |
| 按投影点在 2D 图上取值 | — | — | grid_sample,坐标 [−1, 1],align_corners=False |
四个最容易踩的坑(每个都能拖)
① 度 vs 弧度
② 行向量 vs 列向量
③ grid_sample 的 align_corners
④ 数组下标顺序
最后四道题
几何约定对照的源码:DiffDRR 0.6 detector.py / pose.py / data.py / drr.py;R2-Gaussian dataset_readers.py / graphics_utils.py / cuda_rasterizer/auxiliary.h。模拟头和所有数值都是示意,不是真实数据。