#智能驾驶谁最厉害

 华为ADS?特斯拉FSD?

最近特斯拉的FSD爆出在国内已经拿到测试牌照,FSD 进入国内倒计时中;而国内的华为在智能驾驶方面也是老早就喊出了遥遥领先的口号,所以,如果让特斯拉 FSD 和华为的 ADS 在能驾驶方面比一次,到底是华为厉害还是特斯拉厉害?

所以,本文将结合我对智能汽车行业的认知和相关知识,从技术和产品角度分析在智能驾驶方面,到底是华为 ADS 厉害还是特斯拉 FSD 厉害?希望能给大家一些信息和启发。

01 纯视觉方案的特斯拉FSD

特斯拉的智能驾驶FSD是纯视觉解决方案,特斯拉主要依靠8个摄像头采集视频数据;依靠AI芯片和算法进行数据处理,实现自动驾驶。

这套方案的优点是:成本低、升级方便、响应速度快

简单拿摄像头来说,特斯拉HW 3.0总共8个摄像头,都是130万像素,而且都是2015年左右就出来的技术。而相比国内环视都到300万的“卷”,这个硬件成本低的够可以了,而且听说未来HW 4.0 也可能就是500万像素。

摄像头作为传感器,输入的数据,决定了后面的传输,处理成本,所以特斯拉的整套成本也会低。

所以,我们可以看到特斯拉的核心能力来自于软件,特斯拉硬件相对简单,规格也相对一致,因此很容易实现迭代升级。只要特斯拉的生产规模扩大,成本优势就越明显。

大家可以看到,特斯拉2019年推出的的HW 3.0,在过去的几年一直不断算法迭代,通过OTA实现更先进的视觉算法。从过去主要靠CNN算法到BEV + Transformer + Occupancy, 到现在端到端的概念,这些新的算法都可以通过OTA升级获取。特斯拉的模块化电脑硬件也可以通过更换模块的方式进行升级,大大降低了车辆的资产折旧。

51c自动驾驶~合集4_迭代

另外,特斯拉的视觉识别和人眼一样,依靠可见光和图像分析。目前摄像头的帧率一般都在每秒30次,所以只要有足够的计算能力,其响应速度的上限也就是33ms,你要知道人类的平均反应时间是250ms,战斗机以及赛车员的反应时间是100ms,当然人类对于显示的反应极限是13ms。所以,特斯拉的视觉方案有对突发、快速的障碍物感知的优势。

那特斯拉的视觉方案,有什么缺点呢?

视觉方案作为传感器,其实是模仿人类的眼睛,所以它也继承了人眼的缺陷 - 怕黑,怕不清楚,怕复杂干扰多的场景。

51c自动驾驶~合集4_数据_02

当车辆处于逆光、夜晚、雨雾、道路标线和路侧标线不清晰的场景,或者路况复杂、干扰物多、或者大片白点的场景时,纯视觉方法会遇到难以克服的障碍和瓶颈。

也就是说,这种解决方案的上限是无限接近人类视觉驾驶的水平,但无法超越人类视觉驾驶的水平。

目前,受视觉方案限制,特斯拉的FSD在夜间及雨雾等能见度较差的自然条件下,智驾风险依然较大,很多场景依然需要驾驶员接管。另外就是视觉含有丰富的信息,特斯拉方案在不同国家和地区的通用性也需要强大的训练资源。

02 依赖激光雷达视觉融合的华为ADS

中国的华为走的方式却是截然不同,华为的智能驾驶方案,其车顶必须要有一个非常明显的激光雷达凸起。华为应该是全球唯一一家在智能驾驶中把车用激光雷达用的溜溜的公司。

华为的智能驾驶技术采用的是激光雷达和视觉融合方案,华为的技术主要依靠激光雷达采集数据,特别是GOD中用应该主要是用激光雷达来探索可行驶的空间(Lidar occupancy),通过AI芯片和算法对数据进行处理,实现自动驾驶。

所以,华为的方法是通过激光雷达观察三维世界。

该方案的主要缺点是:成本较高、硬件升级困难、响应速度慢

相比于摄像头,车载激光雷达技术出现时间较短,产业化规模不大,因此硬件成本较高,即使在中国多家激光雷达厂家互卷的情况下,当前一个激光雷达的BOM价格也还需要3000元左右,更别说欧美世界了。

所以,采用激光雷达的厂商在硬件成本上难以与特斯拉视觉solely的方案竞争。

另外,激光雷达和视觉同时采集数据需要数据融合处理,对算力和算法的要求也较高,如果看目前学术界,基本主流的人工智能研究和公布信息都是基于视觉,所以,华为走的激光雷达路线,需要自己内部较长时间的研发和实验投入。

总体来说,华为选择了高成本的方案,当然华为希望借助中国市场的规模优势,最终压低成本,所以也不难理解华为到处和主机厂合作构建鸿蒙智行生态,一起来将华为激光雷达方案规模化,从而达到降本的目的。

其次,华为方案对硬件依赖较大,熟悉华为ADS的应该,知道华为从2021年推出ADS 1.0到现在,其硬件方案已经经历了三代:

  • ADS 1.0基于13个摄像头(前视觉双目+长短距),三个激光雷达,六个毫米波,400TOPs算力的MDC,高精地图的方案。这个基本上算智能驾驶全家桶或者大杂烩。
  • ADS 2.0,11个摄像头,一个激光雷达,200TOPs算力的MDC。相比之前ADS,减掉双目,两个激光雷达,三个毫米波,减掉一块昇腾610算力为200TOPs算力的MDC,减掉了高精地图。
  • ADS 3.0,相比ADS 2.0,一个激光雷达从128线换到了192线,其中一个毫米波换成了4D毫米波。

所以,明显华为方案不同年份生产的车型硬件配置会有所不同,软件版本维护会越来越复杂,老款车型OTA难度也会越来越大,导致车辆资产折旧增加。普遍预计3-5年后,由于硬件升级,已经售出的车型很难再获得能力的进一步提升。

在感知响应速度方面,一般激光雷达的采样率一般为10hz,其响应速度的上限为100ms,当然华为ADS 3.0采用的192线雷达采样频率已经达到20hz。

所以从理论上来讲,不如视觉方法快。当然,从感知到执行响应,还需要经历通讯、计算、执行响应延迟等原因,所以,两者的差异很难体现出来。

华为激光雷达方案的主要优点是上限较高

通过激光雷达感知环境数据,可以突破视觉限制。华为采用的1500波长激光雷达,无论是白天还是夜晚,无论是雨天、雾天还是尘土,对激光雷达的工作影响都很小。

而且激光雷达收集的数据自带距离向量,不需要AI芯片进行计算,对芯片算力和算法的要求比较低。

理论上,随着激光雷达方案的不断演进,最终可以超越人类驾驶水平,甚至实现夜间熄灯驾驶,是L4级自动驾驶的终极路线。

所以,华为车型已具备在中国所有城市L2++的智能驾驶;华为智能驾驶的夜间驾驶表现非常优秀,甚至可在乡村道路和村落上实现智能驾驶。实际表现已全面领先特斯拉方案。

并且大家可以从媒体以及自媒体视频中,看到华为车辆在一些极端条件下,如夜间、雨雾天气、逆光场景等,表现惊人,比特斯拉方案更可靠,接管率更低。当然不少人怀疑视频的真实性,相信看完我们文章,明白原理之后,应该不会怀疑真实性。

03 写在最后

那到底华为和特斯拉的智能驾驶哪个更厉害呢?

从产品和商业的角度来看,特斯拉方案采用成熟传感系统,通用发展的AI技术,所以可以用低廉的成本实现更好的辅助驾驶,从而以更低的价格占领市场,为公司获取更好的利润,进入良性循环。

从体验和技术上限来看,华为的方案未来应该可以无缝演化到L3以上的智能驾驶,可以实现更稳健和更广阔的ODD。但摆在华为面前的是如何扩大规模,通过用规模化降低成本,当然目前情况华为应该是on track。

特斯拉底层技术是目前火爆世界的AI,跟随和共享世界视觉和语言通用AI技术的发展。而华为,底层技术也是AI,但是华为需要自己钻研自己独特的激光雷达应用技术。长期来讲,或许能走出一个无人能做的特色的道路,或许走入死胡同。

这也就是在策划这篇文章时候,蹦出我脑袋里面第一句话“中国的华为,世界的特斯拉”,很遗憾这个标题有点怪,会引发一些争论,所以,没用,但通过这个可以无奈的看到地缘政治或许是下一个时代的常态。



#2D Gaussian Splatting 

首先开门见山,为什么我非常关注2DGS而不是3DGS。3D Gaussian Splatting作为去年年末至今最火爆的方向之一,诞生了非常多好的作品。但是,在我尝试了非常多的代码后。我发现,最好的效果,或者换句话说,转Mesh之后,最好的效果,2DGS几乎是完胜的。

首先我们来看一下具体的一个展示效果:

51c自动驾驶~合集4_激光雷达_03

3DGS 使用元象UE5插件自带的3DGS构建工具

51c自动驾驶~合集4_激光雷达_04

2DGS训练

2DGS的效果非常好,并且转mesh的质量也非常高。因此我自认为这是一个当前对于自动驾驶中,街景重建的一个很好的解决方案。因此,我花了大概3天的时间,梳理了一下整体的代码。和大家一起,串读一下整篇文章。

2DGS的文章链接:
arxiv.org/pdf/2403.17888

2DGS的代码链接:
github.com/hbb1/2d-gaussian-splatting

INTRODUCTION

首先,引言中主要讲的内容,是新颖视图合成的工作,以及致敬3DGS的贡献。同时暗喻自己的观点,就是表面元素基元是复杂几何的有效表达(但碍于时代无法落地)2DGS呢主要就是结合了3DGS和表面元素基元的优势(将高斯椭球定义为椭圆盘):1. 使用2D椭球盘用来进行表面建模(Gaussian model部分);2. 使用Ray-Split和体积积分来实现透视正确的Splatting (Cuda部分);3. 引入了两种新的损失改进表面重建。

RELATED WORK

接下来就是相关工作。主要讲俩事儿,第一个,致敬新颖视图合成,致敬3D Gaussian Splatting及其后续开发的工作。第二个,回顾可微点渲染的进展。那么对于可微点渲染,这里推荐两篇可以看看:

Perspective Accurate Splatting
graphics.stanford.edu/~mapauly/Pdfs/perspective_accurate_splatting.pdf

Differentiable Surface Splatting for Point-based Geometry Processing
arxiv.org/abs/1906.04173

3D Gaussian Splatting

回顾了3DGS的基础知识。这部分我比较推荐看一下这两篇文章:

holk:一文带你入门 3D Gaussian Splatting

八氨合氯化钙:3D Gaussian Splatting中的数学推导

这俩肯定比2DGS那一小段讲得清楚哈。由于3DGS的原文相对还是对初学者不太友好。我建议可以粗略看看上面两篇文章即可。在阐述完3DGS的基本原理后,2DGS其实还是针对表面重建的问题,对3DGS进行了一手拷打:1. 体积辐射率与表面的薄特性相冲突;2. 3DGS没有对法线进行建模;3. 3DGS的光栅化过程缺乏多视图一致性,导致了不同相机的2D相交平面不同,因此产生了我们开头提到的噪声(模糊杂块)



2D Gaussian Splatting

(Part 1)

首先,我们来看一下2DGS的整体模型。2DGS整体采用“平坦2D椭圆盘”来表示稀疏点云,这种表面基元将密度分布到平面圆盘中。将法线定义为密度变化最陡的方向(云里雾里的。。),为了与薄表面对齐。


三维空间刚体运动2:旋转向量与罗德里格斯公式(最详细推导)_空间刚体位移计算公式-CSDN博客
blog.csdn.net/shao918516/article/details/105109278

OK,因此呢,我们便可以求出2DGS局部的切平面,在世界空间中的定义,并将其参数化:


其中,H是一个4X4的矩阵。表示2DGS的齐次变换(也就是2DGS的几何形状)(这里使用的是uv坐标系而不是xyz坐标系,更多是方便大家理解。对于局部平面的一个思想),对于整个2DGS局部切平面中的一点(u,v),我们此时理解为高斯函数的均值为0,方差为1,就可以通过下面这个公式(标准的高斯)去计算此时u的函数值:

细讲2

这里我们需要讲一下3DGS的情况。方便大家理解。首先我们需要了解下球谐函数。球谐函数可以类比泰勒展开或傅里叶级数,都是不同价的基函数线性组合而来。只不过傅里叶变换,是三角基;小波变换是小波基;OK,球谐函数是球基(球函数)。球谐函数,我推荐下面这个博客,讲得还不错:

浦夜:球谐函数介绍(Spherical Harmonics)

那么它本质上和傅里叶函数一样,我们认为阶数越多,拟合效果就越好。(当然也可能过拟合)


球谐函数有啥性质呢,为啥我们想用球谐函数作为图像渲染的手段呢?球谐函数主要有俩性质:1.正交性:基函数之间是线性独立的;2.旋转不变性:改变光照后,可以推导新的光照结果。

我们认为,空间中的每一个单一点带一组基函数,就可以描述空间中的光照(应用中一般只用3-4阶)。具体的球谐光照,大家可以参考以下文章,也是3DGS论文中所引用的文章:

(Part 3)

接下来呢,就是非常给劲儿的抛射环节。这个部分与3DGS也有非常大的区别。具体体现,可以查看Cuda的Forward.h部分。我们还是先讲paper。首先作者blablabla说了一堆,大概意思是说,它使用了齐次坐标的计算公式,将传统的2D

(Part 4)

接下来,作者开始对2DGS的分布映射进行了一些优化。因为2DGS的分布,是一个圆盘,当你侧面看的时候,会变成一条线,在Cuda光栅化的时候,可能会把它给弄没了。所以作者搞了一个低通滤波器来处理这个问题。

(Part 5)

接下来,就是光栅化问题,2DGS的光栅化与3DGS一模一样。首先为每个2DGS计算空间边界框,然后2DGS根据每个中心的深度进行排序,并根据深度框组织图块。最后,体积累积得到最终的color。

细讲3

首先3DGS也是参考的下面这篇文献做的工作:

EWA splatting | IEEE Journals & Magazine | IEEE Xplore
ieeexplore.ieee.org/document/1021576

