☰
从零构建机器人地图模块:2D激光雷达、栅格地图与粒子滤波定位实践
2026/10/21 13:17:12 网站建设 项目流程

1. 先聊聊 lingbot-map 到底在解决什么问题

1.1 机器人没有地图,就像人没有方向感

做机器人的朋友应该都遇到过这个场景:底盘能跑、电机能转、遥控能走,但一旦你下达“去厨房门口等我”这种带位置的指令,它就傻在当场。原因很简单——它不知道自己在哪里,也不知道厨房在哪。没有地图,机器人能做的只有“见障就躲”,永远只能原地打转。

lingbot-map 就是我自己在维护的机器人平台“lingbot”里长出来的地图模块。它要解决的是一条完整链路:用一颗便宜的 2D 激光雷达加上两个轮子的编码器,让小车在室内跑上几圈后自己构建出可用的栅格地图;地图建好后,再通过粒子滤波定位让小车知道自己在地图里的精确位置;最后交给路径规划器,让它真正能从 A 点走到 B 点而不是撞墙。

这个模块最适合两类人:一类是刚把底盘跑通、想给机器人加“脑子”的硬件派;另一类是看过一大堆算法公式,但始终不明白激光数据到底怎么变成一张图像的算法派。看完这篇,你可以照着代码把整条链路搭起来,至少不会在“建图炸了、定位飘了、路径撞墙”这三个问题上不知所措。

1.2 为什么我选了栅格地图,而不是别的方案

地图的表示方式有很多种,拓扑地图、特征地图、栅格地图是最常见的三兄弟。拓扑地图用节点和边表示“房间—走廊—房间”的关系,内存小、规划快,但它丢掉了几何细节,机器人很难回答“这扇门我能不能钻过去”这种问题。特征地图依赖墙面、柱子的几何特征,在规则走廊里很稳定,可一旦环境空旷或者特征重复,匹配就容易崩。

我最后选了栅格地图,因为它把空间切成一个个固定大小的格子,每个格子用概率表示“这里有没有东西”。它直观、好实现、也方便做路径规划,更关键的是:激光雷达的数据天然适合往栅格里塞。你把一条射线经过的区域标记为“空”,把射线末端打到的点标记为“占”,扫上几圈,地图自然就出来了。

栅格地图也不是没有缺点,地图分辨率越高,内存和计算量涨得越夸张。比如一个 20m×20m 的空间,用 5cm 栅格就是 400×400 个格子,换算成浮点数也才几百 KB,完全能接受;但如果用 1cm 栅格,直接飙到 2000×2000,粒子滤波器算一轮就要多等好几倍时间。所以我的默认分辨率是 5cm,室内导航足够,机身又不会太大。

1.3 一个地图模块,我是怎么拆成四块的

lingbot-map 在实现时没有把“建图”和“定位”揉成一团,而是拆成四块相互独立的部分:数据预处理层、地图构建层、粒子滤波定位层、路径规划层。

数据预处理层负责把激光雷达和里程计的原始数据收拾干净。雷达收到的不一定都是有效点,有的太近、有的太远、有的是无效值,这些脏数据如果直接进建图算法,地图上会出现大量黑色噪点。地图构建层接收处理后的激光和里程计位姿,负责维护那张概率栅格图。定位层和建图层分开很重要:建图时我可以开环跑,定位时必须有一张已经建好的地图再去做匹配;两者混在一起,出了问题很难定位到是哪个环节。

路径规划层放的是 A* 和局部避障。A* 负责在全局栅格上找一条粗路径,局部避障负责响应突然出现的障碍。这样的拆分方式让我可以单独给某一层换实现方法,比如把 A* 换成 JPS,粒子滤波换成扫描匹配,其他层不用动。

2. 核心细节解析:这几个技术环节不能不较真

2.1 坐标系统一:所有数据必须在同一个世界里说话

