ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AVM环视系统IPM变换:从内外参推导到Python实现与调试

AVM环视系统IPM变换:从内外参推导到Python实现与调试 做AVM环视监控系统这些年绕不开的一个基础问题就是IPM变换。很多人一开始以为IPM不过是用OpenCV算一个单应矩阵H把前视图像做一个透视变换就完事。但在实际项目中尤其是要生成四个鱼眼相机拼接的360环视图时你会发现内外参、坐标系、畸变模型、映射方向、融合权重任何一个环节没理清最后出来的图都是歪的、虚的甚至接缝处直接断裂。这篇文章我打算从相机内外参出发把IPM变换方程一步步推清楚再给出可复用的Python/OpenCV代码实现最后把我在实车调试中踩过的坑一起讲透。内容面向做自动泊车、360影像、摄像头标定和视觉感知的工程师也适合刚接触鸟瞰图的同学。1. 项目概述与方案选型1.1 什么是IPM变换AVM为什么绕不开它IPM全称Inverse Perspective Mapping逆透视映射。简单说就是把相机拍的透视图通过几何变换变成从车辆正上方往下看的“鸟瞰图”。透视效果的本质是近大远小地面上的两条平行线在图像里会收敛到一个消失点。IPM就是要消除这种透视效应把每个像素重新投到地面坐标上让平行线重新变成平行线。AVMAround View Monitor系统通常由四颗鱼眼相机组成分别装在车头、车尾、左右后视镜下方。四路图像覆盖车身周围一圈但每路相机原本都是向斜下方看的透视图直接放在一起根本拼不成连贯的画面。必须先把每一路都投影到统一的地面平面上变成俯视视角然后再把四张“局部鸟瞰图”拼成一张完整环视图。这个“投到地面平面”的过程就是IPM。它和普通的透视变换不太一样透视变换可以是任意平面到任意平面而IPM特指“相机图像平面”到“地平面”的映射。因为地面通常是平面所以可以用单应矩阵描述这也是为什么实现IPM时很多人第一反应是找四个对应点算单应。1.2 方案选型直接单应 vs 内外参推导实现IPM常见有三条路我分别说说适用场景和坑。第一种是“标定板上取点法”。在地上铺一张棋盘格标出格子的四个角点在像素坐标系和地面坐标系中的对应坐标然后调用cv2.getPerspectiveTransform或者cv2.findHomography算出3x3单应矩阵H。这个方法在固定相机、固定安装角度的静态场景里完全够用几分钟就能出图。但缺点是只要相机支架稍微松动、或者换了安装位置原来的H就废了必须重新取点。第二种是“内外参推导法”。先通过标定拿到相机内参K、畸变系数以及相机相对于车体坐标系的外参R和t然后用针孔模型公式推导出图像平面到地面的单应矩阵H。这样做的好处是物理意义清晰安装参数变化时可以直接更新外参不用重新取点。AVM量产项目中每个相机的外参通常由标定设备自动输出所以内外参推导法更适合工程落地。第三种是“基于DLT或非线性优化的万能法”。比如直接用地面上的多个特征点用直接线性变换求H甚至用光束平差同时优化内外参。这种方法适合没有现成标定结果的科研场景但对于AVM这种已经知道大致外参的情况有点杀鸡用牛刀而且结果不保证物理合理。我在这个项目里选择的是第二种。四路鱼眼相机标定完之后内参和外参都存在一个标定文件里我只需要写一段通用代码输入标定参数就能生成任意视角下的IPM变换。这个方案最大的收益在于“可复现”同一套代码换一辆车、换一个安装位置只改参数不用改逻辑。2. 从内外参推导IPM变换方程2.1 先定好坐标系这一步乱后面全乱推导IPM之前必须把坐标系定义清楚否则矩阵乘来乘去全是错的。以车辆后轴中心为原点建立车体坐标系(X_w)车头方向为正(Y_w)车辆左侧为正(Z_w)垂直地面向上为正地面平面就是 (Z_w 0)。相机坐标系以相机光心为原点(Z_c)沿着光轴向前(X_c)向右(Y_c)向下。像素坐标系以图像左上角为原点(u)向右(v)向下。相机内参矩阵用 (K) 表示[ K \begin{bmatrix} f_x 0 c_x \ 0 f_y c_y \ 0 0 1 \end{bmatrix} ]其中 (f_x, f_y) 是归一化焦距单位是像素(c_x, c_y) 是光心像素坐标。外参表示车体坐标系到相机坐标系的变换(\mathbf{p}_c R \mathbf{p}_w t)。即一个车体坐标下的三维点先旋转再平移得到它在相机坐标系下的坐标。这里的 (R) 是3x3旋转矩阵(t) 是平移向量。注意有些资料里外参反过来定义为相机到世界使用前一定要确认方向不然后面IPM出来的图会旋转错乱。2.2 由针孔模型到地面单应矩阵推导针孔相机模型把三维点投影到像素坐标[ s \cdot \begin{bmatrix} u \ v \ 1 \end{bmatrix} K \cdot [R \mid t] \cdot \begin{bmatrix} X_w \ Y_w \ Z_w \ 1 \end{bmatrix} ]其中 (s) 是缩放因子等于点在相机坐标系下的深度 (Z_c)。因为所有需要映射的地面点都在 (Z_w 0) 平面上所以可以把第三列 (R) 中的第3列 (r_3) 乘0消掉只保留前两列和 (t)[ s \cdot \begin{bmatrix} u \ v \ 1 \end{bmatrix} K \cdot \begin{bmatrix} r_1 r_2 t \end{bmatrix} \cdot \begin{bmatrix} X_w \ Y_w \ 1 \end{bmatrix} ]定义单应矩阵[ H K \cdot \begin{bmatrix} r_1 r_2 t \end{bmatrix} ]那么地面点 ((X_w, Y_w)) 到像素点 ((u, v)) 的投影就是[ s \cdot \begin{bmatrix} u \ v \ 1 \end{bmatrix} H \cdot \begin{bmatrix} X_w \ Y_w \ 1 \end{bmatrix} ]这里的 (H) 是3x3矩阵。注意它只适用于 (Z_w0) 的平面这也是“平面假设”的由来。如果想从像素坐标反算地面坐标就用 (H^{-1})[ \lambda \cdot \begin{bmatrix} X_w \ Y_w \ 1 \end{bmatrix} H^{-1} \cdot \begin{bmatrix} u \ v \ 1 \end{bmatrix} ]同样需要除以齐次坐标最后一个分量 (\lambda)才能得到真正的 (X_w, Y_w)。掌握了这个推导过程你就明白了两件事第一单应矩阵 (H) 不是随便猜的它是内参和外参组合出来的第二IPM本质上是在做“图像平面”到“地面平面”的透视逆变换只要外参正确映射关系就是确定的。2.3 用欧拉角构造旋转矩阵换算安装姿态实际标定得到的外参有很多种表达方式。一种直接给旋转矩阵 (R)另一种给欧拉角或者四元数。在AVM项目里我们最关心的是相机相对于车辆坐标系的横滚roll、俯仰pitch和偏航yaw。假设旋转顺序为 (Z)-(Y)-(X)即先绕 (Z) 轴偏航再绕 (Y) 轴俯仰最后绕 (X) 轴横滚那么合成旋转矩阵是[ R R_z(\text{yaw}) \cdot R_y(\text{pitch}) \cdot R_x(\text{roll}) ]其中三个基本旋转矩阵为[ R_x(\theta) \begin{bmatrix} 1 0 0 \ 0 \cos\theta -\sin\theta \ 0 \sin\theta \cos\theta \end{bmatrix} ][ R_y(\theta) \begin{bmatrix} \cos\theta 0 \sin\theta \ 0 1 0 \ -\sin\theta 0 \cos\theta \end{bmatrix} ][ R_z(\theta) \begin{bmatrix} \cos\theta -\sin\theta 0 \ \sin\theta \cos\theta 0 \ 0 0 1 \end{bmatrix} ]实际安装时俯仰角 (pitch) 是最敏感的。一般相机都斜向下看(pitch) 在十几到三十几度之间。偏航角如果装歪了一点IPM输出的鸟瞰图会呈平行四边形而不是矩形这个现象在拼接AVM时非常明显。横滚角通常非常小但如果有会导致鸟瞰图倾斜。平移向量 (t) 就是相机光心在车体坐标系下的位置。对AVM来说最有用的是 (t_z)也就是相机离地高度。它放在 (H) 的第三列对地面尺度有直接影响。高度错了IPM输出图会整体缩放不准车辆模型和真实车身大小对不上。2.4 畸变校正与鱼眼相机的前置处理AVM用的都是鱼眼镜头畸变非常严重不能直接套用针孔模型。在推导上述单应矩阵时默认输入图像已经是校正到针孔模型下的图像。所以完整流程是用鱼眼相机标定得到内参、畸变系数 (k_1,k_2,k_3,k_4)OpenCV fisheye模型或针孔畸变系数 (k_1,k_2,p_1,p_2)对原始鱼眼图做畸变校正undistort得到满足针孔模型的“透视图像”对透视图像做IPM。OpenCV提供了两种思路。对普通广角镜头直接用cv2.undistort对鱼眼镜头用cv2.fisheye.undistortImage。实际操作中我更建议一次性把畸变校正和IPM合并成一张remap映射表不要运行时重复计算畸变这样能省不少CPU。合并的原理很简单IPM的目标位置是输出鸟瞰图上的每个像素通过 (H) 投影到“校正后的图像”上的一个像素位置然后这个位置还要继续通过畸变模型反算到原始鱼眼图上的像素位置。把两步查表合成一张map运行时一次remap搞定。3. 代码实现从单路IPM到AVM拼接3.1 准备标定参数与IPM输出范围开始写代码前需要定义几个关键参数。相机内参K3x3浮点数组鱼眼畸变系数D这里用cv2.fisheye模型的4个系数外参旋转矩阵R_cam_to_veh或者车体系到相机系的旋转需要换算成我们需要的 (R)外参平移向量t车体系下相机光心位置IPM图像宽度和高度以及每个像素对应的地面尺寸比如每像素5毫米鸟瞰图在原车体坐标下的起点比如 (X) 从车尾方向开始。我习惯把一个相机封装成一个类初始化时就把映射表算好。下面给出一个典型的结构import cv2 import numpy as np class IPM: def __init__(self, K, D, R_cam2veh, t_veh, map_width600, map_height800, resolution0.01, origin_x-1.0, origin_y-1.0): K: 鱼眼相机内参 (3,3) D: 鱼眼畸变系数 (4,) R_cam2veh: 相机-车体 旋转矩阵 t_veh: 相机在车体坐标下的位置 map_width, map_height: 输出鸟瞰图宽高 resolution: 每个像素对应的地面米数 origin_x, origin_y: 输出图左上角对应的车体坐标 self.K K self.D D self.map_shape (map_height, map_width) self.resolution resolution self.origin_x origin_x self.origin_y origin_y # 需要推导从“车体地面”到“相机图像”的单应矩阵H # 注意R_cam2veh是相机系到车体系而我们公式需要的是车体系到相机系 R_veh2cam R_cam2veh.T t_cam -R_veh2cam t_veh r1 R_veh2cam[:, 0] r2 R_veh2cam[:, 1] self.H self.K np.column_stack([r1, r2, t_cam]).astype(np.float64) # 之后根据H和畸变系数生成remap映射表这里有一个容易绕晕的点标定文件里常见的rvec、tvec是棋盘格坐标系到相机坐标系的变换未必直接是我们需要的车体系到相机系。一定要明确旋转矩阵的方向。我习惯统一成“车体系到相机系”公式里的R_veh2cam和t_cam必须满足[ p_c R_{veh2cam} p_v t_{cam} ]如果标定给出的是R_cam2veh那么取转置即可。3.2 用查表法生成IPM映射生成映射表的思路是遍历输出鸟瞰图上的每个像素计算该像素对应的车体地面坐标再用单应矩阵 (H) 投影到校正后的针孔图像上最后用畸变模型反查到原始鱼眼图像素位置。我把两步合在一起写进build_map方法。def build_map(self): h, w self.map_shape map_x np.zeros((h, w), dtypenp.float32) map_y np.zeros((h, w), dtypenp.float32) # 生成地面网格坐标注意Y方向通常取向下为正还是向上为正 # 如果希望鸟瞰图上方向为车头那么车体X增加的方向对应图像从上到下还是从下到上 u_coords, v_coords np.meshgrid(np.arange(w), np.arange(h)) # 根据输出像素位置计算车体坐标(X_w, Y_w) X_w self.origin_x u_coords * self.resolution Y_w self.origin_y v_coords * self.resolution # 齐次坐标 ones np.ones_like(X_w) ground_pts np.stack([X_w.ravel(), Y_w.ravel(), ones.ravel()], axis1).T # shape (3, N) # 投影到校正后的针孔图像 img_pts self.H ground_pts # 3xN img_pts img_pts / img_pts[2, :] # 归一化 u_img img_pts[0, :].reshape(h, w) v_img img_pts[1, :].reshape(h, w) # 反畸变已知针孔模型下的(u_img, v_img)在鱼眼图像中的位置 # 这里用OpenCV的fisheye.projectPoints更直接或者用迭代校正。 # 简化处理如果镜头畸变不大可以省略这一步但鱼眼必须先做。 # 实际操作中我会使用cv2.fisheye.initUndistortRectifyMap生成的 # “畸变校正图-原图”映射和“鸟瞰图-校正图”映射做复合。更简洁的做法是先调用一次cv2.fisheye.initUndistortRectifyMap得到从校正图到原始鱼眼图的映射map1_u、map1_v再把IPM的H变换结果作为校正图坐标去查这个map。这样能复用OpenCV的优化实现而且代码更清晰。def build_map(self, D, R, K): # Step 1: 生成校正图到原鱼眼图的remap map1_u, map1_v cv2.fisheye.initUndistortRectifyMap( K, D, np.eye(3), K, (self.corrected_w, self.corrected_h), cv2.CV_32FC1) # Step 2: 对校正图上的网格坐标做IPM变换得到校正图坐标 # 生成目标鸟瞰图网格对应地面坐标然后 H - 校正图坐标 # ... 省略中间代码最终得到 bird_view_coord_u, bird_view_coord_v # Step 3: 复合查表最终map_x cv2.remap采样自map1_umap_y同理 map_x cv2.remap(map1_u, bird_view_coord_u, bird_view_coord_v, cv2.INTER_LINEAR) map_y cv2.remap(map1_v, bird_view_coord_u, bird_view_coord_v, cv2.INTER_LINEAR) return map_x, map_y这个方法的好处是明显映射表提前算好运行时对每帧图片只调用cv2.remap性能很高。AVM需要同时处理四路视频流每路都可以预先算好映射表放在内存里图像数据来了直接查表出图。3.3 四路拼接融合策略四路相机的IPM图像生成后需要在同一张鸟瞰图上拼接。我的做法是为每个相机定义一个有效区域掩膜通常是车身周围的梯形区域四个区域之间有重叠重叠部分不能直接硬切否则接缝处会有明显跳变采用距离加权融合计算输出像素到当前相机有效区域中心的距离距离越远权重越低归一化后作为融合权重。下面是一个简化的前后两路重叠融合示例# front_ipm, rear_ipm 已经生成 # 计算融合权重w_range是重叠宽度 alpha np.clip((x_start overlap_w - x) / overlap_w, 0, 1) merged front_ipm * alpha rear_ipm * (1 - alpha)当然工程上不会只处理前后两路而是四路两两重叠。更稳妥的方案是“金字塔融合”或者“拉普拉斯融合”但那样计算量偏大。AVM系统对实时性要求高我用线性加权已经能解决大部分问题关键是要保证各路的曝光和增益一致。如果前后相机的自动曝光差异太大再好的融合算法也救不回来。3.4 性能优化与内存管理经验在嵌入式设备上跑AVM性能是第一道坎。我总结几个行之有效的优化点映射表提前算绝对不要在渲染循环里算投影矩阵或畸变只做一次cv2.remap。多线程并行四路相机采集和IPM可以拆到四个线程每个线程独立处理一路图像最后在主线程拼接。Python里可以用concurrent.futures.ThreadPoolExecutor注意OpenCV的remap线程安全但要注意GIL重活最好让C干。单精度存储map_x、map_y用float32不要用float64否则内存翻倍而且OpenCV部分接口不支持double。裁剪输出范围鸟瞰图不需要全部显示比如只保留以车辆为中心的 7m x 9m 区域超出范围的空间根本用不上反而增加计算量。分辨率权衡每像素5毫米和每像素10毫米视觉差距不大但计算量差4倍。我一般先用10毫米跑通流程再按需提高。4. 常见问题与排查技巧实录4.1 外参不准引起的错位怎么查AVM拼接最常见的现象是相邻相机的鸟瞰图对不齐比如车道线在前后两路重叠区错开一个像素到几十像素。首当其冲的原因是外参不准尤其是偏航角和高度。排查方法是在车身周围铺几根长直胶带或者画直线从车头到车尾、从左到右形成明显的直线标线。然后分别查看四路IPM图看同一条线在相邻视角里是否对齐。如果发现某一路里的直线歪了一般就是该相机的外参偏航角有误差。如果整体缩放不对那就是高度不准。另一种情况是左右两侧和前后相机的重叠区域都已经对齐但车辆四周一圈的车位线整体偏移。这往往是车体坐标系定义不一致导致的比如左右相机安装时X轴方向标错了需要重新核对标定流程。4.2 单应矩阵退化与数值陷阱在某些安装角度下H矩阵可能接近奇异。什么意思就是相机光轴几乎平行于地面或者俯仰角接近90度导致地面点在图像中投影后区分度很低IPM变换会把很远的地面点映射到很近的像素输出图严重拉伸。经验上相机相对地面的俯仰角小于10度时IPM结果会很糟糕因为透视效果太弱没有足够的“顶视信息”。这时候应该调整相机安装角度或者考虑用多个视角融合。代码上我习惯在初始化时检查det np.linalg.det(self.H) cond np.linalg.cond(self.H) assert abs(det) 1e-8, H matrix is singular assert cond 1e8, fH matrix is ill-conditioned: {cond:.2e}另外注意浮点精度。如果直接手动拼H建议用双精度计算最后存map_x/map_y再转float32避免累计误差。4.3 拼接缝发虚、亮度不均的调整即便外参正确拼接缝处仍可能出现“发虚”或者“一条线”的现象。原因多数是曝光不一致。AVM四路相机朝向不同车头的天空和车尾的地面亮度差异很大自动曝光各自为政重叠区的灰度就会突变。我采用的临时方案是在标定阶段固定四路相机的曝光时间和增益不启用自动曝光如果必须自动曝光就需要做图像亮度均衡比如计算重叠区域的灰度直方图做线性映射。更进一步的做法是在融合阶段使用渐变权重。但底层原因不解决权重只能让边界“模糊化”不能完全消除。如果接缝处出现“重影”而不是亮度突变重点检查外参微调量。有些标定工具输出的是“相机到标定板”的外参传到IPM代码里方向反了H就错了表现就是部分区域完全错位。我建议在代码里写一个自检函数输入一副带直线的地面图像输出IPM图后计算边缘角点的位置已知量自动判断外参是否可信。4.4 常见问题速查表现象可能原因解决方法鸟瞰图整体旋转了一个角度外参偏航角符号或旋转顺序不对检查旋转矩阵定义统一坐标方向地面物体尺寸不对相机高度或IPM分辨率设置错误核对t向量中的高度确认每像素实际米数拼接处车道线断开相邻相机外参相对误差大重新标定或加一个在线优化的小偏转角图像边缘严重拉伸相机俯仰角过小或输出范围过大缩小IPM输出范围调整相机安装角度鱼眼图边缘畸变校正后视角缺失鱼眼内参不准镜头视场角超出模型范围重新标定鱼眼内参或只用FOV内的区域映射表耗时太长循环遍历每个像素使用numpy批量计算或者预生成一次5. 实操中的一点体会代码跑通只是第一步真正把AVM做到好用拼的是细节。我在实车调试中体会最深的是永远不要相信外参是一次标定永远不变的。车辆悬挂高度、轮胎胎压、负载都会改变相机相对地面的位置导致原本调好的拼接图过几天又偏了。所以量产系统里通常会加一个“在线自标定”环节比如用车道线或者停车位的特征来实时修正外参。这篇文章没有展开那一部分但如果你打算做产品级AVM可以往这个方向继续深入。最后再分享一个小技巧调试IPM时把单应矩阵H打印出来看一眼如果发现第三列平移部分和第一列、第二列量级差距悬殊或者H的行列式特别大多半是内外参的单位或者坐标系方向没统一。把这些基础问题排掉IPM本身其实是一个非常稳定的几何工具。希望这篇推导和代码实现能帮你少走一段弯路。
返回列表