深度解析自动驾驶中的BEV和SLAM技术

2024-04-03  

Birds-Eyes-View(BEV):鸟瞰图,这个词本身没什么特别意义,但在自动驾驶(Autonomous Driving,简称AD)领域逐渐普及后变成了这个行业内的一种术语。

Simultaneous Localization and Mapping(SLAM):并发定位与地图测绘,相对于BEV的另外一种感知技术。


Perception:感知,SLAM和BEV在AD领域里都是协助控制系统了解车辆周围状况的感知技术:知道自己在哪,有哪些障碍物,障碍物在自己的什么方位,距离多远,哪些障碍物是静态的那些是移动的,等等相关信息,便于随后做出驾驶决策。


SLAM VS BEV:SLAM主要通过各种传感器扫描周围空间的物体结构,以3维数据来描述这些信息。BEV同样通过传感器扫描获知周边状况,主要以2维数据来描述这些信息。从应用范围来讲,目前SLAM更为广阔,在AD火起来之前主要应用在VR/AR等领域,BEV主要集中在AD行业里。从技术实现来看,SLAM偏向于传统数学工具,包括各种几何/概率论/图论/群论相关的软件包,而BEV基本上清一色的基于深度神经网络DNN。两者最好不要对立着看,很多情况下可以互补。


以下将侧重于BEV的基础介绍。

SLAM和BEV最基础和核心的传感器就是相机(Camera),所以两者在计算过程中有大量的算力都被消耗在了图像中信息提取/识别和变换计算。SLAM倾向于识别图像中的特征(Feature)点,属于特征信息里的低级信息,通过计算这些特征点在不同图像帧上的位置来获取场景结构以及相机自身的位姿(Position and Pose)。而BEV倾向于识别车辆/道路/行人/障碍物等等高级特征信息,这些是卷积网CNN和Transformer擅长的。

相机有两个最基础的数据:内参(Instrinsics)和外参(Extrinsics),内参主要描述的是相机的CCD/CMOS感光片尺寸/分辨率以及光学镜头的系数,外参主要描述的是相机在世界坐标系下的摆放位置和朝向角度。

其中内参的常见矩阵是:

799ea252-57ba-11ee-939d-92fbcf53809c.png

其中fx和fy分别表示光学镜头的横向/纵向焦距长度(Focus),正常情况下焦距是不分横纵向的,但因为CCD/CMOS感光片上的像素单元不够正,如果这个像素是绝对的正方形,那么fx = fy,实际上很难做到,有微小的差异,导致光线经过镜头投射到感光片上后,横纵坐标在单位距离上出现不等距的问题,所以相机模块的厂家会测量这个差异并给出fx和fy来,当然开发者也可以利用标定(calibration)过程来测量这两个值。

79af9d00-57ba-11ee-939d-92fbcf53809c.png

图1

79cb6ada-57ba-11ee-939d-92fbcf53809c.png

图2

另外,在传统的光学领域里,fx和fy的默认单位是毫米:mm,但在这个领域默认单位是像素:Pixel,导致很多有摄影经验的人看到fx和fy的值都挺纳闷,特别大,动不动就是大几千,这数值都远超业余天文望远镜了。为什么这里用像素?我们试着通过内参计算一下相机的FOV(Field of View,视场大小,通常以角度为单位)就明白了:

79d682d0-57ba-11ee-939d-92fbcf53809c.png

图3

79efb5fc-57ba-11ee-939d-92fbcf53809c.png

这里fy是纵向焦距,h是照片高度。因为h的单位是像素,所以fy也必须是像素,这样才好便于计算机处理,所以fx和fy的单位就统一成了像素。其实都不用到计算机这步,CCD/CMOS感光片一般是要集成另外一块芯片ISP(Image Signal Processor)的,这块芯片内部就要把感光数据转成数字化的图片,这里就可以用像素单位了。

