
简介面向自动驾驶环境感知、全景监控等应用场景的鱼眼相机生成鸟瞰图测试演示程序提供了一套简洁的工程示例帮助算法工程师和研究者验证鱼眼图像到鸟瞰视角的转换效果。资源包大小772KB共40个文件涵盖cmake构建脚本、C源码、Makefile、配置文件、jpg测试图片以及编译生成的可执行文件这些文件从工程构建、算法实现到结果输出形成完整链路便于直接编译运行或按需修改。已有322人学习下载。该演示程序涉及鱼眼相机近180度超广角成像原理、镜头畸变校正、多帧图像融合以及透视几何变换等关键步骤通过源码与测试数据的配合用户可直观对比鱼眼原图与生成的鸟瞰图理解完整生成流程与数学映射关系非常适合用于算法验证、论文复现及工程预研在自动驾驶环境感知与全景监控等场景中具有较高的实用价值。1. 鱼眼相机生成BEV鸟瞰图为什么先要过一关畸变矫正一辆车前后左右各装一颗大广角鱼眼相机中控屏幕上的环视鸟瞰图看起来天衣无缝但实际落地时会发现停车线在车头边缘位置严重弧化越靠近图像边缘弧得越明显。这就是鱼眼镜头自身物理特性造成的桶形畸变如果直接把原始鱼眼图做二维透视变换BEV画面会像哈哈镜一样扭曲根本没法用。一个合格的离线测试demo至少要把“鱼眼畸变矫正→地面逆透视映射→图像裁剪与缩放”这三个环节串起来才能拿到一张基本等距的俯视图。这篇文章会以OpenCV和Python为主写一套能在本地拿图片、视频或摄像头直接跑通的最小demo并把标定参数、透视四点选法、实时性能踩坑这些工程细节都讲清楚。适合做车载环视、泊车辅助的算法或嵌入式开发人员也可以拿Carla仿真器的鱼眼相机输出做离线验证。2. 鱼眼相机畸变矫正先给BEV一张“平直”的图鱼眼相机的视场角往往超过180度投影模型不再是针孔模型的等距投影而是采用r fθ或r 2f sin(θ/2)这类非线性映射。这也意味着普通OpenCV的cv2.calibrateCamera对鱼眼镜头并不完全适用必须用cv2.fisheye模块里的标定函数得到K内参矩阵和4个畸变系数k1,k2,k3,k4。矫正环节直接决定BEV的底图质量矫正不足的地面线会在后续透视变换后变成曲线。2.1 鱼眼相机畸变系数标定最少需要拍多少张棋盘格常见的做法是打印一张7x9棋盘格贴着地面或墙面用鱼眼相机从不同角度拍摄。我一般至少拍20张其中要保证棋盘格覆盖到画面边缘和四角因为鱼眼畸变在边缘最严重边缘样本不足会导致外参外推时边缘区域仍然发虚。标定代码大致如下import cv2 import numpy as np import glob CHECKERBOARD (6, 9) subpix_criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.01) objp np.zeros((CHECKERBOARD[0]*CHECKERBOARD[1], 1, 3), np.float32) objp[:,0,:2] np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) objpoints [] imgpoints [] images glob.glob(calib_images/*.jpg) for fname in images: img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, CHECKERBOARD, None) if ret: objpoints.append(objp) corners2 cv2.cornerSubPix(gray, corners, (11,11), (-1,-1), subpix_criteria) imgpoints.append(corners2)这里CHECKERBOARD是内部角点数不是棋盘格数角点提取用的cornerSubPix会做亚像素细化。标定函数DIM gray.shape[::-1] K np.zeros((3, 3)) D np.zeros((4, 1)) rvecs [np.zeros((1, 1, 3), dtypenp.float64) for _ in range(len(objpoints))] tvecs [np.zeros((1, 1, 3), dtypenp.float64) for _ in range(len(objpoints))] ret, K, D, rvecs, tvecs cv2.fisheye.calibrate( objpoints, imgpoints, DIM, K, D, rvecs, tvecs, cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC cv2.fisheye.CALIB_FIX_SKEW, criteria(cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-5) ) np.savez(calib.npz, KK, DD, DIMnp.array(DIM))代码里的DIM是原始图像分辨率必须和实际加载图片一致。CALIB_FIX_SKEW在大多数传感器上没有意义因为鱼眼镜头一般不存在像素倾斜。标定结果会保存为calib.npz后续demo直接加载。2.2 用initUndistortRectifyMap生成映射表避免每帧重复计算鱼眼去畸变本身是像素级重映射如果每一帧都调用cv2.fisheye.undistortImageCPU占用会非常高。正确用法是先初始化一次映射表之后每帧只用cv2.remapK np.load(calib.npz)[K] D np.load(calib.npz)[D] DIM tuple(np.load(calib.npz)[DIM]) balance 0.0 dim1 dim2 DIM Knew K.copy() Knew[(0,1), (0,1)] balance * dim2[0] (1 - balance) * K[(0,1), (0,1)] map1, map2 cv2.fisheye.initUndistortRectifyMap( K, D, None, Knew, dim2, cv2.CV_32FC1 ) def undistort(img): return cv2.remap(img, map1, map2, interpolationcv2.INTER_LINEAR, borderModecv2.BORDER_CONSTANT)这里的balance控制矫正后图像到底保留多少有效像素balance0时画面被裁剪到只剩中央区域但像素利用率高适合做BEV局部变换balance1时会保留尽量大的视角但图像边缘会出现不规则的黑色空白。实际做环视BEV时我倾向于选balance0.5因为后面透视变换会裁剪掉边缘噪声太小的视角反而损失两侧视野。2.3 矫正不彻底导致的“BEV拉花”现象很多初写demo的人发现矫正后图像上原本水平的瓷砖线不再弯曲就以为完成了。其实鱼眼矫正还有一个隐含要求主点位置要落在画面中心附近。如果你用手机鱼眼镜头方案标定出的cx和cy偏移较大矫正后图像会整体偏移最后BEV俯瞰图就会朝一个方向倾斜。遇到这种情况检查K[0,2]和K[1,2]是否接近图像尺寸的一半。如果偏差超过10%可以使用cv2.fisheye.estimateNewCameraMatrixForUndistortRectify重新计算矫正矩阵让主点归中。这一步不多花时间却能让后面的单应变换更稳定。模型畸变参数个数适用场景针孔径向切向模型k1, k2, p1, p2视场角 90度鱼眼等距模型k1, k2, k3, k4视场角 150度统一相机模型xi, fx, fy, cx, cy折反射鱼眼工程中少见在实际标定过程中如果发现重投影误差大于0.3像素我会先剔除重投影误差最大的5张图再重标通常能将误差降到0.1像素以内。矫正阶段做完BEV的第一步地基就稳了。3. BEV视角的单应变换与四点标定鱼眼相机标定完成后的目标是生成车体正上方的俯视图。这里不需要做多视角三维重建只要把地面看成平面就可以用一个3x3的单应矩阵H表达图像平面和地平面之间的映射关系。这就是逆透视映射IPM的基础。3.1 为什么IPM用单应变换就够平坦地面假设单应变换是平面到平面的映射。鱼眼相机离地高度固定地面也近似平面时世界坐标系中的任意地面点P投影到图像像素点p两者之间满足线性关系p — 齐次坐标 —— H * PH是3x3矩阵包含相机内参、俯仰角、高度等所有外参信息矩阵本身有8个自由度。在demo阶段不需要精确测量安装角度可以直接在矫正后的图像上选取4个地面点比如停车位的四条边线对应到俯视图的矩形位置就能解出H。这也是跑通流程最快的方式。3.2 选地面四点和scale参数生成俯视图网格假设已经得到一张矫正后的前视鱼眼图undist_img我要做的是选一组地面点把它们映射成一个等间隔的俯视网格。选点时的通用原则选容易识别的地面特征点最好形成一个梯形比如车道边缘或地面方格砖的边角。下面代码演示手动选4个点然后生成BEVimport cv2 import numpy as np # 从矫正后的图像中手工选出的4个地面点像素坐标 src_pts np.float32([[560, 420], [720, 420], [900, 520], [380, 520]]) # 对应到俯视图中的物理坐标米按图案尺寸换算 dst_pts np.float32([[0, 0], [2.0, 0], [2.0, 2.0], [0, 2.0]]) H cv2.getPerspectiveTransform(src_pts, dst_pts) scale 200 # 每米200像素 bev_h, bev_w 2.0 * scale, 2.0 * scale bev_img cv2.warpPerspective(undist_img, H, (bev_w, bev_h))逻辑说明src_pts是在图像上踩出的梯形顶点dst_pts是地面坐标系中的对应点。这里我只给了4个点cv2.getPerspectiveTransform会解出唯一的H。scale决定最终BEV分辨率设得越大画面越精细但远处区域会被放大得更模糊。俯视图中每个像素对应的实际地面大小是1/scale米例如scale200时每个像素代表5毫米。3.3 从外参标定推导H矩阵替代手动选点手动选点只是测试demo最粗暴的落地方式因为它对选购点位置很敏感。如果src点选在像素边缘或者地面有坡度BEV近处和远处的地面误差会非常大。代价稍高一点、但更稳健的方式是利用相机外参直接构建H。例如已知相机安装高度h、俯仰角φ、水平偏航角θ焦距fx, fy和主点cx, cy来自鱼眼标定可以通过旋转矩阵和平移向量构造Hdef build_ipm_h(fx, fy, cx, cy, h, pitch_deg0.0, yaw_deg0.0): pitch np.deg2rad(pitch_deg) yaw np.deg2rad(yaw_deg) # 相机到地面坐标系的旋转矩阵RPY常用Z-Y-X顺序 R_yaw np.array([[np.cos(yaw), -np.sin(yaw), 0], [np.sin(yaw), np.cos(yaw), 0], [0, 0, 1]], dtypenp.float32) R_pitch np.array([[1, 0, 0], [0, np.cos(pitch), np.sin(pitch)], [0, -np.sin(pitch), np.cos(pitch)]], dtypenp.float32) R R_pitch R_yaw t np.array([0, 0, h], dtypenp.float32).reshape(3, 1) K np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtypenp.float32) # 从相机坐标到地面坐标的变换 H_from_ground K np.hstack([R, t]) # 最终只需要前三列因为地面点Z0最后一列与Z相乘为0 H_ipm H_from_ground[:, :3] return H_ipm这里有个容易忽略的点鱼眼相机的内参矩阵fx, fy在矫正后已经发生了变化不能用原始鱼眼标定得到的焦距。正确做法是先去畸变再用矫正图像重新标定近似针孔内参否则构建的H同样不准。手动选点和外参推导各有适用场景测试单相机透视效果用手动选点最快后续做多相机环视拼接外参推导更稳定。4. 鱼眼相机到BEV的demo程序从图片到实时视频理论已经通了这一节给一个能直接跑的Python demo。它可以从摄像头实时读取帧也支持图片或视频输入。整个程序只需要一个主文件依赖numpy和opencv-python即可没有框架负担。4.1 demo的目录结构和运行命令我习惯按下面方式组织文件bev_demo/ ├── run_demo.py ├── calib.npz └── sample.jpg运行前先确认calib.npz存在如果不做标定也可以准备一张鱼眼测试图。命令行用法如下python run_demo.py --source sample.jpg python run_demo.py --source 0 --calib calib.npz--source可以传图片路径、视频文件路径或摄像头索引。传入数字0时代表打开第一个摄像头传绝对路径则用视频或图片。--calib是鱼眼标定文件加载后会跳过手动选点逻辑直接用calib.npz里的K和D生成去畸变映射。4.2 主循环代码读取帧、去畸变、透视变换关键代码段如下注释里标出了变量含义import argparse import cv2 import numpy as np def load_calib(calib_path): calib np.load(calib_path) return calib[K], calib[D], tuple(calib[DIM]) def precompute_maps(K, D, dim): balance 0.5 Knew K.copy() Knew[(0, 1), (0, 1)] balance * dim[0] (1 - balance) * K[(0, 1), (0, 1)] map1, map2 cv2.fisheye.initUndistortRectifyMap( K, D, None, Knew, dim, cv2.CV_32FC1) return map1, map2 def main(): ap argparse.ArgumentParser() ap.add_argument(--source, requiredTrue, helpimage path / video path / camera index) ap.add_argument(--calib, defaultNone, helpfisheye calibration npz) args ap.parse_args() cap cv2.VideoCapture(args.source) if args.calib: K, D, dim load_calib(args.calib) map1, map2 precompute_maps(K, D, dim) while True: ret, frame cap.read() if not ret: break if args.calib: frame cv2.remap(frame, map1, map2, cv2.INTER_LINEAR, borderModecv2.BORDER_CONSTANT) # 手动选点模式先用鼠标点4次生成H src_pts np.float32([[450, 250], [650, 250], [800, 350], [300, 350]]) dst_pts np.float32([[0, 0], [2.0, 0], [2.0, 2.0], [0, 2.0]]) H cv2.getPerspectiveTransform(src_pts, dst_pts) bev_w int(2.0 * 200) bev_h int(2.0 * 200) bev cv2.warpPerspective(frame, H, (bev_w, bev_h)) cv2.imshow(original/undistort, frame) cv2.imshow(bev, bev) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()代码逻辑precompute_maps在初始化阶段生成数组后续循环里只做remap和warpPerspective避免每帧重复构造矩阵。warpPerspective的H是手动选点的硬编码版本实际使用中你可以把数组换成从文件读取或者读取四个鼠标点击点。所有float32数组都应显式指定因为某些嵌入式平台对数组类型很敏感。4.3 参数调整与嵌入式优化上面demo在PC上跑实时30fps没什么压力但放到RK3588这类边缘设备上就要小心。常用的优化顺序如下优化手段效果实现要点降低输入分辨率减少remap和warp耗时约一半先resize到640x480再做矫正用cv2.UMat处理在OpenCL环境下加速重映射frame_umat cv2.UMat(frame)只处理ROI区域减少透视变换像素数把H覆盖到车体附近区域将映射表转为CV_16SC2降低内存带宽占用map1_16s map1.astype(np.int16)一个工程经验是千万不要在错误的方向上优化先测remap和warpPerspective各自耗时再用cv2.getTickCount()打点确认瓶颈在哪个函数。如果图像从鱼眼矫正到BEV整条链路上每帧花费超过30ms大概率是warpPerspective的输出分辨率设置得太大。一般来说单一前视相机BEV输出不需要超过400x400像素scale可以设到0.1米/像素到0.2米/像素之间。还有一个容易踩的坑是摄像头输入时部分USB摄像头默认输出帧率只有15fps导致BEV画面看起来卡顿。用cap.set(cv2.CAP_PROP_FPS, 30)可以尝试提升但也要确认接口带宽。如果用的是Carla仿真器输出鱼眼图像注意它保存的帧可能是三通道BGR或RGBA加载路径要统一转换到BGR否则后续矫正结果会整体偏色。5. 用标尺和车道线校验BEV精度demo跑起来之后不要只看“大概像俯视图”就收工。BEV是一个测量工具俯视图里的距离是否等比例直接关系到后续车位线检测和障碍物测距。这里给出两个能在桌面上快速验证的方法。5.1 在BEV上叠加虚拟网格观察直线度先用真实标尺在地面上平行摆放两张卷尺让卷尺方向和外参标定的朝向一致。然后在BEV图上用OpenCV画等间距横线和竖线grid_canvas bev.copy() for i in range(1, 6): x int(i * bev_w / 6) cv2.line(grid_canvas, (x, 0), (x, bev_h), (0, 255, 0), 1) for j in range(1, 6): y int(j * bev_h / 6) cv2.line(grid_canvas, (0, y), (bev_w, y), (255, 255, 0), 1) cv2.imshow(grid, grid_canvas)如果真实世界中的平行线在BEV图上仍然看起来有夹角说明src选点不在同一个平面上或者相机安装高度在车运动时发生了变化。查看地面线在网格上的走向能够直接判断哪个区域畸变严重。5.2 量化误差计算矫正后直线夹角写一个快速工具函数在BEV图上选取两个点用直线拟合测出角度偏移def line_angle_deg(pt1, pt2): dx pt2[0] - pt1[0] dy pt2[1] - pt1[1] return np.degrees(np.arctan2(dy, dx)) # 示例在BEV中沿某条横向车道线取两个点 angle_left line_angle_deg((100, 300), (300, 305)) angle_right line_angle_deg((100, 250), (300, 255)) print(parallel angle diff:, abs(angle_left - angle_right))真实地面上平行的车道线经过正确标定与透视变换后在BEV中依然应该近乎平行。如果两条线的角度差大于0.5度就要微调src_pts或外参。我通常把这个角度差当作一个损失函数反复调整pitch_deg或scale直到左右线差值小于0.2度。这个方法比肉眼检查更客观也能用来做自动化标定筛选。本文还有配套的精品资源点击获取