首先,在确定了相机位姿以后,在空间中确立相机此时的视锥体。然后,需要将3D的空间中的3D椭球投影到2D空间中进行渲染。对于3DGS来说,这一步就像雪秋啪的一声撩墙上了,所以叫Splatting。这里涉及到从3D的协方差矩阵,换到2D的协方差矩阵,其可以表示为:

(Part 6)

对于2DGS的参数更新及致密化,前期是与3DGS几乎是一模一样的。这里我们直接推荐两篇博客给大家看。这玩意儿,大家看到相应的代码就懂了。非常简单哈:

这一部分2DGS的作者并没有去细讲,但是其逻辑与3DGS是一样的。

(Part 7)

损失函数,我不是特别想讲。损失函数并不是本文的重点有一说一。总的来说就是2DGS加入了两个新的约束,用来对深度和法线进行监督。

代码详解

对于代码来说,我提供几乎全文详尽注释的2DGS代码。在本文章中,我们主要关注非常重要的代码即可。

首先,对于所有的开源代码而言,最重要的就是Train.py这个文件。Train几乎囊括了整个2DGS的构建过程,因此这十分重要。首先,我们来看一下Train.py中的执行部分:

if __name__ == "__main__":

    # 创建解析器,初始化操作
    parser = ArgumentParser(description="Training script parameters")
    # 设置模型参数,并使用parser外部数据进行添加
    # 本身模型属性可修改内容:sh_degree,source_path,model_path,image属性,resolution,white_background,data_device,eval,render_items
    lp = ModelParams(parser)
    # 精细化调参,非常多
    op = OptimizationParams(parser)
    # convert_SHs_python, compute_cov3D_python, depth_ratio, debug四个参数
    pp = PipelineParams(parser)
    # parser的额外参数,与上述参数相同
    parser.add_argument('--ip', type=str, default="127.0.0.1")
    parser.add_argument('--port', type=int, default=6009)
    parser.add_argument('--detect_anomaly', action='store_true', default=False)
    parser.add_argument("--test_iterations", nargs="+", type=int, default=[7_000, 30_000])
    parser.add_argument("--save_iterations", nargs="+", type=int, default=[7_000, 30_000])
    parser.add_argument("--quiet", action="store_true")
    parser.add_argument("--checkpoint_iterations", nargs="+", type=int, default=[])
    parser.add_argument("--start_checkpoint", type=str, default = None)
    # 读取参数
    args = parser.parse_args(sys.argv[1:])
    args.save_iterations.append(args.iterations)
    
    print("Optimizing " + args.model_path)

    # 初始化系统状态
    # 这段代码的目的是为了在执行过程中控制标准输出的行为,添加时间戳并在需要时禁止输出,以便在某些场景下更方便地进行调试和记录。
    safe_state(args.quiet)

    # 然后就是启动GUI以及运行训练的代码
    # 这行代码初始化一个 GUI 服务器,使用 args.ip 和 args.port 作为参数。这可能是一个用于监视和控制训练过程的图形用户界面的一部分。
    network_gui.init(args.ip, args.port)
    # 这行代码设置 PyTorch 是否要检测梯度计算中的异常。
    torch.autograd.set_detect_anomaly(args.detect_anomaly)
    # 输入的参数包括:模型的参数(数据集的位置)、优化器的参数、其他pipeline的参数,测试迭代次数、保存迭代次数 、检查点迭代次数 、开始检查点 、调试起点
    training(lp.extract(args), op.extract(args), pp.extract(args), args.test_iterations, args.save_iterations, args.checkpoint_iterations, args.start_checkpoint)

    # All done
    print("\nTraining complete.")

对于整个执行部分,前半段全部都是对参数的提取。在2DGS的代码里,主要有三类参数。第一类参数就是模型的参数,具体看arguments/init.py中的ModelParams:

class ModelParams(ParamGroup): 
    def __init__(self, parser, sentinel=False):
        self.sh_degree = 3
        self._source_path = ""
        self._model_path = ""
        self._images = "images"
        self._resolution = -1
        self._white_background = False
        self.data_device = "cuda"
        self.eval = False
        self.render_items = ['RGB', 'Alpha', 'Normal', 'Depth', 'Edge', 'Curvature']
        super().__init__(parser, "Loading Parameters", sentinel)

    def extract(self, args):
        g = super().extract(args)
        g.source_path = os.path.abspath(g.source_path)
        return g

Ok,非常easy哈。基本定义完了。第二类就是PipelineParams,还是在同一个文件里:

class PipelineParams(ParamGroup):
    def __init__(self, parser):
        self.convert_SHs_python = False
        self.compute_cov3D_python = False
        self.depth_ratio = 0.0
        self.debug = False
        super().__init__(parser, "Pipeline Parameters")

第三个就是OptimizationParams,一些调参啥的:

class OptimizationParams(ParamGroup):
    def __init__(self, parser):
        self.iterations = 30_000
        self.position_lr_init = 0.00016
        self.position_lr_final = 0.0000016
        self.position_lr_delay_mult = 0.01
        self.position_lr_max_steps = 30_000
        self.feature_lr = 0.0025
        self.opacity_lr = 0.05
        self.scaling_lr = 0.005
        self.rotation_lr = 0.001
        self.percent_dense = 0.01
        self.lambda_dssim = 0.2
        self.lambda_dist = 0.0
        self.lambda_normal = 0.05
        self.opacity_cull = 0.05

        self.densification_interval = 100
        self.opacity_reset_interval = 3000
        self.densify_from_iter = 500
        self.densify_until_iter = 15_000
        self.densify_grad_threshold = 0.0002
        super().__init__(parser, "Optimization Parameters")

接下来,在获取了参数后,整个执行部分,最重要的就是执行了Training这个函数,Training整个函数呢输入的参数包括:模型的参数(数据集的位置)、优化器的参数、其他pipeline的参数,测试迭代次数、保存迭代次数 、检查点迭代次数 、开始检查点 、调试起点:

def training(dataset, opt, pipe, testing_iterations, saving_iterations, checkpoint_iterations, checkpoint):
    # 初始化迭代次数
    first_iter = 0
    # 设置 TensorBoard 写入器和日志记录器。
    tb_writer = prepare_output_and_logger(dataset)
    # -------------------------------------------------------------------------
    #(重点看,需要转跳)创建一个 GaussianModel 类的实例,输入一系列参数,其参数取自数据集。
    # -------------------------------------------------------------------------
    gaussians = GaussianModel(dataset.sh_degree)
    #(这个类的主要目的是处理场景的初始化、保存和获取相机信息等任务,)
    # 创建一个 Scene 类的实例,使用数据集和之前创建的 GaussianModel 实例作为参数。
    # 这里非常重要,此时已经初始化了整个高斯的点云。
    scene = Scene(dataset, gaussians)
    # 设置 GaussianModel 的训练参数
    gaussians.training_setup(opt)
    # if有预训练模型
    if checkpoint:
        # 通过 torch.load(checkpoint) 加载检查点的模型参数和起始迭代次数
        (model_params, first_iter) = torch.load(checkpoint)
        # 通过 gaussians.restore 恢复模型的状态。
        gaussians.restore(model_params, opt)
    # 设置背景颜色,根据数据集是否有白色背景来选择。
    bg_color = [1, 1, 1] if dataset.white_background else [0, 0, 0]
    # 将背景颜色转化为 PyTorch Tensor,并移到 GPU 上。
    background = torch.tensor(bg_color, dtype=torch.float32, device="cuda")

    # 创建两个 CUDA 事件,用于测量迭代时间。
    iter_start = torch.cuda.Event(enable_timing = True)
    iter_end = torch.cuda.Event(enable_timing = True)


    viewpoint_stack = None
    ema_loss_for_log = 0.0
    ema_dist_for_log = 0.0
    ema_normal_for_log = 0.0

    # 创建一个 tqdm 进度条,用于显示训练进度。
    progress_bar = tqdm(range(first_iter, opt.iterations), desc="Training progress")
    first_iter += 1

    #  接下来开始循环迭代 -------------------------------------------------------------------------------------------------
    for iteration in range(first_iter, opt.iterations + 1):        

        # 用于测量迭代时间。
        iter_start.record()
        # 新学习率
        gaussians.update_learning_rate(iteration)

        # 每 1000 次迭代,增加球谐函数的阶数
        if iteration % 1000 == 0:
            gaussians.oneupSHdegree()

        # 随机选择一个训练相机
        if not viewpoint_stack:
            viewpoint_stack = scene.getTrainCameras().copy()
        viewpoint_cam = viewpoint_stack.pop(randint(0, len(viewpoint_stack)-1))

        render_pkg = render(viewpoint_cam, gaussians, pipe, background)
        image, viewspace_point_tensor, visibility_filter, radii = render_pkg["render"], render_pkg["viewspace_points"], render_pkg["visibility_filter"], render_pkg["radii"]

        # Loss Function 损失函数 写得挺乱 给拆开了
        gt_image = viewpoint_cam.original_image.cuda()
        Ll1 = l1_loss(image, gt_image)
        loss = (1.0 - opt.lambda_dssim) * Ll1 + opt.lambda_dssim * (1.0 - ssim(image, gt_image))
        
        # 正则化
        # 正态分布
        lambda_normal = opt.lambda_normal if iteration > 7000 else 0.0
        lambda_dist = opt.lambda_dist if iteration > 3000 else 0.0
        rend_dist = render_pkg["rend_dist"]
        rend_normal  = render_pkg['rend_normal']
        surf_normal = render_pkg['surf_normal']
        normal_error = (1 - (rend_normal * surf_normal).sum(dim=0))[None]
        normal_loss = lambda_normal * (normal_error).mean()
        dist_loss = lambda_dist * (rend_dist).mean()

        # 总损失
        total_loss = loss + dist_loss + normal_loss
        # 反向传播
        total_loss.backward()
        # 测量总迭代时间
        iter_end.record()
        # 记录损失的指数移动平均值,并定期更新进度条
        with torch.no_grad():
            # Progress bar
            ema_loss_for_log = 0.4 * loss.item() + 0.6 * ema_loss_for_log
            ema_dist_for_log = 0.4 * dist_loss.item() + 0.6 * ema_dist_for_log
            ema_normal_for_log = 0.4 * normal_loss.item() + 0.6 * ema_normal_for_log


            if iteration % 10 == 0:
                loss_dict = {
                    "Loss": f"{ema_loss_for_log:.{5}f}",
                    "distort": f"{ema_dist_for_log:.{5}f}",
                    "normal": f"{ema_normal_for_log:.{5}f}",
                    "Points": f"{len(gaussians.get_xyz)}"
                }

                progress_bar.set_postfix(loss_dict)

                progress_bar.update(10)
            if iteration == opt.iterations:
                progress_bar.close()

            # 将 L1 loss、总体 loss 和迭代时间写入 TensorBoard。
            if tb_writer is not None:
                tb_writer.add_scalar('train_loss_patches/dist_loss', ema_dist_for_log, iteration)
                tb_writer.add_scalar('train_loss_patches/normal_loss', ema_normal_for_log, iteration)

            training_report(tb_writer, iteration, Ll1, loss, l1_loss, iter_start.elapsed_time(iter_end), testing_iterations, scene, render, (pipe, background))
            # 如果达到保存迭代次数,保存场景
            if (iteration in saving_iterations):
                print("\n[ITER {}] Saving Gaussians".format(iteration))
                scene.save(iteration)

            # 在一定的迭代次数内进行密集化处理
            if iteration < opt.densify_until_iter:
                # 将每个像素位置上的最大半径记录在 max_radii2D 中。这是为了密集化时进行修剪(pruning)操作时的参考。
                gaussians.max_radii2D[visibility_filter] = torch.max(gaussians.max_radii2D[visibility_filter], radii[visibility_filter])
                # 将与密集化相关的统计信息添加到 gaussians 模型中,包括视图空间点和可见性过滤器
                gaussians.add_densification_stats(viewspace_point_tensor, visibility_filter)
                # 在指定的迭代次数之后,每隔一定的迭代间隔进行以下密集化操作
                # 大于500次的时候,并且除以100余0
                if iteration > opt.densify_from_iter and iteration % opt.densification_interval == 0:
                    # 据当前迭代次数设置密集化的阈值。如果当前迭代次数大于 opt.opacity_reset_interval,则设置 size_threshold 为 20,否则为 None
                    size_threshold = 20 if iteration > opt.opacity_reset_interval else None
                    # 执行密集化和修剪操作,其中包括梯度阈值、密集化阈值、相机范围和之前计算的 size_threshold。
                    gaussians.densify_and_prune(opt.densify_grad_threshold, opt.opacity_cull, scene.cameras_extent, size_threshold)
                # 在每隔一定迭代次数或在白色背景数据集上的指定迭代次数时,执行以下操作。
                if iteration % opt.opacity_reset_interval == 0 or (dataset.white_background and iteration == opt.densify_from_iter):
                    # 重置模型中的某些参数,涉及到透明度的操作,具体实现可以在 reset_opacity 方法中找到。
                    gaussians.reset_opacity()

            # 执行优化器的步骤,然后清零梯度。
            if iteration < opt.iterations:
                gaussians.optimizer.step()
                gaussians.optimizer.zero_grad(set_to_none = True)

            # 如果达到检查点迭代次数,保存检查点
            if (iteration in checkpoint_iterations):
                print("\n[ITER {}] Saving Checkpoint".format(iteration))
                torch.save((gaussians.capture(), iteration), scene.model_path + "/chkpnt" + str(iteration) + ".pth")



        with torch.no_grad():        
            if network_gui.conn == None:
                network_gui.try_connect(dataset.render_items)
            while network_gui.conn != None:
                try:
                    net_image_bytes = None
                    custom_cam, do_training, keep_alive, scaling_modifer, render_mode = network_gui.receive()
                    if custom_cam != None:
                        render_pkg = render(custom_cam, gaussians, pipe, background, scaling_modifer)   
                        net_image = render_net_image(render_pkg, dataset.render_items, render_mode, custom_cam)
                        net_image_bytes = memoryview((torch.clamp(net_image, min=0, max=1.0) * 255).byte().permute(1, 2, 0).contiguous().cpu().numpy())
                    metrics_dict = {
                        "#": gaussians.get_opacity.shape[0],
                        "loss": ema_loss_for_log
                        # Add more metrics as needed
                    }
                    # Send the data
                    network_gui.send(net_image_bytes, dataset.source_path, metrics_dict)
                    if do_training and ((iteration < int(opt.iterations)) or not keep_alive):
                        break
                except Exception as e:
                    # raise e
                    network_gui.conn = None

