1. 从一张“矮胖柱子”图说起:Stixel World 到底在解决什么问题
如果你做过自动驾驶或者移动机器人感知相关的项目,大概率在某个深夜盯着点云和视差图发过呆:摄像头数据那么稠密,可真正要拿来做决策的,其实只是“前面这块能不能走”“左边那个障碍离我多远”。Stixel World 就是冲着这个矛盾来的。它把一张稠密的视差图,压缩成一排竖直的、贴着地面的“矮胖柱子”,每根柱子代表一个障碍物或可通行区域,高度、距离、类型都标得清清楚楚。我第一次接触这个概念的时候,脑子里蹦出来的画面是超市货架上一排排整齐的罐头——每个罐头就是一个 stixel,简单、直接、不啰嗦。
这个标题叫“Stixel World 原理深度解析报告”,核心关键词是Stixel、视差图、障碍物检测、可通行区域、地面估计、动态规划。它要讲清楚的是:为什么要把稠密视觉信息压成这种柱状结构,背后的数学原理是什么,工程上怎么一步步实现,以及在实际项目里踩过哪些坑。适合谁看?做 ADAS、机器人导航、视觉感知的工程师,或者正在学立体视觉、想找一个能落地的中层表示方法的学生。哪怕你之前只听过“视差图”三个字,跟着走一遍也能明白这套东西的来龙去脉。
我先把结论摆在这儿:Stixel World 不是又一个花哨的深度学习模型,它是一套基于几何和动态规划的经典中层表示方法。它的价值在于把“像素级”的稠密计算,转化成“柱级”的稀疏结构,让后续的跟踪、避障、路径规划都能在一个干净、紧凑的数据结构上跑。你完全可以在一个嵌入式平台上把它跑起来,这也是为什么很多量产辅助驾驶系统里,你都能隐约看到它的影子。
2. 核心思想拆解:为什么是“柱子”,而不是框或点
2.1 从稠密到稀疏:中层表示的取舍逻辑
立体视觉给出的视差图,分辨率动辄百万像素。如果直接把每个像素都当成一个障碍点,计算量爆炸不说,噪声还特别多。你想想,路面上一块小石子、一片树叶,在视差图上都会产生一堆跳变的点,如果全送给规划模块,车就没法开了。所以感知系统需要一个“中间层”,把像素级信息聚合成有物理意义的单元。
常见的中间层表示有三种:边界框、点云聚类、柱状结构。边界框适合做目标检测,但它把物体框成一个矩形,对于“这堵墙到底从哪开始到哪结束”“这个路沿能不能压过去”这类问题,框给不了精细答案。点云聚类呢,在激光雷达上很自然,但视觉视差图转出来的点云,密度随距离急剧下降,远处物体聚成一团,近处又散得厉害,聚类参数很难调。
Stixel 走的是第三条路:在图像列方向上做分割。每一列像素,从下往上扫,先找到地面,再找到地面之上的障碍物,把障碍物的高度和距离用一个矩形块表示出来。这个矩形块就是一根 stixel。它的宽度是固定的(比如 5 个像素一列),高度是障碍物的实际高度,底边贴在地面上。这样一来,整张图就变成了一排宽度均匀、高度不一的柱子,像栅栏一样立在相机前面。
提示:Stixel 的“宽度固定”是它和普通分割方法最大的区别。固定宽度意味着计算量可控,而且天然适合做列扫描和动态规划。
2.2 地面估计:一切从“脚底下”开始
Stixel 能成立的前提,是先把地面找出来。地面估计不准,柱子就会飘在空中或者陷进地里。常用的做法是:在视差图上,利用v-视差或者U-视差来拟合地面平面。简单说,就是把视差图按行投影,地面上的点会形成一条斜线,这条斜线的斜率就对应相机高度和俯仰角。
我试过两种地面估计方式。一种是基于RANSAC的平面拟合,在三维点云里随机采样三个点,算平面,看有多少点落在平面附近。这种方式对离群点鲁棒,但计算量偏大,而且如果场景里地面占比小(比如前面全是车),拟合容易失败。另一种是基于视差图的列式地面估计,对每一列,从图像底部往上找第一个视差跳变点,认为跳变点以下就是地面。这种方式快,但要求地面在图像底部可见,而且对阴影和纹理缺失比较敏感。
实际项目里,我通常会把两者结合起来:先用列式方法快速得到一个粗略地面,再用RANSAC在局部窗口内 refine。这样既保证了速度,又提高了鲁棒性。地面估计的精度直接决定了 stixel 底边的位置,底边偏一个像素,远处障碍物的距离误差可能就好几米。
2.3 动态规划:在每一列里找“最优分割”
有了地面,接下来要在每一列像素里,从地面往上,决定哪些像素属于障碍物,哪些属于背景。这个问题可以形式化成:在一条一维的像素序列上,找一个分割点,使得“障碍物假设”和“背景假设”的总代价最小。代价函数通常包含两部分:数据项和平滑项。
数据项衡量的是:如果把这个像素归为障碍物,它的视差和地面视差差多少?差得越多,越可能是障碍物。平滑项衡量的是:相邻像素的分割结果应该连续,不能这一列说障碍物到 10 米高,下一列突然变成 1 米。这个优化问题可以用动态规划在 O(N) 时间内解出来,N 是列方向的像素数。
为什么用动态规划而不是图割?因为 Stixel 的优化是逐列独立的,列与列之间没有耦合(或者说耦合很弱,可以通过后续的列间平滑来处理)。逐列动态规划的好处是并行度高,每一列可以独立计算,非常适合在 GPU 或者多核 CPU 上加速。我实测过,在 1242x375 的视差图上,逐列动态规划跑一遍只要几毫秒,完全满足实时性要求。
3. 核心细节解析:从视差图到 Stixel 的完整链路
3.1 输入准备:视差图的质量决定上限
Stixel World 的输入是一张稠密视差图。这张图的质量直接决定了最终 stixel 的精度。我见过太多项目,视差图本身边缘就糊成一团,后面再怎么优化 stixel 也是白搭。所以第一步,得确保你的立体匹配算法靠谱。
常用的立体匹配有SGM(半全局匹配)和BM(块匹配)。SGM 效果好,但计算量大;BM 快,但边缘和弱纹理区域容易出错。如果是在嵌入式平台上跑,我建议用 SGM 的变种,比如SGM 的快速近似,或者用ELAS这类基于概率的方法。关键是看你的应用场景:如果只是做前方障碍物检测,对远处精度要求不高,BM 也能凑合;但如果要做可通行区域分析,地面附近的视差必须准,那就得上 SGM。
视差图出来之后,通常还要做一步左右一致性检查,把遮挡区域和误匹配点剔掉。这些点的视差是无效的,在后续动态规划里要特殊处理,不能直接当成障碍物。
3.2 地面模型:参数化与更新策略
地面模型一般用一个平面方程表示:n^T * X = d,其中n是平面法向量,X是三维点,d是相机原点到平面的距离。在相机坐标系下,如果相机水平安装,地面平面大致是Y = -h,h是相机高度。但实际车辆会有俯仰和侧倾,所以平面法向量需要实时估计。
我常用的做法是:在视差图的下半部分,取一个感兴趣区域(ROI),把里面的点反投影到三维,然后用 RANSAC 拟合平面。拟合出来的平面参数,再用卡尔曼滤波做时序平滑,防止帧间跳变。这里有个细节:ROI 的选择很关键。如果 ROI 选得太小,拟合样本不够;选得太大,又容易把远处障碍物上的点混进来。我的经验是,ROI 的高度取图像高度的 1/3 左右,宽度取中间 2/3,这样既能保证样本量,又能避开两侧的建筑物和树木。
地面平面估计出来之后,对于每一列,地面在图像上的位置就可以算出来。具体来说,对于图像上的像素(u, v),如果它在地面上,它的视差d和v满足一个线性关系:v = a * d + b。这个关系可以从平面方程推导出来。有了这个关系,每一列的地面位置就确定了,动态规划只需要在地面以上的区域搜索。
3.3 代价函数设计:数据项与平滑项的平衡
代价函数是 Stixel 的灵魂。设计得好,柱子又准又稳;设计得不好,柱子要么碎成一片,要么把整棵树都吞进去。数据项通常用视差残差来定义:对于像素(u, v),如果它属于障碍物,它的视差应该和地面视差有显著差异。差异越大,代价越小。
具体公式可以写成:
C_data(v) = -log(P(d(u,v) | obstacle)) // 障碍物假设 C_data(v) = -log(P(d(u,v) | ground)) // 地面假设实际工程里,为了简化,经常用截断的线性函数或者高斯核来近似。比如,如果|d(u,v) - d_ground(u,v)| > threshold,就认为它是障碍物,代价设为一个负常数;否则代价设为零。这个 threshold 通常取 2-3 个像素的视差。
平滑项则是为了约束分割边界的位置。如果相邻两列的障碍物高度差太大,就施加一个惩罚。这个惩罚项可以用总变差或者二次惩罚。我一般用二次惩罚,因为它对小的不连续更宽容,对大的跳变惩罚更重。平滑项的权重需要调,权重太大,柱子会变得很“软”,边界模糊;权重太小,柱子会碎,噪声多。我的经验值是:数据项和光滑项的权重量级要匹配,通常光滑项权重取数据项的 0.1 到 0.5 倍。
3.4 动态规划求解:状态定义与转移方程
动态规划的状态定义很直接:对于每一列u,状态s表示障碍物的上边界在图像上的行号v。从地面行号v_ground到图像顶部v_top,一共有H个可能的状态。转移方程是:
DP(u, s) = C_data(u, s) + min_{s'} (DP(u-1, s') + C_smooth(s, s'))其中C_smooth(s, s')是相邻两列状态差的惩罚。这个 DP 可以在 O(H^2) 时间内解出来,但 H 通常只有几百,所以实际计算量很小。如果 H 很大,可以用距离变换或者斜率优化把复杂度降到 O(H)。
我踩过的一个坑是:DP 的初始状态和终止状态。如果不对第一列和最后一列做特殊处理,柱子会在图像边缘出现奇怪的截断。我的做法是,在第一列之前虚拟一列,状态设为地面;在最后一列之后也虚拟一列,状态也设为地面。这样边缘的柱子就会自然收敛到地面。
4. 实操过程:手把手搭一个最小可用的 Stixel 管线
4.1 环境与数据准备
要复现这套东西,你不需要什么高端设备。一台普通笔记本,一个双目相机(或者用公开数据集),加上 Python 和 OpenCV 就能跑起来。我推荐用KITTI 数据集里的双目图像和视差图,因为它有 ground truth,方便你验证地面估计和 stixel 的精度。
数据准备步骤:
- 下载 KITTI 的 stereo 2015 数据集,里面包含左右目图像和视差图。
- 如果视差图是稀疏的(比如激光雷达投影的),先用 SGM 或者 BM 生成稠密视差图。
- 把视差图归一化到 0-255,方便可视化。
代码上,我习惯用 Python 做原型,因为调试快。核心库就是numpy和opencv-python。如果你要上嵌入式,再用 C++ 重写。
4.2 地面估计的代码实现
地面估计我一般分两步:先粗估,再精修。粗估用列式方法,精修用 RANSAC。
import numpy as np import cv2 def estimate_ground(disparity, camera_height, focal_length, baseline): """ 粗估地面:对每一列,从底部往上找第一个视差跳变点 """ h, w = disparity.shape ground_v = np.zeros(w) for u in range(w): col = disparity[:, u] # 从底部往上扫 for v in range(h-1, -1, -1): if col[v] > 0: # 有效视差 # 检查是否与地面视差一致 d_ground = focal_length * baseline / (camera_height * (v - h/2) / focal_length) if abs(col[v] - d_ground) < 2: ground_v[u] = v else: break return ground_v这段代码只是示意,实际用的时候要处理无效视差和噪声。精修的时候,把ground_v对应的三维点拿出来,用cv2.solvePnPRansac或者自己写 RANSAC 拟合平面。
4.3 动态规划的核心代码
动态规划部分,我用一个二维数组dp来存每一列每个状态的累计代价。状态数就是图像高度。
def stixel_dp(disparity, ground_v, params): h, w = disparity.shape dp = np.full((w, h), np.inf) back = np.zeros((w, h), dtype=int) # 初始化第一列 for v in range(h): dp[0, v] = data_cost(disparity, 0, v, ground_v[0], params) # 逐列递推 for u in range(1, w): for v in range(h): # 只考虑地面以上的状态 if v < ground_v[u]: continue best_prev = np.inf best_v = -1 for v_prev in range(ground_v[u-1], h): cost = dp[u-1, v_prev] + smooth_cost(v, v_prev, params) if cost < best_prev: best_prev = cost best_v = v_prev dp[u, v] = best_prev + data_cost(disparity, u, v, ground_v[u], params) back[u, v] = best_v # 回溯 stixels = [] v = np.argmin(dp[w-1, :]) for u in range(w-1, -1, -1): stixels.append((u, ground_v[u], v)) v = back[u, v] stixels.reverse() return stixels这段代码的时间复杂度是 O(w * h^2),对于 1242x375 的图,w=1242,h=375,大概 1.7 亿次操作,在 Python 里会有点慢。实际用的时候,我会用 numpy 向量化,或者用 C++ 重写。如果 h 很大,可以用距离变换把内层循环优化掉。
4.4 后处理与可视化
DP 出来的 stixel 是逐列的,相邻列之间可能高度跳变。后处理通常做两件事:列间平滑和小柱子合并。列间平滑可以用中值滤波,对每一列的 stixel 高度做 5 点中值滤波。小柱子合并则是把高度小于某个阈值的 stixel 去掉,或者合并到相邻的 stixel 里。
可视化的时候,我习惯把 stixel 画成半透明的矩形,底边贴地面,高度用颜色表示距离。这样一眼就能看出哪些是近处障碍,哪些是远处背景。
5. 常见问题与排查技巧实录
5.1 柱子“飘”在空中怎么办
这是最常见的问题。柱子底边不贴地面,说明地面估计偏了。排查步骤:
- 检查地面平面拟合的 ROI 是否包含了非地面点。如果 ROI 里有车、行人,拟合出来的平面会往上翘。
- 检查相机标定参数。俯仰角标定不准,地面平面就会倾斜。
- 检查视差图在底部的质量。如果底部视差全是无效值,地面估计就无从谈起。
我的经验是,在 ROI 里先做一次视差有效性过滤,只保留视差连续且平滑的区域,再做 RANSAC。这样能滤掉大部分非地面点。
5.2 柱子“碎”成一片怎么办
柱子碎,说明平滑项权重太小,或者数据项噪声太大。解决办法:
- 增大平滑项权重,让相邻列的 stixel 高度更连续。
- 在数据项里加入视差一致性检查,把左右目不一致的点代价设高。
- 对 stixel 高度做时序滤波,用卡尔曼滤波或者滑动平均,把帧间抖动压下去。
我试过在数据项里加一个高度先验:如果 stixel 高度超过某个阈值(比如 3 米),就施加额外惩罚。这样能防止把远处的树冠误判成近处障碍。
5.3 远处柱子距离误差大怎么办
远处视差精度本来就低,这是立体视觉的物理限制。缓解方法:
- 用亚像素插值提高视差精度。SGM 本身就有亚像素能力,BM 也可以做抛物线拟合。
- 对远处 stixel 的距离做时序累积,多帧平均。
- 如果应用允许,对远处障碍物只做“有/无”判断,不做精确测距。
我实测过,在 50 米以外,视差误差 0.5 个像素,距离误差可能就有好几米。所以如果你的应用需要远处精确测距,Stixel 可能不是最佳选择,得靠激光雷达或者毫米波雷达。
5.4 动态规划太慢怎么办
Python 里跑 DP 确实慢。优化手段:
- 用 numpy 向量化内层循环,把
for v_prev换成矩阵运算。 - 用 C++ 重写,开 OpenMP 多线程,每一列独立计算。
- 用 GPU,把 DP 写成 CUDA kernel,每一列一个线程块。
我在 Jetson 上试过,用 C++ 加 OpenMP,1242x375 的图能跑到 30fps 以上。如果用 GPU,还能更快。
6. 参数调优与实战心得
6.1 关键参数速查表
| 参数 | 含义 | 推荐值 | 调整方向 |
|---|---|---|---|
| stixel_width | 每根柱子的像素宽度 | 5 | 越小越精细,但计算量越大 |
| data_threshold | 视差残差阈值 | 2.0 | 越大越宽容,柱子越少 |
| smooth_weight | 平滑项权重 | 0.3 | 越大柱子越连续,但可能吞掉小障碍 |
| ground_roi_ratio | 地面ROI高度比例 | 0.33 | 越大样本越多,但可能混入障碍物 |
| min_stixel_height | 最小柱子高度 | 0.2m | 越小越敏感,但噪声也多 |
6.2 我踩过的三个坑
第一个坑:地面估计用了整张图。一开始我图省事,直接在全图做 RANSAC,结果前面有辆车的时候,地面平面被车顶的点带偏了,柱子全飘了。后来改成只在底部 ROI 做,问题解决。
第二个坑:平滑项权重设成了固定值。不同场景下,平滑项的最优值不一样。城市道路障碍物密集,平滑项要小一点;高速公路障碍物稀疏,平滑项可以大一点。后来我改成根据场景自适应调整,用视差图的梯度能量来估计场景复杂度。
第三个坑:忘了处理无效视差。视差图里有很多 0 值或者负值,这些点在 DP 里如果直接参与计算,会产生奇怪的代价。后来我在数据项里加了一个 mask,无效视差的代价设为一个很大的常数,相当于强制 DP 跳过这些点。
6.3 一个实用的调试技巧
调试 Stixel 的时候,我习惯把中间结果都可视化出来:地面平面、每一列的代价曲线、DP 的累计代价矩阵。这样一旦柱子出问题,能快速定位是地面估计错了,还是代价函数设计有问题,还是 DP 求解有 bug。特别是代价曲线,画出来之后,你能直观看到障碍物和背景的代价差异,对调参帮助很大。
7. 扩展方向:Stixel 还能怎么玩
Stixel World 本身是一个中层表示,它的输出可以接很多下游任务。我做过几个扩展,效果还不错。
一个是多帧 Stixel 跟踪。把每一帧的 stixel 当成观测,用卡尔曼滤波或者粒子滤波做跟踪,能得到障碍物的速度估计。这对于预测行人运动、车辆 cut-in 很有用。
另一个是Stixel 与语义分割融合。单独靠几何,很难区分“可通行的草地”和“不可通行的灌木”。如果把语义分割的结果叠加上去,给不同类别的 stixel 不同的通行代价,就能做更精细的可通行区域分析。
还有一个是Stixel 用于视觉里程计。Stixel 的底边贴地面,高度反映障碍物,这些几何信息可以用来约束相机位姿。我试过把 Stixel 的重投影误差加到 BA 里,对俯仰角的估计有改善。
这些扩展不需要改动 Stixel 的核心管线,只需要在输出端做文章。这也是 Stixel 作为中层表示的魅力所在:它足够简单,又足够灵活,能适配各种下游需求。
我个人在实际操作中的体会是,Stixel World 这套东西,原理不复杂,但细节特别多。地面估计、代价函数、动态规划、后处理,每一步都有坑。但一旦调通了,它就是一个非常稳的感知模块,计算量小,可解释性强,特别适合嵌入式平台。如果你正在做视觉感知,又觉得深度学习模型太黑盒、太吃资源,不妨试试 Stixel,它可能会给你带来一些不一样的思路。