很多同学第一次建图失败,不是算法不行,而是坐标系乱了。雷达测出来的是“以雷达为原点、极坐标形式”的距离和角度;里程计算出来的是“以底盘初始位置为原点”的位移和转角;栅格地图则用自己的像素坐标系和原点。如果这三者之间的转换关系没有一个固定的约定,你扫出来的一堵墙在地图上是斜的、散开的,甚至一段一段错开。

我的做法是统一到一个“地图坐标系”。以机器人第一次通电的位置为原点,x 轴朝机器人正前方,y 轴朝左,地图的像素原点放在左下角。所有激光点先根据雷达在车体上的安装位置,从“雷达系”转到“车体系”,再由里程计给出的车体在地图系中的位姿转到“地图系”。这个转换链在代码里只是一个坐标变换函数,但它决定了后面所有计算的正确性。

这里有一个特别容易踩的坑:雷达不一定安装在底盘正中心。很多成品小车把雷达装在车体前侧,如果你忽略这个偏移,就会看到同一堵墙在地图上有两条平行的影子。我自己用一个很土的办法检查外参:让小车贴着长墙直行 2 米,理想情况下地图上应该是一根单线墙;如果出现了平行双线,那个距离差就是雷达安装偏移量,直接在配置里补上就行。这个土办法比任何标定板都有效。

2.2 激光雷达数据预处理:比你想的更影响地图质量

激光原始数据并不是拿过来就能用的。廉价雷达在强光下、透明障碍物前、甚至高速旋转时都会产生坏点。最常见的两类问题:一是近距离的“飞点”,本来 30cm 外是墙,偶尔冒出一个 2m 的读数;二是边缘跳变点,在两个物体交界处,读数会在两个距离之间反复横跳。

如果这些点直接进入建图循环,栅格地图上会不断出现小概率的占据更新,表现为随机分布的黑色噪点。我的预处理流程做了三件事:第一,把无效值、NaN、Inf 直接丢弃;第二,把雷达最大量程以内的点保留,超过量程的点视作“没有返回,不参与更新”;第三,对距离做一关中值滤波,把明显离群的点剔除。判断离群点的方法是:与相邻 5 个点的距离差异超过 20%,就认为它是异常点。

还有一个看似不起眼的问题:机器人静止时,雷达数据也会有抖动。尤其是电池电压不稳的时候,一圈数据的起始角度和上一圈差那么零点几度。处理方式是在读取扫描数据时只取完整一圈的等间距角度数组,不做部分扫描的融合。那些为了省时间只拼半圈数据的做法,在地图边缘会产生很明显的锯齿,看着难受,后面做定位时匹配分数也会不稳定。

2.3 占据栅格地图的更新:概率的加法不是加概率

栅格地图的核心是“概率更新”。每一格存的是“这格被占据的概率”,一开始我们不知道,就设成 0.5。雷达射线穿过的格子,我们认为它是“空”的,概率压低;射线末端点所在的格子,我们认为它是“占”的,概率拉高。问题来了:如果直接拿概率去乘,第一次更新后是 0.4,第二次 0.32,很快接近 0,再来一两次就饱和了;反过来占据概率也一样,很快就会刷到 1。那时候“概率”就失去了区分度,新数据也改不动它。

我用的是 log-odds 形式,也就是把概率变换到对数赔率空间。一个格子的状态不是概率 p,而是 L = log(p / (1-p))。更新时只需要做加法:L_new = L_old + l_free 或者 + l_occ。l_free 是负数,l_occ 是正数。想恢复概率看的时候再套一次 sigmoid 变换。

这样做的好处非常明显:新旧观测的权重是线性叠加,旧的观测慢慢被新的观测“抹平”,格子不会因为扫过一次就永远卡死。代码实现上其实非常简单,一个 float64 的二维数组就够了。我建议你不要图省事用 uint8 存概率值,log-odds 连续累加用整数表达很容易溢出。实测下来,float64 就是在 20m×20m、400×400 格子上也才占 1MB 左右,完全没必要省。

2.4 路径规划:A* 算出来的最短路径,小车不一定走得出来