对于Training函数,我们就需要非常认真地逐行阅读了。

# 初始化迭代次数
first_iter = 0
# 设置 TensorBoard 写入器和日志记录器。
tb_writer = prepare_output_and_logger(dataset)

首先前两行不用细看。是一些基本参数的设置。然后就是这个函数:

gaussians = GaussianModel(dataset.sh_degree)

这个函数进行了一个Gaussian model的初始化。在scene/gaussian_model.py中定义了这个函数的初始化:

class GaussianModel:


    # 用于设置一些激活函数和变换函数
    def setup_functions(self):
        # 构建协方差矩阵,该函数接受 scaling(尺度)、scaling_modifier(尺度修正因子)、rotation(旋转)作为参数
        # 与原文一致
        """这个地方与3DGS不同"""
        def build_covariance_from_scaling_rotation(center, scaling, scaling_modifier, rotation):

            RS = build_scaling_rotation(torch.cat([scaling * scaling_modifier, torch.ones_like(scaling)], dim=-1), rotation).permute(0,2,1)
            trans = torch.zeros((center.shape[0], 4, 4), dtype=torch.float, device="cuda")
            trans[:,:3,:3] = RS
            trans[:, 3,:3] = center
            trans[:, 3, 3] = 1
            return trans

        # 将尺度激活函数设置为指数函数
        # 原因可能: 缩放必须是正数,而指数函数的返回值一定是正数。
        self.scaling_activation = torch.exp
        # 将尺度逆激活函数设置为对数函数
        self.scaling_inverse_activation = torch.log
        # 将协方差激活函数设置为上述定义的 build_covariance_from_scaling_rotation 函数。
        self.covariance_activation = build_covariance_from_scaling_rotation
        # 将不透明度激活函数设置为 sigmoid 函数,保证(0,1)
        self.opacity_activation = torch.sigmoid
        # 将不透明度逆激活函数设置为一个名为 inverse_sigmoid 的函数
        self.inverse_opacity_activation = inverse_sigmoid
        # 用于归一化旋转矩阵
        self.rotation_activation = torch.nn.functional.normalize


    def __init__(self, sh_degree : int):
        # 球谐阶数
        self.active_sh_degree = 0
        # 最大球谐阶数
        self.max_sh_degree = sh_degree
        # 存储不同信息的张量(tensor)-------------
        # 空间位置
        self._xyz = torch.empty(0)
        self._features_dc = torch.empty(0)
        self._features_rest = torch.empty(0)
        # 椭球形状尺度
        self._scaling = torch.empty(0)
        # 椭球的旋转
        self._rotation = torch.empty(0)
        # 不透明度
        self._opacity = torch.empty(0)
        self.max_radii2D = torch.empty(0)
        self.xyz_gradient_accum = torch.empty(0)
        self.denom = torch.empty(0)
        # 初始化优化器
        self.optimizer = None
        # 初始化百分比密度
        self.percent_dense = 0
        # 初始化空间学习率
        self.spatial_lr_scale = 0
        # 调用setup_functions() 设置各种激活函数和变换函数
        self.setup_functions()

我们这里只看初始化的部分。这里只输入了一个参数,就是球谐函数的阶数,这里设置的是3。在setup_functions这个函数中,设置了协方差矩阵。这里与文中是一样的,求出了RS之类的数据。并且设置了很多激活函数。具体的可以看我的注释。

接下来呢,就进入了本文的重中之重的第一个环节:

scene = Scene(dataset, gaussians)

我们找到scene/init.py这个文件,它定义了整个场景的初始化,也就是初始化了整个2DGS:

class Scene:

    gaussians : GaussianModel

    def __init__(self, args : ModelParams, gaussians : GaussianModel, load_iteration=None, shuffle=True, resolution_scales=[1.0]):
        """b
        :param path: Path to colmap scene main folder.
        """
        self.model_path = args.model_path
        self.loaded_iter = None
        self.gaussians = gaussians

        # 初始化时,未执行
        # 首先,如果load_iteration参数不是None,Scene.__init__会在输出文件夹下的point_cloud/文件夹搜索迭代次数最大的iteration_xxx文件夹
        # (例如有iteration_7000和iteration_30000的文件夹则选取后者),将最大的迭代次数记录到self.loaded_iter

        if load_iteration:
            if load_iteration == -1:
                self.loaded_iter = searchForMaxIteration(os.path.join(self.model_path, "point_cloud"))
            else:
                self.loaded_iter = load_iteration
            print("Loading trained model at iteration {}".format(self.loaded_iter))

        self.train_cameras = {}
        self.test_cameras = {}

        # scene_info的信息有如下:
        # point_cloud=pcd,
        # train_cameras=train_cam_infos,
        # test_cameras=test_cam_infos,
        # nerf_normalization=nerf_normalization,
        # ply_path=ply_path)

        # 这里是判断读取的Colmap还是Blender
        if os.path.exists(os.path.join(args.source_path, "sparse")):
            scene_info = sceneLoadTypeCallbacks["Colmap"](args.source_path, args.images, args.eval)
        elif os.path.exists(os.path.join(args.source_path, "transforms_train.json")):
            print("Found transforms_train.json file, assuming Blender data set!")
            scene_info = sceneLoadTypeCallbacks["Blender"](args.source_path, args.white_background, args.eval)
        else:
            assert False, "Could not recognize scene type!"


        # 这里是None
        # 先进入这个环节,填充相机COLMAP的所有内容
        if not self.loaded_iter:

            with open(scene_info.ply_path, 'rb') as src_file, open(os.path.join(self.model_path, "input.ply") , 'wb') as dest_file:
                dest_file.write(src_file.read())
            json_cams = []
            camlist = []
            if scene_info.test_cameras:
                camlist.extend(scene_info.test_cameras)
            if scene_info.train_cameras:
                camlist.extend(scene_info.train_cameras)
            for id, cam in enumerate(camlist):
                json_cams.append(camera_to_JSON(id, cam))
            with open(os.path.join(self.model_path, "cameras.json"), 'w') as file:
                json.dump(json_cams, file)

        # 执行了随机相片的步骤。在3DGS内需要执行,但是对于4DGS可以不用
        if shuffle:
            random.shuffle(scene_info.train_cameras)  # Multi-res consistent random shuffling
            random.shuffle(scene_info.test_cameras)  # Multi-res consistent random shuffling

        # getNerfppNorm读取所有相机的中心点位置到最远camera的距离 * 1.1
        self.cameras_extent = scene_info.nerf_normalization["radius"]


        # 用【】填充一个camera_list
        for resolution_scale in resolution_scales:
            print("Loading Training Cameras")
            self.train_cameras[resolution_scale] = cameraList_from_camInfos(scene_info.train_cameras, resolution_scale, args)
            print("Loading Test Cameras")
            self.test_cameras[resolution_scale] = cameraList_from_camInfos(scene_info.test_cameras, resolution_scale, args)


        # 没有执行
        # 如果self.loaded_iter有值,则直接读取对应的(已经迭代出来的)场景
        if self.loaded_iter:
            self.gaussians.load_ply(os.path.join(self.model_path,
                                                           "point_cloud",
                                                           "iteration_" + str(self.loaded_iter),
                                                           "point_cloud.ply"))
        else:
        #执行的这里,点云高斯化
        #给所有目前采集到的点云做初始值,也就是COLMAP的值直接就进来
        #俩参数,一个是点云,一个是所有相机的中心点位置到最远camera的距离 * 1.1
            self.gaussians.create_from_pcd(scene_info.point_cloud, self.cameras_extent)

    def save(self, iteration):
        point_cloud_path = os.path.join(self.model_path, "point_cloud/iteration_{}".format(iteration))
        self.gaussians.save_ply(os.path.join(point_cloud_path, "point_cloud.ply"))

    def getTrainCameras(self, scale=1.0):
        return self.train_cameras[scale]

    def getTestCameras(self, scale=1.0):
        return self.test_cameras[scale]

这里通过读取了Colmap的点云、相机等等信息,初始化了点云。初始化主要用到的函数,是gaussian_model中的create_from_pcd函数:

def create_from_pcd(self, pcd : BasicPointCloud, spatial_lr_scale : float):

        # 所有相机的中心点位置到最远camera的距离 * 1.1
        # 根据大量的3DGS解读,应该与学习率有关
        # 防止固定的学习率适配不同尺度的场景时出现问题。
        self.spatial_lr_scale = spatial_lr_scale
        # 点云转tensor送入GPU,实际上就是稀疏点云的3D坐标
        # (N, 3) 这里输出的是所有点云
        fused_point_cloud = torch.tensor(np.asarray(pcd.points)).float().cuda()
        # RGB转球谐函数送入GPU
        # 也是(N,3),应为球谐的直流分量
        # RGB2SH(x) = (x - 0.5) / 0.28209479177387814
        # 0.28209479177387814是1 / (2*sqrt(pi)),是直流分量Y(l=0,m=0)的值
        fused_color = RGB2SH(torch.tensor(np.asarray(pcd.colors)).float().cuda())



        #  RGB三通道球谐的所有系数,大小为(N, 3, (最大球谐阶数 + 1)²), 这部分 3DGS与 2DGS毫无区别
        features = torch.zeros((fused_color.shape[0], 3, (self.max_sh_degree + 1) ** 2)).float().cuda()
        features[:, :3, 0 ] = fused_color
        features[:, 3:, 1:] = 0.0
        print("Number of points at initialisation : ", fused_point_cloud.shape[0])







        # 调用simple_knn的distCUDA2函数,计算点云中的每个点到与其最近的K个点的平均距离的平方
        # dist2的大小应该是(N,)。
        # 首先可以明确的是这句话用来初始化scale,且scale(的平方)不能低于1e-7。
        # 我阅读了一下submodules/simple-knn/simple_knn.cu,大致猜出来了这个是什么意思。
        # distCUDA2函数由simple_knn.cu的SimpleKNN::knn函数实现。
        # KNN意思是K-Nearest Neighbor,即求每一点最近的K个点。
        # simple_knn.cu中令k=3,求得每一点最近的三个点距该点的平均距离。
        # 原理是把3D空间中的每个点用莫顿编码(Morton Encoding)转化为一个1D坐标
        # 用到了能够填满空间的Z曲线
        # 然后对1D坐标进行排序,从而确定离每个点最近的三个点。
        # simple_knn.cu实际上还用了一种加速策略,是将点集分为多个大小为1024的块(box),
        # 在每个块内确定3个最近邻居和它们的平均距离。用平均距离作为Gaussian的scale。
        dist2 = torch.clamp_min(distCUDA2(torch.from_numpy(np.asarray(pcd.points)).float().cuda()), 0.0000001)
        #---------------------------------------------------------------------------------------------------------------
        # 因为2DGS中只有2个缩放值
        # 因为scale的激活函数是exp,所以这里存的也不是真的scale,而是ln(scale)。
  # 注意dist2其实是距离的平方,所以这里要开根号。
  # repeat(1, 2)标明两个方向上scale的初始值是相等的。
  # scales的大小:(N, 2) 这是与3DGS完全不同的
        scales = torch.log(torch.sqrt(dist2))[...,None].repeat(1, 2)
        #---------------------------------------------------------------------------------------------------------------

        # 2DGS不是,2DGS使用[0,1]的均匀分布进行初始化
        # 这里与3DGS有明显区别
        rots = torch.rand((fused_point_cloud.shape[0], 4), device="cuda")

        # 完全不同,这里使用:torch.log(x/(1-x)),而不是sigmoid。
        # 因为输入时,透明度是(N, 1),这里统一后的初始值为-2.1972
        # 原因不明,但这里最终的值,与3DGS保持一致(-2.197)
        opacities = self.inverse_opacity_activation(0.1 * torch.ones((fused_point_cloud.shape[0], 1), dtype=torch.float, device="cuda"))

        # 初始化位置,sh系数(直接+剩余),缩放(3个轴),旋转(四元数),不透明度(逆sigmoid的值)
        # ---------------------------------------------------------------------------------------------------------------
        # 一些函数的解释:requires_grad=True 的作用是让 backward 可以追踪这个参数并且计算它的梯度。
        # 最开始定义你的输入是 requires_grad=True ,那么后续对应的输出也自动具有 requires_grad=True ,如代码中无关联的数值 x ,其 requires_grad 仍等于 False。
        # ---------------------------------------------------------------------------------------------------------------
        # 这里就是直接初始化每个单独的点云。
        # ---------------------------------------------------------------------------------------------------------------
        # 一些函数的解释:nn.Parameter():
        # 将一个不可训练的tensor转换成可以训练的类型parameter,并将这个parameter绑定到这个module里面。
        # 即在定义网络时这个tensor就是一个可以训练的参数了。使用这个函数的目的也是想让某些变量在学习的过程中不断的修改其值以达到最优化。
        # 输出点云坐标(N,3)
        self._xyz = nn.Parameter(fused_point_cloud.requires_grad_(True))
        # RGB三个通道的直流分量,(N, 1, 3), 3DGS是(N, 3, 1)
        self._features_dc = nn.Parameter(features[:,:,0:1].transpose(1, 2).contiguous().requires_grad_(True))
        #  RGB三个通道的高阶分量,(N, (最大球谐阶数 + 1)² - 1, 3), 这里不太一样,与上面相同后两位换了位置
        self._features_rest = nn.Parameter(features[:,:,1:].transpose(1, 2).contiguous().requires_grad_(True))
        # 这里的缩放尺度,为(N,2)
        self._scaling = nn.Parameter(scales.requires_grad_(True))
        # 旋转4原数 (N,4)
        self._rotation = nn.Parameter(rots.requires_grad_(True))
        # (N.1)透明度
        self._opacity = nn.Parameter(opacities.requires_grad_(True))
        # 投影到2D时, 每个2D gaussian最大的半径,这里初始为(N,)
        self.max_radii2D = torch.zeros((self.get_xyz.shape[0]), device="cuda")

我这里解释得非常详细哈。大家可以仔细看看。除此之外,最重要的,就是train环节中得填充致密化环节。

