1. 从像素到柱子:为什么需要 Stixel 这种中间表示
第一次接触 Stixel World 这个概念,是在做一个低成本障碍物检测方案的时候。当时手头只有一个单目摄像头和一套算力相当有限的嵌入式平台,激光雷达用不起,双目立体匹配跑起来又太吃资源。摆在面前的问题很直接:怎么在有限的算力下,把前方“哪些地方能走、哪些地方有东西挡着”这件事说清楚。
大多数人的第一反应是直接做逐像素的语义分割,把每个像素分类成路面、车辆、行人、天空。这个思路在精度上确实诱人,但代价是每一帧都要对整张图做密集预测,算力和内存开销都很大,而且在嵌入式设备上很难做到实时。另一条路是只做目标检测框,输出一堆矩形框,但矩形框有个天然缺陷——它不告诉你障碍物和地面的接触点在哪里,对于“这个障碍物离我多远、我能不能从它旁边绕过去”这类问题,框给不出精确答案。
Stixel 这个表示法就是为了填这个空档而生的。它的核心思想非常朴素:把图像沿垂直方向切成若干根细长的“柱子”,每根柱子用一个带高度和距离的矩形来近似描述这一竖条上的场景结构。你可以把它理解成一种“稀疏但结构化”的中间层——比逐像素分割稀疏得多,又比检测框携带了更多几何信息。关键词里的“Stixel”本身就是“stick”加“pixel”的合成词,直译就是“像素柱”,这个名字已经把它的形态说得很清楚了。
我后来把这个方法用在一个模拟的园区低速代步车项目上,前方主要是平整路面加上偶尔出现的行人和静态障碍物。实测下来,Stixel 表示最大的好处是它天然区分了“地面”和“地面上的物体”这两类东西,而且每根柱子都带一个距离估计,这就让后续的可通行区域判断变得非常直接。这篇报告我打算把 Stixel World 的来龙去脉拆开讲清楚:它到底怎么定义、底层依赖什么假设、从图像到柱子的完整流程是什么、参数怎么调、实际落地时会踩哪些坑。不管你是做自动驾驶感知、机器人导航,还是单纯对这类中层次视觉表示感兴趣,应该都能从中拿到能直接用的东西。
2. Stixel 的数据结构定义与它背后的几何假设
2.1 一根 Stixel 到底装了什么信息
先把最基础的东西说清楚:一根 Stixel 在数学上是一个四元组。它由底部的图像纵坐标、顶部纵坐标、所属的列(横坐标)、以及一个距离值组成。底部纵坐标通常对应这根柱子与地面的接触点,顶部纵坐标对应这根柱子所代表物体的上边缘,距离值则是这根柱子代表的物体到摄像头的纵向距离。
用更直白的话讲,一根 Stixel 就是图像里某一竖条上“从地面往上长出来的一段东西”,并且告诉你这段东西离你有多远。如果这一竖条上什么都没有,只有路面,那这根 Stixel 就退化成一段很矮的、贴着地面的柱子,代表可通行地面。如果这一竖条上站了个人,那这根柱子就会从地面一直延伸到人的头顶,距离值就是这个人离摄像头的距离。
这种表示的精妙之处在于它的“有向性”。每一根柱子都是从地面往上生长的,底部锚定在地面上。这个设计不是随便定的,它直接来自一个关键假设:场景中的物体都是立在地面上的,而不是悬浮在空中的。对于道路场景来说,这个假设绝大多数时候成立——车、人、路牌、护栏,都是接地的。正是这个接地假设,让 Stixel 能够用一个底部点加一个高度就把物体的空间位置约束住。
2.2 接地假设与地面平面:整个方法的地基
Stixel World 能成立,靠的是两个几何前提,我把它们称为“地基”,因为一旦这两个前提崩了,整个方法就会给出错误结果。
第一个前提是地面近似为平面。在摄像头坐标系下,地面被建模成一个平面,通常用平面方程来描述。这个平面可以通过标定得到,也可以在行驶过程中在线估计。有了这个平面,图像中任意一个像素点,只要我们知道它对应的地面深度,就能反推出它在三维空间中的位置;反过来,给定一个三维地面点,也能投影回图像。地面平面是连接图像坐标和三维世界坐标的桥梁。
第二个前提是物体垂直于地面站立。也就是说,一根 Stixel 在三维空间里是一段竖直的线段,它的底部落在地面上,顶部在物体上边缘。这个假设让 Stixel 的高度可以直接换算成物体的真实高度——因为竖直方向没有倾斜,高度就是简单的比例关系。
这两个前提合在一起,带来一个非常实用的推论:一根 Stixel 只需要一个距离值就能确定它的三维位置。因为底部点在地面上,地面平面已知,所以只要知道距离,底部点的三维坐标就定了;顶部点在同一竖直线上,高度由图像上的像素高度差和距离共同决定。这就是为什么 Stixel 表示如此紧凑——每根柱子只存一个距离,却隐含了完整的三维信息。
注意:接地假设在上下坡、减速带、路面坑洼处会失效。如果你的应用场景里地面起伏明显,要么先做地面平面的分段拟合,要么对 Stixel 的距离估计加一层鲁棒性处理,否则底部点会漂移,距离跟着错。
2.3 为什么是“柱子”而不是“超像素”或“区域”
有人会问,既然要做稀疏表示,为什么不用超像素或者区域分割?这两者的区别很关键。超像素是在二维图像平面上做聚类,它保证的是颜色或纹理的相似性,但完全不保证几何一致性——一个超像素可能横跨地面和物体,也可能跨越不同深度的物体。而 Stixel 是沿垂直方向切分的,每一根柱子内部强制满足“同一竖直线上、同一距离”的约束,这就保证了每根柱子内部的几何是自洽的。
换句话说,超像素是“看起来像一块”的区域,Stixel 是“在三维里站得住”的柱子。对于要做距离估计和可通行性判断的任务来说,几何自洽比颜色一致重要得多。这也是我在实际项目里最终选择 Stixel 而不是超像素的根本原因——后续的避障逻辑需要的是可靠的距离和接地信息,而不是漂亮的分割边界。
3. 从一张图像到一排柱子:完整计算链路拆解
3.1 第一步:稠密深度或视差图的获取
Stixel 本身不产生深度,它依赖一个前置的深度来源。最常见的来源是双目立体匹配得到的视差图,因为视差和深度成反比,转换关系简单。单目方案则通常依赖地面平面假设加物体高度先验,或者用单目深度估计网络来提供初始深度。不管哪种来源,这一步的输出是一张稠密的、逐像素的深度或视差图,以及一张对应的置信度图。
这里有个容易被忽略的点:Stixel 对深度图的噪声相当敏感,尤其是物体边缘处的深度不连续区域。立体匹配在物体边界处经常给出错误的视差值,如果直接拿来做 Stixel 分割,柱子会在物体边缘处“撕裂”。所以实际流程里,通常要先对视差图做一层一致性检查,把左右视图不匹配的像素标记为无效,这些无效像素在后续分割时会被特殊处理。
3.2 第二步:沿列做一维分割,把柱子切出来
拿到深度图之后,核心操作是逐列处理。对图像中的每一列,算法要在这条竖直线上找到若干个“分段”,每一段对应一根 Stixel。分割的依据是深度值的跳变:同一根柱子内部深度应该基本一致,而相邻两根柱子之间深度会有明显变化。
这个一维分割问题通常被建模成一个能量最小化问题。能量函数一般包含两项:一项是数据项,衡量某个像素被分到某根柱子时的深度一致性代价;另一项是平滑项,惩罚相邻像素被分到不同柱子时的边界代价,但这个惩罚在深度跳变明显的地方会被削弱,以免把真实的物体边界也平滑掉。用动态规划沿列求解这个能量函数,就能得到这一列的最优分割。
我实测下来,动态规划在这里非常合适,因为它是沿一维方向做的,复杂度是线性的,一列几百个像素跑起来毫无压力。整个图像按列并行处理,实时性很容易满足。这也是 Stixel 相比逐像素分割在工程上更讨喜的地方——它的计算结构天然适合并行。
3.3 第三步:给每根柱子赋距离和高度
分割完成后,每一段像素对应一根候选柱子。接下来要给这根柱子确定两个关键量:距离和顶部高度。距离通常取这一段内所有有效深度值的中位数或加权平均,用中位数是为了抗离群点。顶部高度则由这一段的顶部像素位置、底部像素位置和距离共同决定,通过相机投影模型反算出来。
这里有个细节值得说:底部点的确定。理论上底部点应该是柱子与地面的接触点,但实际中由于深度噪声,分割出来的段底部可能并不精确落在地面上。常见做法是用地面平面方程反投影,找到这一列上深度值与地面深度最接近的位置作为底部锚点,然后以这个锚点向上延伸。这样能保证每根柱子的底部都稳稳地踩在地面上,避免出现“悬空柱子”。
3.4 第四步:跨列一致性约束与后处理
逐列独立分割会带来一个问题:相邻列的柱子可能高度和距离跳变很大,看起来不连续。真实的物体在图像上是连续的一片,所以需要一层跨列的一致性约束。常见做法是对相邻列的柱子做平滑,或者用一个简单的跟踪/聚类把属于同一物体的柱子归到一起。
后处理里还有一步是剔除明显不合理的柱子,比如高度为负的、距离超出量程的、或者置信度过低的。这一步看似琐碎,但对最终输出的干净程度影响很大。我在项目里就吃过亏,早期没做这层过滤,结果地面上偶尔冒出一两根“幽灵柱子”,导致避障逻辑误触发刹车。
| 处理阶段 | 输入 | 输出 | 主要计算 | 实时性关注点 |
|---|---|---|---|---|
| 深度获取 | 双目图像/单目图像 | 稠密视差图+置信度 | 立体匹配或深度网络 | 最耗时,常是瓶颈 |
| 逐列分割 | 单列深度序列 | 若干候选段 | 动态规划能量最小化 | 线性复杂度,可并行 |
| 赋距赋高 | 候选段+地面平面 | 带距离高度的柱子 | 中位数+投影反算 | 计算量小 |
| 一致性后处理 | 全部柱子 | 干净柱子集合 | 平滑+过滤+聚类 | 逻辑简单但影响大 |
4. 能量函数与动态规划:分割质量的决定性环节
4.1 数据项怎么设计才抗噪
数据项的作用是告诉算法“这个像素的深度值更像属于哪根柱子”。最朴素的做法是看像素深度和柱子代表深度的差,差越小代价越低。但直接用原始深度差有个问题:深度值在远处数值小、近处数值大,同样的绝对误差在远处和近处的意义完全不同。所以更合理的做法是在视差域或者对数深度域上计算代价,让代价在远近处具有可比性。
另一个抗噪手段是引入置信度加权。深度图里每个像素通常带一个匹配置信度,置信度低的像素(比如纹理稀疏区域、遮挡边缘)在数据项里的权重应该降低,避免它们把分割带偏。我在实际调参时发现,把置信度作为数据项的权重乘进去,对分割稳定性的提升非常明显,尤其是在弱纹理的路面上。
4.2 平滑项与边界保持的平衡
平滑项是 Stixel 分割里最需要拿捏的部分。它的作用是让同一根柱子内部的像素尽量被分到一起,但它不能强到把真实的物体边界也抹平。解决办法是让平滑项的强度依赖于相邻像素的深度差:深度差小的时候,平滑惩罚大,鼓励合并;深度差大的时候,平滑惩罚迅速衰减,允许在这里切开。
这个机制本质上是一个“对比度敏感”的平滑,和很多图像分割方法里的思路一致。参数上通常用一个指数衰减或者阈值截断来实现。阈值设得太小,物体边界切不开,柱子会糊成一片;设得太大,噪声也会被当成边界,柱子碎成渣。我的经验是先用一个中等阈值跑一遍,看分割结果里物体边界是否清晰、地面是否连续,再微调。
4.3 动态规划求解的递推结构
沿一列做分割,可以形式化成一个序列标注问题:每个像素标注它属于第几根柱子,代价是数据项加相邻标注的平滑项。这个问题用动态规划求解,状态是“当前像素属于哪根柱子”,转移是“从上一像素的某根柱子转到当前像素的某根柱子”。递推式就是标准的维特比式递推,每一步取当前代价加转移代价的最小值。
实际实现时,柱子的数量是未知的,所以状态空间需要动态扩展。常见做法是预设一个最大柱子数,或者用在线的方式在递推过程中决定是否新开一根柱子。新开柱子的代价要单独设计,通常包含一个固定惩罚,防止柱子数量爆炸。这个固定惩罚相当于一个“分割粒度”的旋钮,调大柱子少而粗,调小柱子多而细。
提示:动态规划的状态数如果设得太大,单列计算量会上去。一般一列里真正有意义的柱子不会超过十几根,把最大柱子数限制在合理范围,既省算力又不影响结果。
4.4 一个具体的参数配置参考
下面这张表是我在园区低速场景下调出来的一套相对稳定的参数,供参考。注意这些值跟相机内参、图像分辨率、场景尺度强相关,不能直接照搬,但可以作为调参的起点。
| 参数 | 含义 | 参考取值 | 调整方向 |
|---|---|---|---|
| 最大柱子数 | 单列允许的最大分段数 | 8~12 | 场景复杂则调大 |
| 新开柱子惩罚 | 新开一根柱子的固定代价 | 中等偏大 | 调大则柱子更少更整 |
| 平滑衰减阈值 | 深度差超过此值平滑失效 | 中等 | 调小则边界更敏感 |
| 置信度权重 | 数据项中置信度的权重 | 0.5~1.0 | 噪声大则调大 |
| 最小柱子高度 | 低于此高度的柱子丢弃 | 若干像素 | 抑制地面噪声 |
5. 距离估计的误差来源与实测校准经验
5.1 深度误差如何传导到 Stixel 距离
Stixel 的距离来自它内部像素深度的聚合,所以深度误差会直接传导过来。深度误差有两个主要来源:一是立体匹配本身的误差,二是深度到距离转换时的量化误差。前者在纹理丰富区域小、在弱纹理区域大;后者在远处尤其明显,因为视差和距离成反比,远处一点点视差误差就会放大成很大的距离误差。
我在实测中做过一个粗略统计:在十米以内的近处,Stixel 距离误差通常在几十厘米量级;到了二三十米,误差可能就到米级了。这不是 Stixel 本身的锅,而是深度来源的物理限制。所以如果你的应用对远处距离精度要求高,要么换更好的深度来源,要么对远处柱子的距离做额外的时序滤波。
5.2 地面平面标定误差的放大效应
地面平面是 Stixel 的几何基准,它的标定误差会被放大到所有柱子的底部点上。俯仰角标定差一点点,远处的地面点就会在图像上偏移很多,导致底部锚点错位,进而让距离估计系统性偏大或偏小。这个误差是系统性的,比随机噪声更麻烦,因为它不会通过平均消除。
我的做法是定期做一次地面平面在线校验:在车辆静止或匀速直行时,用图像下半部分的地面像素反算平面参数,和标定值比对,偏差超过阈值就更新。这个校验逻辑不复杂,但能显著提升长期运行的稳定性。
5.3 时序滤波:让柱子不要抖
单帧的 Stixel 输出会有抖动,尤其是远处柱子的距离和高度。直接拿去做决策会让避障逻辑一惊一乍。解决办法是对每根柱子做时序跟踪和滤波。简单一点的做法是对同一空间位置的柱子做卡尔曼滤波,复杂一点的做法是先做柱子级的关联匹配,再对匹配上的柱子做滤波。
我倾向于先用一个轻量的关联策略:把当前帧的柱子和上一帧的柱子按图像位置和距离做最近邻匹配,匹配上的用指数滑动平均更新距离和高度,没匹配上的作为新柱子加入,连续几帧没匹配上的就删除。这套逻辑实现起来不重,但对输出平滑度的改善立竿见影。
6. 落地时的典型坑与规避策略
6.1 弱纹理路面导致的柱子缺失
在纹理稀疏的路面(比如刚铺好的沥青、地下车库的环氧地坪)上,立体匹配几乎给不出有效视差,深度图大片空洞。这时候 Stixel 分割会因为缺少数据项而退化,要么切不出柱子,要么切出来的柱子距离乱跳。这是我在实际项目里遇到的第一个大坑。
规避策略有几条。一是引入地面平面先验:在深度缺失的区域,直接用地面平面方程给出一个“地面深度”作为默认值,让分割至少能把地面段识别出来。二是降低对深度的依赖,在弱纹理区域用图像梯度或边缘信息辅助分割。三是接受现实,在弱纹理区域只输出“可通行”判断,不输出精确距离,把决策逻辑设计成对距离缺失有容错。
6.2 物体边缘的深度撕裂
物体边界处,前景和背景的深度差异大,立体匹配容易在这里出错,导致柱子边界模糊或者出现错误的中间距离。这个问题的根源在深度图,不在 Stixel 本身。缓解办法是在分割的平滑项里对图像边缘做保护——如果两个相邻像素在图像上处于强边缘两侧,就降低它们之间的平滑惩罚,让分割更倾向于在这里切开。
另一个办法是在后处理里做边缘对齐:把分割出来的柱子边界和图像边缘做一次对齐微调,让柱子边界尽量贴合真实的物体轮廓。这一步能明显改善视觉效果和距离精度。
6.3 柱子数量与算力的权衡
柱子数量直接决定后续处理的计算量。柱子太多,虽然表示更精细,但跟踪、聚类、决策的负担都上去了;柱子太少,表示太粗,可能漏掉细小的障碍物。这个权衡没有标准答案,取决于你的应用。
我的经验是:对于低速、障碍物较大的场景,柱子可以粗一些,单列五六根就够;对于需要检测细杆状物体(比如路桩、细柱子)的场景,柱子要细一些,单列十几根。可以先按场景需求定一个目标柱子密度,再反推分割参数。
6.4 与下游模块的接口设计
Stixel 输出给下游时,接口设计很关键。我见过有人直接把柱子列表丢给规划模块,结果规划模块不知道怎么用。合理的做法是给下游提供几个派生量:可通行区域(由地面柱子构成)、障碍物列表(由非地面柱子聚类得到)、以及每个障碍物的最近距离和方位。这样下游拿到的是它真正需要的东西,而不是一堆原始柱子。
接口里还要明确坐标系和单位。柱子距离是纵向距离还是欧氏距离?高度是相对地面还是相对相机?这些不写清楚,联调时必然出问题。我在项目里就因为这个吃过亏,前期没统一,后期返工改了一堆代码。
7. 这套表示法适合什么、不适合什么
Stixel World 最舒服的场景是结构化的道路环境:地面相对平整、物体基本接地、障碍物以车辆行人为主。在这种场景下,它用很小的计算代价换来了带距离和接地信息的结构化表示,性价比很高。低速代步车、园区物流车、扫地机器人这类对算力敏感、场景相对受控的应用,是它的主场。
它不太适合的场景也很明确。一是地面剧烈起伏的环境,接地假设直接失效。二是大量非接地物体,比如悬挂的线缆、空中的树枝、飞行的物体,这些用柱子表示会很别扭。三是需要精细三维形状的任务,比如要区分一个物体的具体轮廓,柱子只能给出一个矩形近似,粒度不够。
我个人的判断是,Stixel 不是要取代逐像素分割或三维重建,而是在“算力有限、场景结构化、需要几何信息”这个特定区间里,提供了一个非常务实的折中方案。理解它的假设边界,比记住它的算法细节更重要——知道它什么时候会失效,你才能在合适的场景用它,在不合适的场景果断换方案。
最后分享一个我在调参时的小习惯:每次改完参数,不要只看单帧效果,一定要在连续几十帧的视频上看柱子的稳定性。单帧看着漂亮的参数,放到时序里可能抖得没法用。这个习惯帮我省了很多返工时间。