纯 A* 算法处理的是“格子是否可通行”的问题。它会把地图里的占据格子、未知格子都标成不可通行,然后找一条从起点到终点避开所有障碍的最短路径。这里有个细节:A* 找到的是几何最短,但不一定是运动学上最好走的路径。如果路径贴着墙根走,轮式机器人一把方向盘拐不过来,很容易在拐角处刮蹭。

所以我在地图上方又做了一层“膨胀地图”。把每个占据格子的影响范围向外扩展一个半径,半径至少等于机器人半径加上 10cm 安全余量。这样 A* 规划出来的路径天然与障碍物保持了距离,全局路径看起来不那么“极限”了。

但是全局路径只是在静态地图上规划的,真实环境里可能刚扫完地图就有人放了一把椅子,所以还需要一层局部避障。我的方案是:全局规划只在收到新目标时重算,局部避障每 100ms 执行一次,读取最新激光数据,在机器人前方生成一个速度空间,选择一条不与障碍碰撞、又尽量贴合全局路径的轨迹去执行。这个思路业内叫“全局规划 + 局部实时避障”,我用它在模拟环境里测试,中途乱塞纸箱也能绕过去。

3. 实操过程:从零把 lingbot-map 跑起来

3.1 最小硬件与软件清单

先看看你需要什么。底盘我用的是带双路编码器的差速小车,编码器用于推算里程计,这是整个定位系统的基础。激光雷达选的是 2D 三角测距原理的入门款,扫描距离 6m 以上、频率 10Hz 就够了。再配一块能跑 Python 和 NumPy 的小主机,实验阶段用笔记本也行。

如果没有真机,也没有关系。我在开发 lingbot-map 时先用模拟器跑通算法,再上真机。模拟器里会生成一个包含几面墙的房间,雷达扫描数据通过射线仿真产生,里程计数据通过给真实位移叠加高斯噪声产生。这样可以在完全可控的条件先验证算法逻辑,再上真机排硬件问题。所有调试用的代码我都是先写脚本,输出地图图像和定位轨迹,看一眼结果再继续调。

代码本身只依赖 Python 3、NumPy 和 Matplotlib。Matplotlib 只用来把栅格地图和轨迹可视化,不是算法依赖。整个地图构建模块的核心代码,大概 300 行就能写清楚。这个体量很适合做嵌入式,树莓派这个级别的小主机跑 10Hz 的雷达完全不吃力。

3.2 地图构建核心代码:Map 类与射线更新

先定义栅格地图类。核心成员就三样:log-odds 数组、地图原点、分辨率。数组的每个元素表示一格,索引 (gy, gx) 对应世界坐标 (x, y) 的格子。

import numpy as np import math class GridMap: def __init__(self, map_size=20.0, resolution=0.05): self.resolution = resolution self.size = int(map_size / resolution) self.log_odds = np.full((self.size, self.size), 0.0, dtype=np.float64) self.origin = (-map_size / 2, -map_size / 2) self.l_occ = 0.6 self.l_free = -0.4 def world_to_grid(self, x, y): gx = int((x - self.origin[0]) / self.resolution) gy = int((y - self.origin[1]) / self.resolution) return gx, gy def inside(self, gx, gy): return 0 <= gx < self.size and 0 <= gy < self.size def update_hit_ray(self, x0, y0, x1, y1): gx0, gy0 = self.world_to_grid(x0, y0) gx1, gy1 = self.world_to_grid(x1, y1) for gx, gy in bresenham_lines(gx0, gy0, gx1, gy1): if not self.inside(gx, gy): continue if gx == gx1 and gy == gy1: self.log_odds[gy, gx] += self.l_occ else: self.log_odds[gy, gx] += self.l_free def to_image(self): prob = 1.0 / (1.0 + np.exp(-self.log_odds)) return (prob * 255).astype(np.uint8)