# 在一定的迭代次数内进行密集化处理
            if iteration < opt.densify_until_iter:
                # 将每个像素位置上的最大半径记录在 max_radii2D 中。这是为了密集化时进行修剪(pruning)操作时的参考。
                gaussians.max_radii2D[visibility_filter] = torch.max(gaussians.max_radii2D[visibility_filter], radii[visibility_filter])
                # 将与密集化相关的统计信息添加到 gaussians 模型中,包括视图空间点和可见性过滤器
                gaussians.add_densification_stats(viewspace_point_tensor, visibility_filter)
                # 在指定的迭代次数之后,每隔一定的迭代间隔进行以下密集化操作
                # 大于500次的时候,并且除以100余0
                if iteration > opt.densify_from_iter and iteration % opt.densification_interval == 0:
                    # 据当前迭代次数设置密集化的阈值。如果当前迭代次数大于 opt.opacity_reset_interval,则设置 size_threshold 为 20,否则为 None
                    size_threshold = 20 if iteration > opt.opacity_reset_interval else None
                    # 执行密集化和修剪操作,其中包括梯度阈值、密集化阈值、相机范围和之前计算的 size_threshold。
                    gaussians.densify_and_prune(opt.densify_grad_threshold, opt.opacity_cull, scene.cameras_extent, size_threshold)
                # 在每隔一定迭代次数或在白色背景数据集上的指定迭代次数时,执行以下操作。
                if iteration % opt.opacity_reset_interval == 0 or (dataset.white_background and iteration == opt.densify_from_iter):
                    # 重置模型中的某些参数,涉及到透明度的操作,具体实现可以在 reset_opacity 方法中找到。
                    gaussians.reset_opacity()

            # 执行优化器的步骤,然后清零梯度。
            if iteration < opt.iterations:
                gaussians.optimizer.step()
                gaussians.optimizer.zero_grad(set_to_none = True)

            # 如果达到检查点迭代次数,保存检查点
            if (iteration in checkpoint_iterations):
                print("\n[ITER {}] Saving Checkpoint".format(iteration))
                torch.save((gaussians.capture(), iteration), scene.model_path + "/chkpnt" + str(iteration) + ".pth")

这部分,主要调用的是gaussian_model中的densify_and_split、densify_and_prune、densify_and_clone函数:

def densify_and_split(self, grads, grad_threshold, scene_extent, N=2):
        # 获取初始点的数量。
        n_init_points = self.get_xyz.shape[0]
        # 创建一个长度为初始点数量的梯度张量,并将计算得到的梯度填充到其中。
        padded_grad = torch.zeros((n_init_points), device="cuda")
        padded_grad[:grads.shape[0]] = grads.squeeze()
        # 创建一个掩码,标记那些梯度大于等于指定阈值的点。
        selected_pts_mask = torch.where(padded_grad >= grad_threshold, True, False)
        # 一步过滤掉那些缩放(scaling)大于一定百分比的场景范围的点
        # 这里是一个高斯分裂的过程:被分裂的Gaussians满足两个条件:
        #   1. (平均)梯度过大;
        #   2. 在某个方向的最大缩放大于一个阈值。
        #   参照论文5.2节“On the other hand...”一段,大Gaussian被分裂成两个小Gaussians,
        #   其放缩被除以φ=1.6,且位置是以原先的大Gaussian作为概率密度函数进行采样的。
        selected_pts_mask = torch.logical_and(selected_pts_mask,
                                              torch.max(self.get_scaling, dim=1).values > self.percent_dense*scene_extent)


        # 为每个点生成新的样本,其中 stds 是点的缩放,means 是均值, 第一步是一样的
        # 这里从新的点云中更新缩放因子,并且进行同样的复制一份
        stds = self.get_scaling[selected_pts_mask].repeat(N,1)
        # 这里我大致明白,本身获取的是(su, sv),为了与旋转矩阵相对应,构建(su,sv,0)
        stds = torch.cat([stds, 0 * torch.ones_like(stds[:,:1])], dim=-1)
        # 这里就是一个同样大小的矩阵
        means = torch.zeros_like(stds)
        # 使用均值和标准差生成样本
        samples = torch.normal(mean=means, std=stds)
        # 为每个点构建旋转矩阵,并将其重复 N 次。
        rots = build_rotation(self._rotation[selected_pts_mask]).repeat(N,1,1)
        # 将旋转后的样本点添加到原始点的位置。
        new_xyz = torch.bmm(rots, samples.unsqueeze(-1)).squeeze(-1) + self.get_xyz[selected_pts_mask].repeat(N, 1)
        # 生成新的缩放参数。
        new_scaling = self.scaling_inverse_activation(self.get_scaling[selected_pts_mask].repeat(N,1) / (0.8*N))
        # 将旋转、原始点特征、等等重复N次
        new_rotation = self._rotation[selected_pts_mask].repeat(N,1)
        new_features_dc = self._features_dc[selected_pts_mask].repeat(N,1,1)
        new_features_rest = self._features_rest[selected_pts_mask].repeat(N,1,1)
        new_opacity = self._opacity[selected_pts_mask].repeat(N,1)

        # 调用另一个方法 densification_postfix,该方法对新生成的点执行后处理操作(此处跟densify_and_clone一样)。
        self.densification_postfix(new_xyz, new_features_dc, new_features_rest, new_opacity, new_scaling, new_rotation)
        # 创建一个修剪(pruning)的过滤器,将新生成的点添加到原始点的掩码之后。
        prune_filter = torch.cat((selected_pts_mask, torch.zeros(N * selected_pts_mask.sum(), device="cuda", dtype=bool)))
        # 根据修剪过滤器,修剪模型中的一些参数。
        self.prune_points(prune_filter)

    def densify_and_clone(self, grads, grad_threshold, scene_extent):
        # 建一个掩码,标记满足梯度条件的点。具体来说,对于每个点,计算其梯度的L2范数,如果大于等于指定的梯度阈值,则标记为True,否则标记为False。
        # 提取出大于阈值`grad_threshold`且缩放参数较小(小于self.percent_dense * scene_extent)的Gaussians,在下面进行克隆

        selected_pts_mask = torch.where(torch.norm(grads, dim=-1) >= grad_threshold, True, False)
        selected_pts_mask = torch.logical_and(selected_pts_mask,
                                              torch.max(self.get_scaling, dim=1).values <= self.percent_dense*scene_extent)
        # 在上述掩码的基础上,进一步过滤掉那些缩放(scaling)大于一定百分比(self.percent_dense)的场景范围(scene_extent)的点。
        # 这样可以确保新添加的点不会太远离原始数据。
        # 根据掩码选取符合条件的点的其他特征,如颜色、透明度、缩放和旋转等。

        new_xyz = self._xyz[selected_pts_mask]
        new_features_dc = self._features_dc[selected_pts_mask]
        new_features_rest = self._features_rest[selected_pts_mask]
        new_opacities = self._opacity[selected_pts_mask]
        new_scaling = self._scaling[selected_pts_mask]
        new_rotation = self._rotation[selected_pts_mask]

        self.densification_postfix(new_xyz, new_features_dc, new_features_rest, new_opacities, new_scaling, new_rotation)


    # 执行密集化和修剪操作
    def densify_and_prune(self, max_grad, min_opacity, extent, max_screen_size):

        # 计算密度估计的梯度
        grads = self.xyz_gradient_accum / self.denom
        # 将梯度中的 NaN(非数值)值设置为零,以处理可能的数值不稳定性
        grads[grads.isnan()] = 0.0



        # 对under reconstruction的区域进行复制操作,为了稠密化
        self.densify_and_clone(grads, max_grad, extent)
        # 对over reconstruction的区域进行分裂操作,为了稠密化
        self.densify_and_split(grads, max_grad, extent)


        # 接下来移除一些Gaussians,它们满足下列要求中的一个:
        # 1. 接近透明(不透明度小于min_opacity)
        # 2. 在某个相机视野里出现过的最大2D半径大于屏幕(像平面)大小
        # 3. 在某个方向的最大缩放大于0.1 * extent(也就是说很长的长条形也是会被移除的)
        # 创建一个掩码,标记那些透明度小于指定阈值的点。.squeeze() 用于去除掩码中的单维度。
        prune_mask = (self.get_opacity < min_opacity).squeeze()
        # 设置相机的范围
        if max_screen_size:
            # 创建一个掩码,标记在图像空间中半径大于指定阈值的点。
            big_points_vs = self.max_radii2D > max_screen_size
            # 创建一个掩码,标记在世界空间中尺寸大于指定阈值的点。
            big_points_ws = self.get_scaling.max(dim=1).values > 0.1 * extent
            # 将这两个掩码与先前的透明度掩码进行逻辑或操作,得到最终的修剪掩码。
            prune_mask = torch.logical_or(torch.logical_or(prune_mask, big_points_vs), big_points_ws)
        # 根据修剪掩码,修剪模型中的一些参数
        self.prune_points(prune_mask)
        # 清理 GPU 缓存,释放一些内存
        torch.cuda.empty_cache()


    # 统计坐标的累积梯度和均值的分母(即迭代步数)
    def add_densification_stats(self, viewspace_point_tensor, update_filter):
        self.xyz_gradient_accum[update_filter] += torch.norm(viewspace_point_tensor.grad[update_filter], dim=-1, keepdim=True)
        self.denom[update_filter] += 1

上述代码包含了密集化、修建、分裂等步骤。除此之外,我认为最重要的,就是Splatting的渲染过程。这部分在submodules/diff-surfel-rasterization/cuda_rasterizer/forward.cu中。2DGS在这部分,与3DGS有着明显的不同,因此也可以细细品味这部分:

#include "forward.h"
#include "auxiliary.h"
#include <cooperative_groups.h>
#include <cooperative_groups/reduce.h>
namespace cg = cooperative_groups;

// 该代码实现了一个名为 `computeColorFromSH` 的函数,它用于计算基于 SH (Surface Hemi-Sphere) 光线着色模型的颜色
__device__ glm::vec3 computeColorFromSH(int idx, int deg, int max_coeffs, const glm::vec3* means, glm::vec3 campos, const float* shs, bool* clamped)
{

/*
* `idx`:索引,表示在数组中的索引。
* `deg`:光线着色模型的阶数。
* `max_coeffs`:SH 函数的系数数量。
* `means`:包含模型中每个点的平均颜色。
* `campos`:观察者的位置。
* `shs`:包含 SH 函数系数的指针。
* `clamped`:一个 bool 数组,用于记录是否颜色被限制
*/


/*
1. **计算点位置和方向向量:**计算点与观察者的距离和方向向量,并标准化方向向量。
2. **获取 SH 函数系数:**根据索引从 `shs` 中获取相应的 SH 函数系数。
3. **计算颜色:**根据光线着色模型的阶数,逐步计算颜色。
4. **限制颜色:**如果颜色值小于 0,则将其限制为 0,并记录是否颜色被限制。
5. **返回颜色:**返回计算出的颜色,以及是否颜色被限制。
*/
 glm::vec3 pos = means[idx];
 glm::vec3 dir = pos - campos;
 dir = dir / glm::length(dir);

 glm::vec3* sh = ((glm::vec3*)shs) + idx * max_coeffs;
 glm::vec3 result = SH_C0 * sh[0];

 if (deg > 0)
 {
  float x = dir.x;
  float y = dir.y;
  float z = dir.z;
  result = result - SH_C1 * y * sh[1] + SH_C1 * z * sh[2] - SH_C1 * x * sh[3];

  if (deg > 1)
  {
   float xx = x * x, yy = y * y, zz = z * z;
   float xy = x * y, yz = y * z, xz = x * z;
   result = result +
    SH_C2[0] * xy * sh[4] +
    SH_C2[1] * yz * sh[5] +
    SH_C2[2] * (2.0f * zz - xx - yy) * sh[6] +
    SH_C2[3] * xz * sh[7] +
    SH_C2[4] * (xx - yy) * sh[8];

   if (deg > 2)
   {
    result = result +
     SH_C3[0] * y * (3.0f * xx - yy) * sh[9] +
     SH_C3[1] * xy * z * sh[10] +
     SH_C3[2] * y * (4.0f * zz - xx - yy) * sh[11] +
     SH_C3[3] * z * (2.0f * zz - 3.0f * xx - 3.0f * yy) * sh[12] +
     SH_C3[4] * x * (4.0f * zz - xx - yy) * sh[13] +
     SH_C3[5] * z * (xx - yy) * sh[14] +
     SH_C3[6] * x * (xx - 3.0f * yy) * sh[15];
   }
  }
 }
 result += 0.5f;

 // RGB colors are clamped to positive values. If values are
 // clamped, we need to keep track of this for the backward pass.
 clamped[3 * idx + 0] = (result.x < 0);
 clamped[3 * idx + 1] = (result.y < 0);
 clamped[3 * idx + 2] = (result.z < 0);
 return glm::max(result, 0.0f);
}








// 计算仿射变换齐次矩阵------------------------------------------------------------------------------------------------------------
// Compute a 2D-to-2D mapping matrix from a tangent plane into a image plane
// given a 2D gaussian parameters.
// glm::mat 生成矩阵 后面+数字表示维度
// glm::vec 生成向量 后面+数字表示维度
__device__ void compute_transmat(


 const float3& p_orig,
 const glm::vec2 scale,
 float mod,
 const glm::vec4 rot,
 const float* projmatrix,
 const float* viewmatrix,
 const int W,
 const int H, 
 glm::mat3 &T,
 float3 &normal
) {
    // rot转换为旋转矩阵R
 glm::mat3 R = quat_to_rotmat(rot);
 // 函数将缩放向量 `scale` 和缩放因子 `mod` 转换为缩放矩阵 `S`
 glm::mat3 S = scale_to_mat(scale, mod);
 // 计算变换矩阵 `L`
 glm::mat3 L = R * S;

 // 计算世界坐标到 NDC 的矩阵
 // splat2world矩阵 将 Gaussians 的中心从局部坐标转换为相机坐标。
 glm::mat3x4 splat2world = glm::mat3x4(
  glm::vec4(L[0], 0.0),
  glm::vec4(L[1], 0.0),
  glm::vec4(p_orig.x, p_orig.y, p_orig.z, 1)
 );
    // world2ndc矩阵 将世界坐标转换为 NDC坐标
 glm::mat4 world2ndc = glm::mat4(
  projmatrix[0], projmatrix[4], projmatrix[8], projmatrix[12],
  projmatrix[1], projmatrix[5], projmatrix[9], projmatrix[13],
  projmatrix[2], projmatrix[6], projmatrix[10], projmatrix[14],
  projmatrix[3], projmatrix[7], projmatrix[11], projmatrix[15]
 );
    // 矩阵将 NDC坐标转换为像素坐标
 glm::mat3x4 ndc2pix = glm::mat3x4(
  glm::vec4(float(W) / 2.0, 0.0, 0.0, float(W-1) / 2.0),
  glm::vec4(0.0, float(H) / 2.0, 0.0, float(H-1) / 2.0),
  glm::vec4(0.0, 0.0, 0.0, 1.0)
 );
    // 计算传输矩阵 T
 T = glm::transpose(splat2world) * world2ndc * ndc2pix;
 // 计算法线 normal
 normal = transformVec4x3({L[2].x, L[2].y, L[2].z}, viewmatrix);

}