内参除了这个矩阵外还有一套畸变(Distortion)系数K,这个东西不详细说了,正常的镜头成像后都是居中位置的变形小,四周变形大,一般通过标定(Calibration)获得这个参数后,对照片做反畸变处理,恢复出一个相对“正常”的照片。SLAM算法里很强调这个反畸变的重要性,因为特征点在照片上的绝对位置直接关系到了定位和建图的准确性,而大部分的BEV代码里看不到这个反畸变处理,一方面是BEV注重物体级别的高级特征,像素级别的轻微偏移影响不大,另一方面是很多BEV项目都是为了写论文,采用了类似nuScenes/Argoverse这类训练数据,这些数据的畸变比较小而已,一旦你在自己的项目里用了奇怪的镜头还是老老实实得做反畸变预处理。

7a0078ec-57ba-11ee-939d-92fbcf53809c.png

图4

外参就简单多了,一个偏移(Transform)系数加一个旋转(Rotation)系数。

7a1295cc-57ba-11ee-939d-92fbcf53809c.png

3维空间里表述旋转的计算方式常见的有2种:矩阵(Matrix)和四元数(Quaternion),为了防止矩阵方式存在万向节死锁(Gimbal Lock)问题,通常采用四元数来计算旋转。但在AD领域里很少这么干,因为相机是固定在车子上,只有垂直于地面的轴(一般是Z轴)才会发生360度的旋转,根本无法引发万向节问题,总不至于用户坚持在翻车的阶段仍旧保持自动驾驶这个诡异的需求。所以BEV的代码里通常就是矩阵形式,SLAM因为还会用在AR和其它领域,相机不是相对固定的,所以会采用四元数。另外,AD领域里不考虑透视现象,所以外参都是仿射矩阵(Affine Matrix),这点和CG领域的3维渲染是不同的。

另外,一般文章里介绍内参时还会考虑旋转偏差,这是由于CCD/CMOS感光片在工厂里被机器给装歪了,但AD领域一般不会考虑它,误差太小,而相机安装在车辆上时本身外参就有很大的相对旋转,不如一并算了,最后交由DNN学习过滤掉,而AR领域里的SLAM更是要主动计算外参,这点毛毛雨就不考虑了。

内外参了解之后,下一个基础的重点就是坐标系。AD的坐标系有好几个,不事先理清楚就直接看代码有点晕。

世界坐标系(World Coordination),这个是真实世界空间里,车辆的位置和方位角,通常粗略的位置是由GNSS(Global Navigation Satellite System)卫星定位系统获取,GNSS包括了美国GPS/中国BDS/欧洲Galileo/毛子GLONASS/日本QZSS/印度IRNSS,各有千秋,定位精度一言难尽,一般标称的精度都是指:车辆在空旷地区,上面有好几颗定位卫星罩着你,车辆静止,定位设备天线粗壮,无其它信号源干扰的情况下的测试结果。如果你处在城市内,四周高楼林立,各种无线电干扰源,卫星相对你时隐时现,车速还不慢,这种情况下给你偏个几十米都是对的起你了。为此有两种常见解决方案:差分基站纠偏和地图通行大数据纠偏。这能给你造成一种错觉:卫星定位还是蛮准的。不管怎么弄,最后得到的坐标位置是经纬度,但跟常规GIS(Geographic Information System)相比,AD的经纬度不是球面坐标系,而是展开成2维地图的坐标系,所以最终在系统内的坐标系也是有区别的,比如google会把WGS84的经纬度换算成它自家地图的矩形切片编码,Uber提出过一种六边形切片的H3坐标编码,百度则是在火星坐标的基础上叠加了一个BD09的矩形切片坐标,等等诸如此类。这些都是绝对坐标位置,而通过类似SLAM技术扫描的高精度地图还会在这个基础上引入一些相对坐标。不管怎么样,最后在代码里看到的只剩下XY了。但这些系统都不能获取车辆朝向(地理正北为0度,地理正东为90度,依此类推,这仍旧是在2维地图上表示方式),所以AD里的车辆角度都是指“轨迹朝向”,用当前位置坐标减去上一时刻的坐标获得一个指向性的矢量。当然在高精度地图的加持下,是可以通过SLAM技术算出车辆的瞬时方位角。在缺失GNSS定位的时候,比如过隧道,需要用车辆的IMU(Inertial Measurement Unit)这类芯片做惯性导航补充,它们提供的数值是一个相对的坐标偏移,但随着时间的推移累积误差大,所以长时间没有GNSS信号的时候,IMU表示也没办法。