看代码你会发现,更新逻辑真的很简单:一条射线,沿途格子都加 l_free,末端格子加 l_occ。但这里有个必须注意的点:激光打中的端点格子的判定。如果激光打到 5m 处的墙面,你却把 5.05m 外的格子当成端点,地图边缘就会向外糊一层阴影。所以我在循环里判断“当前格子是否等于端点格子”时,用的是整数格坐标,不是浮点世界坐标,这样能保证端点落在哪个格子就更新哪个格子。

这里还要说明一个被很多人忽略的问题:激光射线没有打中任何障碍时,端点是不可靠的。比如射线的最大量程是 6m,6m 内什么都没有,此时端点并不是真实的障碍物,不应该做“占据”更新。处理方式是只更新射线经过的所有格子为“空”,不更新端点。我在 update_hit_ray 外面再包一层逻辑,只有激光距离小于最大量程时才调用完整版本,超出量程时只更新射线上的空格。

3.3 Bresenham 光线投射:栅格地图的底层地基

射线更新里最关键的函数是 bresenham_lines,它负责把一条 2D 射线经过的所有整数格子列出来。可能有人会问:为什么不直接在每个格子里做几何相交判断?那样当然可以,但计算量太大。激光雷达一圈 360 个点,每点射线的长度从几格到一百多格不等,如果用浮点直线方程逐格求交点,一帧数据就要做上万次三角函数。

Bresenham 算法只做整数加减法,没有乘除,没有三角函数。它的思路用一句话概括:从起点到终点,每次在 x 方向或 y 方向走一步,判断哪个方向更贴近理想直线,就走哪个方向。代码实现如下:

def bresenham_lines(x0, y0, x1, y1): dx = abs(x1 - x0) dy = -abs(y1 - y0) sx = 1 if x0 < x1 else -1 sy = 1 if y0 < y1 else -1 err = dx + dy while True: yield x0, y0 if x0 == x1 and y0 == y1: break e2 = 2 * err if e2 >= dy: err += dy x0 += sx if e2 <= dx: err += dx y0 += sy

注意这段代码里的 dy 是负数,这是标准实现,不是笔误。它巧妙地用 err 的符号来决定下一次往哪个方向走。我把这段函数放在建图模块里之后,一帧雷达 360 条射线完整更新 400×400 的栅格,在我的测试小主机上大约只要 15 到 20ms。这个性能完全够在 10Hz 雷达的帧间隔内完成建图。

有一点提醒:如果你的地图分辨率调到 1cm,射线经过的格子数量会暴增,性能会明显下降。我建议先用 5cm 跑通,确认地图质量没问题,再根据实际需求调高分辨率,否则很容易在生产环境被性能问题搞到怀疑人生。

3.4 粒子滤波定位:让机器人在地图里找到自己

建图完成只是第一步。机器人要导航,得能实时知道“我现在在哪”。我的定位模块用的是粒子滤波,核心思想很直白:在地图里撒上一把随机粒子,每个粒子都代表一种“我可能在这里”的假说,然后不断用里程计预测粒子的移动、用激光观测打分,最后把分数低的粒子淘汰、让分数高的粒子复制自己。

每个粒子就是一组坐标和姿态 (x, y, yaw)。预测阶段,根据里程计给出的位移增量,给每个粒子加上高斯噪声,用来模拟轮子打滑和里程计漂移。观测阶段,把该粒子位置下“虚拟雷达应该看到的距离”和真实雷达数据做对比,越一致,权重越高。这个相似度计算不需要逐格做完整的射线投射,可以预先对地图计算一张“似然场”:每个栅格存一个“离最近障碍物的距离”,粒子的激光点打在似然场上,取对应位置的数值,数值越小,说明这个点越接近障碍物,分数越高。

def measurement_score(x, y, yaw, ranges, angles, likelihood_field): score = 0.0 for r, a in zip(ranges, angles): if not 0.2 < r < 6.0: continue beam = yaw + a px = x + r * np.cos(beam) py = y + r * np.sin(beam) gx, gy = world_to_grid(px, py) if inside(gx, gy): score += likelihood_field[gy, gx] return score

