内参 K:相机的说明书
针孔模型里的 K
针孔相机(pinhole camera)把相机系下的 3D 点 (x,y,z) 投影到像素坐标:
u=fxzx+cx,v=fyzy+cy
写成矩阵形式就是内参矩阵(intrinsic matrix):
K=fx000fy0cxcy1
z 是点在相机前方的深度(除以 z 就是透视投影的”近大远小”)。(cx,cy) 是主点(principal point):光轴与成像面的交点在像素坐标里的位置,是”零偏移”的基准。fx,fy 是以像素为单位的焦距,不是毫米。
像素焦距和物理焦距的换算
fx=sxf物理
f物理 是镜头焦距(米),sx 是传感器单个像素的物理宽度(米/像素)。所以同一个镜头换不同分辨率的传感器,fx 会变——内参跟着分辨率走,图像缩放一半,fx,fy,cx,cy 全部减半。改图不改 K,是复现别人数据时最常见的错误之一。
本项目实测值
- fx=fy=935.3074:两值相等说明像素是正方形(sx=sy),绝大多数现代传感器成立,但 K 结构上并不要求。
- (cx,cy)=(960,540):画面 1920×1080 的几何中心恰为 (960,540)(连续坐标下图像覆盖 [0,1920]×[0,1080])。主点居中说明采集时没有做非对称裁剪——用裁过的图当训练数据而不改主点,射线会整体偏一个角度。
- 相机模型 OPENCV、无畸变:这是 COLMAP / Nerfstudio 的相机模型命名,OPENCV 模型含径向、切向畸变系数,全为零即退化为理想针孔——现实中的直线拍出来仍是直线,公式里不需要畸变校正项。
容易看错的点
- K 只有 5 个有效数,不是 9 个:底行恒为 (0,0,1),斜切项(skew)恒为 0。
- 主点不是”正中央像素 (959,540)“。连续坐标中心 960 落在像素 959 与 960 的边界上,这正是像素中心要 +0.5 的原因之一(见 B27)。
- 无畸变是这台采集器的属性,不是 3DGS 的假设。实拍镜头常有桶形畸变,必须先标定去畸变才能当针孔用。
参考
FOV:焦距不是随便选的
从 K 到视场角
视场角 FOV(field of view)是画面边缘两条射线关于光轴的张角。取竖直方向,半幅高 H/2=540 像素对应的归一化偏移是 fyH/2,所以:
FOVv=2arctan(fyH/2)
代入 fy=935.3074:
935.3074540=0.57735…=tan30∘⇒FOVv=2×30∘=60∘
反方向看同一个式子:fy=tan(FOVv/2)H/2=tan30∘540。焦距和 FOV 是同一个量的两种写法——这台相机的竖直 FOV 是被设计成整 60° 的,935.3074 只是它在像素单位下的样子。
顺手算水平 FOV
同一个公式换宽度:
FOVh=2arctan(935.3074960)=2arctan(1.0264)≈91.5∘
注意 FOVh=10801920×60∘=106.7∘。FOV 不按宽高比缩放,因为 arctan 非线性——这是口算 FOV 最常犯的错误。
不变量:低头 28° 时地平线在哪
相机向下俯仰(pitch down)28°,世界水平方向相对相机前向上抬 28°。地平线落在画面纵坐标(v 从顶部数):
fyv−cy=−tan28∘⇒v=540−935.3074×0.5317≈43
即距画面顶端约 43 像素(画面上沿往下 4% 处)。半视场角 30° 而俯仰 28°,地平线几乎贴着上沿——这是几何必然,与训练无关。如果渲染出来的地平线不在这个位置,说明图像或姿态在某个环节被翻转/错位了。这类”不依赖训练结果、纯由相机几何推出”的判据叫不变量(invariant),是排查坐标约定 bug 最硬的证据。
容易看错的点
- 公式里是 H/2 不是 H。漏掉除以 2,FOV 直接翻倍量级地错。
- arctan 的参数是无量纲比值(像素/像素)。把毫米焦距和像素高度混着代,结果毫无意义。
参考
c2w:三列就是三根轴
列的几何含义
外参(extrinsics)回答”相机在世界里的位置和朝向”。c2w(camera-to-world)是 4×4 刚体变换矩阵:
Tc2w=∣r∣0∣d∣0∣b∣0∣t∣1
前三列不是抽象的数字,是相机坐标系三根基向量在世界系下的坐标:
- 第 1 列 r:相机的 right 轴(图像 u 增大的方向)
- 第 2 列 d=−up:相机的 down 轴(图像 v 增大的方向)。注意它已经是朝下的向量,负号在采集端就乘进去了
- 第 3 列 b:backward 轴,指向相机身后;相机实际看的前方是 −b
- 第 4 列 t:相机中心的世界坐标
“第二列是向下向量”这条约定让相机系 (x,y,z)=(right,down,backward) 与图像像素坐标 (u→,v↓) 完全对齐:v 增大 = 沿第二列走。后面像素→射线公式里纵向分量不带负号,根源就在这。
为什么用 c2w 而不是 w2c
两者互逆:Tw2c=Tc2w−1。对刚体变换 [R∣t],逆有闭式:
[R∣t]−1=[R⊤∣−R⊤t]
发射射线要”相机系方向 → 世界系方向”,c2w 直接乘;投影(第三集)要”世界点 → 相机系”,用 w2c,其旋转部分就是 c2w 前三列的转置——同一组数字,行读还是列读,方向相反。
常见误解
- “第三列是朝向”。错。第三列是 backward(身后),前方是 −1 倍第三列。把第三列当 forward 直接发射线,画面里外翻转。
- 把 JSON 里的矩阵行当轴。transforms.json 按行存储 16 个数,取”第 j 列”要拿每行的第 j 个元素。行、列读反等价于转置,得到的”right”其实是别的轴。
- 与 NeRF/OpenGL 约定的差别。NeRF 常见列序是 [right,up,backward](y 向上);本管线第二列是 −up(y 向下)。两套都自洽,混用必翻车。
参考
像素→射线:逐项拆
公式
像素 (u,v)(v 从画面顶部往下数,标准图像约定)发射的世界系射线方向:
d∝r⋅fxu−cx+down⋅fyv−cy+b⋅(−1)
r,down,b 是 c2w 的前三列(right / down / backward)。起点即相机中心 t(c2w 第四列)。
在相机系里看,这不过是归一化平面上的点 (fxu−cx,fyv−cy,−1):把 K 的投影式 u=fxx/z+cx 反解出来,取 z=−1(前方是 −z)。c2w 的作用只是把这个相机系方向旋到世界系。
逐项符号检查
- 横向:(u−cx)/fx。像素在主点右侧,u−cx>0,沿 right 为正,射线右偏。直觉一致。
- 纵向:(v−cy)/fy。v 从顶部数,画面上半部分 v−cy<0,乘 down 基向量得负的”下”= 上抬。负号不写在公式里,藏在”v 从顶部数”这条约定里——这是全套公式最容易抄错的一处。
- 前向:b 指向身后,前方恒为 −1 倍。任何像素的这一分量都是 −1,不偏不倚。
为什么是 ∝ 而不是 =
结果没有归一化:∥d∥=(fxu−cx)2+(fyv−cy)2+1=1。发射时只管方向,交给下游时再 d←d/∥d∥。是否归一化要看下游契约——用 t 当真实距离(如深度图反投影)就必须归一,只用于求交方向则无所谓。
双约定对照(翻转 bug 的数学形态)
同一管线存在两个等价形式,差别只在 v 的原点:
| v 的约定 | 纵向分量 |
|---|
| 从顶部数(标准图像/PNG,本课公式) | +fyv−cy⋅down |
| 从底部数(GL ReadPixels,数据实际存储) | −fyv−cy⋅down |
根源:OpenGL 的 glReadPixels 输出左下原点,而 PNG 编码器和图像查看器按左上原点解释——同一段字节流,两种读法,图像行序正好颠倒。公式写对了但数据按错的约定存(或反过来),渲染/训练照样跑,只是每个像素的射线在竖直方向翻面。
参考
手算:左上角像素的射线
完整代值
左上角像素编号 (0,0),取其中心 (u,v)=(0.5,0.5)(+0.5 约定见 B27)。代入 fx=fy=935.3074,(cx,cy)=(960,540):
fxu−cx=935.30740.5−960=935.3074−959.5≈−1.0259
fyv−cy=935.30740.5−540=935.3074−539.5≈−0.5769
d∝(−1.0259, −0.5769, −1)(相机系,分量为 right/down/backward)
符号复核(手算的价值就在这几步)
- 横向 −1.0259:左上角在主点左侧,沿 right 为负 —— 向左偏,对。
- 纵向 −0.5769:顶部像素 v−cy<0,乘 down 基 = 向上抬。画面顶部对应世界里上方,对。
- 前向 −1:沿 backward 取负 = 朝前,对。
- 纵向数值 0.5769≈tan30∘:上边缘像素贴竖直半视场角 30°,与 B23 的 FOVv=60∘ 自洽。
归一化补一刀
∥d∥=1.02592+0.57692+1=2.3853≈1.5445
d^≈(−0.664, −0.373, −0.648)
偏离光轴的角度 arctan1.02592+0.57692=arctan(1.1773)≈49.7∘——角落射线偏轴近 50°,这就是为什么渲染图四角的几何扭曲最显眼、翻转 bug 在角落最容易露馅。
容易看错的点
- 用 (0,0) 而不是 (0.5,0.5)。差半个像素:(0−960)/935.3=−1.0264 vs −1.0259,单看无所谓,但训练是 109 像素级的一致性问题,系统性偏半格会体现为整体几何漂移。
- (−1.026,−0.577,−1) 是相机系向量,不是世界方向。要世界方向必须左乘 c2w 的旋转部分:dw=Rd,t(第四列)是平移,不加到方向上。
- 归一化与否看下游。本集只管方向对不对,“长度回头再归一”。
参考
约定族:四条军规
坐标约定(convention)不是数学定理,是人为选择——每个选择都自洽,但跨环节必须单源一致。以下四条的每一条都给出”为什么”和”错了的后果”。
① 图像原点在左上,v 向下增大
标准图像存储与显示约定(PNG、JPEG、绝大多数图像库)。它决定了像素→射线公式里纵向分量写 +(v−cy)/fy 且乘 down 基。与它对立的是 OpenGL glReadPixels 的左下原点——两套都能用,但不能一边存一边读。
② 像素中心加 0.5
像素 (u,v) 是一块 [u,u+1)×[v,v+1) 的小方格,其几何中心在 (u+0.5,v+0.5)。发射射线要穿过像素中心,否则全部射线系统性偏移半格。
- 后果量纲:Δu=0.5 对应角度偏移 ≈arctan(0.5/935.3)≈0.03∘。单像素无所谓,但对训练是全图一致的系统性误差——几何整体漂移,模型靠拉伸高斯硬补,收敛变差。
- 连带约定:主点 (960,540) 正是连续坐标中心,落在像素 959/960 的边界上,与 +0.5 自洽。
③ 图像可能竖直翻转
不是”相机装倒了”,是存储原点与读取原点不一致:采集端按左下原点写出(GL 语义),消费端按左上原点读入(PNG 语义),同一段字节被解释成上下颠倒的两张图。肉眼无法察觉(尤其对称场景),loss 照样下降,但所有像素的纵向射线全部翻面。这是 16 dB 事件(B30)的机制,也是四条里最贵的一条。
④ c2w 列序 [right, −up, backward]
相机系 y 轴向下(第二列是 down 而非 up),与约定①的 v 向下严格对齐,全管线统一。变体(NeRF 式 [right,up,backward]、COLMAP 式 w2c 行读法)在别的代码库里都对,搬公式时必须连约定一起搬。
为什么”组合错一条,训练照跑”
这些约定错误不改变问题的数学类型:数据依然是一堆”图片 + 位姿”,损失函数依然可微、可下降。错误只体现在数据内部不自洽——多视图对同一个 3D 结构给出矛盾的光度证据。梯度下降不会报错,只会找到一个更差的妥协解。所以这类 bug 不能用”训练跑不跑得动”检测,只能用几何不变量(B23 的地平线判据)检测。
参考
采集实战:三高度路径
为什么一个高度的环绕不够
3DGS 训练本质是解一个多视图逆问题:每个高斯的位置、形状由多个视角的光度证据共同约束。约束的方向取决于基线(baseline)——相邻相机位置的连线方向。
- 单高度水平环绕:所有基线都在水平面内。方位角方向(左右)视差充足,但俯仰方向(上下)几乎没有视差——同一个点从 1.5 m 高处看过去,所有视角的纵向夹角几乎不变。垂直方向的几何欠约束,高斯在竖直方向容易拉伸/漂移。
- 三层高度 0.8 / 1.5 / 2.8 m:引入垂直基线,低层仰视天花板、高层俯视地板,俯仰角差异把竖直方向的约束补上。三角形在两个方向都能闭合。
高度差换来的俯仰角差异还另有用途:它是后续排查翻转 bug 的几何弹药(B23 的地平线不变量需要非零俯仰角才能成立——平视相机里地平线恒在画面正中,翻转前后不变,判据失效)。
规模数字
1020 张 1920×1080 全高清,全部由 Unity 采集端按三高度环绕路径自动拍摄,每张配一个 c2w(B29)。主点居中、无畸变的内参(B22)全程复用——同机位同镜头,内参一份即可,这是用虚拟相机采集的便利,实拍换镜头就得重新标定。
容易看错的点
- “照片越多越好”不成立。欠约束方向上的冗余照片(同高度多拍 500 张)边际收益递减,不如换一个高度/角度。覆盖度比数量值钱。
- 高度选择不是随意的。0.8 m 低于常规视角、2.8 m 接近走廊层高上限——在场景允许范围内把垂直基线拉到最大,才是这三个值的用意。
参考
格式
Nerfstudio / instant-NGP 一系的 transforms.json:顶层放相机内参,每帧一条 transform_matrix——4×4 的 c2w 矩阵:
{ "frames": [
{ "file_path": "images/aisle_frame_00000.png",
"transform_matrix": [
[ r_x, d_x, b_x, t_x ],
[ r_y, d_y, b_y, t_y ],
[ r_z, d_z, b_z, t_z ],
[ 0, 0, 0, 1 ] ] } ] }
JSON 按行存储,但几何含义按列读:第 1 列 right、第 2 列 −up(down)、第 3 列 backward、第 4 列相机位置(列的含义见 B24)。取第 j 根轴 = 取每一行的第 j 个元素拼成向量。
校验这十六个数的三步
怀疑相机数据出错时,transforms.json 是第一现场,按顺序查:
- 正交归一性:前三列应两两正交且模长为 1。∥r∥=∥d∥=∥b∥=1,点积约 10−6 量级以内。不满足说明写出的不是旋转矩阵。
- 手性:r×d=?b。差一个负号 = 某处翻了一根轴 = 镜像坐标系,后面全错。
- 行列序:把第一帧的第四列(位置)和采集路径对照——1020 张环绕相机,t 应该画得出三层高度的轨迹;位置在第四列末行元素是 0,0,0,1,若读到明显非 1 的数,就是行/列读反了。
常见误解
- “矩阵存的是 w2c”。Nerfstudio 约定
transform_matrix 是 c2w。训练器内部要 w2c 时自行求逆([R⊤∣−R⊤t],B24)。假设错了方向,投影出来相机朝后。
- 内参也在文件里。标准格式顶层有
fl_x, fl_y, cx, cy, w, h, camera_model 字段(本数据 camera_model: OPENCV、无畸变系数,f=935.3074)。改图缩放时记得同步改这里,和 K 同步缩放(B22)。
参考
伏笔:16 个 dB 的陷阱
PSNR 的 16 dB 到底差多少
PSNR(峰值信噪比,peak signal-to-noise ratio)定义为:
PSNR=10log10MSEMAX2
MAX 是像素最大值(8-bit 图取 255),MSE 是渲染图与真值的均方误差。30.2 dB 对 13.9 dB,差 16.3 dB 意味着 MSE 相差:
1016.3/10≈43 倍
不是”画质差 43 倍”的主观感受,而是逐像素均方误差大 43 倍——模型对训练视图的拟合能力被彻底压垮。dB 是对数刻度:每差 10 dB,误差能量差一个数量级。
为什么约定错了训练还照跑
竖直翻转所有训练图而不动位姿,得到的数据集不存在任何一致的 3D 解释:图像翻转等价于给每个相机的投影乘一个反射 diag(1,−1),而反射不是旋转——没有哪组刚体位姿能同时解释所有翻转后的视图。
但损失函数不在乎”有没有解”。光度损失依然可微,梯度依然存在,优化照常下降——只是每个高斯收到来自不同视图的矛盾证据,梯度互相拉扯,最终收敛到一个模糊的平均妥协。这就是”曲线照降、收敛慢得离谱”的数学机制:优化器没坏,数据自相矛盾。
这也解释了为什么这类 bug 难抓:没有任何报错、没有 NaN、loss 曲线形态正常,唯一的异常是收敛平台低了 16 dB——而没有对照组时,你甚至不知道”正常水平”是多少。
为什么不能用 PSNR 检测翻转
PSNR 是逐像素统计量,不含几何语义。在近似上下对称的场景里,翻转前后的渲染质量数值可能非常接近——误差分布被场景对称性掩盖。可靠的判据必须是几何不变量:例如低头 28° 机位下地平线必在距画面顶端约 43 像素处(推导见 B23)。这个预言只由相机几何决定,翻转图像会让地平线出现在错误的一侧,一眼可辨。排查坐标约定类 bug,用几何,不用统计。
容易看错的点
- “loss 降了就是在学对”。loss 下降只证明梯度方向局部有效,不证明数据自洽。多视图一致性是数据的性质,优化器无从验证。
- 把 13.9 dB 当”模型不行”。两个数字出自同一训练器、同一超参,唯一变量是约定——先怀疑数据管线,再怀疑算法。
参考