我在调一版Cartographer建图时遇到过一种很诡异的现场:一条笔直的走廊,前20米地图干干净净,后20米墙壁开始出现一条条细碎的裂缝,再往后整面墙变成一片虚影。我第一反应是激光标定有问题,查了odom、lidar、imu一圈,全正常,最后把submap里的栅格值直接打印出来看了一眼,才发现问题根本不在传感器,而在占据栅格地图的更新策略上。
这篇是Cartographer源码解读系列的第一篇,我把占据栅格地图更新策略这条链路完整拆开:从概率表示、查找表、光线投射,到多次扫描后的概率收敛,最后给你我在实际建图调试里踩过的坑。内容不依赖你已经读过源码,但最好已经跑通过Cartographer的demo,知道submap大概长什么样。看完之后,至少再遇到"地图为什么碎、为什么虚、为什么黑斑一堆"这类问题,你能有一个清晰的排查方向。
1. 建图前先搞懂"底色":概率栅格、Submap和占据更新到底是什么关系
1.1 从一张"干净"的地图反推栅格更新的作用
先看现象。同一台设备、同一段rosbag,有人建出来的地图墙体轮廓锐利、走廊边界笔直,有人建出来的地图就像用毛笔画的一样,边缘全是毛刺,甚至出现半透明墙体。差别往往不在算法参数调得有多好,而在最基础的栅格更新是否按预期执行。
栅格地图本质上是一个二维概率场,每个格子存放"这里被占据的概率"。Cartographer在算法层面并不直接给整张全局地图做更新,而是把地图切分成一个个submap,每个submap由连续若干帧激光数据累积而成。前端每插入一帧扫描,就往当前submap里刷一批栅格更新;后端闭环检测再做submap之间的位姿优化。占据栅格地图的更新策略,就是决定"这一帧激光数据到底改了哪些格子、各改多少"的那套规则。
这套规则直接决定了三个结果:地图里障碍物的边界清不清晰、自由空间干不干净、可通行区域能不能被正确区分出来。所以排查建图质量问题时,栅格更新永远是第一优先级,而不是一上来就调scan matching参数。
1.2 Cartographer的双层建图架构里,栅格地图藏在哪
整个Cartographer的建图链路可以简化成两层。前端(Local SLAM)维护一个当前submap,把新到的激光帧和已有submap做匹配,得到一个相对位姿,然后把这帧数据插入submap。后端(Global SLAM)负责处理已经完成的submap,通过闭环约束修正位姿漂移,最终把所有submap拼成一张完整地图。
这里的核心单位是submap,不是全局地图。每个submap内部包含一个Grid2D,Grid2D里真正存栅格值的是一个一维数组cells。我们对地图的每一次认知更新,最终都落到对这个cells数组的修改上。
源码里Submap2D有一句很关键的逻辑:插入数据时,不是把激光点云直接"画"到地图上,而是先把点云分成两类——命中的点和穿过的点——再分别用不同策略处理。这个"分而治之"就是整个占据栅格地图更新策略的起点。
1.3 概率与赔率(odds):更新策略的数学底座
栅格里存的是概率,但更新时不能直接做概率加减。原因很简单:概率的加法不满足独立证据的合成规则。比如某个格子原本被占据概率是0.7,又来了一次"被占据"的观测,你不能直接加0.7或者取平均,那样既不严谨也容易让数值失控。
Cartographer用的是工程上很成熟的odds表示法。定义占据概率p对应的赔率odds为:
odds = p / (1 - p)
这个式子的意思是"占据概率和空闲概率的比值"。当p=0.5时odds=1;p>0.5时odds>1;p<0.5时odds<1。
每次新的观测到来,不需要对概率做复杂的贝叶斯积分,只需要做一次乘法:
odds_new = odds_old × odds_measurement
其中odds_measurement是这次观测本身携带的赔率。为了进一步把乘法变成加法,工程实现里通常取对数:
log_odds_new = log_odds_old + log_odds_measurement
这就是占据栅格更新的数学底座。它足够简单,又能严格反映多次独立观测对同一格子的证据累积。Cartographer的源码里没有直接维护浮点log_odds,而是用一种更工程化的手段来加速它,下一节讲。
我对这个公式的实际体会是:刚开始看源码时容易被各种"概率""赔率"绕晕,但只要抓住"每次测量只在旧赔率上乘一个固定因子"这一条,后面所有查找表、光线投射的行为就都能解释了。
2. 栅格值的真实形态:不是浮点概率,是一张uint16查找表
2.1 源码里栅格的真实类型:uint16与未知值0
打开Cartographer的栅格存储实现,最反直觉的一点是:cells数组里的元素类型是uint16,不是float。也就是说栅格概率并不会以0.35、0.72这种浮点形式存在,而是被映射到一个整数刻度上。
具体映射范围如下:
- 0:表示未知,也就是从来没被任何激光更新过
- 1:对应最小概率值,源码里kMinProbability一般为0.1
- 32767:对应最大概率值,源码里kMaxProbability一般为0.9
这个映射关系是线性的。把0.1到0.9的区间均匀切分成32766份,每个整数代表其中一份。为什么这样设计?一方面uint16只占2字节,内存占用比float少一半,cache命中率更好;另一方面整数查找表天然避免了浮点运算的不确定性和性能损耗。
这里有个新手容易误解的地方:栅格值1并不代表"完全空闲",它代表的是"当前证据下概率下限0.1";栅格值32767也不代表"100%占据",而是0.9的上限。也就是说,即使被激光打了无数遍,栅格也不会直接变成0或1,始终留有余地。
2.2 为什么不现算概率,而要预先搓查找表
按第一节的公式,每次更新似乎都要做一次odds乘法和对数运算。但仔细想一下:测量的观测值只有两类——"命中"和"穿过",也就是说每次更新的odds_measurement只有两个固定值。而栅格旧值本身只有32767种可能的整数取值。
那么问题就变成一个平凡的查表问题:对于某种测量类型,给定旧值x,新值y = f(x)是可以预先枚举的。Cartographer把这个f(x)表格化,就叫查找表,源码里对应hit_table_和miss_table_。
不同的submap之间甚至共享同一套查找表,因为概率映射规则与具体栅格位置无关。源码里通过ValueConversionTables对象统一管理这些表,每个子图构造时向它请求对应概率参数的查找表,避免重复计算。
我一开始看这段代码时会想:"就一个乘法,现算能慢到哪去?"但当一帧激光有几千个命中点和几万个穿过栅格时,每帧都做浮点运算和查表的差别就放大了。更重要的是查表逻辑简单,不容易出现不同模块之间概率公式不统一的问题。
2.3 ApplyLookupTable:一行代码背后的一次"翻页"
栅格更新的核心入口是ApplyLookupTable。简化后的行为就是:拿着某个格子当前的值,在查找表里找到更新后的值,写回去。
// 简化后的语义,实际实现还包含边界判断和位姿转换 void ApplyLookupTable(Eigen::Array2i cell_index, const std::vector<uint16>& table) { uint16& cell_value = cells[ToFlatIndex(cell_index)]; cell_value = table[cell_value]; }单元格的值从0变到某个非零值,是"从未知变成了有信息";从某个低值变成一个更高值,是"证据累积让占据置信度上升";从高值降到低值,则是"该栅格被后续穿过观测拉回空闲侧"。
2.4 hit_table和miss_table的不对称设计
查找表不是对称的。常见实现中hit观测对应的概率是0.55,miss观测对应的概率是0.49。乍一看很奇怪,为什么不是0.5和0.5?
我用一个具体的数值演变来说明。假设栅格初始概率为0.5,此时odds=1。来一次命中:
odds_new = 1 × (0.55 / 0.45) ≈ 1.222
对应的概率变为0.55。如果同一格后来又被一次穿过观测覆盖:
odds_new = 1.222 × (0.49 / 0.51) ≈ 1.174
对应的概率只降到0.54。也就是说,一次miss观测并不会完全抵消一次hit观测,因为hit对应的odds因子(1.222)大于miss对应的odds因子(0.9608)。这是一种"占据证据优先"的不对称设计:避免自由空间被一两次可疑的穿透观测就永久污染成空地。
这种不对称在实践中很有用。室内场景里激光穿过玻璃、或者打在某些低反光材质上产生跳变点时,偶尔出现的错误命中不至于立刻让一堵墙变成豁口。后面几帧稳定命中会把概率重新拉回高位。
用一张表表示hit和miss在累计中的不对称效应:
| 连续观测序列 | 栅格概率变化(从0.5开始) |
|---|---|
| 1次hit | 0.550 |
| 2次hit | 0.599 |
| 3次hit | 0.645 |
| 3次hit后1次miss | 0.632 |
| 3次hit后3次miss | 0.586 |
可以看到,即使连续三次miss也压不过三次hit的影响,这符合"墙不会因为几帧异常点就消失"的直觉。
3. 一帧激光从发射到落图:hits/misses拆分和光线投射的完整链条
3.1 激光点云先分成hits和misses两类
在很多初学者的想象里,激光数据落图应该是"每个点直接在对应栅格上+1或者标记障碍物"。但Cartographer不是这么干的,它在插入数据前先做了一次很关键的分类。
一帧激光数据里的每个点,如果它的测量距离小于传感器最大量程,并且对应的回波强度没有异常,就被视作一个"命中点"(hit point)。这个点代表激光真正碰到东西的位置。传感器原点到命中点之间的所有栅格,都被视作"穿过点"(miss point),代表激光经过但没有任何障碍物的自由空间。
另一个重要分支是:如果某个方向没有可靠回波,也就是说测量距离等于最大量程,那么这个方向只产生穿过点,不产生命中点。这个细节在开阔场景里特别重要——空旷区域的地图就是靠这些"无回波方向的miss更新"刷出来的。
3.2 光线投射:miss栅格怎么被找出来
有了命中点坐标和传感器原点坐标,怎么求出"中间穿过哪些栅格"?Cartographer用的是光线投射算法,把一个网格化的遍历问题转换成沿直线的栅格序列问题。常见实现基于DDA超体素遍历:从起点栅格出发,每次沿增量较小的那个轴步进一个栅格,直到到达终点或超过最大更新距离。
简化后的行为可以用下面这段伪代码理解:
// 伪代码:从射线起点到终点,返回经过的栅格索引序列 std::vector<Eigen::Array2i> RayToPixelMask( const Eigen::Vector2f& begin, const Eigen::Vector2f& end, float max_length, const MapLimits& limits) { std::vector<Eigen::Array2i> cells; // 1. 将begin和end转换到栅格坐标 // 2. 若起点越界,直接返回空集合 // 3. 使用DDA遍历,从起点逐步走到终点 // 4. 沿途收集经过的栅格索引 // 5. 超过max_length后停止 return cells; }这段逻辑的意义在于:它能保证每一个被激光穿过的栅格都被标记为"这次观测到了空闲证据"。如果缺失这一环,地图上会出现大量"从未被观测过"的灰色区域,即使明明有激光扫过。
3.3 先更新miss再更新hit,顺序不能反
源码里插入submap时,实际执行顺序是:先遍历所有miss栅格,对它们应用miss_table;再遍历所有hit栅格,对它们应用hit_table。
为什么要刻意规定这个顺序?因为存在一种边界情况:一束激光的命中点,恰好落在另一束激光的穿过路径上。如果先处理hit再处理miss,后处理的miss会把先前的命中覆盖,最终这个格子被标记为"空闲"。而真实物理意义应该是"一束激光确实打到了这里,另一束只是路径上路过",命中证据更硬,应该保留最终判定为占据。
这个顺序问题在真实数据里并不是极端罕见的情况。尤其是多线激光雷达,不同俯仰角的激光束在近处会交叉,远处同一栅格也经常被多束光同时覆盖。顺序一旦反了,地图边缘就会出现大量忽明忽暗的闪烁栅格,scan matching的梯度和稳定性都会受影响。
3.4 update_range:距离越界截断
还有一个容易被忽略的参数是最大更新距离。Cartographer在光线投射时会对射线长度做截断,超过一定距离的miss栅格不会更新。原因很直接:很远距离处的"空闲证据"可信度低,一个10米外被误判为空地的栅格,对scan matching的误导比近处大得多。
实际调参时的体会是,室内小场景可以把update_range调得稍近一些,既能保证地图干净,又能减少submap边界无意义扩张;室外开阔场景则要稍微调大,否则远处墙体永远是灰色。这里需要平衡,不是越大越好,也绝不是越小越稳。
4. 为什么同一块墙"越扫越实":概率收敛、饱和值与更新边界的相互作用
4.1 反复扫描同一区域,概率是怎么收敛的
单次激光扫描对栅格概率的改变其实是微小的。0.5的格子打一次hit只变成0.55,看起来距离"黑色墙体"还很远。但SLAM建图是连续运动的,同一面墙会被激光反复扫过多次,每一次都在原来的odds上继续乘因子。
这正是概率栅格优于简单"计数栅格"的地方。计数栅格的问题是:扫到一次就标记障碍物,一旦某帧有噪声,这个错误就被永久焊死在地图上。概率栅格则天然具备时间累积性——只有多帧一致认为这里被占据,概率才会爬到接近0.9的上限;偶发一帧低质量数据只能造成微小扰动,后续正常数据能把它拉回来。
这也解释了建图时间越久,地图越"实"的现象。如果你做一个小场景绕圈采集,第一圈结束后submap里墙体还是浅灰色,第三圈结束后明显变深,这是概率收敛的正常过程。
4.2 概率上下限:为什么地图不会立刻变黑
既然重复观测会让概率往一个方向推,为什么不直接推到0或1?Cartographer设置了kMinProbability和kMaxProbability,把概率限幅在0.1到0.9之间。
这个设计保证了两个性质。第一,地图对动态障碍物有天然容忍度:一个人从墙边走过被他遮挡了几帧,墙的概率只是暂时降低,不会因为他走过去了就被永久判定为空地。第二,scan matching需要概率值保持一定"软"度,如果栅格全部变成0和1,匹配代价函数在很多区域会变成平底锅,梯度信息几乎消失,Ceres优化器就没法精确迭代位姿。
从数值角度看,0.1和0.9也不是随便选的。太接近0或1会放大少量观测对地图的影响,太接近0.5又会让地图永远处于"不确定"状态。0.1~0.9这个区间在工程里是被反复验证过的较优选择。
4.3 越界与动态扩展:submap边界是怎么长出来的
栅格地图不是预先开好一个固定尺寸再往里填数据,而是随激光覆盖范围动态扩展。Submap在插入数据后会检查当前更新涉及的栅格范围,如果超出了已有栅格范围,就触发GrowLimits逻辑,把地图向对应方向扩展。
这里面有个容易踩坑的点:如果某帧点云经过坐标变换后出现异常离群点,会直接把submap边界撑得很大,导致后续所有正常帧都只能更新在一个"大而空"的地图里,分辨率被稀释,浪费大量内存。排查这类问题时建议先看点云变换后的坐标分布是否合理,再看update_range是否截断了远点。
5. 我在建图调试中反复踩过的更新策略细节
5.1 与代价地图更新逻辑的本质差异
很多从导航切入SLAM的人会把Cartographer的栅格地图和move_base里的costmap混为一谈,实际上两者更新逻辑差异非常大。代价地图存的是"导航代价",包含障碍物、膨胀层、内切半径等概念,一次标记就变成致命障碍;而Cartographer的栅格存的是"占据概率证据",需要多次观测累积。
这个差异解释了为什么直接拿Cartographer的栅格图给导航用往往要再做转换——不是分辨率问题,而是语义不同。Cartographer地图适合做匹配和回环,costmap适合做路径规划,两者用一套数据硬套就会出现"明明地图上没障碍物,导航却把整面墙当威胁"的怪相。
| 对比项 | Cartographer概率栅格 | 导航代价地图 |
|---|---|---|
| 存储内容 | 占据概率(uint16映射) | 导航代价(如致命、内切、膨胀) |
| 更新方式 | 多次观测的odds累积 | 单次标记+膨胀 |
| 数学基础 | 贝叶斯证据累积 | 距离变换与膨胀算法 |
| 典型用途 | 激光SLAM匹配、闭环 | 路径规划、障碍物规避 |
| 对噪声的抵抗 | 较强,异常观测影响有限 | 较弱,单帧错误可能直接生成致命障碍 |
5.2 地图断线、重影、局部噪点,先查哪几个点
如果建图结果出现墙体断线,第一步不是调参数,而是检查这一区域是否真的被充足扫描过。概率栅格本质是证据累积,没扫过的地方本来就是灰色或未知;如果扫了很多遍仍然断线,才需要怀疑hit/miss更新逻辑。
重影问题则要先区分是submap内部重影还是submap之间重影。前者多半是单帧点云位姿跳变,导致hits集合投影错误,需要查odom和scan matching;后者是后端回环没闭合,概率栅格本身没问题,是拼接出了问题。我见过太多人一看到重影就去改栅格概率参数,结果越改越糟。
局部异常黑色噪点的排查方向更明确:看这个噪点是否只在特定角度存在,如果是,大概率是激光点云异常离群点被当成了真正的hit,而不是更新策略的锅;如果噪点遍布全图,才需要考虑把hit概率调低或过滤掉短距离异常点。
5.3 判断栅格更新是否正常的两个土办法
第一个办法:打印某个固定栅格在连续几帧数据下的值变化。从传感器原点附近选一个已知会被反复扫到的墙格,在插入数据前后打印它的uint16值,正常情况下应该单调往32767方向爬升,中间被miss覆盖时会有小幅回落,但整体趋势稳定。如果看到值反复剧烈跳动,基本可以确定是坐标变换或hits/misses分类出了问题。
第二个办法:用离线数据复现,把一帧激光单独插入一个全新的submap,查看该帧覆盖范围内的栅格变化。只来一帧数据时,墙体应该呈现一个不太深的灰色,自由空间应该呈现一个略低于0.5的空闲值,远处超出update_range的区域应该保持未知。如果单帧插入后墙体直接变成黑色甚至白色,说明查找表参数或hit/miss分配可能被改动过。
我自己在代码里排查时还有一个习惯:先在源码里给ApplyLookupTable加一行日志,统计每帧更新了多少个miss cell和多少个hit cell。正常室内场景,miss cell数量通常是hit cell数量的几十倍,因为一束激光对应几十个穿过的栅格。如果miss cell数量异常稀少,那你的地图大概率会变成"只有墙没有路"的形态。
写在最后
占据栅格地图的更新策略是Cartographer从"能跑"到"跑得好"之间最容易卡住的一环。很多建图异常表面看是传感器、标定、位姿的问题,追到最后往往都落在hits/misses的拆分、查找表参数、update_range和更新顺序这些看似琐碎的细节上。
我个人折腾了很长时间才建立的排查思路是:先看单帧栅格变化是否符合物理直觉,再看多帧累积是否收敛,最后才是调参数。跳过前两步直接动参数,你永远不知道自己在跟哪一个环节的问题较劲。这篇先把栅格更新这条主线讲透,下一篇可以继续聊submap插入的触发时机和与scan matching之间的交互。