这段代码看起来简单,但里面有两个工程细节。一是距离太近的激光点不用来打分,因为近距离点噪声大;二是超出地图范围的激光点直接跳过,避免墙外的点破坏分数。实际落地的粒子滤波中,我每个周期会计算有效粒子数 N_eff,当 N_eff 低于粒子总数的 10% 时才做重采样,而不是每帧都重采样。这样可以避免粒子“早熟”,防止所有粒子瞬间挤到一个错误位置,后面再也拉不回来。

3.5 规划参数实测:哪些参数最值得调

路径规划模块我直接用前面提到的 A* 加膨胀地图。最值得花时间调的参数不是 A* 本身,而是膨胀半径和规划分辨率。

参数数值说明
栅格分辨率0.05 m建图和规划共用的地图分辨率
地图尺寸20m x 20m适合单层室内
粒子数量5002D 小车定位足够
粒子重采样阈值N_eff < 50低于总数 10% 触发重采样
激光最大匹配距离6.0 m超过此距离的数据不参与打分
膨胀半径0.25 m车身半径 0.15m 加 0.1m 余量
A* 搜索方向8 方向允许斜向移动但检查拐角

我把 5cm 和 10cm 两种解析度都实测过。10cm 解析度下,A* 的节点数量少很多,规划速度快,但路径会更早贴近墙边。5cm 下路径质量明显更平滑,代价是每次规划多花几毫秒。这个差异在单层房间里基本可以忽略,所以我选了 5cm 作为统一分辨率。调规划参数时,我习惯先开膨胀半径,让小车在真机环境中走上几圈,观察它离墙的最近距离。如果接近或小于安全距离,就说明膨胀半径不够。

4. 常见问题与排查技巧实录

4.1 地图重影错位:先查里程计,再查雷达外参

地图重影是建图阶段最典型的故障。表现是同一面墙在地图上被扫成两条甚至多条平行线,或者整个房间的轮廓像被“揉”过一样。遇到这种情况,我的排查顺序永远是先里程计,再雷达外参。

先让机器人走一条直线,左右轮编码器反馈的位移应该一致,如果地图上的直线变成了圆弧,说明左右轮半径或编码器脉冲数不一致,需要重新标定轮径。再让机器人原地旋转 360 度,在地图上画一个圆,如果圆偏扁,说明轮距标定不对。除了标定,还要检查里程计噪声参数。我在运动模型里加的高斯噪声标准差,如果调太小,粒子滤波会把错误的位姿当成“可信位置”,地图照样会漂。

如果里程计没问题,那就是雷达外参。前面提到的“贴墙走直线看双线”方法,可以快速定位雷达安装偏移。注意偏移不仅是前后左右,还有角度。雷达装歪了零点几度,远处墙体的重影会非常明显。这个角度误差在距离越远时放大得越厉害,所以我总是检查 3m 外的墙体轮廓是否连续。

4.2 地图上的黑噪点和白飞点:数据预处理没有做到位

地图上出现随机分布的黑色噪点,常见原因是激光坏点直接参与了占据更新。那些离群飞点打在哪一格,哪一格就被更新成“占据”,而且 log-odds 累加很慢才消掉。我遇到黑噪点时的第一反应不是调地图参数,而是回去看雷达原始数据。

给一条经验:把雷达数据可视化出来,用 Matplotlib 画出每一帧的散点图,盯着看一分钟。你很快会发现某些角度、某些距离下,点云一直在抖。特别是阳光直射窗边的时候,三角测距雷达经常产生周期性坏点。我在预处理里加了“距离突变过滤”后,这类噪点基本消失。

透明物体造成的白墙穿洞就比较麻烦。玻璃门、亚克力板这种材质,雷达波束会直接穿过去,地图上该有墙的地方变成空白。我的处理办法不是修改算法,而是把这类区域标记为“已知动态区域”,让规划器默认导航路径避开。遇到透明障碍物,视觉或者超声波传感器会比激光更可靠,如果项目预算允许,再加一个超声波探头专门补盲区。