// --------------------------------------------------------------------------------------------------------------------
// 该代码计算 2D 高斯分布的边界框和中心,并将中心用于创建低通滤波器
// --------------------------------------------------------------------------------------------------------------------
/*
`T`:3x3 矩阵,用于转换图像坐标到特征空间。上一个函数已经计算了
* `cutoff`: 高斯分布的截止频率。
* `point_image`: 指标图像上的点坐标。
* `extent`:bounding box 的扩展。
*/
__device__ bool compute_aabb(
 glm::mat3 T, 
 float cutoff, 
 float2& point_image,
 float2 & extent
) {
    // 计算 T 矩阵的逆向,并将 T0、T1 和 T3 值计算
    // T0、T1 和 T3 值是 T 矩阵的第 0、第 1 和第 2 行,分别对应特征空间坐标的三个维度
 float3 T0 = {T[0][0], T[0][1], T[0][2]};
 float3 T1 = {T[1][0], T[1][1], T[1][2]};
 float3 T3 = {T[2][0], T[2][1], T[2][2]};

 // 计算 AABB
 // 计算 temp_point 值
 // temp_point 值为 (cutoff * cutoff, cutoff * cutoff, -1.0f),其中cutoff 是高斯分布的截止频率
 float3 temp_point = {cutoff * cutoff, cutoff * cutoff, -1.0f};
 // 计算 distance 和 f 值
 // distance 是从 T3 到 temp_point 的距离。
 // f 值是 distance 的倒数,乘以 temp_point。
 float distance = sumf3(T3 * T3 * temp_point);
 float3 f = (1 / distance) * temp_point;

 if (distance == 0.0) return false;

    // `sumf3` 和 `maxf2` 等函数用于计算向量和矩阵的和、最大值等操作。
 point_image = {
  sumf3(f * T0 * T3),
  sumf3(f * T1 * T3)
 };  
 
 float2 temp = {
  sumf3(f * T0 * T0),
  sumf3(f * T1 * T1)
 };
 float2 half_extend = point_image * point_image - temp;
 extent = sqrtf2(maxf2(1e-4, half_extend));
 return true;
}

// 名为 `preprocessCUDA`,它负责每个 Gaussian 前处理,并将数据转换为 raster 化
template<int C>
__global__ void preprocessCUDA(int P, int D, int M,
 const float* orig_points,
 const glm::vec2* scales,
 const float scale_modifier,
 const glm::vec4* rotations,
 const float* opacities,
 const float* shs,
 bool* clamped,
 const float* transMat_precomp,
 const float* colors_precomp,
 const float* viewmatrix,
 const float* projmatrix,
 const glm::vec3* cam_pos,
 const int W, int H,
 const float tan_fovx, const float tan_fovy,
 const float focal_x, const float focal_y,
 int* radii,
 float2* points_xy_image,
 float* depths,
 float* transMats,
 float* rgb,
 float4* normal_opacity,
 const dim3 grid,
 uint32_t* tiles_touched,
 bool prefiltered)
{
 auto idx = cg::this_grid().thread_rank();
 if (idx >= P)
  return;

 // Initialize radius and touched tiles to 0. If this isn't changed,
 // this Gaussian will not be processed further.
 radii[idx] = 0;
 tiles_touched[idx] = 0;

 // Perform near culling, quit if outside.
 float3 p_view;
 if (!in_frustum(idx, orig_points, viewmatrix, projmatrix, prefiltered, p_view))
  return;
 
 // Compute transformation matrix
 glm::mat3 T;
 float3 normal;
 if (transMat_precomp == nullptr)
 {
  compute_transmat(((float3*)orig_points)[idx], scales[idx], scale_modifier, rotations[idx], projmatrix, viewmatrix, W, H, T, normal);
  float3 *T_ptr = (float3*)transMats;
  T_ptr[idx * 3 + 0] = {T[0][0], T[0][1], T[0][2]};
  T_ptr[idx * 3 + 1] = {T[1][0], T[1][1], T[1][2]};
  T_ptr[idx * 3 + 2] = {T[2][0], T[2][1], T[2][2]};
 } else {
  glm::vec3 *T_ptr = (glm::vec3*)transMat_precomp;
  T = glm::mat3(
   T_ptr[idx * 3 + 0], 
   T_ptr[idx * 3 + 1],
   T_ptr[idx * 3 + 2]
  );
  normal = make_float3(0.0, 0.0, 1.0);
 }

#if DUAL_VISIABLE
 float cos = -sumf3(p_view * normal);
 if (cos == 0) return;
 float multiplier = cos > 0 ? 1: -1;
 normal = multiplier * normal;
#endif

#if TIGHTBBOX // no use in the paper, but it indeed help speeds.
 // the effective extent is now depended on the opacity of gaussian.
 float cutoff = sqrtf(max(9.f + 2.f * logf(opacities[idx]), 0.000001));
#else
 float cutoff = 3.0f;
#endif

 // Compute center and radius
 float2 point_image;
 float radius;
 {
  float2 extent;
  bool ok = compute_aabb(T, cutoff, point_image, extent);
  if (!ok) return;
  radius = ceil(max(extent.x, extent.y));
 }

 uint2 rect_min, rect_max;
 getRect(point_image, radius, rect_min, rect_max, grid);
 if ((rect_max.x - rect_min.x) * (rect_max.y - rect_min.y) == 0)
  return;

 // Compute colors 
 if (colors_precomp == nullptr) {
  glm::vec3 result = computeColorFromSH(idx, D, M, (glm::vec3*)orig_points, *cam_pos, shs, clamped);
  rgb[idx * C + 0] = result.x;
  rgb[idx * C + 1] = result.y;
  rgb[idx * C + 2] = result.z;
 }

 depths[idx] = p_view.z;
 radii[idx] = (int)radius;
 points_xy_image[idx] = point_image;
 normal_opacity[idx] = {normal.x, normal.y, normal.z, opacities[idx]};
 tiles_touched[idx] = (rect_max.y - rect_min.y) * (rect_max.x - rect_min.x);
}

// Main rasterization method. Collaboratively works on one tile per
// block, each thread treats one pixel. Alternates between fetching 
// and rasterizing data.
template <uint32_t CHANNELS>
__global__ void __launch_bounds__(BLOCK_X * BLOCK_Y)
renderCUDA(
 const uint2* __restrict__ ranges,
 const uint32_t* __restrict__ point_list,
 int W, int H,
 float focal_x, float focal_y,
 const float2* __restrict__ points_xy_image,
 const float* __restrict__ features,
 const float* __restrict__ transMats,
 const float* __restrict__ depths,
 const float4* __restrict__ normal_opacity,
 float* __restrict__ final_T,
 uint32_t* __restrict__ n_contrib,
 const float* __restrict__ bg_color,
 float* __restrict__ out_color,
 float* __restrict__ out_others)
{
 // Identify current tile and associated min/max pixel range.
 auto block = cg::this_thread_block();
 uint32_t horizontal_blocks = (W + BLOCK_X - 1) / BLOCK_X;
 uint2 pix_min = { block.group_index().x * BLOCK_X, block.group_index().y * BLOCK_Y };
 uint2 pix_max = { min(pix_min.x + BLOCK_X, W), min(pix_min.y + BLOCK_Y , H) };
 uint2 pix = { pix_min.x + block.thread_index().x, pix_min.y + block.thread_index().y };
 uint32_t pix_id = W * pix.y + pix.x;
 float2 pixf = { (float)pix.x, (float)pix.y};

 // Check if this thread is associated with a valid pixel or outside.
 bool inside = pix.x < W&& pix.y < H;
 // Done threads can help with fetching, but don't rasterize
 bool done = !inside;

 // Load start/end range of IDs to process in bit sorted list.
 uint2 range = ranges[block.group_index().y * horizontal_blocks + block.group_index().x];
 const int rounds = ((range.y - range.x + BLOCK_SIZE - 1) / BLOCK_SIZE);
 int toDo = range.y - range.x;

 // Allocate storage for batches of collectively fetched data.
 __shared__ int collected_id[BLOCK_SIZE];
 __shared__ float2 collected_xy[BLOCK_SIZE];
 __shared__ float4 collected_normal_opacity[BLOCK_SIZE];
 __shared__ float3 collected_Tu[BLOCK_SIZE];
 __shared__ float3 collected_Tv[BLOCK_SIZE];
 __shared__ float3 collected_Tw[BLOCK_SIZE];

 // Initialize helper variables
 float T = 1.0f;
 uint32_t contributor = 0;
 uint32_t last_contributor = 0;
 float C[CHANNELS] = { 0 };


#if RENDER_AXUTILITY
 // render axutility ouput
 float N[3] = {0};
 float D = { 0 };
 float M1 = {0};
 float M2 = {0};
 float distortion = {0};
 float median_depth = {0};
 // float median_weight = {0};
 float median_contributor = {-1};

#endif

 // Iterate over batches until all done or range is complete
 for (int i = 0; i < rounds; i++, toDo -= BLOCK_SIZE)
 {
  // End if entire block votes that it is done rasterizing
  int num_done = __syncthreads_count(done);
  if (num_done == BLOCK_SIZE)
   break;

  // Collectively fetch per-Gaussian data from global to shared
  int progress = i * BLOCK_SIZE + block.thread_rank();
  if (range.x + progress < range.y)
  {
   int coll_id = point_list[range.x + progress];
   collected_id[block.thread_rank()] = coll_id;
   collected_xy[block.thread_rank()] = points_xy_image[coll_id];
   collected_normal_opacity[block.thread_rank()] = normal_opacity[coll_id];
   collected_Tu[block.thread_rank()] = {transMats[9 * coll_id+0], transMats[9 * coll_id+1], transMats[9 * coll_id+2]};
   collected_Tv[block.thread_rank()] = {transMats[9 * coll_id+3], transMats[9 * coll_id+4], transMats[9 * coll_id+5]};
   collected_Tw[block.thread_rank()] = {transMats[9 * coll_id+6], transMats[9 * coll_id+7], transMats[9 * coll_id+8]};
  }
  block.sync();

  // Iterate over current batch
  for (int j = 0; !done && j < min(BLOCK_SIZE, toDo); j++)
  {
   // Keep track of current position in range
   contributor++;

   // Fisrt compute two homogeneous planes, See Eq. (8)
   const float2 xy = collected_xy[j];
   const float3 Tu = collected_Tu[j];
   const float3 Tv = collected_Tv[j];
   const float3 Tw = collected_Tw[j];
   float3 k = pix.x * Tw - Tu;
   float3 l = pix.y * Tw - Tv;
   float3 p = cross(k, l);
   if (p.z == 0.0) continue;
   float2 s = {p.x / p.z, p.y / p.z};
   float rho3d = (s.x * s.x + s.y * s.y); 
   float2 d = {xy.x - pixf.x, xy.y - pixf.y};
   float rho2d = FilterInvSquare * (d.x * d.x + d.y * d.y); 

   // compute intersection and depth
   float rho = min(rho3d, rho2d);
   float depth = (rho3d <= rho2d) ? (s.x * Tw.x + s.y * Tw.y) + Tw.z : Tw.z; 
   if (depth < near_n) continue;
   float4 nor_o = collected_normal_opacity[j];
   float normal[3] = {nor_o.x, nor_o.y, nor_o.z};
   float opa = nor_o.w;

   float power = -0.5f * rho;
   if (power > 0.0f)
    continue;

   // Eq. (2) from 3D Gaussian splatting paper.
   // Obtain alpha by multiplying with Gaussian opacity
   // and its exponential falloff from mean.
   // Avoid numerical instabilities (see paper appendix). 
   float alpha = min(0.99f, opa * exp(power));
   if (alpha < 1.0f / 255.0f)
    continue;
   float test_T = T * (1 - alpha);
   if (test_T < 0.0001f)
   {
    done = true;
    continue;
   }

   float w = alpha * T;
#if RENDER_AXUTILITY
   // Render depth distortion map
   // Efficient implementation of distortion loss, see 2DGS' paper appendix.
   float A = 1-T;
   float m = far_n / (far_n - near_n) * (1 - near_n / depth);
   distortion += (m * m * A + M2 - 2 * m * M1) * w;
   D  += depth * w;
   M1 += m * w;
   M2 += m * m * w;

   if (T > 0.5) {
    median_depth = depth;
    // median_weight = w;
    median_contributor = contributor;
   }
   // Render normal map
   for (int ch=0; ch<3; ch++) N[ch] += normal[ch] * w;
#endif

   // Eq. (3) from 3D Gaussian splatting paper.
   for (int ch = 0; ch < CHANNELS; ch++)
    C[ch] += features[collected_id[j] * CHANNELS + ch] * w;
   T = test_T;

   // Keep track of last range entry to update this
   // pixel.
   last_contributor = contributor;
  }
 }

 // All threads that treat valid pixel write out their final
 // rendering data to the frame and auxiliary buffers.
 if (inside)
 {
  final_T[pix_id] = T;
  n_contrib[pix_id] = last_contributor;
  for (int ch = 0; ch < CHANNELS; ch++)
   out_color[ch * H * W + pix_id] = C[ch] + T * bg_color[ch];

#if RENDER_AXUTILITY
  n_contrib[pix_id + H * W] = median_contributor;
  final_T[pix_id + H * W] = M1;
  final_T[pix_id + 2 * H * W] = M2;
  out_others[pix_id + DEPTH_OFFSET * H * W] = D;
  out_others[pix_id + ALPHA_OFFSET * H * W] = 1 - T;
  for (int ch=0; ch<3; ch++) out_others[pix_id + (NORMAL_OFFSET+ch) * H * W] = N[ch];
  out_others[pix_id + MIDDEPTH_OFFSET * H * W] = median_depth;
  out_others[pix_id + DISTORTION_OFFSET * H * W] = distortion;
  // out_others[pix_id + MEDIAN_WEIGHT_OFFSET * H * W] = median_weight;
#endif
 }
}

