如果你做过立体视觉相关的车载感知,第一次接触 Stixel World 的时候,大概率会冒出两个念头:这玩意儿看起来就是把视差图压扁成一堆“竖直小条”,为什么大家还在反复提它?另一句是,这些竖条到底是怎么从一帧图和一张视差图里“切”出来的?
Stixel World 本质上是一种介于稠密视差图和稀疏三维检测框之间的中间表示。双目相机输出视差图之后,常规思路是生成点云或者直接跑二维目标检测,而 Stixel World 换了一种写法:把图像每一列里的场景,抽象成若干条垂直于地面的“小棍”(stixel)。每一根小棍带着自己的底部位置、顶部位置、深度和类别,直接告诉下游模块:“这里是一个障碍物,边界在哪儿,大概多远”。
这篇文章我会把 Stixel World 的来龙去脉、几何模型、动态规划优化思路、工程实现步骤和踩坑经验一次性讲清楚。适合刚接手立体视觉感知项目、想在检测之外增加一个几何兜底方案的开发者,也适合想理解经典立体感知算法细节的从业者。
1. 先搞懂 Stixel World 到底在表达什么
1.1 为什么需要 Stixel 这种“垂直小条”
先看一个很现实的问题:立体相机跑完匹配之后,得到的视差图是逐像素的。1080p 图像就有两百多万个像素,每个像素一个视差值,直接把这张“深度毯子”交给路径规划模块,计算量很感人,而且噪声会原封不动地传下去。
但真正开车跑起来又会发现,结构化场景里绝大多数障碍物是长在地面上的:车、行人、路灯、护栏、墙面。它们的共性就是“垂直于地面”。既然这样,我为什么还要记住每一个像素?对于图像里的每一列,我只需要记住几个关键转折点:这个位置哪里是路面、哪里突然出现一个立着的东西、那个东西顶部在哪里、它距离相机多远。
Stixel World 就是把这个直觉工程化。它把图像一列一列处理,每一列被压缩成若干个竖直放置的矩形条,每个矩形条就是一个 stixel。这些竖条像一串珠子一样从上到下排列,刚好表示这一列中的自由空间、障碍物、遮挡区域。
这个思路带来的收益非常直接。原来一列里可能有几百上千个有效视差点,压缩成几个 stixel 之后,数据量能降两个数量级。更重要的是,stixel 输出的不是散点,而是带语义的“块”,后面的目标聚类和测距变得省事很多。
1.2 Stixel 输出形态与适用边界
一个 stixel 在数学上可以描述成:
- 所在的图像列 x;
- 底部图像坐标
v_bottom; - 顶部图像坐标
v_top; - 对应的视差值 d,或者换算后的深度 Z;
- 类别标签,通常分为地面(Ground)、障碍物(Object)、遮挡(Occlusion)。
实际工程中还会附带一个置信度,用来表示这个 stixel 在当前列上有多可靠。为什么会有多个 stixel 在同一列?因为场景不是一面墙。比如路边先有一辆高度 1.2m 的车挡住视线,紧贴着它后面可能还露出一幢楼的立面顶部,那这一列里就会有两个障碍物 stixel:近处一根,远处一根,中间用遮挡或天空状态的分段隔开。
Stixel World 最舒服的战场是结构化道路上的前向感知,包括城市道路、高速公路、园区封闭道路。这些场景里障碍物与地面的垂直关系非常明确,几何先验可靠。反过来,如果你要做的是高精地图测绘、复杂地形重建,或者场景里有大量不规则堆叠物、密集树枝,那它只能作为辅助表示,不能替代真正的三维重建。
边界想清楚之后,再去看原理就不会被“这技术很神奇”这种滤镜干扰了。它就是一个聪明、工程友好的几何压缩算法。
2. 深度拆解核心原理:从视差图到垂直小棍
2.1 双目几何基础与视差到深度的换算
要理解 Stixel World,绕不开双目立体视觉的基本公式。
两个经过标定和极线校正的相机,同一个三维点分别成像在左图和右图上,视差 d 就是两个成像点列坐标的差值。深度 Z 和视差 d 之间满足:
Z = (f * B) / d
其中 f 是相机焦距(单位像素),B 是左右相机光心之间的距离,也就是基线。这个公式看起来简单,却决定了 Stixel 的两个重要性质。
第一,深度和视差是反比关系。近处物体视差大,深度小;远处物体视差小,深度大。如果把视差换成深度再做拟合,远处一点点的深度误差会对应很大的视差抖动,所以 Stixel 的优化通常选择在视差空间而不是深度空间里进行。第二,物体在图像里的高度和它到相机的距离,会影响它在视差图里的表现。一个 2m 高的物体在 10m 处可能占图像 100 个像素,到 40m 处只剩 25 个像素。Stixel 算法需要把这个投影关系放进模型里,才不会在小目标上产生离谱的 stixel。
坐标转换同样重要。给定图像坐标(u, v)和视差 d,可以先恢复深度 Z,再得到相机坐标系下的三维坐标:
X = (u - c_x) * Z / f_x
Y = (v - c_y) * Z / f_y
其中c_x、c_y是主点坐标,f_x、f_y是归一化焦距。有了这个坐标,stixel 的“高度”才有物理意义。比如一个 stixel 从图像第 100 行到第 80 行,在 20m 处对应的高度可能只有 0.8m;在 5m 处对应的高度可能就是 3m。所以 stixel 的顶部和底部不能只看图像像素差,必须结合深度换算成真实世界高度。
2.2 V-disparity 与地面先验
Stixel 的另一个关键前置是“知道哪条线是地面”。如果连地面都分不出来,算法会在路面的小坑、反光点或者远处天空上到处乱标障碍物。
地面估计里最经典的工具是 V-disparity 图。做法很简单:对视差图里的每一行 v,统计该行中每个视差 d 的像素个数,形成一个二维累积表,行坐标是 v,列坐标是 d,格子的值是计数。这个表叫 V-disparity,也叫行视差统计图。
地面在这种图里非常显眼,因为路面上的所有点在同一个相机高度和安装角度下,像素行 v 越大(越靠近图像底部),视差 d 越大。于是地面像素在 V-disparity 图中呈现出一条从左上到右下的直线,或者轻微弯曲的曲线。用直线检测算法或者 RANSAC 最小二乘法把这条线拟合出来,就得到了地面的视差函数:
d_ground(v) = a * v + b
有了这个函数,任意一行 v 的“理论地面视差”就知道了。在 Stixel 模型里,如果某一列中某一段的视差接近d_ground(v),那这段大概率是路面;如果某一段的视差明显小于理论地面值,说明这里有一个比路面更近的直立物体,它遮挡了后面的地面。这个对比关系是所有数据项设计的起点。
这里要强调一点:直线模型只适用于近似平整的地面。遇到上下坡、凸起减速带时,地面线会变成折线或者曲线,后面我会在实战部分专门讨论。
2.3 动态规划如何选出最优 Stixel 组合
有了地面先验和像素级视差,接下来就是“每一列到底怎么切”的问题。
不考虑时间维度时,Stixel 生成可以建模为一个标记问题。对于某一列 u,从图像底部向上扫描,每个像素点都需要归属到某个状态:地面、障碍物、遮挡。地面和障碍物之间的切换位置,就是 stixel 的边界。
问题在于,逐像素做独立分类会非常碎。相邻列之间稍微有一点噪声,stixel 边界就会大幅抖动,画出来以后像一排锯齿。所以经典实现不考虑单列独立分割,而是把“所有列一起切”放宽成一个马尔可夫模型,再用动态规划求全局最优。
典型能量函数可以写成:
E = Σ D(column_state) + λ * Σ S(state_u, state_{u+1})
前一项是数据项,衡量当前列的状态是否和该列视差分布吻合;后一项是平滑项,惩罚相邻列之间 stixel 边界位置和深度变化太突兀。λ 越大,结果越平滑,但会把真实的细小障碍物一起抹掉。
动态规划的求解过程通常从图像底部开始,对每一列维护一个累计代价,在相邻列之间做状态转移。由于每一列可能的 stixel 边界组合很多,实际实现会限制转移范围,比如边界位置最多偏移几个像素,深度最多跳变多少。这样单列复杂度可控,整张图的求解仍然能在毫秒级完成。
这里有一个很多人忽略的细节:Stixel 的“列”到底是一像素宽的列,还是多像素宽的列。用一像素列,空间分辨率最高,但计算量更大;用多像素宽的小块,计算量更小,抗噪更强。实际工程里,我更推荐先在一列像素的层级做算法验证,确认没问题后,再为了速度合并成 2 像素或者 4 像素宽的列。合并之后再做边界后处理,效果不会差太多。
3. 工程实现:从标定到 Stixel 输出的完整流程
3.1 第一步:立体标定与视差图预处理
开始跑 Stixel 之前,我要确认双目系统是经过正确标定和极线校正的。这个环节出错,后面所有工作都会白费。操作流程可以参考:
- 使用棋盘格采集左右相机同步图像;
- 标定单目内参、畸变系数;
- 标定左右相机相对外参,得到旋转矩阵和平移向量;
- 做立体校正,让左右图像对应点在同一行;
- 用立体匹配算法计算视差图。
立体匹配我一般优先选 SGM 或者更轻量的半全局变体。OpenCV 里对应的接口参数有一堆,但最关键的几个值有固定套路:
| 参数 | 推荐起点 | 说明 |
|---|---|---|
| numDisparities | 64 或 128 | 覆盖最近物体的视差范围,需要是 16 的倍数 |
| blockSize | 9 或 11 | 太小纹理不足,太大会损伤物体边缘 |
| P1 | 8 * channels * blockSize^2 | 小视差变化的惩罚 |
| P2 | 32 * channels * blockSize^2 | 大视差变化的惩罚,通常 4 倍 P1 |
| uniquenessRatio | 10 到 15 | 抑制非唯一匹配,减少噪点 |
注意 OpenCV 返回的视差图默认是CV_16S类型,实际的浮点视差值要除以 16。很多新手在这个地方直接拿 int16 当浮点用,结果 Stixel 的高度和深度全错了。
视差图拿到之后还要做预处理,我会先跑一个中值滤波,窗口大小 3x3 或 5x5,去掉孤立噪点。再用左右一致性检查去掉被遮挡区域的错误匹配值。这些无效视差区域要保留成无效标记,后面 DP 的状态设计会把它们归到遮挡类,而不是硬塞一个不可靠的深度。
3.2 第二步:拟合地面先验
地面先验是整个 Stixel 算法里影响最大的模块,地面线一旦偏移,所有 stixel 的高度都会系统性出错。
第一步是生成 V-disparity 图。遍历视差图,按行 v 和视差 d 累计计数,得到一个二维矩阵。矩阵行数是图像高度,列数是最大视差加一。为了提高抗噪能力,可以用 2x2 或者 4x4 的格子做下采样再统计。
第二步是拟合地面线。如果没有坡道,直接做直线拟合就行。我习惯使用 RANSAC 而不是普通最小二乘,因为普通最小二乘会被车道线、护栏等突出计数带拉跑。RANSAC 在迭代时随机采样两对(v, d),计算直线方程,统计直线附近满足内的像素计数,选择计数最多的直线作为最终地面线。
第三步是转换和验证。地面线拟合出来以后,要在原始视差图上做一次抽样验证:随机取一些被判定为地面的像素,看看它们的视差和d_ground(v)的差值是否在容差范围内。如果误差普遍大于阈值,说明标定或者视差图本身有问题,不要急着往下走。
对于坡道场景,可以事先把图像按照纵向分成上、中、下三段,每段单独拟合一条直线,然后在段与段之间做插值。这样做虽然粗暴,但在大多数停车场出入口、短坡场景下已经够用。
3.3 第三步:逐列动态规划生成 Stixel
这一步是核心中的核心,我直接给出一个能体现主要逻辑的伪代码框架:
def compute_stixels_for_column(disp_col, ground_disp, params): # disp_col: 当前列从底部到顶部的视差数组 # ground_disp: 当前列从底部到顶部的理论地面视差 n = len(disp_col) # 数据项:每个位置属于 ground/object/occlusion 的代价 data_cost = compute_data_cost(disp_col, ground_disp, params) # 动态规划表 dp = [inf] * n parent = [-1] * n # 从底部向上遍历,记录最小累计代价和转移来源 for i in range(n): # 在相邻位置间做状态转移 for j in range(max(0, i - max_jump), i): cost = dp[j] + smooth_cost(i, j, params) if cost < dp[i]: dp[i] = cost parent[i] = j # 回溯路径,得到 stixel 边界 boundaries = backtrack(parent, n) return build_stixels_from_boundaries(boundaries, disp_col)实际工程里不会直接对每个像素点做 DP,而是先生成候选边界。候选边界来自两个线索:一是视差在这一列上发生跳变的位置,这些位置最可能是物体和地面的分界;二是物体顶部和底部的最小高度范围限制。有了候选边界,DP 的搜索空间会小很多。
数据项的计算可以这么理解:如果一段区间被分类成地面,那么区间内每个有效像素的视差都应该接近ground_disp,偏差越远代价越高;如果被分类成障碍物,那么区间内的有效视差应该集中在一个深度层附近,深度越一致代价越低。对无效像素集中的区间,则倾向于标记为遮挡,这样不会强行给出一个错误深度。
DP 结束后,每个 stixel 还需要做一步“高度合理性修正”。例如一个障碍物 stixel 的真实世界高度如果小于 0.3m,很可能是一块路肩或者小坑,不是需要避障的物体;大于 4m 的则可能是远处建筑物的立面,是否输出要看具体任务需求。这些规则放在 DP 之后做,不会破坏优化过程。
3.4 第四步:后处理与多帧稳定
单帧 DP 算出的 stixel 画出来已经像模像样,但连续视频流里仍然会抖。原因很简单:同一帧的视差图里,物体边缘的像素归属受噪声影响,边界可能在相邻帧之间跳一两列。
常见的时间稳定手段有三种。
第一种是滑动平均。对同一个列上的 stixel 底部行号和深度做指数移动平均,公式大概是value_t = alpha * observed_t + (1 - alpha) * value_{t-1},alpha 取 0.3 到 0.5。好处是实现简单,坏处是大目标突然出现时会滞后。
第二种是最近邻跟踪。把上一帧的 stixel 投影到当前帧,在附近范围内找最近的同一类 stixel,配对成功后做卡尔曼滤波。这种方法对物体边界的保持更好,适合用在需要输出稳定的自由空间边界的场景。
第三种是重投影校验。利用相机运动估计,把上一帧的 stixel 边界重投影到当前帧,和当前帧检测到的边界做比较,如果差异超过阈值,优先相信相邻多帧的一致性结果,而不是当前帧的跳动值。
后处理不是越强越好。平滑系数太大会让 stixel 对真实障碍物的响应变慢,这在紧急制动场景里非常危险。我个人的做法是先保证单帧响应足够快,再小幅增加时间平滑,确保系统在丢帧或者光照突变时不会输出一个“僵尸 stixel”。
4. 实战中难啃的硬骨头与排查技巧
4.1 视差噪声引发的“幽灵 Stixel”
最有意思的现象是:一段看似平整的路面,DP 结果里却出现一个 0.5m 高的 stixel,隔几帧又消失了。对当时经验不足的我来说,第一反应是调大平滑系数,结果发现压下去了,但旁边一堵真实矮墙也被抹平了。
后来排查才发现,根因出在视差图的孤立噪点上。路面上的车道线、反光点、树叶阴影都会造成立体匹配出错的像素,这些像素的视差可能比真实地面小很多,在列方向上形成一条伪“障碍物”线索。
解决思路分两层。视差层面:在输入 DP 之前,先把无效匹配点和左右一致检查失败的点全部标记为遮挡状态,不是所有像素都必须参与分类。状态层面:给障碍物 region 加一个最小宽度和最小高度约束,宽度小于 2 个像素、高度小于真实世界 0.3m 的候选结果直接丢弃。
排查这类问题,最好的工具不是盯着 stixel 图看,而是把同一列上的原始视差曲线、地面视差曲线和 DP 输出的边界叠在一起画出来。如果边界位置没有对应视差跳变,那基本可以断定是模型假设或者参数设置的问题。
4.2 非常规目标与遮挡
Stixel 的垂直小棍假设在多数场景下很优雅,但遇到“横着”的物体会露馅。最典型的是立交桥上方的大块路牌,它平行于地面,高度又悬在空中。Stixel 会把它分成两段:下方是地面,中间出现一段视差比地面小的区域,但这段的垂直延伸和真实障碍物完全不同,直接拿去做避障很容易误刹。
这一类问题不算 bug,而是模型假设的边界。我的解决办法是把 Stixel 输出和深度学习检测结果做一个交叉验证:如果 2D 检测给出一个很宽的障碍物框,但对应区域的 stixel 高度和深度变化不符合正常的垂直物体分布,我会降低这个目标的置信度,而不是直接相信几何结果。
遮挡也要小心。近处车辆遮挡了远处行人的下半身,同一列上可能出现“前景障碍物 stixel + 后景障碍物 stixel”的组合。后景 stixel 的底部不是地面,而是近处车辆的顶部。如果规划模块把这个底部当成碰撞点,会导致目标高度被低估。因此 stixel 里的每个障碍物必须额外携带一个“是否被遮挡”标记,后续处理才能正确判断该用哪个点做最近距离。
4.3 参数调试优先顺序与速查表
Stixel 参数不算多,但调参顺序错了,会一直原地打转。我总结的调试顺序是:
- 保证视差图整体质量过关;
- 先把地面线拟合调到肉眼看起来贴地;
- 再调 stixel 的平滑系数和最小高度;
- 最后才动时间域平滑参数。
如果发现 stixel 高度忽小忽大,先别急着调 stixel 参数,回头检查地面线是否被坡道带偏。地面线偏了,后面全是错的。
下面是一张我给团队新人常用的参数速查表:
| 参数 | 作用 | 调大影响 | 调小影响 |
|---|---|---|---|
| 视差可信度阈值 | 过滤不可靠视差 | 保留更多目标,但噪声变多 | 噪声减少,远处小目标丢失 |
| 平滑系数 λ | 约束相邻列一致性 | stixel 更稳定,小目标被吞 | 边界更碎,噪声敏感 |
| 最小真实高度 | 过滤过低 stixel | 忽略矮路肩,易漏低障碍 | 更容易把路肩当障碍 |
| 最大视差跳变 | 允许 stixel 深度突变幅度 | 能表达近远景交叠,易产生伪目标 | 限制深度分层,目标粘连 |
| 时间平滑 alpha | 控制多帧稳定 | 结果更稳,延时增加 | 响应快,但更抖 |
调参时不要一次性同时改三个以上参数。每改一个,观察一段含上下坡、行人、车辆的连续视频,不能只看十几帧。
4.4 常用的工程小技巧
再分享几个我实际项目中用过且有效的小技巧。
第一个技巧是在低分辨率层上先跑 Stixel。把图像和视差图同时缩小一半,DP 的搜索空间直接变成四分之一,跑完后把 stixel 边界乘 2 映射回原图,再做一次边界修正。对于 1080p 的输入,这个操作能把单帧处理时间从 20ms 压到 10ms 以内。
第二个技巧是把 DP 放到 GPU 上做。Stixel World 的列与列之间其实只有平滑项耦合,但平滑项转移只依赖相邻两列,所以可以按列分块并行。配合 CUDA 实现,处理时间能进一步降到几毫秒。如果只是做算法原型,先跑 CPU 版本,没必要一上来就追求极端性能。
第三个技巧是做输出单元测试。我会准备三组有代表性的数据:平直道路、上下坡道路、带行道树的遮挡场景。每次改完地面拟合或 DP 代价函数,就在这三组数据上跑一遍,记录 stixel 数量、平均高度、自由空间边界误差。没有基准测试,光靠肉眼很容易把参数调到“当前视频好看”,换个场景立刻崩。
5. 最后分享几条实战体会
5.1 如何把 Stixel 和后续检测模块串联
Stixel World 很少单独作为最终感知输出,更多时候它是几何传感器里最稳的一条支线。
我会把 Stixel 的自由空间边界送给路径规划模块,作为可行驶区域的几何约束。障碍物 stixel 则和 2D 目标检测结果做前融合:检测框给出类别和粗位置,stixel 给出框内目标的底部接触点和深度分布。当 2D 检测框在远距离上剧烈抖动时,stixel 的深度连续性反而更可信;当 stixel 把同一目标拆成两截时,2D 检测框又能提供横向合并的依据。
这套组合不太吃算力,但能显著提升最终障碍物测距的稳定性。尤其在没有高线数激光雷达的中低配置设备上,双目 + Stixel + 2D 检测几乎是低成本感知方案里最接近量产效果的一条路线。
5.2 我对这套技术路线的判断
Stixel World 不是一个新得发亮的技术,但它值得所有做视觉感知的人认真学一遍。因为它演示了一个很重要的工程思维:如何用场景先验把海量低层数据压缩成任务真正关心的信息。
同时也要清醒地看到它的局限。遇到强烈逆光、低纹理墙面、极端天气时,双目视差本身就不稳定,Stixel 作为纯视觉派的输出不可能好到哪去。所以在量产方案里,我会把它定位成“几何兜底 + 引导聚焦”,而不是唯一的安全屏障。
如果你正在做立体视觉感知项目,我建议先找一帧带道路和行人的校正后图像,手工画一下你期望的 stixel 边界,再对照算法输出,很快就能理解这个表示到底捕捉到了什么。整个过程不需要复杂 GPU,一块普通 CPU 就能跑通原理程序,但带来的模型理解价值,比直接套一个现成检测网络要高得多。