BEV训练数据集的世界坐标系(nuScenes World Coordination,其它训练集就不特别说明了),这个跟GNSS的绝对坐标系就不同了:

7a1dcf32-57ba-11ee-939d-92fbcf53809c.png

图5

这是一个nuScenes地图,它的世界坐标系是图片坐标系,原点在图片左下角,单位是米,因此在使用训练数据集时,是不用考虑经纬度的。数据集中会根据时间序列给出车辆的瞬时位置,也就是在这个图片上的XY。

Ego坐标系(Ego Coordination),在BEV里,这个Ego是特指车辆本身,它是用来描述摄像机/激光雷达(Lidar,light detection and ranging)/毫米波雷达(一般代码里就简称为Radar)/IMU在车身上的安装位置(单位默认都是米)和朝向角度,坐标原点一般是车身中间,朝向如图:

7a35c2a4-57ba-11ee-939d-92fbcf53809c.png

图6

所以车头正放的相机默认都是Yaw(Z轴)为0度,外参(Extrinsics Matrix)主要就是描述这个坐标系的。

相机坐标系(Camera Coordination),切记,这个不是照片坐标系,坐标原点在CCD/CMOS感光片的中央,单位是像素,内参(Intrinsics Matrix)主要就是描述这个坐标系的。

照片坐标系(Image Coordination),坐标原点在图片的左上角,单位是像素,横纵坐标轴一般不写成XY,而是uv。

7a5230ba-57ba-11ee-939d-92fbcf53809c.png

图7

左中右三套坐标系分别为:Ego Coordination, Camera Coordination, Image Coordination。

所以,当在BEV中做LSS(Lift,Splat,Shoot)时,需要把照片中的像素位置转换到世界坐标系时,要经历:

Image_to_Camera, Camera_to_Ego, Ego_to_World,用矩阵表示:

Position_in_World = Inv_World_to_Ego * Inv_Ego_to_Camera * Inv_Camera_to_Image * (Position_in_Image)

其中Inv_表示矩阵的逆。实际代码里,Camera_to_Image通常就是Intrinsics参数矩阵,Ego_to_Camera就是Extrinsics参数矩阵。

这里要注意的一点是:fx,fy,它们实际上是这样计算得到的:

7a608066-57ba-11ee-939d-92fbcf53809c.png

Fx和Fy分别是横向/纵向的镜头焦距,但单位是米,Dx和Dy分别是一个像素有几米宽几米高,得出fx和fy的单位就是像素。当使用(Ego_to_Camera * Camera_to_Image)矩阵乘上Ego空间的坐标,会以像素为单位投影到照片空间,当使用(Inv_Ego_to_Camera * Inv_Camera_to_Image)矩阵乘上照片空间的坐标,会以米为单位投影到Ego空间,不会有单位上的问题。

大部分的BEV是多摄像头的,意味着要一次性把多组摄像头拍摄的照片像素换算到Ego或者世界坐标系:

在统一的坐标系下,多角度的照片才能正确得“环绕”出周边的景象。另外还有一些单目(Monocular)摄像头的BEV方案,它们有的不考虑Ego坐标系,因为只有一个朝向正前方(Yaw,Pitch,Roll全部为0)的摄像头,而且原点就是这个摄像头本身,所以直接从相机坐标系跳到世界坐标系。

Frustum,这个东西在3维渲染领域通常叫做“视锥体”,用来表示相机的可视范围:

7a950af2-57ba-11ee-939d-92fbcf53809c.png

图9