void FORWARD::render(
 const dim3 grid, dim3 block,
 const uint2* ranges,
 const uint32_t* point_list,
 int W, int H,
 float focal_x, float focal_y,
 const float2* means2D,
 const float* colors,
 const float* transMats,
 const float* depths,
 const float4* normal_opacity,
 float* final_T,
 uint32_t* n_contrib,
 const float* bg_color,
 float* out_color,
 float* out_others)
{
 renderCUDA<NUM_CHANNELS> << <grid, block >> > (
  ranges,
  point_list,
  W, H,
  focal_x, focal_y,
  means2D,
  colors,
  transMats,
  depths,
  normal_opacity,
  final_T,
  n_contrib,
  bg_color,
  out_color,
  out_others);
}

void FORWARD::preprocess(int P, int D, int M,
 const float* means3D,
 const glm::vec2* scales,
 const float scale_modifier,
 const glm::vec4* rotations,
 const float* opacities,
 const float* shs,
 bool* clamped,
 const float* transMat_precomp,
 const float* colors_precomp,
 const float* viewmatrix,
 const float* projmatrix,
 const glm::vec3* cam_pos,
 const int W, const int H,
 const float focal_x, const float focal_y,
 const float tan_fovx, const float tan_fovy,
 int* radii,
 float2* means2D,
 float* depths,
 float* transMats,
 float* rgb,
 float4* normal_opacity,
 const dim3 grid,
 uint32_t* tiles_touched,
 bool prefiltered)
{
 preprocessCUDA<NUM_CHANNELS> << <(P + 255) / 256, 256 >> > (
  P, D, M,
  means3D,
  scales,
  scale_modifier,
  rotations,
  opacities,
  shs,
  clamped,
  transMat_precomp,
  colors_precomp,
  viewmatrix, 
  projmatrix,
  cam_pos,
  W, H,
  tan_fovx, tan_fovy,
  focal_x, focal_y,
  radii,
  means2D,
  depths,
  transMats,
  rgb,
  normal_opacity,
  grid,
  tiles_touched,
  prefiltered
  );
}

代码+注释下载

所有的注释,我基本上对应了原文中,进行了标注。我打包后已经上传到CSDN平台和百度云平台,供大家下载查看。后续我会更新一下VastGaussian、GaussianPRO以及4DGS等环节,欢迎大家关注我。

请注意,大佬上传的版本,是带有查看器的版本。其中,diff-surfel-rasterization和simple-knn我都是安装好了的。所有运行方式请查看2DGS的Github代码主页。开发板商城 天皓智联


#SparseDet

稀疏检测的神!SparseDet:特征聚合玩明白了,爆拉VoxelNeXt!

基于激光雷达的稀疏3D目标检测因其计算效率优势在自动驾驶应用中起着至关重要的作用。现有的方法要么使用单个中心体素的特征作为目标代理,要么将前景点的聚合视为目标agent。然而,前者缺乏聚合上下文信息的能力,导致目标代理中的信息表达不足。后者依赖于多级流水线和辅助任务,降低了推理速度。为了在充分聚合上下文信息的同时保持稀疏框架的效率,在这项工作中,我们提出了SparseDet,它将稀疏查询设计为目标代理。它引入了两个关键模块,即局部多尺度特征聚合(LMFA)模块和全局特征聚合(GFA)模块,旨在充分捕获上下文信息,从而增强代理表示目标的能力。其中LMFA子模块通过坐标变换和使用最近邻关系来捕获目标级细节和局部上下文信息,实现稀疏关键体素在不同尺度上的特征融合,GFA子模块使用self-att来选择性地聚合整个场景中关键体素的特征,以捕获场景级上下文信息。在nuScenes和KITTI上的实验证明了我们方法的有效性。具体来说,在nuScene上,SparseDet以13.5 FPS的帧率超越VoxelNeXt 2.2% mAP,在KITTI上,它以17.9 FPS的帧率超越VoxelNelXt 1.12% AP3D。

51c自动驾驶~合集4_迭代_05

为了在稀疏框架中有效地聚合上下文信息的同时实现高效的检测,在这项研究中,我们提出了一种简单有效的全稀疏3D目标检测框架SparseDet。SparseDet使用3D稀疏卷积网络从点云中提取特征,并将其转换为2D稀疏特征,以便通过检测n头进行进一步预测。如图2(c)所示,SparseDet将稀疏查询设计为目标代理,允许灵活和选择性地聚合点云以获得场景中的目标代理。与之前的稀疏聚合范式相比,首先,SparseDet将局部上下文信息的聚合扩展到多尺度特征空间,从而获得更丰富的局部信息。此外,与仅关注聚合前景点特征的现有方法相比,SparseDet可以聚合每个实例的场景级上下文,以促进场景和实例特征之间的潜在协作。最后,SparseDet不需要任何额外的辅助任务。

51c自动驾驶~合集4_激光雷达_06

相关工作回顾

LiDAR-based Dense Detectors

尽管点云数据与2D图像数据相比表现出不同的稀疏特性,但3D目标检测器通常是通过参考2D检测器来设计的。大多数工作都使用了2D dense检测头来解决3D检测问题。这些方法通常被称为基于激光雷达的dense detectors。

作为先驱,VoxelNet将点云划分为规则网格,并使用3D骨干网络进行特征提取。然后,它应用dense head进行预测。基于VoxelNet,SECOND实现了稀疏卷积和子流形卷积算子的高效计算,通过构建哈希表来获得快速的推理速度。然而,SECOND仍然需要dense的鸟瞰图(BEV)特征图和dense的检测头进行检测。在SECOND的影响下,大多数后续网络都遵循利用3D稀疏骨干与2D dense检测头相结合的范式。

尽管基于激光雷达的dense detectors在多个基准数据集上表现出了出色的性能,但它们对dense的鸟瞰图(BEV)特征图和dense的探测头的依赖使其难以扩展到long-range检测。这是因为dense BEV特征图的计算成本随着检测距离的增加呈二次方增长。这一缺点严重限制了基于激光雷达的dense detectors在现实世界场景中的实际应用。

LiDAR-based Sparse Detectors

目前,稀疏检测器包括基于点的方法和基于部分体素的方法。基于点的方法使用点云中的关键点进行特征聚合和检测。这些方法不需要在整个空间内进行dense的采样和计算,使其具有固有的稀疏检测器。FSD和FSDV2是这一系列方法的代表。FSD通过对分割的前景点进行聚类来表示单个目标。然后,它将PointNet提取的特征输入检测头进行校准和预测。在FSDv2中,实例聚类步骤被虚拟体素化模块所取代,该模块旨在消除手动构建的实例级表示所引入的固有偏差。尽管充分聚合了前景信息,但对额外辅助任务和众多超参数的依赖导致推理速度差。

在基于体素的稀疏方法中,VoxelNeXt引入了额外的下采样层,将体素放置在目标中心附近,随后对关键体素进行特征扩散,将特征传播到目标中心。SAFDNet通过提出自适应特征扩散策略来解决缺失中心特征的问题。尽管SAFDNet和VoxelNeXt取得了令人印象深刻的效率,但它们仅依赖单中心体素特征进行检测,这大大削弱了目标代理的信息表示能力,最终导致模型性能下降。如前所述,仅将中心体素特征视为目标代理会导致图2(a)所示的同一实例中的一些点云信息丢失。在这项工作中,我们使用稀疏查询和注意力机制通过LMFA和GFA模块获取目标代理,从而能够动态捕获不同粒度的上下文信息。这促进了场景级和实例级特征之间的协作,从而使模型能够获得更丰富、更准确的目标表示。

SPARSEDET详解

51c自动驾驶~合集4_数据_07

在本节中,我们提出了一种简单高效的基于激光雷达的稀疏检测框架SparseDet。图3展示了其结构,该结构遵循完全稀疏网络VoxelNeXt的流水线。但不同的是,为了充分聚合点云中的上下文信息以增强稀疏目标代理的信息表达能力,我们设计了两个子模块,LMFA(局部多尺度特征聚合)模块和GFA(全局特征聚合)模型。这两个模块旨在自适应地聚合点云上的多级上下文信息,并使SparseDet能够强烈增强目标代理的信息表示能力,从而以较低的计算成本提高3D检测的性能。

Local Multi-scale Feature Aggregation

大多数基于激光雷达的稀疏检测方法利用中心体素特征作为检测的目标代理。虽然使用中心特征作为目标代理可以提供准确的位置信息,但单个中心体素特征不足以完全捕获目标的全部信息。这严重削弱了目标代理的表达能力。因此,我们提出了LMFA模块来弥补这些缺点。在LMFA模块中,我们专注于学习目标周围的局部上下文信息,这有助于理解目标目标的形状、大小和相对位置等细节。如图4所示,我们通过K个最近邻(KNN)位置关系动态聚合关键体素的邻域信息,以增强其特征表示能力。然后,聚合的关键体素特征将用于初始化稀疏目标查询。值得注意的是,考虑到3D目标尺度的分布差异,我们将LMFA扩展到多尺度空间。因此,LMFA主要由两个步骤组成,稀疏关键体素选择和不同尺度体素特征的融合。

51c自动驾驶~合集4_迭代_08

1)稀疏关键体素选择:首先,我们将点云体素化,并将其输入到3D稀疏卷积骨干网络中。参考VoxelNeXt,我们在3D稀疏骨干网络中添加了两个额外的下采样层。这一步有两个关键目的。首先,它通过额外的下采样过程构建多尺度特征空间,以促进LMFA模块中的后续特征聚合。其次,通过额外的采样和高度压缩操作,我们可以将体素特征放置在空白的目标中心,以更准确地构建邻域关系。通过上述操作,原始稀疏3D卷积骨干从{Fs1、Fs2、Fs3、Fs4}转换为{Fs1,Fs2,Fs3,Fs4,Fs5{Fs6},特征步长为{1,2,4,8,16,32}。然后,我们将Fs5和Fs6变换到Fs4的特征空间,并将Fs4、Fs5和Fs 6连接在一起以获得FF融合。然后,我们对FFusion、Fs4、Fs5和Fs6进行高压缩,以获得。具体来说,遵循VoxelNeXt,我们替换地平面上的所有体素特征,并在相同的位置对其进行求和。

为了选择关键体素,我们使用heatmap操作,该操作基于稀疏体素特征F2D预测Cls类的体素得分Score。我们将最靠近目标中心的体素指定为阳性样本,并使用Focal Loss进行监督。这意味着得分较高的体素属于前景的概率较高。随后,我们将top-分数操作应用于,以获得Nkey稀疏体素候选。这里,被设置为默认值500。

2)不同尺度体素特征的融合:在本节中,我们构建了一个K近邻图,以获取不同尺度下稀疏候选体素的邻域信息,从而获得更全面的局部上下文,解决了稀疏特征信息表示能力不足的问题。

在稀疏关键体素选择之后,我们得到了稀疏体素的特征,记为。相应的坐标位置索引被定义为Ikey,形状为(,2),表示2D位置索引。我们首先将体素在S4尺度上的位置坐标(表示为Is4)分别除以2和4,将其转换为{S5,S6}的低分辨率体素空间。然后,我们将相应的空间坐标索引保存为Is5、Is6。给定Nkey稀疏体素在不同尺度空间中的位置坐标信息,我们的目标是为每个关键体素找到K个最近的体素。的值随着缩放空间的变化而减半,这可以使用以下公式确定。

51c自动驾驶~合集4_数据_09

为了提高LMFA的效率,我们采用KD树算法来获得特定尺度Si下每个关键体素的邻居的索引。环视的邻域体素具有特征。然后,利用MLP来聚合相邻体素特征的特征,这是通过以下公式实现:

51c自动驾驶~合集4_迭代_10

给定稀疏体素的编码多尺度特征,一种朴素的融合方法是将多尺度特征连接起来形成一个特征。然而,我们观察到,一些目标检测更多地依赖于来自特定尺度的信息,而不是来自所有尺度的信息。例如,低分辨率特征映射了关于小目标的漆信息。因此,与小目标相关的关键体素应该更有效地仅从高分辨率特征图中收集信息。

我们建议使用可学习的比例权重来自动选择每个关键体素Fkey的比例,如下所示

51c自动驾驶~合集4_数据_11

通过这种比例选择机制,与每个关键体素最相关的比例被柔和地选择,而来自其他比例的视觉特征被抑制。然后,我们根据Fkey的位置索引将Fkey放入中,得到增强的。我们的自适应融合的整个过程如图5所示。

51c自动驾驶~合集4_数据_12

Global Feature Aggregation

LMFA模块旨在通过使用最近邻位置关系动态聚合关键体素的邻域信息来学习目标周围的局部上下文信息。

尽管邻域体素特征的融合增强了前景稀疏体素特征表达能力,但LMFA模块在处理稀疏检测场景时仍然存在局限性。1)对于大目标,使用单个聚合稀疏体素作为目标检测的代理仍然会丢失信息,因为目标代理应该包含整个目标的信息,而不仅仅是局部区域的信息。2)LMFA忽略了整个场景和实例特征之间的潜在协作。例如,场景中的假阴性目标可以通过与共享相似语义信息的实例交互来增强其特征,从而得到潜在的纠正。因此,我们提出了GFA(全局特征聚合)模块,通过学习整个场景的全局结构和语义信息,进一步解决了LMFA模块的局限性。这使得SparseDet能够以局部和全局的方式利用目标的上下文信息来消除歧义,从而提高检测精度。

实验

1)LMFA和GFA模块的影响:本节讨论了在基线detectorsVoxelNeXt上进行的消融实验的结果,以评估SparseDet中每个组件的性能。表VI和表VII分别报告了KITTI和nuScenes 14子集的结果。表VI显示了KITTI上AP3D和APBEV的初始AP评分,分别为78.44%和87.10%。如表六所示,LMFA和GFA模块显著提高了硬级KITTI任务的性能,AP3D和APBEV分别提高了4.27%和3.35%。所有的改进都没有显著增加模型的参数或降低推理速度。

如表七所示,当使用LMFA模块时,SparseDet实现了出色的性能提升,这表明有效地聚合上下文信息可以更好地增强稀疏特征的表示能力,从而提高稀疏3D目标检测器的性能。这促进了场景和实例特征之间的协作,从而产生了更丰富、更准确的目标表示。当LMFA和GFA结合时,这种增强效果进一步增强,导致mAP改善2.4%,NDS改善1.3%。总之,我们的消融实验表明,SparseDet在具有挑战性的数据集上有效地提高了基线的性能。研究结果强调了上下文信息聚合在稀疏检测框架中的重要性,并为设计有效的聚合策略提供了宝贵的见解。