4.3 粒子退化与早熟:一种难察觉的定位失控

粒子滤波最怕两件事:退化与早熟。退化指粒子都集中到极少数点,多样性消失,机器人一旦被推一下或撞到障碍,粒子就再也追不上真实位姿。早熟指粒子在错误区域聚集,但因为观测噪声太大,错误区域的得分和正确区域差不多,系统一直以为自己定位成功,实际上距离真实位置差了好几米。

判断退化不能只靠眼睛盯着粒子簇。我在每次观测更新后计算有效粒子数 N_eff。N_eff 越低,说明粒子越集中。集中本身不一定坏事,但如果 N_eff 长期低于 50 且定位误差没有收敛,就要考虑是不是测量模型给错了权重。我遇到过一种情况:激光匹配时把动态行人当作静态墙,结果每帧都有几条高权重射线把粒子往行人身上拉,N_eff 一直很低。后来我把距离变化率过大的激光点临时剔除掉,问题才解决。

4.4 规划路径贴着墙,甚至切角:膨胀半径和拐角检查没做好

A* 出来的路径贴着墙走,十有八九是膨胀半径设小了。膨胀半径至少要盖住机器人底盘的最大外接圆半径。我建议在半径基础上加 4 到 5 厘米,给控制误差留余量。完成膨胀后,再检查一下起点和终点本身是否落在不可通行区域。常见现象是目标点设在了紧贴墙的位置,膨胀后起点或终点变成了障碍,A* 直接无解。

还有一个容易被忽略的细节:斜向移动。A* 如果允许 8 方向搜索,斜向前进时会穿过两个正交障碍格子的“角”。机器人跑到那个角上,身体会同时碰到两堵墙。很多初学者以为膨胀地图能解决这个问题,其实膨胀只能保证格子中心远离障碍,不能保证路径对角安全。我的做法是在扩展斜向邻居时,额外检查相邻的两个正交格子是否都可通行;如果有一个被占据,就禁止斜向走。这个小检查能避免大量“切角”事故。

4.5 一圈实测下来需要检查的数据文件

调试 lingbot-map 的时候,我有一个固定的“数据留档”习惯:把每帧雷达原始数据、里程计数据、粒子滤波后的最优位姿全部存成文本文件。这样后面调参数时,可以重放同一段数据来对比不同参数的效果,不会因为今天的实验环境跟昨天不一样而得出错误结论。

重放数据比真机测试有用得多。真机上你无法精确重复同一个动作,但重放数据可以。我一般会固定跑三组数据:直行 3 米、绕 180 度弯、绕一个房间走一圈。每组数据用不同的参数组合跑十遍,对比最终地图与标准地图的重合率。我用的是最简单的方法:把生成地图二值化之后,计算与手工标注标准地图的重叠像素比例。重合率低于 95% 的参数组合直接淘汰,只保留最好的几个组合上真机验证。

这个习惯帮我避过了一个大坑:有一次地图上出现规律性波纹,我一直怀疑是激光雷达有问题,后来重放数据才发现是里程计更新频率和雷达帧率不同步,导致每次建图位姿都滞后了一段距离。如果当时不重放数据,光靠真机反复试,可能要排查好几天才能想到是时间戳对齐的问题。

说实话,做 lingbot-map 这个模块,最磨人的并不是算法本身。那些数学看起来复杂,代码写起来反而快;真正烦人的是硬件噪声、时间戳对齐、参数耦合这种琐碎问题。踩过几次坑之后,我最大的体会是:地图模块的数据链路一定要保持简单、可重放。任何环节出问题,都要能快速定位到是“数据问题”还是“算法问题”。这套地图代码现在运行在 lingbot 平台上,每次实际部署时我还会重复一遍“贴墙走直线—看地图重影”的土办法,它一点都不过时,反而比很多高端标定工具更直观。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询