4. 占据栅格地图原理

栅格地图,说白了就是把环境切成一个个小方块,每个方块独立回答一个问题:「这里有障碍吗?」。我刚开始接触SLAM时,觉得这方法太朴素了——但后来才发现,正是这种「朴素」让它极其鲁棒。今天咱们就把它从数学到工程细节彻底聊透。

占据栅格地图核心逻辑 数学表示 m = {m₁, m₂, …, mₙ} 贝叶斯更新 lₜ = lₜ₋₁ + lₒᵣₑᵥ₋ₗₒ₉ 反演测量模型 p(m|z₁ₜ, x₁ₜ) 分辨率 + 内存优化 每一格独立建模,用对数概率避免数值溢出 反演模型:从传感器读数反推栅格状态 分辨率决定地图精度,压缩技巧决定内存

4.1 栅格地图的数学表示

我们把环境划分成均匀的网格,每个格子记作 mᵢ。整个地图就是一组随机变量:m = {m₁, m₂, …, mₙ}。每个 mᵢ 只有两种状态:占据(1)或空闲(0)。

你可能会问:为什么不直接用0和1?因为传感器有噪声,直接二值化太武断。所以我们用概率表示——p(mᵢ = 1) 表示格子被占的概率。我个人习惯用 对数概率(log-odds)来存,原因后面会讲。

核心假设: 每个栅格独立于其他栅格。虽然现实世界中墙是连续的,但这个假设让计算量从指数级降到线性。实际工程中效果足够好。

4.2 贝叶斯更新公式

有了传感器新数据,怎么更新格子概率?贝叶斯公式就是我们的武器。对于单个栅格 mᵢ,给定观测 z₁ₜ 和位姿 x₁ₜ:

p(mᵢ | z₁ₜ, x₁ₜ) = 
    p(zₜ | mᵢ, z₁ₜ₋₁, x₁ₜ) · p(mᵢ | z₁ₜ₋₁, x₁ₜ₋₁) / p(zₜ | z₁ₜ₋₁, x₁ₜ)

看着复杂?实际用对数概率形式就清爽多了。定义 l(mᵢ) = log( p(mᵢ) / (1 - p(mᵢ)) ),更新公式变成:

lₜ(mᵢ) = lₜ₋₁(mᵢ) + lₒᵣₑᵥ(mᵢ) - l₀

其中 lₒᵣₑᵥ 是反演测量模型给出的对数概率,l₀ 是先验。说白了,就是加减法,没有乘除,数值稳定还快。我在项目里用这个公式跑了上千次,从未出现概率溢出。

💡 我的小习惯: 初始先验概率设0.5,对应 l₀ = 0。这样新格子既不吃亏也不占便宜。

4.3 反演测量模型

反演测量模型,听起来高大上,其实就是回答:「如果传感器读到这个值,格子应该更可能是占据还是空闲?」。以激光雷达为例,一束激光打到某个距离,那么:

  • 击中点对应的格子 → 占据概率增加
  • 激光路径上的格子(除击中点) → 空闲概率增加
  • 未探测到的格子 → 保持原样

我见过不少新手直接把激光终点标记为占据,忽略了路径上的空闲信息。结果地图里全是「假墙」——明明激光穿过了空白区域,却什么都没学到。反演模型把这两部分都考虑进去,地图才干净。

⚠️ 避坑: 我曾经在动态环境中没做异常值过滤,结果一个人走过,激光路径上的格子全被标记为空闲,而人本身又被标记为占据——地图乱成一团。后来加了基于距离的异常剔除,才稳定下来。

4.4 地图分辨率与内存优化

分辨率直接决定地图精度。5cm 的格子比 10cm 的格子能表现更细的走廊边缘,但内存占用是平方倍增长。一张 100m×100m 的地图:

分辨率栅格数量内存(float32)典型场景
20 cm250,000~1 MB室外大场景
10 cm1,000,000~4 MB室内仓库
5 cm4,000,000~16 MB高精度建图
2 cm25,000,000~100 MB精细重建

你想想看,如果每个格子用 float64 存概率,内存直接翻倍。我一般用 int8 存对数概率,范围 -127~127,精度完全够用。另外,ROS2 的 nav_msgs/msg/OccupancyGrid 默认就用 int8,-1 表示未知,0~100 表示空闲到占据。

内存优化三板斧:
① 用对数概率 + int8 存储,比 float32 省 4 倍空间。
② 只更新传感器视野内的格子,懒加载未探索区域。
③ 对大型地图使用分块(tile)管理,只加载当前任务区域。

我在一个园区项目里,地图 500m×400m,分辨率 10cm,按传统方式要 80MB。用了分块 + int8 后,实际常驻内存只有 12MB。嗯,优化空间永远比你想的大。

🧠 个人经验: 如果你用 ROS2 的 occupancy_grid,记得把未知区域设为 -1,而不是 50。很多导航算法会把 50 当作「半信半疑」,导致规划路径畏畏缩缩。