2)M数量的影响:选择相邻体素特征,以增强关键位置的特征表示,是LMFA模块的关键组成部分。在本节中,我们将讨论相邻体素数量M的选择及其相应的有效性。因此,我们为超参数M(相邻体素的数量)配置了不同的值,包括4、8、16和32。如表八所示,M值的变化对模型的性能没有显著影响。值得注意的是,当M设置为8时,我们的SparseDet模型达到了最高的mAP,而将M设置为16则可获得最佳的NDS性能。考虑到整体模型性能、推理时间、训练记忆和模型参数,我们最终将M设置为8作为默认值。

3)Nkey数量的影响:如表IX所示,我们对nuScenes验证数据集中LMFA模块内关键体素Nkey的数量进行了消融研究。我们在500、1000、1500和2000之间配置超参数Nkey的值。综上所述,随着Nkey值的增加,SparseDet的性能相应有不同程度的提高。从表中可以看出,模型的性能对Nkey的变化没有表现出很强的敏感性。虽然简单地增加Nkey的值可以提高模型的性能,但这是以降低推理速度为代价的。在权衡了模型的准确性和推理延迟后,我们最终选择500作为Nkey的默认值。

4)数量的影响:如表X所示,我们对nuScenes验证集GFA模块中的超参数NK,V进行了消融研究。我们在6000、8000、10000和12000之间配置超参数的值。值得注意的是,当的值设置为12000时,SparseDet的mAP和NDS得分最高,但推理速度最低。在权衡了模型的准确性和推理延迟后,我们最终将NK,V设置为10000作为默认值。

5)模型在不同距离下的性能:与dense检测器相比,稀疏检测器的一个关键优势是它们能够扩展模型的远程检测能力,而不会显著增加推理延迟。因此,对远距离目标的稳定检测是评估稀疏检测器性能的关键指标。为了更好地了解我们的SparseDet在长距离下的卓越性能,我们在表XI和表XII中提供了不同距离范围的性能指标。具体来说,与VoxelNeXt相比,我们的指标显示出更显著的改善,特别是在20-40m和40m-inf的距离范围内。例如,在KITTI 40m-inf下的3D检测中,我们的SparseDet将AP3D提高了9.28%。在40m-inf的BEV检测中,我们的SparseDet将APBEV提高了9.40%。在nuScenes数据集上,在40m-inf的检测中,我们的SparseDet在mAP和NDS上分别提高了4.1%和3.6%。这些结果清楚地反映了我们的SparseDet模型在远程检测方面的优势。

在图6中,与VoxelNeXt相比,我们以KITTI中汽车类0-70.4m的检测范围为例,说明了我们的SparseDet在远程/远距离目标检测方面的优越性。根据该图,我们的SparseDet有一个假阳性结果,但没有遗漏实例。其中,VoxelNeXt存在远距离目标丢失的问题。这可以归因于Our SparseDet充分利用了点云中的多尺度上下文语义信息,这对于稀疏点云中的远程目标至关重要,因为这些目标通常因缺乏信息而较弱。总体而言,我们的方法在远程目标检测的精度方面有了显著提高。

51c自动驾驶~合集4_数据_13

结论

在这项工作中,我们提出了SparseDet,这是一个简单有效的全稀疏3D目标检测框架。具体来说,基于VoxelNeXt,我们设计了一个高效的稀疏检测框架,更合理地使用实例级和场景级点云上下文信息。这显著增强了目标代理的表达能力,从而大大提高了稀疏检测器的检测性能。综合实验结果表明,与KITTI和nuScenes数据集上的基线相比,SparseDet显著提高了性能。我们希望我们的工作能够为自动驾驶的稀疏检测器提供新的见解。

目前,稀疏3D检测器的研究工作还不足以满足多模态3D检测等其他方向的需求。这使得3D稀疏框架的比较方法受到限制。然而,对于现实世界的应用程序,模型的延迟非常重要。因此,对全稀疏快速detectors的研究需要更多的关注和重点。



#AsyncDriver 

LLM提升Transformer规划器的性能 !

尽管实时规划器在自动驾驶中表现出卓越的性能,但对大型语言模型(LLM)的深入探索为提高运动规划的解读性和可控性开辟了新途径。然而,基于LLM的规划器仍然面临重大挑战,包括资源消耗的提升和推理时间的延长,这对实际部署构成了重大障碍。

鉴于这些挑战,作者引入了.AsyncDriver,这是一个新的异步LLM增强的闭环框架,旨在利用LLM产生的与场景相关的指令特征来指导实时规划器做出精确可控的轨迹预测。

一方面,作者的方法突显了LLM在理解和推理矢量化场景数据及一系列路线指令方面的能力,证明了其对实时规划器的有效辅助。

另一方面,所提出的框架将LLM和实时规划器的推理过程解耦。通过利用它们推理频率的异步性,作者的方法成功降低了由LLM引入的计算成本,同时保持了可比的性能。

实验表明,在nuPlan的挑战性场景中,作者的方法在闭环评估性能上取得了优越表现。


1 Introduction

运动规划在自动驾驶中扮演着至关重要的角色,因其对车辆导航和安全产生直接影响而引起了广泛关注。一种特别值得注意的评估方法就是采用闭环仿真,这涉及到根据规划器预测的轨迹动态发展驾驶场景,从而需要模型展现出更强的预测准确性和偏差校正能力。

如图1(a)所示,当前的基于学习的实时运动规划框架,如Kendall等人(2019年),Li等人(2023年),Hallgarten等人(2023年),Renz等人(2022年),Scheel等人(2022年),Huang等人(2023年)通常使用矢量化地图信息作为输入,并采用解码器来预测轨迹。作为纯粹的数据驱动方法,它们特别容易受到长尾现象的影响,在罕见或未见过的场景中,它们的性能可能会显著下降(Chen等人,2022年)。此外,尽管存在一些基于规则的策略,但这些手动制定的规则被发现不足以捕捉所有潜在的复杂场景,导致驾驶策略倾向于两个极端:过度谨慎或过于激进。此外,基于学习和基于规则的规划框架都存在可控性低的问题,这引起了人们对这些系统在动态环境中的安全性和可靠性的担忧。

51c自动驾驶~合集4_数据_14

近期,包括GPT-4 Achiam等人(2023)和Llama2 Touvron等人(2023)在内的大型语言模型(LLM)在自动驾驶领域的潜力已被广泛探索。它们在大规模数据集上的广泛预训练为理解交通规则和场景奠定了坚实的基础。因此,基于LLM的规划器在场景分析、推理和人际交互方面表现出卓越的性能,为提高运动规划的解读性和可控性带来了新的前景,如周等人(2023);杨等人(2023);崔等人(2024)。然而,如图1(b)所示,这些模型经常遇到以下特定挑战:1) 场景信息通过语言描述,可能受到允许的输入标记长度的限制,这使得全面而准确地封装复杂的场景细节变得具有挑战性,如毛等人(2023);沙等人(2023);温等人(2023);毛等人(2023)。2) 通过语言输出的预测意味着要么直接发出高级命令,然后将其转换为控制信号,可能导致不准确,要么通过语言输出轨迹点作为浮点数,这是LLM不擅长的任务,如徐等人(2023);毛等人(2023);Keysan等人(2023)。3) 现有的框架主要使用LLM作为核心决策实体。尽管这种策略在性能上有优势,但LLM固有的大量参数导致与实时规划器相比,推理速度明显下降,给其实际应用带来了重大障碍。

在本工作中,作者引入了AsyncDriver,这是一个新颖的异步大语言模型增强的闭环运动规划框架。如图1(c)所示,此方法对矢量化的场景信息和一系列路线指令的模态进行对齐,充分利用大语言模型在解释指令和理解复杂场景方面的强大能力。场景关联指令特征提取模块提取高级指令特征,然后通过作者提出的自适应注入块将其整合到实时规划器中,显著提高了预测准确性,并确保了更精细的轨迹控制。此外,作者的方法保留了实时规划器的架构,允许大语言模型与实时规划器的推理频率解耦。通过控制异步推理间隔,它显著提高了计算效率,并减轻了大语言模型引入的额外计算成本。此外,作者提出的自适应注入块具有广泛的应用性,确保了作者的框架可以无缝扩展到任何基于Transformer的实时规划器,突显了其通用性和潜在的广泛应用前景。

总结来说,作者的论文做出了以下贡献:

  • 作者提出了AsyncDriver,这是一个新颖的异步大语言模型增强框架,其中大语言模型的推理频率可控,且可以从实时规划器的频率中解耦。在保持高性能的同时,它显著降低了计算成本。
  • 作者引入了自适应注入块,它是模型无关的,并且可以轻松地将场景关联指令特征整合到任何基于Transformer的实时规划器中,增强了其在理解和遵循基于语言的路线指令序列方面的能力。
  • 与现有方法相比,作者的方案在nuPlan的挑战性场景中展示了卓越的闭环评估性能。


2 Related Work

近年来,在计算机视觉领域,深度学习得到了迅速发展。卷积神经网络(CNNs)已成为视觉识别任务的主导模型,在图像分类、目标检测和语义分割等各种应用中取得了显著的成功。受到CNNs成功的启发,众多研究专注于探索和改进网络结构。在本节中,作者将回顾一些与作者的方法密切相关的工作。

Motion Planning For Autonomous Driving

经典的自动驾驶模块化 Pipeline 包括感知、预测和规划。在这个框架内,规划阶段利用感知的输出确定自动驾驶车辆的行为并制定未来的轨迹,随后由控制系统执行。这种模块化 Pipeline 架构已经在行业内被广泛采用,如在Apollo Baidu(2019)等框架中。与端到端方法Hu等人(2023,2022)不同,模块化架构通过定义各模块之间的数据接口,便于研究专注于单个任务。

自动驾驶规划器主要可以分为基于规则和基于学习的规划器。基于规则的规划器Treiber等人(2002),Kesting等人(2007),Dauner等人(2023),Fan等人(2018),Urmson等人(2008),Leonard等人(2008),Bacha等人(2008)依据一组预定义的规则来确定车辆的未来轨迹,包括保持安全跟车距离和维护交通信号的规定。IDM Treiber等人(2002)确保 ego 车与前方车辆保持最小的安全距离。它包含一个公式来计算适当的速度,该速度考虑了前方车辆的制动距离、后方车辆的停车距离以及必要的安全裕度。PDM Dauner等人(2023)通过选择得分最高的IDM Proposal 作为最终轨迹来扩展IDM。该模型在nuPlan Challenge 2023 Caesar等人(2021)的闭环评估中取得了最先进的性能。然而,基于规则的规划器通常难以有效处理复杂驾驶场景,这些场景超出了其预定义规则集的范围。

基于学习的规划器Kendall等人(2019),Li等人(2023),Hallgarten等人(2023),Renz等人(2022),Scheel等人(2022),Huang等人(2023)试图通过模仿学习或离线强化学习,从大规模驾驶数据集中模仿人类专家驾驶轨迹。然而,受限于数据集的广度和模型的复杂性,这些规划器在诸如路径信息理解、环境感知和决策制定等方面仍有很大的提升空间。

LLM For Autonomous Driving

大型语言模型(LLM)的快速进步是值得关注的。探讨了如何通过语言方式将场景信息——包括自车状态以及障碍物、行人和其他车辆的信息——整合到LLM中以辅助决策并生成决策及解释。由于上下文长度有限,这些方法遇到了限制,难以编码足够精确的信息以进行有效的决策和推理。为了解决这些限制,开发了多模态策略,例如DrivingWithLLM Chen等人[2023b]和DriveGPT4 Xu等人[2023]。这些方法将向量化或图像/视频模态与语言指令对齐,使驾驶场景得到更全面的解读。然而,这些方法使用语言表达控制指令,存在一定的局限性。DrivingWithLLM以语言表达输出高级命令,尽管这提高了问答(QA)互动性能,却牺牲了将复杂推理转化为精确车辆控制的忠实度。DriveGPT4通过语言表达航点,在开环性能上表现出色,但在闭环模拟中的评估尚缺。

此外,一些努力 Sha等人[2023],Shao等人[2023]旨在进行闭环评估,通过在大型语言模型后连接低级控制器或回归器以实现精确的车辆控制。LMDrive Shao等人[2023]使用一系列连续的图像帧,辅以导航指令,实现闭环驾驶能力,但仍然需要在每个规划步骤中进行LLM的完整推理过程。LanguageMPC Sha等人[2023]使用LLM获取模型预测控制参数,以无训练的方式实现控制。此外,这些方法不可避免地需要在每个规划步骤中串行解码语言,或者至少进行LLM的完整推理,这在实时响应性方面存在挑战,限制了它们的实际部署和应用。

在作者的方法中,作者将重点从使用LLM直接输出语言转向扩展基于实时学习规划器。这一策略旨在增强现有基于学习规划器对环境的理解。此外,作者的框架允许LLM和实时规划器以解耦方式运行,在不同的推理速率下执行规划。这种策略有效地缓解了LLM的推理延迟,从而为现实世界的部署铺平了道路。

3 Data Generation

nuPlan Caesar等人[2021]数据集是首个大规模的自动驾驶规划基准,包含了总共1200小时真实世界四大城市(波士顿、匹兹堡、拉斯维加斯和新加坡)的人类驾驶数据。为了适应不同阶段的训练,作者开发了来自nuPlan _训练集_和_验证集_的预训练和微调数据集。具体来说,作者只选择了与14种官方挑战性Motional [2023]场景类型相对应的数据。

Pre-training Data Generation

为了增强LLM在自动驾驶背景下对指令的理解,作者设计了一个仅包含基于语言的问答数据集,这与LLM固有的模态相一致,以促进对指令语义更有效的吸收。预训练数据集包括两个部分:Planning-QAReasoning1K。两个数据集的样本在附录材料中展示。

3.1.1 Planning-QA

该数据集是通过基于规则的方法构建的,从而便于数据集的可扩展性。它战略性地设计用于增强LLM对“航点-高级指令-控制”之间相互关系的理解。在这个框架中,航点构成了一组点阵,而高级指令由速度命令(包括_停止_、加速减速保持速度)和路由命令(包括_向左转_、向右转直行)构建,控制方面主要涉及评估速度和加速度的值。Planning-QA包含了6种类型的问题,每种问题都专注于“航点-高级指令-控制”之间的转换。

3.1.2 Reasoning1K

