title: 从四路鱼眼到鸟瞰图:BEV 环视原理与 surround-view-system-introduction 工程精读
slug: bev-surround-view-system-introduction从四路鱼眼到鸟瞰图:BEV 环视原理与 surround-view-system-introduction 工程精读
摘要
本文围绕开源工程 surround-view-system-introduction,梳理四路鱼眼 BEV 环视系统的技术链路。
内容包括:BEV 的核心数学工具(Homography、IPM、地面平面假设),四路鱼眼环视的数据流,该工程的四个阶段(内参标定→投影矩阵→融合权重→实时运行),参数模型解析,以及复现过程中遇到的常见问题。
写在前面
实习中涉及到了BEV相关的内容。这个仓库我也复现了下。这篇博客是个人关于这部分的知识记录。(虽然移植到公司车上的效果并不是很好,孩子们,想做什么东西之前先看看机构行不行,what can i say)
1. 什么是 BEV / 环视?
BEV(Bird's Eye View,鸟瞰图)是将车周围的多路相机图像,统一变换到一个从上往下看的俯视视角。最终效果类似于你在车顶上装了一个摄像头直直朝下拍。
典型应用场景包括泊车辅助、狭窄空间驾驶、自动泊车等,在这些场景中需要看清车身周围地面上的标线和障碍物。
环视 BEV 的核心挑战:怎么把几个不同方向、不同视角的相机图像,拼到同一张俯视图里,让地面上的物体和标线对齐?

2. 核心数学工具
2.1 Homography(单应性矩阵)
假设地面上有一个标定点,在相机图像中的像素坐标是 (u, v),在俯视图(BEV 图)中的坐标是 (x, y)。我们可以用一个 3×3 的矩阵 H 来描述这两个坐标之间的透视变换关系:
s · [x, y, 1]^T = H · [u, v, 1]^T
H 就是单应性矩阵。已知至少 4 对对应点,通过 OpenCV 的 cv2.getPerspectiveTransform(src, dst) 求解。
有了 H 之后,用 cv2.warpPerspective(image, H, output_size) 就可以把整张相机图像"拉成"鸟瞰图。
2.2 地面平面假设
Homography 做 BEV 有一个重要前提:被投影的点都在同一个地面平面上。
单应性矩阵描述的是两个平面之间的映射关系。图像中不在地面上的点(墙面、车身、行人等),用针对地面计算的 H 投影后会产生拉伸和错位。因此 Homography + IPM 方案只对地面内容准确——这是它的主要限制。
2.3 IPM(Inverse Perspective Mapping,逆透视变换)
IPM 本质上就是 Homography 的一种特例。只不过它更强调"透视去除":把相机的透视效果去掉,恢复成地面上物体在俯视图中的真实形状和尺寸。
两者的关系可以用一句话概括:
如果知道相机的内参 K、外参 R/t 和地面平面方程,可以直接推导出 H。如果不知道这些,也可以在地面上放几个已知物理位置的点,通过图像像素和地面坐标的对应关系直接求解 H。
很多环视工程(包括本文分析的这个)直接用第二种方法:在地面上铺标定布/放标志点,手动或自动点击图像中的对应点,然后用 getPerspectiveTransform 求 H。
3. 四路鱼眼环视的大致流程
一个典型的四路鱼眼 BEV 系统,数据流如下:
四路鱼眼原始图像
↓
去畸变(每个相机用自己的 K 和 D)
↓
透视变换(每个相机用自己的 project_matrix H 映射到 BEV 平面)
↓
四路投影图拼到同一张画布上
↓
重叠区域融合(避免拼接缝)
↓
亮度均衡(让四路图像的亮度看起来一致)
↓
输出最终 BEV 鸟瞰图
第一步(标定)是离线跑好的:算每个相机的内参 K/D,以及从相机视角到 BEV 俯视的映射矩阵 H。
第二步(实时运行)就是不断采集图像→去畸变→投影→拼接→显示。

4. surround-view-system-introduction 仓库概述
- 仓库地址:https://github.com/neozhaoliang/surround-view-system-introduction
- 许可证:MIT
- 定位:用最少 Python 代码展示鱼眼环视全景系统的完整流程,面向初学者,追求可读性而非工程优化
- 架构:标定阶段离线执行(3 个脚本),运行阶段在线多线程(PyQt5 QThread)
- 依赖:Python 3, OpenCV 3+, PyQt5, NumPy
5. 阶段 1:棋盘格内参标定
脚本:run_calibrate_camera.py
功能:对每个相机单独采集棋盘格图像,用 OpenCV fisheye 模型标定内参。
典型命令:
python run_calibrate_camera.py \
-i 0 \ # 摄像头设备号
-grid 9x6 \ # 棋盘格内角点数
-out fisheye.yaml \ # 输出 YAML 路径
-framestep 20 \ # 每 20 帧取一帧
--resolution 640x480 \ # 采集分辨率
--fisheye # 使用 fisheye 模型
输出:每个相机一个 YAML 文件(如 yaml/front.yaml),包含:
camera_matrix(3×3):鱼眼内参 Kdist_coeffs(4×1):鱼眼畸变系数 [k1, k2, k3, k4]resolution(2×1):图像分辨率
6. 阶段 2:手动选点求投影矩阵
脚本:run_get_projection_maps.py
功能:在去畸变后的图像上手动点击 4 个地面标志点,计算图像到 BEV 平面的透视变换矩阵。
典型命令:
python run_get_projection_maps.py \
-camera front \
-scale 0.7 0.8 \ # 去畸变后缩放(方便看到标定点)
-shift -150 -100 # 去畸变后平移
操作流程:
- 弹出窗口显示去畸变后的图像
- 用鼠标按顺序点击标定布上的 4 个特征点
- 按回车预览投影结果
- 满意则保存,不满意按
q重来
代码逻辑:
src = np.float32(gui.keypoints) # 用户点的 4 个图像像素坐标
dst = np.float32(dst_points) # 这 4 个点在 BEV 图中的目标坐标
project_matrix = cv2.getPerspectiveTransform(src, dst)
输出:向 YAML 文件中追加:
project_matrix(3×3):图像到 BEV 的透视变换矩阵scale_xy:用户指定的缩放系数shift_xy:用户指定的平移量
注意:用户点击的顺序必须和 project_keypoints 中定义的 dst 点顺序一致,否则投影结果会旋转或镜像。

7. 阶段 3:生成融合权重
脚本:run_get_weight_matrices.py
功能:计算 4 个相机在重叠区域的融合权重,让拼接缝尽可能自然。
算法(距离加权法):
- 4 个相机的投影图各自裁出在 BEV 中的对应区域
- 在相邻两路的重叠区中,提取两个相机各自的独占区域轮廓
- 对重叠区中每个像素,计算
w = d_B² / (d_A² + d_B²),其中 d_A 是该像素到相机 A 独占区的距离 - 这意味着:像素离谁的独占区近,谁的权重就大
输出:
weights.png:RGBA 四通道图像,每个通道存储一个相机在重叠区的权重矩阵masks.png:RGBA 四通道图像,每个通道存储一个相机的重叠区 mask
下面是前方相机单独投影到 BEV 后的效果,可以看到标定布区域被拉成了规整矩形,但远处非地面区域出现了拉伸:

8. 阶段 4:实时多线程运行
脚本:run_live_demo.py
功能:多线程实时采集四路相机图像,在线完成去畸变→投影→拼接→显示。
线程架构:
- 4 个采集线程(CaptureThread):各自从一个摄像头读取帧
- 1 个帧同步缓冲(MultiBufferManager):保证四路图像时间戳对齐
- 4 个处理线程(CameraProcessingThread):各自完成
undistort → project → flip - 1 个拼接对象(BirdView):
stitch_all_parts()完成四路融合
每帧处理流水线:
# fisheye_camera.py
undistorted = cv2.remap(frame, map1, map2, cv2.INTER_LINEAR)
projected = cv2.warpPerspective(undistorted, project_matrix, project_shape)
flipped = flip(projected) # 按相机方向翻转/转置
# birdview.py
birdview = stitch_all_parts(front, back, left, right)
birdview = make_luminance_balance(birdview, masks) # 亮度均衡
final = place_car_image(birdview, car_image) # 车体图覆盖

9. 参数模型解析
param_settings.py 中的参数定义的是 BEV 俯视图的物理几何布局,不是可以随意调整的数值。理解这组参数的含义是使用和适配这个工程的关键。
9.1 横向布局
shift_w | board_w | inn_shift_w | car_w | inn_shift_w | board_w | shift_w
car_w:车宽(单位与像素 1:1 时就是 cm)board_w:侧面标定布的宽度inn_shift_w:标定布内侧边界到车身侧面的距离shift_w:标定布外侧再向外扩展的留白
9.2 纵向布局
shift_h | board_h | inn_shift_h | car_h | inn_shift_h | board_h | shift_h
对应的四个参数与横向类似,只是沿车身前后方向。
9.3 从参数计算关键量
total_w = car_w + 2*inn_shift_w + 2*board_w + 2*shift_w # BEV 画布总宽度
total_h = car_h + 2*inn_shift_h + 2*board_h + 2*shift_h # BEV 画布总高度
xl = shift_w + board_w + inn_shift_w # 车体左边界在 BEV 图中的 x 坐标
xr = xl + car_w # 车体右边界
yt = shift_h + board_h + inn_shift_h # 车体上边界
yb = yt + car_h # 车体下边界
9.4 project_shapes 和 project_keypoints
project_shapes[camera]:该相机 warpPerspective 后输出图像的尺寸 (width, height)。
project_keypoints[camera]:该相机画面中被点选的 4 个标定点,在最终 BEV 图中的目标坐标。
这两个参数的关键在于:
- 它们定义的是你期望的投影结果,不是标定算法自动算出来的
- 它们的值取决于标定布的尺寸和在 BEV 图中的位置
- 如果你点的是整个标定布的外角点,坐标就应该对应标定布整体的位置;如果你点的是棋盘格内部的角点,坐标就应该缩进
9.5 拼接区域(9 宫格)
最终 BEV 图被 xl, xr, yt, yb 四个边界线切成一个 3×3 = 9 宫格:
FL | F | FR
-----+-----+-----
L | C | R
-----+-----+-----
BL | B | BR
- F/B/L/R = 前/后/左/右单视野区
- FL/FR/BL/BR = 四个角的重叠区(需要融合权重)
- C = 车体区域(用车标图片覆盖)

9.6 适配新车辆时的修改顺序
如果要把这个工程用到自己的车上,按以下顺序改参数:
- 改
car_w/car_h(车身尺寸) - 改
inn_shift_w/inn_shift_h(标定布到车的距离) - 改
shift_w/shift_h(扩展视野范围) - 重算
total_w/total_h/xl/xr/yt/yb - 更新
project_shapes和project_keypoints - 重跑阶段 1 + 2(内参不变的话可以只重跑阶段 2 + 3)

10. 复现时候踩过的坑
- project_keypoints 和点选顺序不一致 → 投影图旋转/镜像
- 改了
xl/xr/yt/yb但没重跑阶段 3 → weights.png 用旧的区域定义,拼出来对不齐 - 棋盘格只集中在画面中间 → 畸变矫正不充分,边缘投影不准
- 四个相机的内参不匹配 → 去畸变后的图像尺度不一致,拼接错位
- OpenCV 版本差异:OpenCV 4.x 的
cv2.FileStorage加载 2×1 矩阵返回 2D array,需要.flatten()才能当 1D 使用 - PyYAML 和项目内的
yaml/目录冲突:Python 导入优先级导致import yaml导到了项目目录而不是 PyYAML 库