红面和绿面以及线框包围起来的空间就是视锥体,绿面通常叫做近平面(Near Plane),红面叫做远平面(Far Plane),线框构成的角度叫做FOV,如果CCD/CMOS成像的高宽相同,那么近平面和远平面就都是正方形,一个FOV就足以表示,反之,就要区分为FOVx和FOVy了,超出这个视锥体范围的物体都不考虑进计算。图7中由6个三角面构成了组合的可视范围,实际上应该是6个俯视的视锥体构成,能看出视锥体之间是有交叠区域的,这些区域有利于DNN在训练/推理中对6组数据做相互矫正,提高模型准确性,在不增加相机数量的前提下,如果想扩大这个交叠区域,就必须选择FOV更大的相机,但FOV越大的相机一般镜头畸变就会越严重(反畸变再怎么做也只能一定程度上的矫正图片),物体在图片上的成像面积也越小,干扰DNN对图片上特征的识别和提取。

BEV是个庞大的算法族,倾向于不同方向的算法选择,粗略得看,有Tesla主导的以视觉感知流派,核心算法建立在多路摄像头上,另外一大类是激光雷达+毫米波雷达+多路摄像头的融合(Fusion)派,国内很多AD公司都是融合派的,Google的Waymo也是。

严格得讲,Tesla正在从BEV(Hydranet)过渡到一种新的技术:Occupancy Network,从2维提升到3维:

7aacafe0-57ba-11ee-939d-92fbcf53809c.png

图10

无论是2维的还是3维的,都在试图描述周遭空间的Occupany(占用)情况,只是一个用2维棋盘格来表述这种占用情况,一个是用3维的积木方式表述占用。DNN在度量这种占用时采用的是概率,比如我们直观看到某个格子上是一辆车,而DNN给出的原始结果是:这个格子上,是车的可能性有80%,是路面的可能性为5%,是行人的可能性为3%。。。。。所以,在BEV代码里,一般将各种可能出现的物体分了类,通常是两大类:

不常变化的:车辆可通信区域(Driveable),路面(Road),车道(Lane),建筑(Building),植被(Foliage/Vegetation),停车区域(Parking),信号灯(Traffic Light)以及一些未分类静态物体(Static),它们之间的关系是可以相互包容的,比如Driveable可以包含Road/Lane等等。

可变的,也就是会发生移动的物体:行人(Pedestrian),小汽车(Car),卡车(Truck),锥形交通标/安全桶(Traffic Cone)等等

这样分类的目的是便于AD做后续的驾驶规划(Planning,有的翻译成决策)和控制(Control)。而BEV在感知(Perception)阶段就是按照这些物体在格子上出现的概率打分,最后通过Softmax函数将概率归一取出最大的那个可能性作为占用这个格子的物体类型。

但这有个小问题:BEV的DNN模型(Model)在训练阶段,是要指明照片中各个物体是啥?也就是要在标注数据(Labeled Data)上给各种物体打上类型标签的:

右边的我们权当做是标注数据吧,左边是对应的相片,按照这个物体分类训练出来的DNN模型,真得跑上路面,如果遭遇了训练集里未出现的物体类型怎么办?如果模型效果不好,比如某个姿势奇葩的人体未被识别成行人和其它已知类型,又当如何?Occupancy Network为此改变的感知策略,不再强调分类了(不是不分类,只是重点变了),核心关注路面上是否有障碍物(Obstacle),先保证别撞上去就行了,别管它是什么类型。3维的积木方式表述这种障碍物更为贴切,有的地方借用了3维渲染(Rendering/Shading)领域的常见概念把这种3维表述叫做体素(Voxel),想象一下我的世界(MineCraft)就很简单了。

以上是视觉流派的简述,混合派在干嘛?它们除了相机外,还侧重于激光雷达的数据,毫米波雷达由于数据品相太差逐渐退出,留守的去充当停车雷达了,也不能说它一无是处,Tesla虽然强调视觉处理,但也保留了一路朝向正前方的毫米波雷达,而且AD这个领域技术变化非常快,冷不丁哪天有新算法冒出又能把毫米波雷达的价值发扬光大一把。