包括由GPT-4生成的1,000条数据,不仅提供答案,它还进一步根据场景描述补充了推理和解释,并用于与Planning-QA混合训练。

Fine-tuning Data Generation

为了进一步实现多模态理解和对接,作者基于种场景构建了微调数据,每8秒捕捉一帧,编制了一个包含帧的训练集和一个包含帧的验证集,每个样本都结合了地图数据和语言提示来构建多模态信息。关键是,训练和验证数据集中的场景类型分布遵循整个nuPlan trainval数据集的分布。

对于提取的向量化的场景信息,类似于Huang等人(2023年)的做法,包括了以自我为中心的信息和过去20帧中20个周围代理的信息,以及以自我为中心的全局地图数据。对于那些无法获得完整的历史特征的代理,通过插值补充缺失的信息。关于地图数据,作者在预定半径内提取了三类几何信息:车道中心线、人行横道和道路车道。

LLM的提示由两部分组成:系统提示和一系列路线指令。在模板中,作者提出_

4 Methodology

在本工作中,作者引入了异步LLM增强的闭环框架AsyncDriver。

如图2所示,作者的方法主要包括两个部分:

1)场景关联指令特征提取模块,其中多模态输入,包括向量化的场景信息和一系列路由指令,通过对齐辅助模块进行对齐,便于提取高级指令特征;

2)自适应注入块,它接收前面的特征作为实时规划器的引导,以获得更精确和可控的轨迹预测。此外,由于作者框架的性质,LLM和实时规划器之间的推理频率可以通过异步间隔解耦和调节,显著提高了推理效率。

51c自动驾驶~合集4_迭代_15

在本节中,作者介绍了场景关联指令特征提取模块(第4.1节),详细说明了自适应注入块的设计(第4.2节),讨论了异步推理的概念(第4.3节),并概述了所采用训练细节(第4.4节)。

Scene-Associated Instruction Feature Extraction Module

4.1.1 Multi-modal Input

在每次规划迭代中,从模拟环境中获取矢量化场景信息。类似于GameFormer Huang等人(2023)采用的方法,提取了自我和他者代理的历史轨迹和状态信息,以及全局地图数据。为实时规划器提供矢量化场景信息的方式相同。所有矢量数据都相对于自我位置。随后,通过矢量地图编码器(Vector Map Encoder)和地图 Adapter (Map Adapter)的处理,作者导出地图嵌入。这些地图嵌入连同语言嵌入一起,被送入Llama2-13B主干网络以获得最终的隐藏特征 。

4.1.2 Alignment Assistance Module

为了在保持对矢量化场景信息的细粒度理解的同时把握路由指令的本质,以增强提取与场景相关的高级指令特征,作者采用了对齐辅助模块来促进多模态输入的对齐。具体来说,作者确定了五个对自动驾驶过程至关重要的场景元素,用于多任务预测,通过五个独立的2层MLP预测头实现。对于当前车辆状态,作者执行回归来估计车辆沿X轴和Y轴的速度和加速度。对于地图信息,作者执行分类任务,以识别左右两侧相邻车道的存在,并评估与当前车道相关的交通灯的状态。此外,为了未来的导航策略,作者分类未来轨迹中是否需要变道,并识别未来的速度决策,包括选项“加速”、“减速”和“保持当前速度”。值得注意的是,对齐辅助模块只在训练阶段用于辅助多模态对齐,并不参与推理阶段。

Adaptive Injection Block

作者采用了黄等人(2023年)的解码器结构作为作者的基本解码层,通过将传统的基于 Transformer 的解码层进化为自适应注入块(Adaptive Injection Block),从而促进了场景关联指令特征的自适应整合。

具体来说,最后一个 Token 的隐藏特征通过特征 Adapter 进行投影,然后输入到自适应注入块中。

在自适应解码块中,实时规划器的基础解码架构被优雅地扩展,以确保每个层的 Query 不仅保留了原始场景信息内的注意力操作,而且与场景关联的指令特征进行跨注意力操作,从而将指令引导融入到预测过程中。之后,通过可学习的自适应门控对更新后的指令增强 Query 特征进行调制,该门控初始化为零,并重新整合到解码层的原始注意力输出中。第_l_个解码块的自适应注入过程可以表述如下:

其中是自适应门控的值,表示原始解码层中的 Query ,和分别表示特征的关键和值,表示第_l_个解码层的场景特征。

作者提出的自适应注入方法不仅保持了实时规划器中原解码层处理完整场景信息的能力,而且增强了规划器对一系列灵活的语言指令的理解和遵守。这一进步使得能够产生更精细和可控的预测。值得注意的是,由于作者的自适应注入块简单而有效的设计,它可以无缝地整合到任何基于 Transformer 的架构中,从而为作者的方法提供了灵活性,使其能够适应其他实时规划器框架。

Asynchronous Inference

作者的设计利用大型语言模型(LLM)指导实时规划器,通过一系列灵活组合的语言指令显著提高了其性能,同时不损害其结构完整性。这种方法促进了受控的异步推理,有效地解耦了LLM和实时规划器的推理频率,因此LLM不需要处理每一帧。在异步间隔期间,先前推导出的高级指令特征继续指导实时规划器的预测过程,这显著提高了推理效率并降低了由LLM引入的计算成本。值得注意的是,作者的框架容纳了一系列灵活组合的路线指令,能够提供长期的高级路线洞察。因此,即使在异步间隔中,先前的高级特征仍能提供有效的指导,增强LLM推理间隔期间的性能鲁棒性。

实验结果表明,当LLM的异步推理间隔延长时,作者的架构保持了近乎鲁棒的性能。通过控制LLM每3帧进行一次推理,可以实现推理时间减少近40%,而准确度损失仅为约1%,这证明了作者方法在准确度和推理速度之间找到最佳平衡的有效性。对于更全面的实验结果及其分析探索,请参考第5.2.2节。

Training Details

在预训练阶段,整个Reasoning1K数据集,以及从Planning-QA中随机选择的个样本,都被用来训练LoRA。这一过程使得LLM从通用大型语言模型转变为专门针对自动驾驶进行优化的模型。这种针对性的适应直接结果是,LLM在理解运动规划背景下指令的准确性方面变得更加熟练。

在微调阶段,由于保留了VectorMap编码器和解码器的架构,作者加载了在相同数据集上预训练的实时规划器的权重,以增强训练稳定性。微调阶段的总损失由对齐辅助损失和规划损失组成。对齐辅助损失分为五个部分:1) 对ego速度和加速度预测的损失,2) 对速度决策预测的交叉熵损失和3) 对交通灯状态预测的交叉熵损失,4) 对邻道存在预测的二进制交叉熵损失和5) 对变道预测的二进制交叉熵损失。完整的对齐辅助损失可以表示如下,其中和分别代表预测和真实值:

跟随Huang等人[2023],规划损失包括两部分:1) 模式预测损失。邻域代理的种不同轨迹模式由高斯混合模型(GMM)表示。对于每种模式,在任何给定的时间戳,其特征由均值和协方差构成的高斯分布来描述。通过将最佳模式与真实值对齐并最小化负对数似然来识别并细化它。2) Ego轨迹预测损失。预测ego车辆的未来轨迹点并通过损失进行细化。因此,规划损失如下:

其中,,,分别表示在时间戳上对应最佳模式的预测均值、协方差、概率和位置,而表示时间戳上的真实位置。

总之,微调阶段的完整损失公式为:

5 Experiments

Experimental Setup

5.1.1 Evaluation Settings

根据2023年nuPlan挑战赛的设置[motional 2023],作者选择了种官方挑战性场景类型进行训练和评估。然而,nuPlan Caesar等人[2021]包含了一个庞大的个场景集合。考虑到一方面,大多数简单场景无法有效评估规划器的关键性能;另一方面,数据量巨大导致了漫长的评估时间,作者精心选择了_Hard20_数据集。具体来说,作者从测试集中为每种类型随机挑选了个场景,并使用了PDM Dauner等人[2023]的规划器(这是nuPlan 2023挑战赛的冠军),每种类型保留了得分最低的个场景,形成了由总共个场景组成的_Hard20_作为作者的测试集。

5.1.2 Implementation Details

表1:在_Hard20_分割上的nuPlan闭环反应挑战评估。最佳结果用粗体突出显示,次佳结果则用下划线进行区分以便清晰。_得分_:平均最终得分。_可行驶_:可行驶区域合规性。_方向_:驾驶方向合规性。_舒适_:自身车辆舒适度。_进度_:沿专家路线的自身进度。_碰撞_:无自身责任的碰撞。_限制_:速度限制合规性。_TTC_:碰撞时间的界限内。

51c自动驾驶~合集4_激光雷达_16

关于实施细节,所有实验都在闭环反应设置中进行,场景中的代理可以配备IDM Treiber等人[2002]的规划器,使其能够对自身车辆的操纵做出反应。模拟频率为,在每次迭代中,预测轨迹的时间范围为。作者遵循nuPlan挑战中提出的闭环指标,详细内容在补充材料中说明。对于模型设置,作者的AsyncDriver基于Llama2-13B Touvron等人[2023]构建,LoRA Hu等人[2021]配置为和。作者使用AdamW优化器,并采用学习率的预热衰减调度器。

主要结果

5.2.1 Hard20 Evaluation

如图表1所示,作者的方法AsyncDriver在_Hard20_上相较于现有规划器的性能最高,比GameFormer Huang et al. (2023)的得分提高了,大约分,甚至超过了当前的SOTA基于规则的规划器。为了公平比较,考虑到轨迹细化和对齐在闭环评估中的重大影响,作者将PDM Dauner et al. (2023)评分器适配到作者的AsyncDriver(表示为AsyncDriver*),这分别导致了比PDM-Hybrid Dauner et al. (2023)和PDM-Closed Dauner et al. (2023)提高了和,相当于大约和分,以及比基于学习的规划器GameFormer提高了(大约分)。从不同的角度来看,图表2展示了_Hard20_分割上每种个别场景类型的得分,以及与现有规划器的比较。很明显,作者的解决方案在大多数场景类型中都取得了卓越的结果。

51c自动驾驶~合集4_迭代_17

表2:在_Hard20_分割上的nuPlan闭环反应挑战中,按场景类型评估的得分。最佳结果以粗体突出显示,而次佳结果则用下划线以清晰区分。类型-代表nuPlan挑战2023 Motional (2023)的种官方场景类型,具体细节在补充材料中提供。

定量结果表明,作者通过场景关联指令特征提取模块提取的高级特征有效地增强了实时规划器在闭环评估中的性能。此外,详细的指标得分显示,与GameFormer相比,作者的方法在可行驶区域性能上有显著提升,增加了分,这证明了作者模型在准确识别和导航可行的驾驶空间方面具有卓越的能力,归功于其先进的场景上下文理解能力。同样,与PDM相比,AsyncDriver*在碰撞时间(TTC)指标上表现出明显的优势,得分提高了,大约分,这表明该模型增强了预测准确性,这对于通过有效预测和减少潜在的碰撞场景来确保更安全的驾驶至关重要。


5.2.2 异步推理

作者认为,尤其是在广义高级指令方面,短时间内帧内表现出显著的相似性。因此,考虑到其在提取这些高级特征方面的作用,LLM不需要在每帧中参与推理过程,这可以显著提高推理速度。为了探索这一点,设计实验以区分LLM和实时规划器的推理频率,在每次LLM推理间隔期间,使用先前的指令特征来指导实时规划器的预测过程。如图3所示,随着LLM规划间隔的增加,作者方法的性能表现出显著的鲁棒性,这表明LLM能够提供长期的高级指令。作者观察到,即使间隔为帧,意味着在每个场景中只进行一次推理,它仍然比GameFormer高出分以上,而推理时间几乎达到了实时水平。随着推理间隔的增加,所需的推理时间急剧下降,而准确性几乎保持稳定。因此,通过采用密集训练与异步推理的策略,作者的方法在准确性和推理速度之间实现了最优平衡。

5.2.3 Instruction Following

图4:跟随人类指令的AsyncDriver可视化。浅蓝色线条表示 ego 车辆未来8秒的轨迹。它对比了在接收到传统路线指令与强制“停止”指令下ego的规划轨迹。

51c自动驾驶~合集4_激光雷达_18

图4展示了作者的方法对不同路线指令的反应,证明了其在指令跟随方面的能力。图3(a)说明了在采用传统路线指令的场景下的预测结果。作者注意到,ego车辆稍微减速,这一操作反映了在转弯时减速的常识。然而,在开阔的道路条件下,ego车辆保持了相对较高的速度。相比之下,图3(b)描述了当“停止”作为路线指令的场景。值得注意的是,即使没有外部障碍物,ego车辆也能迅速对指令做出制动反应,在短短6秒内将速度从10.65m/s降低到1.06m/s。因此,显而易见,作者的AsyncDriver可以作为语言交互界面,提供精确解释和跟随人类指令以规避异常情况的能力。### 消融研究

在本节中,作者研究了AsyncDriver中各个组件的有效性。尝试在LLM之后集成一个简单的MLP作为预测头来进行规划任务,导致了闭环性能的显著下降。这表明,将简单的轨迹回归任务作为监督不能正确对齐多模态信息,使得难以利用LLM的内在知识和其在场景解释和推理中的潜力。随后,作者移除了MLP结构,转而使用实时规划器,并逐步添加了以下四个结构:(i)自适应注入块,(ii)对齐辅助模块,(iii)LoRA,以及(iv)预训练的LoRA。如表3所示,每个引入的模块都对提高性能起到了作用。值得注意的是,对齐辅助模块和预训练的LoRA权重分别带来了0.94和0.97的得分提升,贡献最为显著。

51c自动驾驶~合集4_数据_19

6 Conclusions

在本文中,作者介绍了AsyncDriver,这是一个新的异步的、基于LLM增强的自动驾驶闭环框架。通过将矢量化场景信息与一系列路由指令对齐以形成多模态特征,作者充分利用了LLM的场景推理能力,提取与场景相关的指令特征作为指导。

通过所提出的自适应注入块,作者实现了将一系列路由信息集成到任何基于Transformer的实时规划器中,增强了其理解和遵循语言指令的能力,并在nuPlan的挑战性场景中实现了卓越的闭环性能。

值得注意的是,由于作者方法的结构设计,它支持LLM与实时规划器之间的异步推理。实验表明,作者的方法显著提高了推理速度,而在准确性上的损失最小,减少了由LLM引入的计算成本。