单目3D语义场景补全实战:MonoScene原理、训练与部署避坑
第一次看到单目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 这套东西后面还能接什么单目的三维语义占据作为一个中间表示价值在于它比图像更接近下游需要的空间结构。最直接的用法是给规划模块提供可通行区域判断——地面上有没有障碍物、障碍物大概多高这些信息从体素表里读比从图像里读可靠得多。另一个方向是时序融合。单帧补全的遮挡区全靠猜但如果有多帧前面帧看到的东西可以搬到当前帧的坐标系里遮挡区就有了真实观测。这条路在工程上比单帧做得更极致要划算得多代价是要维护位姿和时序对齐复杂度上去了。还有一个我觉得挺有意思的方向把补全结果当作伪标签去训练其他模型。比如用补全的三维标签去监督一个纯二维的深度或者占据预测网络蒸馏出更轻量的模型。这条路的前提是你得对补全结果的误差模式有清晰认识——哪些区域的预测可信、哪些不可信否则蒸出来的学生模型会继承老师的系统性错误。最后分享一个实操中总结的小经验三维语义场景补全这类任务的调试八成时间会花在数据链路上两成花在模型上。所以每次改动前先问自己一句——我是怀疑数据还是怀疑模型如果答不上来就先做可视化把中间结果画出来看比盯着损失曲线猜要快得多。我个人的习惯是每接一个新数据集先花半天时间只做一件事把标签的俯视图、侧视图、类别分布统计全部导出来看一遍确认没有系统性偏移再开始训。这半天省下来的时间通常是好几天。