激光雷达的好处是什么:可以直接测出物体的远近,精度比视觉推测出的场景深度要高很多,一般会转化为深度(Depth)数据或者点云(Point Cloud),这两者配套的算法有很长的历史了,所以AD可以直接借用,减少开发量。另外,激光雷达可以在夜间或糟糕的天气环境下工作,相机就抓瞎了。

但这几天出现了一种新的感知技术HADAR(Heat-Assisted Detection and Ranging),可以和相机/激光雷达/毫米波雷达并列的传感器级别感知技术。它的特点是利用特殊的算法把常规热成像在夜间拍摄的图片转化为周围环境/物体的纹理和深度,这个东西和相机配合能解决夜间视觉感知的问题。

以前的BEV为什么不提热成像/红外相机,因为传统算法有些明显的缺陷:只能提供场景的热量分布,形成一张灰度(Gray)图,缺乏纹理(Texture),原始数据缺乏深度信息,推算出的深度精度差,如果仅仅通过从灰度图上提取的轮廓(Contour)和亮度过渡(Gradient),很难精确还原场景/物体的体积信息,并且目前的2维物体识别是很依赖纹理和色彩的。这个HADAR的出现,恰好可以解决这个问题:在较暗的环境下提取场景的深度以及纹理:

7b0c2038-57ba-11ee-939d-92fbcf53809c.png

图13

左列,自上而下:

基础的热成像,简称T

用常规热成像算法从T提取的深度

用HADAR算法从T提取的纹理图

用HADAR算法从T提取的深度

真实场景的深度

右列,自上而下:

这个场景在白天用可见光相机拍摄的照片

通过照片推理的深度

真实场景的深度

HADAR的这个深度信息老牛逼了,对比一下激光雷达的效果就知道了:

激光雷达的扫描范围是有限的,一般半径100米,从上图可以看出,没有纹理信息,远处的场景也没有深度了,扫描线导致其数据是个稀疏(Sparse)结构,想要覆盖半径更大更稠密(Dense)就必须买更昂贵的型号,最好是停下来多扫一段时间。激光雷达模块厂家在展示产品时,当然得给出更好看的图了,只有AD研发人员才知道这里面有多苦。

以上都是基础的概念,作为BEV算法的入门,必须先提到LSS(Lift,Splat,Shoot):

https://link.zhihu.com/?target=https%3A//github.com/nv-tlabs/lift-splat-shoot

老黄家的,很多文章都把它列为BEV的开山(Groundbreaking)之作。它构建了一个简单有效的处理过程:

把相机的照片从2维数据投影成3维数据,然后像打苍蝇一样把它拍扁,再从上帝视角来看这个被拍扁的场景,特别符合人看地图的直觉模式。一般看到这里会有疑惑的:都已经建立了3维的场景数据,3维不香么?干嘛还要拍扁?不是不想要3维,是没办法,它不是一个完善的3维数据:

7b456122-57ba-11ee-939d-92fbcf53809c.png

图15

看过这玩意吧,它就是LSS的本质,从正面看,能形成一张2维照片,这个照片被LSS拉伸到3维空间后就是上图,你从BEV的视角也就是正上方向下看会是啥?什么都看不出来,所以后续要拍扁(Splat),具体过程是这样:

先提取图像特征和深度(Feature and Depth,LSS里是同时提取的,后面会具体解释),深度图类似

只能说类似,并不准确,后面也会具体说明的,这个深度信息可以构建一个伪3D模型(Point Cloud点云模式),类似图15:

7ba7529c-57ba-11ee-939d-92fbcf53809c.png

图18

看着还行,但把这个3D模型转到BEV俯视角下,估计亲娘都认不出来了:

7bc103d6-57ba-11ee-939d-92fbcf53809c.png

图19

拍扁后结合特征Feature再做一次语义识别,形成:

7be75eaa-57ba-11ee-939d-92fbcf53809c.png

图20

这个就是喜闻乐见的BEV图了。以上是对LSS的直观认知,算法层面是如何实现的?

先给单个相机可拍摄的范围构建一个立方体模样的铁丝笼子(高8宽22深41),祭出大杀器Blender:

7bfd13b2-57ba-11ee-939d-92fbcf53809c.png

图21

这里是示意图,不要纠结于格子的数量和尺寸。这个3D网格代表的是一路相机的视锥体(Frustum),前面贴过视锥体的形状(图9),这里变形成立方体,在相机空间里看这个照片和这个立体网格的关系就是:

7c1f9e64-57ba-11ee-939d-92fbcf53809c.png

图22

右边是个正对着网格立方体的相机示意图,相片提取深度后(深度图的实际像素尺寸是高8宽22):

7c39230c-57ba-11ee-939d-92fbcf53809c.png

图23

把这个深度图按照每个像素的深度沿着红线方向展开(Lift)后:

7c4d8cca-57ba-11ee-939d-92fbcf53809c.png

图24

可以看到,部分深度像素已经超出了视锥体的范围,因为LSS一开始就假设了这么个有限范围的笼子,超出部分直接过滤掉。这里必须提醒一下:LSS并不是直接算出每个像素的深度,而是推理出每个像素可能处于笼子里每个格子的概率,图24是已经通过Softmax提取出每个像素最有可能位于哪个格子,然后把它装进对应格子的示意结果,便于理解,更准确的描述如下:

7c654bee-57ba-11ee-939d-92fbcf53809c.png

图25

在图25中选取深度图的某个像素(红色格子,事实上LSS的深度图分辨率是很小的,默认只有8*22像素,所以这里可以用一个格子当做一个像素),它隶属于笼子下方边沿的一条深度格子(这条格子其实就代表相机沿着深度看向远方的一条视线):

7c863c3c-57ba-11ee-939d-92fbcf53809c.png

图26

图25中的那个红色的深度像素,沿着图26这条视线格子的概率分布就是:

7ca2df18-57ba-11ee-939d-92fbcf53809c.png

图27

黄线的起伏表示2D深度图像素在Lift后沿着视线3D深度的概率分布(Depth Distribution,我这是示意性得画法,不是严格按照实际数据做的)。等价于LSS论文里的这张图:

7cbce156-57ba-11ee-939d-92fbcf53809c.png

图28

LSS中构建立方笼子的代码位于:

 

class LiftSplatShoot(nn.Module):

    def __init__(self, grid_conf, data_aug_conf, outC):

        self.frustum = self.create_frustum()

    def create_frustum(self):

        # D x H x W x 3

        frustum = torch.stack((xs, ys, ds), -1)

        return nn.Parameter(frustum, requires_grad=False)

    def get_geometry(self, rots, trans, intrins, post_rots, post_trans):

        """Determine the (x,y,z) locations (in the ego frame)

        of the points in the point cloud.

        Returns B x N x D x H/downsample x W/downsample x 3

        """

        B, N, _ = trans.shape


        # undo post-transformation

        # B x N x D x H x W x 3

        points = self.frustum - post_trans.view(B, N, 1, 1, 1, 3)

        points = torch.inverse(post_rots).view(B, N, 1, 1, 1, 3, 3).matmul(points.unsqueeze(-1))


        # cam_to_ego

        points = torch.cat((points[:, :, :, :, :, :2] * points[:, :, :, :, :, 2:3],

                            points[:, :, :, :, :, 2:3]

                            ), 5)

        combine = rots.matmul(torch.inverse(intrins))

        points = combine.view(B, N, 1, 1, 1, 3, 3).matmul(points).squeeze(-1)

        points += trans.view(B, N, 1, 1, 1, 3)

文章来源于:电子工程世界    原文链接
本站所有转载文章系出于传递更多信息之目的,且明确注明来源,不希望被转载的媒体或个人可与我们联系,我们将立即进行删除处理。