从针孔模型到光束法平差:计算机视觉逆问题与三维重建实践
简介:这份PDF是计算机视觉领域的系统性教材,面向高年级本科生、研究生及需要补齐算法基础的工程实践者,围绕图像形成、特征检测与匹配、基于特征的对齐、运动结构、密集运动估计、图像拼接、计算摄影、立体对应、三维重建、基于图像渲染与识别等主题展开。全书兼顾算法原理与工程应用,既讲几何变换、滤波、傅里叶分析、光流与多视角立体等基础方法,也覆盖高动态范围成像、图像抠图、超分辨率、目标检测和人脸识别等应用方向,并配有练习与案例线索,便于按章节建立知识框架或查阅具体技术。文件共1个PDF,约38.5MB,单文件便于离线阅读与检索;目前已有1368人学习。对于希望系统梳理计算机视觉核心算法、了解经典技术脉络与工程取舍的读者,这份教材具有较高的参考价值。
1. 计算机视觉的逆问题本质:为什么一张照片能「反推」三维世界
翻开任何一本叫《计算机视觉算法与应用》的教材,第一页几乎都会问同一个问题:机器凭什么从一张二维的、被噪声污染的、丢了深度信息的像素矩阵里,还原出物体是什么、在哪里、长什么样。直觉答案是「图像里有足够信息」,这只说对了一半。更准确的说法是:图像里的信息在数学上是病态的——同一个二维观测对应无穷多种三维解释,求解必须靠外加约束。这份资源之所以值得深挖,就在于它没有停留在「调用某个算法」,而是把这条逆问题的求解链条一段段拆开:图像怎么形成、特征怎么提取、几何怎么约束、能量怎么优化。
2. 图像形成:针孔模型到透视投影的可逆性边界
2.1 针孔模型与透视除法:投影为什么天生非线性
针孔相机的整个几何可以写成一句话:物体上任意一点 P=(X, Y, Z) 投影到图像平面上的像点,坐标是 (f·X/Z, f·Y/Z)。这个式子里藏着一个关键结构——除以 Z。除法让「近大远小」成立,也让它成为一个非线性映射:距离翻倍,投影尺寸减半,这种双曲关系无法用一次矩阵乘法表达。很多初学者写代码时直接对 3D 点做矩阵乘法再取前两维,结果发现远处和近处的点成像位置不对,问题就出在漏掉了这次透视除法。
从物理上看,针孔模型是一种理想化。真实的镜头有孔径、有焦距、有畸变,传感器的像素也不是无限小。这份资源把从光线到像素的整条链路拆成几段:光照与反射决定物体表面的辐照度,镜头把辐照度积分成一个模糊核,传感器在网格上采样并量化。每一段都在丢失信息,也都构成后面反解时的约束来源。理解这一点很重要,因为它解释了为什么视觉算法从来不是「还原真实」,而是「在给定假设下求最可能的解释」。
import numpy as np def project_pinhole(K, R, t, points_3d): """ K: 3x3 相机内参矩阵 R, t: 世界坐标系到相机坐标系的外参(旋转 + 平移) points_3d: N×3 世界坐标 返回: N×2 像素坐标 """ # 1) 世界坐标 -> 相机坐标 cam = (R @ points_3d.T).T + t.reshape(1, 3) # 2) 透视除法:这一步是整个投影里唯一的非线性环节 uv = cam[:, :2] / cam[:, 2:3] # 3) 内参变换:把归一化像平面坐标映射到像素坐标 pix = (K[:2, :2] @ uv.T).T + K[:2, 2] return pix这份代码的结构对应上面三段:外参把点搬到相机坐标系,透视除法负责近大远小,内参把归一化坐标换算成像素坐标。参数里K的 (0,0) 和 (1,1) 是焦距的像素单位,(0,2)、(1,2) 是主点位置——主点通常不是图像正中心,这一点拿到真实相机上经常被忽略,是后面重投影误差偏大的常见来源之一。
2.2 齐次坐标:把非线性投影「拉直」成线性代数
直接带着除法去求解配准问题非常别扭,于是有了齐次坐标这套工具。把二维点 (x, y) 写成 (x, y, 1)、三维点写成 (X, Y, Z, 1),投影过程就变成「乘一个 3×4 矩阵,再对最后一维做归一化」。矩阵乘法是线性的,除法被推迟到最后一步归一化里完成。这个改写让「相机模型」和「线性代数求解框架」接上了轨:多视图几何里绝大多数方程(对极约束、三角测量、PnP)都建立在齐次坐标之上。
齐次坐标还有一个容易被忽略的性质:尺度无关。点 (x, y, 1) 和 (2x, 2y, 2) 表示同一个像素位置。这带来两个后果。第一,所有矩阵方程都只能确定到相差一个尺度因子,求解时必须额外加归一化约束(比如让 F 的范数为 1)。第二,数值上如果不对坐标做归一化,八点法这类算法会严重翻车——图像坐标动辄几百上千像素,直接塞进矩阵会让条件数爆炸,谱系分解出来的结果飘得离谱。工程上通常先把坐标平移到质心、缩放到平均距离为 √2,再求解,最后把变换乘回去。
2.3 镜头畸变与光度成像:可逆性的实际边界
几何投影之外,还有两件事决定「能不能反推」。一是镜头畸变,径向畸变让直线在图像里变弯,典型的表现是广角镜头拍建筑,边缘的墙是弓形的。畸变模型一般写成 r' = r(1 + k1·r² + k2·r⁴),k1、k2 就是需要标定的畸变系数。这份资源特别强调:畸变不校正,后面的对极约束、三角测量都会引入系统误差,而且这种误差不会因为多拍几张图自动消失。
二是光度成像。像素值不是辐照度,而是经过传感器响应曲线、曝光时间、白平衡、gamma 压缩后得到的一个映射。所谓光度校准,就是估计这条映射并把它反解回线性辐照度空间。这一环书里讲了高动态范围成像:同一个场景用不同曝光拍几张,短曝光保住高光、长曝光保住暗部,再按响应曲线合并成一张覆盖更宽动态范围的图像。这里的机制核心是响应函数可解——只要对同一场景有不同曝光的多次观测,就能把未知的响应曲线和场景辐照度一起解出来,这是一个典型的「多观测消未知」结构。
3. 特征检测与匹配:角点定位与尺度不变的底层机制
3.1 结构张量与 Harris 响应:为什么角点比边缘「值钱」
特征点是图像配准的锚。一个自然的疑问是:边缘上的点不也是特征吗,为什么匹配算法偏爱角点?答案藏在「孔径问题」里。沿着一条边缘滑动窗口,如果你只看窗口内的灰度,你分不清窗口到底往哪个方向移动过——边缘的法向位置可定,切向不可定。换句话说,边缘只提供一个方向的约束。窗口如果同时在两个方向都有明显灰度变化,位置就被唯一锁定,这就是角点。
把这件事写成数学,就是结构张量。在像素邻域内分别统计 Ix²、Iy²、Ix·Iy 的加权和,得到 2×2 矩阵 M。M 的两个特征值 λ1、λ2 描述该邻域在两个主方向上的灰度变化强度:两个都大是角点,一个大一个是边缘,两个都小是平坦区。Harris 的响应函数取 R = det(M) − k·trace(M)²,用行列式与迹代替直接求特征值,省掉一次特征分解。这里的 k 是经验系数,常取 0.04 到 0.06,它影响响应曲线的形状,但不改变「角点响应高」这一定性结论。
import numpy as np from scipy.ndimage import gaussian_filter def harris_response(img, k=0.04, sigma=1.0): """返回每个像素的 Harris 角点响应 R""" # 1) 梯度:用中心差分或 Sobel,注意 x 是列方向 Ix = np.gradient(img, axis=1) Iy = np.gradient(img, axis=0) # 2) 结构张量的三个独立分量 Ixx, Iyy, Ixy = Ix * Ix, Iy * Iy, Ix * Iy # 3) 高斯加权求和,sigma 控制邻域尺度 Sxx = gaussian_filter(Ixx, sigma) Syy = gaussian_filter(Iyy, sigma) Sxy = gaussian_filter(Ixy, sigma) # 4) R = det(M) - k * trace(M)^2 det = Sxx * Syy - Sxy ** 2 trace = Sxx + Syy return det - k * trace ** 2sigma是这份实现里最值得念叨的参数。它同时决定「多大算一个角点」和「对噪声多敏感」:sigma 越大,参与统计的邻域越大,响应越平滑,细小角点会被吞掉;sigma 越小,对纹理细节越敏感,但噪声也一起被放大。工程上常见的做法是对多尺度各算一遍再做非极大值抑制,而不是死守一个 sigma。k的取值在 0.04 到 0.06 之间时,响应函数的等值线形状比较均衡,偏离太远会让角点和边缘的响应区分度下降。
3.2 尺度空间与 DoG:SIFT 如何做到「远近都能认」
Harris 有一个硬伤:尺度固定。窗口大小写死,图像一缩放,同一个物理角点在两幅图里的响应就不一样,匹配自然对不上。SIFT 的核心思路是把「找特征」从单尺度搬到尺度空间。具体做法是用不同 sigma 的高斯核去平滑图像,得到一族逐渐变模糊的图,再把相邻两层相减得到高斯差分(DoG)。DoG 的极值点(在本层以及上下相邻层的 3×3×3 邻域内都比较过)同时给出了位置和特征尺度——一个点在什么尺度上响应最强,这个尺度就跟着它走。
这背后是一个数学事实:高斯的差分近似于高斯的尺度归一化拉普拉斯,而后者对「斑块状」结构在特征尺度处取极值。所以 SIFT 检测出的点天然带着一个直径,后续描述子在这个直径内统计梯度方向直方图,就获得了尺度不变性。再看旋转:描述子里把梯度方向按主方向对齐,旋转也被消掉了。这两步叠加,使得 SIFT 描述子对缩放和旋转都稳定——这也是为什么它能在图像拼接、三维重建里当了这么多年主力特征。
书里没有停在「用现成描述子」,而是把「不变性从哪来」讲清楚了。对做工程的读者来说,这个视角很关键:当你的匹配任务里出现大视角变化、光照剧变,光靠调 SIFT 参数是治不好的,得回到不变性假设这一层去换特征或换模型,比如引入仿射不变的检测器,或者干脆换成基于学习的描述子。
3.3 匹配验证:比值测试与 RANSAC 各自解决什么问题
提取出描述子之后是匹配。最朴素的做法是最近邻,也就是对图 A 的每个描述子,在图 B 里找距离最近的那个。这一步非常容易翻车:纹理重复的场景里,一个描述子最近的邻居可能只是「长得像」,而不是「同一个物理点」。Lowe 提出的比值测试专门治这个:不仅找最近邻 d1,还找次近邻 d2,要求 d1/d2 小于某个阈值(常取 0.6 到 0.8)才保留。直觉解释是:如果这个描述子在另一幅图里只有一个合理对应,那么次近邻应当明显更远;如果最近邻和次近邻差不多近,说明这个描述子有歧义,宁可不匹配也不要错配。
比值测试过滤的是「单个匹配不可靠」,但它管不了「整体几何不一致」。两条匹配就算各自很自信,也可能违反两幅图之间的几何关系。于是有了 RANSAC:随机抽最小样本(比如求基础矩阵需要 8 对点,求单应需要 4 对),拟合模型,统计符合该模型的内点数,重复若干次,保留内点最多的模型,最后用全部内点重拟合一次。RANSAC 能生效的前提是内点比例不能太低——如果错配超过一半,抽到纯内点样本的概率会低到需要天文数字的迭代次数,这是它在弱纹理、大重复场景下失效的根本原因。
4. 从二维对应到三维结构:对极几何、三角测量与平差
4.1 对极约束:一条方程如何把两幅图的搜索从二维压到一维
两幅图之间的几何关系,浓缩在一个叫基础矩阵 F 的 3×3 矩阵里。对任意一对匹配点 x(图 1 的齐次坐标)和 x'(图 2),都必须满足 x'ᵀ F x = 0。这条方程的几何含义是:图 1 中的一个像素,在图 2 里的对应点必然落在一条叫极线的直线上,而这条极线由 F 和 x 唯一确定。这意味着原本要在整幅图里搜对应点,现在只要沿一条线搜,搜索空间从二维降到一维。
F 里面有 9 个元素,但尺度无关去掉一个自由度,再加上行列式为 0 的秩约束,实际自由度是 7。八点法用 8 对匹配点构造 8×9 的线性方程组,取其零空间作为解,得到的是「满足秩 2 最近似」的 F。工程实践中大家更常用归一化八点法,也就是前面提过的先做坐标归一化再解,最后反归一化回来。这一步做不做,结果能差出几个数量级。
import numpy as np def eight_point(pts1, pts2): """ pts1, pts2: N×2 的像素坐标(已去畸变) 返回: 3×3 基础矩阵 F """ # 1) 构造约束矩阵 A,每对点一行 A = np.zeros((pts1.shape[0], 9)) for i, ((x1, y1), (x2, y2)) in enumerate(zip(pts1, pts2)): A[i] = [x2*x1, x2*y1, x2, y2*x1, y2*y1, y2, x1, y1, 1] # 2) 解 Af = 0,取最小奇异值对应的右奇异向量 _, _, Vt = np.linalg.svd(A) F = Vt[-1].reshape(3, 3) # 3) 强制秩 2 约束 U, S, Vt2 = np.linalg.svd(F) S[2] = 0 F = U @ np.diag(S) @ Vt2 return F这里的两次 SVD 分工不同:第一次是为了解齐次线性方程组,第二次是为了施加秩 2 约束。第 3 步不做,F 会带一个接近零但不为零的奇异值,导致极线不过极点,重投影结果肉眼可见地漂。pts1、pts2必须是去畸变后的坐标,否则畸变会让约束方程引入系统残差,这时候即使 RANSAC 选出「内点」,内点本身也是错的。
4.2 本质矩阵的四重歧义与消解
如果两幅图是同一台标定过的相机拍的,基础矩阵可以进一步分解成本质矩阵 E = K'ᵀ F K,其中 K 是内参。E 的奇异值有两个相等、第三个为零,这个结构比 F 更严格,分解出来的结果也更干净。对 E 做 SVD,能得到两个可能的旋转 R 和两个可能的平移方向 t,组合起来是四种可能的相机位姿。这四种里只有一种能让所有点都落在两相机前方——这就是所谓「手性检验」或者「三角测量验证」。
这一步是很多实现里被压缩成一句注释、却最容易埋坑的地方。选错平移方向,得到的三维点全在相机背后,但如果你只验证重投影误差,这个错误在某些退化配置下居然也能过。所以工业实现一般会做一次快速三角测量,统计「正深度的点数」,取其中最多的那组解。退化场景也要处理:纯旋转(相机绕着光心转,没有平移)时平移向量趋近于零,本质矩阵退化,这时候分解出来的尺度完全不可信。
4.3 三角测量与光束法平差:从「逐对解」到「全局最优」
有了相机位姿和匹配点,三维坐标可以靠三角测量求:两条极线在空间中的交点。数学上就是找一点 X,使得它在两幅图上的投影都尽量接近观测到的像素位置。写成最小二乘,两条视线严格来说不相交(因为噪声),所以求的是「到两条视线的距离平方和最小」的那个点。逐对推算的好处是简单,坏处是误差会逐层累积。
真实系统最后一步几乎都会落到光束法平差(Bundle Adjustment)。它把「所有相机位姿」和「所有三维点」作为同一个未知量块一起优化,最小化的是所有观测的重投影误差平方和。参数规模可能上万甚至更大,但雅可比矩阵天然稀疏:每个残差只依赖一个相机和一个点,所以矩阵能按「相机块」「点块」组织成块稀疏结构,用 Schur 补消元后只需对一个相机规模的稠密矩阵求逆。这份资源里反复强调的一点是:平差的好坏很大程度不在优化器,而在初值和鲁棒核。初值差、错配多,再高级的优化器也只是在错误的局部极小附近打转。
5. 避坑实战:视觉算法落地时最容易翻车的几处细节
5.1 现象:匹配点看着挺多,重建结果歪得离谱
原因:只做了最近邻匹配,没有加比值测试和几何验证。错配混进后续位姿估计,RANSAC 内点比例低到一定程度时选不出正确模型。
解决:匹配阶段先加比值测试(d1/d2 小于阈值),再用 RANSAC 做几何一致性检查,最后对筛出来的内点做一次重拟合。两步都做,错配率能压下来一大截。
5.2 现象:标定参数明明算过,重投影误差却怎么都下不来
原因:标定用的图没有去畸变,或者去畸变的畸变系数本身来自没去畸变的图——循环错误。表现是畸变越矫正越偏,边缘点尤其明显。
解决:固定一套标定流程,标定图和测试图走完全相同的去畸变步骤,并且把去畸变这一步单独写成一个函数,所有入口都调它,不要在每个模块里各写一份。
5.3 现象:同一段代码,换台相机或者换个分辨率,结果就变了
原因:算法里硬编码了像素阈值。RANSAC 的内点阈值、特征点的空间距离、光流的最大位移,这些量都和图像分辨率强相关。分辨率一变,同样的一个像素阈值实际代表的物理意义完全不同。
解决:把这些阈值统一换算成「以场景尺度或特征尺度为单位」的量,比如按图像对角线长度的比例给阈值,而不是写死 3.0 或 5.0 这种经验数。
5.4 现象:光束法平差跑起来后,残差先降后升,最后发散
原因:初值里存在明显的外点,而被最小化的又是无界的平方误差。一个错配点能产生上千像素的残差,把整个解拽偏,然后联动所有点一起偏移。
解决:用鲁棒核(Huber 或 Cauchy)替代纯平方损失,或者先用 RANSAC 之外的策略清一遍外点;同时打开优化器的诊断输出,监控每轮迭代的残差分布而不是只看总和。
5.5 现象:单目系统里,重建出来的场景整体缩放了
原因:单目视觉从图像里只能恢复到相差一个尺度因子的结构,绝对尺度本身是不可观测的,这是几何决定的,不是实现 bug。
解决:要么引入已知尺寸的参照物,要么加 IMU 之类的额外传感器提供尺度线索,要么在两帧之间用外部测量固定基线长度。把「尺度不可观」当成前提写进设计文档,比事后怀疑算法要早得多。
6. 分割与能量优化:图割为什么能解全局最优,以及设计取舍
分割这块最能体现「同一问题可以有多种数学建模」。聚类式方法(K-means、均值漂移)把像素当成特征空间里的点,按密度或距离归堆;区域式方法(分水岭、分裂合并)按图像连通性归堆;能量式方法(归一化割、图割、马尔可夫随机场)则把「分割」写成「最小化一个带数据项和平滑项的泛函」。前两类快但缺全局观,最后一类慢但能给出可证明的全局最优(在特定条件下)。
图割之所以值得单独拎出来讲,是因为它把一个组合优化问题转化成了最小割。每个像素当一个节点,相邻像素之间连边,权重量化平滑性;每个像素再往源点和汇点各连一条边,权重量化「偏向某一类」的代价。一次最大流最小割算法跑完,图就被切成两块,等价于在二值标注问题里求出了全局最优。这个等价成立有个硬条件:边的权重必须非负,也就是能量函数必须是子模的(submodular)。一旦你引入的平滑项违反这个条件,图割就不再保证全局最优,结果会依赖算法执行的顺序。
| 分割方法 | 建模对象 | 全局最优 | 典型失效场景 |
|---|---|---|---|
| K-means / GMM | 像素特征分布 | 否(局部最优) | 类内方差大、重叠严重 |
| 均值漂移 | 特征空间密度 | 否(依赖带宽) | 高维特征、带宽难选 |
| 归一化割 | 图拉普拉斯特征向量 | 近似(松弛后取整) | 大图特征分解开销高 |
| 图割 / MRF | 能量函数最小化 | 是(子模条件下) | 非子模平滑项、多标签 |
多标签场景(比如把像素分成前景、天空、建筑、人)是图割的直接扩展难点:直接做多路割是 NP 难的,工程上一般用 alpha-expansion 或 alpha-beta swap,把多标签问题拆成一串二值图割,每一步都保证能量不增。这种「把难问题拆成一串可解的简单问题」的思路,在整个视觉优化里非常常见——它不追求一步到位,而是用局部最优的迭代去逼近全局。
最后落到取舍上。这本书的可贵之处在于,它没有把任何一类方法奉为圭臬,而是一直在「假设、可解性、代价」三者之间做权衡。用特征点还是稠密光流,用两视图几何还是多视图平差,用谱方法还是图割,答案取决于你的场景里哪条假设最接近成立、哪类误差最不能忍。我自己的血泪经验是:拿到一个新场景,别急着上最复杂的模型,先用最朴素的方法跑一遍,把它在什么条件下失效记下来,那个失效条件往往就是真正需要建模的地方。从那以后我每次搭视觉管线,都强制在最小可运行版本上先画一张「假设清单」,再决定要不要加复杂度。希望帮到你。
本文还有配套的精品资源,点击获取