做自动驾驶感知的工程师,应该都听过 Stixel World 这个名字。第一次接触这个技术是在一个道路障碍物检测的项目方案评审会上,当时我满脑子还是逐像素分类那一套,看到团队里一位老哥把整幅道路场景压成几十根竖着的彩色柱子,第一反应是:这也太粗糙了吧。后来花时间把 Stixel World 的原理彻底吃透,又实车调了大半年,才意识到这种“粗糙”恰恰是工程上最聪明的取舍。Stixel World 的中文叫柱状世界表示,核心思路是把三维场景从像素级压缩成一组垂直小柱,每一根柱子用底边位置、高度、宽度、距离四个量来描述,百万像素的视差图最终变成几百个带物理意义的单元。
这年头做自动驾驶感知,算力永远不够用,而 Stixel World 的价值恰恰在于以极低的计算代价,把“哪里是地面、哪里立着东西、立着的东西离我多远、大概多大”这几个最关键的几何问题一次性回答清楚。这篇文章我不会照着论文念公式,而是从一个实际项目经历的角度,拆解它的原理、代码落点、工程实战和踩坑经验。无论你是刚入门视觉感知的学生,还是在做 ADAS 前装量产的老兵,应该都能从里面找到点有用的东西。
1. 先搞清楚 Stixel World 到底解决什么问题
1.1 像素级表示的算力账,绝大多数项目根本算不过来
先来看看朴素的感知方案是什么思路。拿双目相机拿到视差图之后,最直接的做法是把每个像素都分类:这个像素是路面、是车辆、是行人、还是天空。听起来很美好,做起来很绝望。假设图像分辨率是 1280x720,那就是接近 92 万个像素点。对每个像素提取特征、跑分类器、再做条件随机场平滑,这一套下来单帧耗时普遍在几十毫秒到上百毫秒。放到嵌入式平台上,帧率直接掉到个位数,更为致命的是,逐像素分类结果里到处是散粒噪声:同一辆车表面,有的像素被判成障碍物,有的像素被判成地面,后续聚类算法拿到这种输入,最常干的事就是给你把一辆车拆成七八块。
我自己测过,同样的视差图输入,逐像素高斯混合模型分类加后处理,在桌面级 GPU 上跑一帧大约 45 毫秒。可到了车规级芯片上,这个数字直接翻三倍都不止。而 Stixel World 的做法完全绕开了这个泥潭,它不关心每一个像素到底是什么类别,只关心整列像素的视差轮廓怎么分段。这一下就把问题规模从“几十万像素分类”降成了“几百列动态规划”。
1.2 直接上 3D 目标检测,其实也有隐含假设
有人会说:那我不做像素分类,直接上 3D 目标检测不行吗?现在基于点云或基于视觉的 3D 检测确实火,精度也高。但这类方法通常假设场景里有若干个明确的离散目标,比如车、人、自行车。真上了路你会发现,真实道路场景还有大量没法归到某个语义类别的障碍物:路边半米高的水泥墩子、墙面伸出的一截空调外机、施工区域那种歪歪扭扭的锥桶队列。这些物体你很难给它们一个准确的类别标签,但你又绝对不敢让车撞上去。Stixel World 的哲学是:我不管你是什么,我只知道你是一根竖着的、有一定高度的、挡住了行驶路线的柱子,这就足够了。
这个思路特别适合做“保底感知”。在深度学习模型漏检、误检甚至完全失效的时候,Stixel World 这种基于几何的方法依然能给规划控制一个最低限度的障碍物轮廓。它不跟你讲语义,只跟你讲几何。
1.3 中间表示:把三维世界折叠成几百根信息柱
Stixel World 之所以叫“World”,是因为它构建的是一个轻量级的三维世界模型。你看那根柱子,不是图像上的一个 2D 框。柱子有一个底边的三维位置,也就是它触地的那条线距离自车多远;柱子有一个三维高度,表示它从地面向上“长”了多高;柱子还有一个深度距离,表示它在空间里的远近。整个场景,被描述成一组垂直于路面的、宽度固定的柱子阵列。路面本身用自由空间模型表达,障碍物用柱子表达。就是这么一组极简表达,让下游模块——无论是紧急制动 AEB、路径规划还是自由空间检测——都能直接读取物理量,而不是再去做反投影。
2. 原理拆解:从视差图到 Stixel 的三步走
2.1 第一件事:拿到一张靠谱的视差图
Stixel World 不是从单目图像凭空变出来的,它的基础是视差图。视差图里每个像素的值表示左右目图像中对应匹配点的水平位移,单位是像素。有了视差 d,深度 Z 就能通过相机参数算出来:Z = f × B / d。f 是焦距(像素单位),B 是双目基线长度(米)。这个公式很好理解:两件事离得越近(d 越大)、相机基线拉得越开(B 越大)、镜头越长焦(f 越大),测距就越精确。
这里有个很容易犯的认知错误:视差图越“干净”,Stixel 效果越好。但真实场景里,大面积弱纹理区域(白墙、天空、光滑路面)视差是缺失的,雨雾天、夜间更是重灾区。LOW 纹理区域在立体匹配时就容易出现空洞。所以很多成熟的 Stixel 实现,第一步反而不是急着做动态规划,而是先对视差图做一次预处理插值和中值滤波,把孤立噪声去掉,把空洞区域用邻域背景填上。我常用的策略是先做 5x5 中值滤波,再对残余空洞做横向近邻填充,实测能把后续动态规划里因为视差噪声导致的“柱子断层”减少一大半。
2.2 第二件事:不等不靠,先估计出自由空间边界
Stixel 分割的重要前提,是知道哪些区域是“可以走的”,哪些区域是“被物体占着的”。这个任务在算法里一般叫自由空间估计(Free Space Estimation)。传统思路里有个很经典的工具叫 V-视差图。把视差图按行统计视差值直方图,就得到一个 V-disparity 图像,横轴是视差值,纵轴是图像行号。在这个图上,路面有一个非常漂亮的几何性质:路面上的点在视差空间里基本落在一条直线上。因为路面的三维高度处处为 0,视差和图像行号近似成线性关系。
通过在这张 V-disparity 图像上只拟合一条直线,就可以得到整幅图里路面每个行对应的“地面视差期望值”。然后把原始视差图和这个期望值逐像素比较,凡是视差明显大于地面期望的像素,说明它和自车更近,基本可以断定是这个垂直位置上有物体“捅破了”地面。把所有满足条件的像素连起来,就得到了每条列上的自由空间上边界。这一步是整个算法后续动态规划的重要初值,搞错了地面,后面所有 Stixel 的高度和底边位置全都会漂移。我自己在这块踩过最大的坑,是上下坡时路面直线变成折线,所以正规项目里,地面模型一般会扩展成多段线性或二次曲线模型。
2.3 第三件事:动态规划把每一列切成最优分段
地面模型给出来了,每个像素“是不是地面”也有一个大概的判别了,接下来怎么变成那几根干净的柱子?此时就要上动态规划了。对每一列像素单独处理:这一列从上到下,可以看成一组视差值序列。观察这个序列,你会看到它在“地面区域”有比较平滑、贴合地面模型的视差值;一旦遇到障碍物,视差值会突然变大(障碍物离车更近)。我们要做的,就是在这个序列上找到若干条分界线,把这一列切成一段段。每一段要么是“地面段”,要么是“障碍物段”。障碍物段就是一根 Stixel。切分不是随便切的,而是要最小化一个能量函数,这个能量函数通常由两部分构成:
- 数据项:切出来的每个分段,其内部像素的视差值应当与该分段的模型(地面模型或物体平面模型)拟合良好。物体段可以假设近似一个平面甚至常数视差,这一步最常用到中值估计。
- 平滑项:相邻列的切分边界不应该跳来跳去,毕竟真实世界里,墙体、车辆扫过连续几列,底部边界应当连续变化。这一项我们一般用相邻列的分界点之间的差值的绝对值来惩罚。
动态规划的优势在于全局最优,不会像贪心算法那样在某一列上拍脑袋切一刀,结果整面墙参差不齐。一次列扫描里,每一列内部做一维 DP,复杂度是 O(列数 × 行数 × 状态数)。因为是独立列的,天然可以并行处理。我实测过在四核嵌入式处理器上,用线程池把图像分成四条带,单帧完整的 Stixel 提取大概能控制在 5 到 8 毫秒内,这在当时已经是很可观的性能了。
3. 数据结构与伪代码:让 Stixel 真正跑起来
3.1 一根柱子的“五脏六腑”长什么样
看了很多开源实现,它们给 Stixel 定义的数据结构五花八门,但核心字段永远是那几样。我会用一个精简但完整的 C++ 结构体来定义。这个结构体之所以这么设计,是因为下游模块(AEB、泊车、路径规划)最关心的就是这几个物理量:
struct Stixel { int colStart; // 起始列索引(块内第一列) int colEnd; // 结束列索引 int rowBottom; // 底部触地行坐标(像素) int rowTop; // 顶部行坐标(像素) float disparity; // 该柱子代表的视差值(可近似为平均视差) float depth; // 换算后的深度,单位米 float height3D; // 三维高度,单位米 float width3D; // 三维宽度,单位米 bool valid; // 有效标志 float getWidthPixels() const { return static_cast<float>(colEnd - colStart + 1); } };这里有两个容易搞混的概念一定要分清楚:rowTop 是图像行号,数值越小越靠图像上方,而 height3D 是三维世界里的物理高度。有的人做可视化时直接把图像上柱子的高度当成三维高度,结果会闹出“整栋楼是根 40 米高柱子”的笑话。换算高度时,需要通过 rowBottom 和 rowTop 各自反投影出三维坐标,再做差,这里底座必须在同一个地面上。如果没有严格的地面模型,宁可不输出高度,也不要输出一个凭空拍脑袋的高度值。
3.2 完整处理流程,从视差图到柱子列表
下面用伪代码把最朴素的 Stixel 提取流程串一遍。这不是某个特定开源库的实现,而是把论文方法落成可执行逻辑的通用做法:
输入:视差图 disp(float,无效值标记为0或负值) 1. 对视差图做中值滤波,消除孤立噪声点 2. 构建 V-disparity 图(按行统计直方图) 3. 在 V-disparity 图中拟合地面直线(或用RANSAC鲁棒拟合) 4. for col in [0, width, columnStride]: a. 提取该列像素的视差序列 dispSeq b. 用动态规划将 dispSeq 分为若干段(地面段/物体段) c. 记录每个物体段的底部行 rowBottom、顶部行 rowTop d. 计算该物体段的平均视差 dispMean 5. 对相邻列生成的物体段做合并(同一连贯区域合并成宽Stixel) 6. 将每个合并后的 Stixel 做三维反投影,计算 depth 和 height3D 7. 输出 List<Stixel>这段流程里,步骤 5 特别容易被初学者忽略。如果不做列间合并,最终只会输出一堆单列宽、形状像牙签的柱子,视觉上很碎,规划模块也没法用。合并的规则非常简单:只有相邻列在 rowBottom、rowTop、disparity 三个维度上都足够接近,才判定为同一根柱子。这个接近阈值我建议用比例而不是绝对像素:底部行差不超过当前列高度的 5%,顶部行差不超过 10%,视差差不超过 2 个像素。实测这个比例在变道、转弯、上下坡时都能保持较好的稳定性。
3.3 关键参数怎么定,直接给参考值
参数的取值直接决定 Stixel 系统表现,这里把我在项目里调过最核心的几个参数列成表,给出经验值区间和典型配置。请注意,不同相机的分辨率和基线下,数值会有变化,不要无脑照抄,但可以当起点。
| 参数 | 作用 | 经验值/典型值 | 备注 |
|---|---|---|---|
| columnStride / 列宽 | 每多少列取一根柱子 | 2~8 像素 | 数值越小精度越高,代价是计算量大 |
| 地面拟合行数范围 | 只统计哪些行来拟合地面 | 图像底部 60% | 让天空和远处物体不干扰地面模型 |
| V-disparity 直线拟合点数阈值 | 判定一个视差值行是否为路面 | 该行像素数 3%以上 | 太低容易被误判为路面 |
| DP 数据项权重 | 拟合度在能量函数里的占比 | 0.6~0.8 | 太高柱子会贴着单像素噪声走 |
| DP 平滑项权重 | 相邻列边界连续性 | 0.2~0.4 | 太高会抹平真实物体的边界细节 |
| 最小 Stixel 高度 | 低于此高度的物体段直接丢弃 | 图像高度的 1.5% | 过滤地面碎屑、阴影 |
| 位置一致性合并阈值 | 相邻列合并判定 | rowBottom 5%,rowTop 10%,视差 2px | 详见上文 |
关于 columnStride 我要多说一句。很多论文里为了强调“独立列并行”,喜欢把列宽设为 1。实际工程里,1 像素的列宽会使得噪声被当作 1 像素宽的细小柱子输出,掉了满地的噪点。我调下来发现,如果分辨率是 1280 宽,取 stride 为 4 是性价比比较高的选择:既保留了大多数真实的窄障碍物形态,又大幅度抑制了单列噪声。要是做远距离探测(100 米以外),我甚至会把 stride 放到 6~8,因为远处目标在图像上本来就小,列宽加大会减少计算量,而且对远处小目标的检测也不会损失太多。
4. 工程落地:Stixel 在实车感知里的几种典型玩法
4.1 快速障碍物预筛:给 3D 检测节省算力
直接上 3D 检测器话费是很贵的,尤其是像 1280 × 720 输入,全图推理一帧就得几十毫秒 TensorRT 推理时间。而 Stixel 只需要几毫秒。那能不能让 Stixel 先“扫一眼”,告诉检测器“图像这个区域有东西,你重点看看?”这个思路我们在项目里叫 ROI 预筛。具体做法是把 Stixel 结果投影回图像坐标,生成一个包围盒区域。只在这些包围盒区域上调运检测模型。没有 Stixel 覆盖的区域呢?大概率是空旷路面,不需要浪费算力。实测在某些城市场景下,能把后级感知算力节省 50% 以上,而漏检率几乎不变。
当然这套做法的前提是 Stixel 的召回率足够高。我常说要保证“宁多勿漏”,把 DP 阈值调得稍微松一些,宁可多输出几根假柱子,也要保证真障碍物全部落在 ROI 里。因为后面还有类别分类器把关,多余的框最多增加一点算力开销;而漏掉的框直接意味着系统对那个障碍物完全失明,这是安全层面无法接受的。
4.2 自由空间划分:给规控模块画可行驶边界
自由空间估计是 Stixel 最强的一项能力。因为 Stixel 的底边位置直接给出了每个障碍物触地的边界线,把所有柱子的底边点连起来,就得到了场景里一条不规则的可行驶边界。这条边界对路径规划来说极其宝贵,因为它的计算快、物理意义清晰,而且不受光照变化影响——它完全依赖于双目视差几何,不看颜色不看纹理。行人、骑行者、车辆、路缘石,无论外观差异多大,只要它们在物理上是“凸起的、高于路面的一坨”,Stixel 就会在边界线上留一个“尖刺”。这些“尖刺”恰恰是规划算法最需要避开的点。
在这类应用里,障碍物语义分类反而不是重点。更重要的是把底边接触点的世界坐标精确输出。我一般会做一个轻量级的卡尔曼滤波,对每一根 Stixel 的底边点做时间平滑。这样即便某一帧出现一根假柱子,规划的参考线也不会被一带就跑偏。时间平滑的增益我建议控制在 0.2~0.5,太小有延迟,太大会导致真的撞上障碍物时边界线退让不及时。
4.3 时代进化:Stixel 给深度学习当先验
这几年深度学习模型满天飞,有些人觉得 Stixel 这种老派几何方法该被扫进历史了。实际上不完全是那么回事,Stixel 在深度学习框架里重新找到了自己的生态位。最常见的是把 Stixel 结果当成注意力机制的先验输入。深度模型的目标检测分支,只在 Stixel 标记的区域赋予更高的注意力权重,提升远距离小目标的召回率。同时,Stixel 提供的深度先验也可以用来引导单目深度估计网络,缓解单目深度估计尺度模糊的问题。还有团队把 Stixel 当作路径规划决策的“几何安全带”,专门用了与深度学习模型完全独立的置信度通道:模型说前方无障碍,但 Stixel 显示前方有一根 0.5 米高的柱子,最终决策以 Stixel 为准。这其实就是功能安全里“异构冗余”的思想。
从架构演进的角度看,Stixel 这种中间表示不会因为深度学习的流行而消失,它反而会以“几何基础层”的形态沉淀下来。因为视觉模型的软肋恰恰是几何:单目深度可能飘,分割边界可能毛糙,而 Stixel 直接回到底层几何约束上,用三角测量给出实打实的空间位置。两者结合属于取长补短。
5. 常见问题与排查技巧实录
5.1 车身抖动导致 Stixel 边缘像锯齿
实车上最普遍的现象:车辆过减速带、转弯侧倾的时候,Stixel 的边缘会突然参差不齐,看着像锯齿一样。根因在相机运动。双目视觉本身假设两帧之间是静止的,车一颠,地面模型和前一帧的几何先验就不完全对得上了,DP 切分边界随之跳变。排查时不要急着调滤波,先看地平线的位置。把车停稳,看 Stixel 提取结果是否稳定。如果稳定,那基本可以断定是运动干扰。解法主要有三类:接入惯导数据把每帧的相机外参做一次运动补偿;或把 DP 的平滑项权重和时间平滑的增益调大;或者在 V-disparity 地面拟合时使用上一帧的地面模型做初始值,用一个小权重更新,这样能抵抗单帧抖动。我在项目里选的是第三种,实现简单,效果稳定,实测锯齿减少了 80% 左右。
5.2 细长物体被“吞掉”或一分为二
电线杆、路灯杆、锥桶这类目标在图像上可能只占 2~3 列像素,本来就不够宽。两种典型故障:一是被当成噪声直接滤掉,二是虽然找到了,但因为在某些行上视差突变,被 DP 从中间切了一刀,一根柱子变成上下两截。排查建议从两个方向入手。一是降低中值滤波窗口,从 5x5 改成 3x3,避免细长物体被邻域背景抹平。二是在 DP 之后的合并阶段,增加一个“上下段拼接”步骤:如果在同一列上,下方段底部接触地面、上方段紧挨着下方段的顶部,且视差值接近(比如差小于 3px),就把两者合并成一根 Stixel。这个逻辑在掉电杆、扫把类物体上极其常用,因为细长物体经常在某个高度上因为反光、遮挡导致测得的一段高度不可靠。
5.3 上下坡场景:地面模型失效怎么办
平地上 V-disparity 拟合直线效果很好。但一上下坡,地面投影在图像上就不再是一条直线,而是一条折线甚至曲线。如果只做直线拟合,坡顶的物体可能整个被判断为路面的一部分,Stixel 直接漏检;坡底的物体底边位置也会算错。排查时,如果发现固定位置的障碍物在上下坡时底边跳动超过三五个像素,基本就是地面模型错了。解决方法有两个:一是把 V-disparity 的直线拟合改成多段线性拟合,每一段用 RANSAC 找局部直线,段与段之间平滑衔接;二是引入 IMU 的俯仰角先验,用车辆姿态直接修正地面模型参数。我个人的经验是,多段拟合在绝大多数非铺装路面都能应付,IMU 辅助更适合城市快速路那种高频小起伏场景。两个方法可以叠加,但优先保地面模型本身的鲁棒性,毕竟 IMU 也有漂移。
5.4 夜晚、欠曝、雨雾天:视差空洞灾难
晚上在城市道路,路灯照不到的区域基本就是双目的“盲区”,大片视差是 0,Stixel 提取器会把这些区域全部判为地面——对,一片空洞意味着没有阻挡视线的物体,但真实情况可能是有一辆黑色车停在那里。单纯靠 Stixel 的几何信息是救不回来的,因为输入本身就是残缺的。遇到这种情况,最务实的处理方式是“失明报警”:统计整张视差图有效像素比例,如果低于某个阈值,比如 20%,就在系统层上报感知置信度下降,提醒决策模块降低车速或采用冗余传感器(比如毫米波雷达)接管。不要幻想算法层面能无中生有。这个认知非常重要,很多新手在这个问题上死磕,给空洞插值、给低纹理区域填充假视差,折腾一圈反而容易出现更致命的大面积误检。我现在的原则是:视差不够时宁可靠别的传感器兜底,也不要强行让 Stixel 交出错误的答案。
6. 我自己踩过的坑与调参建议
6.1 先别急着调参,先保证标定和同步是好的
刚开始做 Stixel 项目时,我花了两周调参数,效果一直是“一半好一半烂”。后来被一位做相机标定的同事提醒,去查了双目标定参数,果然发现有一台相机的外参在车辆震动后偏了几毫米。这个误差在近距离只有几厘米偏差,但到了 50 米外,直接造成视差计算误差放大到十几个像素。Stixel 的一切基础都是视差,视差错了,柱子位置、高度全错。所以如果你发现无论怎么调 DP 权重都压不住噪声,先回头做一个静态标定验证,把棋盘格放在 5 米、10 米、20 米远处,确认深度误差曲线是否平缓。磨刀不误砍柴工,这个检查通常半小时就能做完。
6.2 宁要物理意义,不要“像素美观”
可视化时,有的人喜欢让 Stixel 和图像边缘严丝合缝地贴合,还把柱子边界描得特别精确。但做感知不是做 UI,过度追求像素级的边缘拟合只会让算法对噪声更敏感。我在实测中发现,真正让下游好用的 Stixel 反而是“稍微丰腴”一点的那种:底边位置稳、高度保守偏高一点、边缘有一定模糊容差。因为规划算法要的是“这里绝对不能走”,如果才刚够到障碍物边缘,就会导致规划路径贴得太极限,很容易擦碰。不少项目里我们故意在 Stixel 的深度和宽度上各加一个安全缓冲量,比如深度加 20 厘米、宽度左右各加 10 厘米,这样规控跑起来会从容很多。
6.3 个人最愿意复盘的收获:先定义“好”再谈“优化”
这是整个 Stixel 项目里让我收获最大的一点。项目刚开始,团队对“好结果”的定义是模糊的。有人觉得 Stixel 数量少了就是好,有人觉得障碍物都被框出来就是好。结果不同模块对参数的要求互相打架。后来我们把“好”拆成三个可量化的指标:一是召回率,场景里的真实障碍物有多少比例生成了 Stixel;二是误检率,没有障碍物的地方有多少假柱子;三是最小障碍物高度检测成功率,像路沿上 15 厘米高的障碍物也要能识别。先把指标定清楚,再去调参,整个项目的进展顺畅了很多,因为每个人心里都有同一个标尺。如果你现在正准备在自己的项目里上 Stixel,我的建议也是先花半小时把评价指标写下来,再做任何参数调整。没有指标调参,就像蒙着眼睛投篮,全凭手感,结果不可能稳定。
Stixel World 不是花瓶算法,它真正解决的是“在有限算力和不完美输入下,如何快速得到可靠物理障碍物描述”这个工程上最实际的问题。这篇报告里写的原理、代码逻辑、调参经验和避坑心得,都是我亲自跑过之后沉淀下来的,希望对正在研究视觉感知的你有所帮助。如果后面你也在做类似的项目,欢迎带着具体问题再来交流。