第一次看到"单目3D语义场景补全"这个说法的人,十有八九会把它和"单目深度估计再加个点云上色"画上等号。我最早也是这么理解的,直到把任务定义摊开来看:输入一张 RGB 图,输出一整块三维空间里每个体素的语义标签,其中还包括那些被墙、被车、被货架挡得死死的、在图像里连一个像素都没占到的区域。这时候你就会意识到,这个任务里真正难的部分不是"测深度",而是"猜空间"。测深度至少有几何约束兜底,猜空间靠的是网络从训练数据里学到的先验——桌子底下通常是空的,墙角后面通常还有地面延续,车头前方那个看不见的位置大概率什么都没有。
MonoScene 就是围绕这件事设计出来的一套方案。它做的事情可以一句话概括:用二维主干网络把图像啃成特征,再把这些特征按几何关系"抬"进三维体素空间,最后用一个三维编解码器把整块空间补全并分类。听起来顺理成章,但每一环都有坑——二维特征抬进三维时会退化、三维卷积的参数量会爆、遮挡区域根本没有直接监督信号、显存吃紧到连 batch size 都上不去。这篇内容我打算把这套流程拆开讲,重点不在复述结构图,而在于讲清楚每个设计为什么非得这么干,以及你自己动手时会在哪几个地方卡住。适合已经跑通过常规二维分割、想往三维感知走一步的读者,也适合正在评估"单目方案到底能不能用"的工程同学。
1. 输入一张照片、输出一整块空间:这个任务到底在算什么
1.1 输出不是一个点云,而是一张排好队的体素表
先把输出形态说清楚,因为很多人第一反应是"输出点云",而点云和体素网格在工程上是两回事。这个任务的输出是一块固定尺寸的、规则排列的三维栅格,每个格子(体素)有一个语义标签,比如地面、建筑、植被、车辆、行人,或者"空"。栅格的范围和分辨率是预先定死的,网络不做"这个方向有多少点就输出多少点"这种事。
业内常用的两套配置差别很大。室外自动驾驶场景一般是 256×256×32,单个体素边长 0.2 米,折算下来覆盖大约 51.2 米见方、高度 6.4 米的一块空间,体素总数两百一十万左右。室内 RGB-D 场景通常是 60×36×60,体素边长 0.08 米,覆盖大约 4.8 米见方、高度 2.88 米,体素总数只有十三万上下。
这两个数字放在一起对比,你就明白为什么同一套代码在两个数据集上的显存表现能差出一个数量级——体素数差了十六倍,而三维卷积的计算量和中间激活的内存占用都是跟着体素数线性甚至超线性涨的。我见过有人把室内配置直接搬到室外数据上跑,模型定义都没改,结果在第一个前向传播就卡死了,这不是代码问题,是物理上放不下。
还有一点容易被忽略:体素表是"稠密"的。真实世界里绝大多数空间是空的,但网络必须对每一个格子都给一个判断,包括那些远到根本看不清、或者被完全遮挡的格子。稠密输出意味着网络不能像点云方法那样只在有观测的地方算,它得对整个空间负责。
1.2 单目丢失的不只是深度,是整条尺度链
双目或者激光雷达方案里,深度的误差是"有界"的——测五米远的东西可能差几厘米,测五十米远的东西差几十厘米,误差随距离增长但至少有个大概。单目不一样,单目图像本身不含绝对尺度,一张桌子摆在近处和一张小桌子摆在远处,在像素层面可以完全一样。网络只能靠语义线索去推断:这看起来像车轮,那车轮直径大概七十厘米,所以它离我大概多远。
这种推断在训练分布内还行,一旦遇到没见过的物体尺寸或者异常的拍摄角度,误差会以很讨厌的方式传播。你可以这样理解:体素投影的位置是由深度决定的,深度错一米,这个体素在三维空间里就整体沿视线方向平移一米。对于近处的物体,一米可能就是整辆车的厚度;对于远处的物体,一米可能只是让它在体素表里挪了一格。所以单目方案在近处容易"糊成一团",在远处反而看起来还行,但远处的语义本来就不可靠。这个特性会直接影响你怎么看指标,后面第 4 节会专门讲。
1.3 "补全"不是附赠功能,它是任务的主要工作量
我做过一个粗略统计,在典型的室外驾驶视角里,单帧图像能直接"看到"的空间占比通常只有百分之十几到二十几,剩下全是遮挡区或者视野外。也就是说,如果一个模型老老实实只对看得见的地方做预测、看不见的地方全填"空",它在观测区的指标可能不难看,但在完整场景的指标上会很难看。
这就带来一个很微妙的工程判断:你到底是要一个"诚实"的模型,还是一个"敢猜"的模型。诚实意味着遮挡区大量预测为空,完整场景的召回率极低;敢猜意味着遮挡区会填出一些其实不存在的物体,虚警率上去。MonoScene 这类方法选择的是"敢猜",靠训练数据里的场景先验去填补,所以你在看它的结果时会发现,车后面的地面是连贯的、墙角后面有延伸的墙面——这些都不是从图像里读出来的,是学出来的。
这里有个很容易踩的认知坑:不要把遮挡区的预测结果当成"检测结果"直接喂给下游规划模块。它的置信度天然偏低,而且错误模式是系统性的(比如总在墙后补出地面),下游必须带着不确定性去用。
2. 二维特征怎么被"抬"进三维空间:MonoScene 的骨架拆解
2.1 视锥投影的老做法,以及它为什么不够用
要把二维特征放进三维体素,最直觉的做法是反投影:对图像上每个像素,沿相机射线拉出一条"视锥",这条射线上所有体素都赋予这个像素的特征。逻辑上没毛病——这条射线上的点确实都投影到同一个像素。
问题出在"同一个像素特征被复制成一整条射线"这件事上。射线上靠近相机的体素和远离相机的体素拿到完全一样的特征向量,网络没有任何信息去区分它俩到底该是车还是背景。早期一些做法靠后续的三维卷积去"打散"这种退化,但三维卷积的感受野有限,射线方向上的歧义很难被彻底消解。表现出来就是:预测结果沿视线方向拉出一条条"拖影",你从俯视图看特别明显,像被抹布擦过一样。
还有一个更实际的麻烦:视锥投影通常是"一个像素一份特征",而一个像素对应的射线可能穿过几百个体素。如果按这个方式物化特征张量,内存会直接爆掉。所以实际实现里要么做稀疏化,要么做采样,绕来绕去都在跟内存较劲。
2.2 FLoSP:让每个体素自己去多尺度特征图上取一次样
MonoScene 换了个思路,叫特征视线投影。它不去复制像素特征,而是反过来——对三维空间里的每一个体素,把它的中心点投影到图像平面上,得到一个浮点坐标,然后在这个坐标上从多个尺度的二维特征图里各做一次双线性采样,把采到的几个向量拼起来作为这个体素的初始特征。
这个改动看着小,效果差别很大。因为采样坐标是连续的,相邻体素投到图像上得到的是相邻但不同的坐标,采到的特征自然就有细微差别,射线方向的退化被大幅缓解。同时多尺度很关键:浅层高分辨率特征保留边界细节,深层低分辨率特征携带语义上下文,一个体素同时拿到两种信息,才好判断"这里是车的边缘"还是"这里是车的中间"。
我自己的理解是,FLoSP 本质上是把"几何投影"和"特征查询"解耦了:几何只负责告诉你该去哪儿找,特征怎么组合交给网络学。这也解释了为什么它对相机内参比较敏感——内参错了,投影坐标全偏,采样位置就全错,后面三维网络再强也救不回来。所以你自己接数据的时候,第一件事是把内参和外参的坐标系约定对齐,别在这一步栽跟头。
2.3 三维 CRP:用三个方向的长条卷积替掉一个大方块
特征进了三维空间,接下来要在三维里做上下文聚合。标准做法是堆 3×3×3 的三维卷积,但这里有个参数账要算:一个 3×3×3 卷积核有 27 个权重,感受野也是 27 个体素。你要是想覆盖 7×7×7 的感受野,标准卷积需要 343 个权重,参数直接翻十倍,而三维卷积的参数量本来就是二维的三倍起步。
MonoScene 里的三维上下文关系先验模块走的是分解路线:把一个大方块拆成三个方向的长条,比如 1×3×3、3×1×3、3×3×1,分别在三个正交平面上做卷积,再把结果融合。要用长条覆盖 7×7×7 的空间,只需要三个 1×7×7 之类的核,参数量是 49×3 而不是 343,省下来的不是一点半点。更关键的是,这种分解天然适合捕捉"沿某个轴延伸"的结构——地面沿水平面铺开、墙面沿垂直方向立起来、道路沿纵深方向延伸,这些恰恰是三维场景里最常见的几何形态。
实际实现里这个模块会插在三维编解码器的瓶颈位置,位置选得挺讲究:放太浅,特征还没抽象出语义,聚合没意义;放太深,分辨率已经降下来了,长条卷积的范围覆盖不到实际空间尺度。这段是我个人踩过的地方——我试过把它往浅层挪,想让它早点起作用,结果指标掉了两个点,原因是浅层特征里噪声太多,长条卷积把噪声也一起拉长了。
2.4 三个损失各管一段:投影一致性、体素监督、场景级亲和
损失函数这块,MonoScene 用了三条并行的监督,我第一次看的时候觉得冗余,后来自己训过才明白每一条都在补别人的漏。
投影损失是把三维输出再投回图像平面,和二维语义标签比。它的好处是信号密集——图像上每个像素都能提供监督,而且这条路径强迫三维预测和二维观测保持一致,等于给三维输出加了个几何约束。但它只管"看得见"的部分,遮挡区完全没有梯度。
体素级损失是直接拿三维预测和三维标签算交叉熵,这条覆盖全空间,是补全能力的主要来源。麻烦在于类别极度不平衡——两百多万个体素里,空体素占九成以上,直接算交叉熵网络会倾向于全预测空。所以实现里一般要做类别加权或者采样。
第三条是场景级亲和损失,思路是从二维场景分割那边借过来的:不看单个像素对不对,看"这一类的整体预测和整体标签之间的相关性"。具体做法是用可微的方式近似 precision 和 recall,构造一个保真度项和一个一致性项。它的价值在于缓解前面说的类别不平衡——哪怕某一类只占千分之几的体素,只要这一类整体预测得准,这条损失就能给出正向信号。
我的经验是:这三条损失的权重不要照抄,得按你的数据调。如果你的数据里遮挡比例特别高,投影损失的权重可以适当降一点,把权重让给体素损失;如果你的场景类别极其不均衡,亲和损失的权重就得往上提。
3. 从零跑通:数据管道和显存这两道闸门
3.1 体素标签不是切方块,是一套带规则的栅格化流程
很多人以为三维标签就是"把点云按格子分箱然后投票",真做起来会发现细节多得离谱。以室外那套基准为例,原始标注是一帧帧的激光雷达点云,每个点带语义标签。要生成稠密的三维标签,得先做多帧累积——把前后若干帧的点云按位姿拼到一个参考帧的坐标系里,这样才能把遮挡区域补上。这一步的帧数选择很关键:累积太少,遮挡区还是空的;累积太多,动态物体(车、行人)会因为移动产生"鬼影",一辆车被拼成一条长长的拖尾。
累积完之后是栅格化。每个点落到哪个体素由坐标直接算出来,但一个体素里可能落进多个不同类别的点,得有优先级规则。常见做法是给类别排优先级,让少数类(行人、自行车)压过多数类(道路、建筑),因为一个体素里同时有路面和行人脚部的时候,你肯定希望标成行人。这个优先级表别看简单,写反了整个模型的少数类召回会崩。
还有坐标系的问题。激光雷达坐标系、相机坐标系、图像坐标系、体素栅格坐标系,四套坐标之间的转换必须严格对齐,尤其是栅格原点选在哪儿——原点偏半个体素,整个标签图就会有系统性偏移,训练出来的模型在推理时会整体往一个方向偏。我在这一步浪费过整整两天,最后是靠把标签可视化叠到点云上才发现的。
3.2 先算显存,再选卡
体素这个任务的显存开销,建议你动手前就粗算一遍,别等跑起来再猜。以室外配置为例:两百一十万个体素,如果三维特征通道数是 128,单精度浮点,那么一个特征张量就是 210 万 × 128 × 4 字节,大约 1.07 GB。这还只是一个张量。编解码器有多个层级、每一层都有输入输出、反向传播要保存中间激活、优化器还要给每个参数存两份状态。乘起来轻松就是几十 GB。
所以真实的配置单卡基本只能放一张图。这带来一个连锁问题:batch size 上不去,批归一化的统计量估计不准。常见对策是把归一化换成对 batch 不敏感的版本,或者用跨卡同步的方式凑等效 batch。我自己更倾向后者,因为你一旦换了归一化方式,预训练权重就不好直接用了。
还有一个省显存的技巧值得说:FLoSP 里每个体素要拼多个尺度的特征,如果每个尺度都是 256 维,拼出来就是上千维,这个张量在进三维网络之前是显存大头。实际实现里会在拼接之后立刻接一个降维卷积,把维度压回去。这个降维层看着不起眼,但省下来的显存可能刚好够你把 batch 从 1 撑到 2——而 batch 从 1 到 2,训练稳定性是质变。
3.3 训练参数怎么定,曲线怎么看
优化器一般用自适应矩估计那一类,学习率在万分之一量级起步,配多项式衰减。迭代轮数在室外数据上通常是几十轮,在室内数据上可以少一些。这些数字别当圣旨,你的数据量、类别分布、体素分辨率一改,全都得重调。
曲线判读这块有个特征你要心里有数:这个任务的损失下降通常很慢,前几轮几乎看不出变化。因为它不是在做逐像素分类,是在从零学一个三维先验,前几轮网络基本在学"大部分空间是空的"这个平凡解。如果你在第三轮就看到损失掉得飞快,反而要警惕——可能标签泄漏了,或者类别权重设得太极端。
我自己习惯盯的不是总损失,而是分开看投影损失和体素损失。如果投影损失降得很快而体素损失几乎不动,说明网络在拟合二维观测、但没学会往遮挡区外推,这时候模型在观测区指标会不错、完整场景指标会很差。反过来如果体素损失降但投影损失震荡,检查一下内参对齐和投影坐标系。
4. 指标会说谎:SC IoU 与观测区 IoU 的差异
4.1 两套指标度量的是完全不同的东西
这个领域的评估通常给两套数字。一套只看观测区域,也就是图像里实际能看到的那些体素;另一套看完整场景,包括所有遮挡区和视野外的体素。两套数字的差距能大到让你怀疑是不是搞错了。
因为观测区的标签基本来自直接观测,可信度高,模型在那儿做对了就是真做对了。而完整场景的标签是靠多帧累积生成的,本身就带噪声——动态物体导致的鬼影、累积不完整导致的空洞,都会算进误差里。更麻烦的是,完整场景里空体素占绝大多数,所以一个把所有东西都预测成空的模型,在完整场景的"空类"指标上会非常好看。
所以你在读结果的时候必须两个一起看:观测区指标高但完整场景指标低,说明模型不敢外推;完整场景指标高但观测区指标低,大概率是空类刷分。单独拿一个数字去比较不同方法,基本没有意义。
4.2 用切片可视化代替单个数字
指标是全局平均,可视化是局部真相。我在调这类模型时,习惯每次评估都固定导出几张俯视图切片和几张侧视图切片,按高度分层看。俯视图看地面和低矮物体,侧视图看墙面和垂直结构。
具体怎么看:先看俯视图里道路的边界是不是连贯。如果不连贯,像被啃过一样,说明投影对齐有问题或者深度估计在边缘处不稳。再看车辆的形状——好的预测里车辆是一个规整的方块,差的预测里车辆会沿视线方向拉长或者拖尾,这就是前面说的射线歧义没解决干净。最后看侧视图里的地面高度,如果地面在远处突然翘起来或者塌下去,说明深度尺度估计在远端偏了。
有个小技巧:把预测和标签同时渲染成同一视角的两张图并排看,比看差值图更容易发现系统性偏差。差值图告诉你"错在哪",并排图告诉你"错成什么样"。
4.3 三类典型的失败模式
第一类是"贴地失败"。所有物体都被压在地面附近,垂直方向几乎没有结构。这通常意味着三维网络在垂直方向的感受野不够,或者体素在高度方向的层数太少。室外配置只有 32 层,如果场景里有很多高杆、树冠、建筑立面,这个分辨率确实吃力。
第二类是"整体偏移"。预测的物体位置和标签之间有一个整体平移,方向一致。这几乎一定是坐标变换的问题,不是模型的问题。别去调网络,回去查外参。
第三类是"遮挡区虚警"。车后面、墙后面凭空多出一堆物体。这类失败有两种原因:要么是训练时遮挡区的标签噪声太大(累积鬼影),模型学到了错误先验;要么是亲和损失权重太高,逼着模型在没证据的地方也给出高置信度预测。区分方法很简单——看虚警的物体是不是动态类。如果全是车和行人,那大概率是鬼影标签的锅。
5. 踩坑实录:四条排查链路
5.1 第一个 epoch 就爆显存:从体素数往下逐层定位
爆显存这事我遇到过三次,每次原因都不一样,所以排查得有顺序。
第一步,确认输入分辨率。别笑,真有人把图像长边设成几千像素就扔进去了,二维主干那一步就把显存吃光,根本走不到三维。第二步,把三维网络的输出头临时改成只输出单个通道,跑一遍看还爆不爆。如果这时不爆了,说明瓶颈在最后的分类头——体素数乘类别数的那个张量比你想象的大,两百一十万体素乘二十类乘四字节就是一百六十多兆,加上反向传播的中间量还得翻几倍。第三步,如果单通道也爆,那就逐层打印每个张量的形状和字节数,一般能在三维解码器的某一层上采样处找到元凶——上采样会把分辨率翻倍,那一瞬间的峰值占用往往是最高的。
还有个隐蔽的原因:数据加载器的预取队列。如果工作进程数设得太多,每个进程都持有一份数据在内存里等着送进显存,看起来是内存问题,实际会挤压显存的可用空间。把工作进程数降到合适值,问题就没了。
5.2 损失卡住不动:从标签分布往回查
训练到某个轮次,损失在一个值附近小幅震荡,怎么调学习率都不动。这时候别急着改网络结构,先统计一下标签的类别分布。
我做过的统计里,室外场景的空体素占比经常在百分之九十以上,地面又占了剩下的很大一部分,真正有意义的少数类可能只占千分之几。这种分布下,交叉熵的梯度几乎全被大类占满,少数类学不动。表现就是损失确实在降,但降的是大类那部分,少数类的召回一直是零。
对策有三条,我建议按顺序试:先加类别权重或者改用对不平衡更友好的损失形式;再引入前面说的场景级亲和损失,它对少数类更敏感;最后如果还不行,就得考虑在采样阶段做文章,比如对包含少数类的局部区域做额外的裁剪采样。第三条改动最大,慎用。
还有个容易误判的情况:损失不动,但其实模型在学,只是你盯的是总损失。把每条损失分开画,经常能看到一条在降一条在涨,互相抵消了。这时候要调的是权重,不是学习率。
5.3 推理结果整片悬浮或塌陷:先怀疑深度尺度
推理时发现整块预测空间像是被抬起来了或者压下去了,所有物体都不在自己该在的位置。这种症状十有八九不是分割出了问题,是深度或外参的问题。
排查顺序是这样:先拿一张训练集里的图做推理,如果训练集上正常、验证集上异常,那是分布偏移,跟几何无关;如果训练集上也异常,那就是几何链路的问题。接下来把中间投影出来的深度或者体素投影位置可视化出来,和真实点云叠在一起看。我遇到过一次是相机高度参数录错了,差了十几厘米,导致整个地面平面抬升——肉眼看预测图觉得"还行",一叠点云立刻原形毕露。
还有一种情况是内参的焦距用错了。焦距偏大会让预测的空间被"压缩",物体看起来被推远了;焦距偏小则相反。这类错误在单张图上看不出来,但在序列上会表现为"前车忽远忽近",因为每帧的估计误差不一致。
5.4 换数据集后指标腰斩:先分清是域差异还是配置差异
把一个在室外数据上训好的配置直接搬到室内数据上,指标掉一半是常态,但掉的原因可能完全不同。
域差异是真实存在的:室内场景的物体尺度小得多、相机高度低、光照复杂、纹理重复(白墙、地板),单目深度估计在这些条件下本来就难。但更常见的是配置差异被忽略了——体素边长不一样了、栅格范围不一样了、类别体系不一样了、甚至图像预处理的归一化参数不一样了。
我的排查办法是做一次"最小可跑"配置:把分辨率调到最小、类别合并成三四个粗类、只训几轮,看能不能收敛。如果最小配置能收敛,说明数据管道没问题,是配置或者容量的问题;如果连最小配置都不收敛,那基本可以确定是数据加载或标签生成环节出了错,回去查坐标系。
6. 部署视角:推理耗时、可砍模块和后续延伸
6.1 一帧的耗时到底花在哪儿
单帧推理的耗时不是均匀分布的。二维主干大概占一成多,三维编解码器占大头,最后的分类头和可视化占剩下的。这个分布意味着你优化二维主干(比如换个更轻的骨干)收益很有限,得往三维那边想办法。
三维部分的耗时又跟体素数强相关。室外配置两百多万体素,哪怕每个体素只做几十次乘加,总量也很可观。而且三维卷积在多数推理框架里的算子优化程度远不如二维卷积,实际跑出来的效率经常只有理论值的一半。
实测下来,一套中等规模的三维编解码在单张主流计算卡上跑一帧大概在百毫秒量级,具体数字跟你的通道数、层数、有没有用半精度关系很大。这个速度放在离线标注场景完全够用,放在实时感知里就得砍。
6.2 想提速可以动的三个地方
第一处是体素分辨率。这是最直接也最伤精度的杠杆。把室外配置从 256×256 砍到 128×128,体素数直接降到四分之一,速度大概能提两三倍,代价是远处的小物体基本消失。我的建议是别全局降,改成"近处密、远处疏"的非均匀栅格,工程上麻烦但效果好得多。
第二处是三维网络的通道数。编解码器的通道配置通常是逐层翻倍的,瓶颈层通道数最大。把瓶颈层砍一半,参数量和计算量都显著下降,精度损失通常在可接受范围。这段我试过,砍完在观测区指标上掉得很少,主要损失在遮挡区的补全质量上——因为通道少了,先验表达力弱了。
第三处是输出头的处理。稠密输出意味着要对每个体素算类别概率,这个操作在显存和算力上都贵。如果下游只关心某几个高度层(比如地面附近的行人和车辆),完全可以只在那些层上算完整分类,其他层粗略判断空或非空。这个优化对精度几乎无损,但需要对下游需求有清晰认识。
6.3 这套东西后面还能接什么
单目的三维语义占据作为一个中间表示,价值在于它比图像更接近下游需要的空间结构。最直接的用法是给规划模块提供可通行区域判断——地面上有没有障碍物、障碍物大概多高,这些信息从体素表里读比从图像里读可靠得多。
另一个方向是时序融合。单帧补全的遮挡区全靠猜,但如果有多帧,前面帧看到的东西可以搬到当前帧的坐标系里,遮挡区就有了真实观测。这条路在工程上比单帧做得更极致要划算得多,代价是要维护位姿和时序对齐,复杂度上去了。
还有一个我觉得挺有意思的方向:把补全结果当作伪标签去训练其他模型。比如用补全的三维标签去监督一个纯二维的深度或者占据预测网络,蒸馏出更轻量的模型。这条路的前提是你得对补全结果的误差模式有清晰认识——哪些区域的预测可信、哪些不可信,否则蒸出来的学生模型会继承老师的系统性错误。
最后分享一个实操中总结的小经验:三维语义场景补全这类任务的调试,八成时间会花在数据链路上,两成花在模型上。所以每次改动前先问自己一句——我是怀疑数据还是怀疑模型?如果答不上来,就先做可视化,把中间结果画出来看,比盯着损失曲线猜要快得多。我个人的习惯是每接一个新数据集,先花半天时间只做一件事:把标签的俯视图、侧视图、类别分布统计全部导出来看一遍,确认没有系统性偏移再开始训。这半天省下来的时间,通常是好几天。