☰
Scan Context回环检测:用点云全局描述子解决SLAM漂流问题
2026/10/20 7:48:52 网站建设 项目流程

如果你做过激光雷达点云建图,大概率遇到过这一类问题:系统跑着跑着,明明已经回到以前经过的位置,回环检测模块却没有任何反应;或者更糟,在结构相似的楼道、停车场里疯狂误报,把两堵长得差不多的墙当成同一个地方。我以前一直以为回环检测就是“把点云做个特征匹配”,真正上手才发现,从“识别出回环”到“给出相对位姿初值”再到“把回环约束接进图优化”,每一步都有坑。

这篇笔记是我对 Scan Context(扫描上下文)论文的系统学习整理。它属于全局点云描述子这一支思路:不靠局部特征点,不用训练深度网络,而是把一帧 3D 点云压缩成一张二维高度矩阵,用矩阵相似度判断两帧是否来自同一个地点,同时还能估算出偏航角偏移。对于没有 GPS 的封闭空间、地下车库、高楼环绕的城区道路,这套方法都非常实用,而且落地门槛极低。适合已经了解 SLAM 基本概念、想搞清楚回环检测内部逻辑、并且准备把回环模块写进自己系统里的朋友。

我按照“它解决什么问题——核心原理怎么设计——匹配搜索怎么跑——代码怎么写——参数怎么调——工程里怎么集成”这条线来拆,尽量让读完的人能直接上手复现。

1. 全局描述子瞄准的靶心:回环识别与位姿初值

1.1 回环检测到底难在哪

SLAM 建图本质上是把不同时刻的传感器观测对齐到同一个坐标系里。哪怕里程计和惯性导航再准,长时间运行后也一定会有漂移。于是回环检测就成了兜底手段:当机器人重新回到曾经到过的区域时,找出当前帧和历史关键帧之间的对应关系,用这段关系把整个轨迹上累积的误差拉回来。

但这件事难在三个地方。

第一,候选范围极大。一个建图工程跑一两个小时,关键帧轻轻松松几千上万帧,你不可能把当前帧跟每一帧都做一次完整点云配准。第二,视角差异大。机器人重访同一个地方,朝向可能完全变了,甚至可能是反方向开回来。第三,环境不是静止的。停着的车、移动的行人、新堆放的货物,都会让两帧点云看起来不那么“一样”。

传统做法里最直接的是 ICP 配准——让两帧点云在几何上对齐。可 ICP 这类方法本质上是局部优化,它需要一个不错的初始位姿,否则很容易陷进局部极小值。全局搜索时,ICP 的计算量又完全不可接受。图像领域的回环方案,比如词袋模型,在纹理丰富的室内场景还行,一到夜间、逆光、无纹理的白墙走廊就废了。

1.2 为什么偏要用“全局描述子”

全局描述子的思路完全不同:不管点云里有多少个点,最后都压缩成一个固定维度的“指纹”。比较两帧是否回环,只需要比较两个固定长度向量或矩阵的距离,速度极快。Scan Context 在这个方向里属于非学习的几何方法,核心假设是:一个地点之所以能认出是同一个地点,靠的是周围环境在空间上的几何轮廓,而不是某个局部细节。

这个方法跟几个常见替代方案的对比我总结在下表里:

方案类型代表思路优点短板
点云直接配准ICP / NDT精度高,能输出完整位姿计算量大,需要初值,容易局部收敛
局部特征匹配3D 关键点 + 描述子对遮挡相对鲁棒低纹理结构下特征点稀疏,不稳定
图像词袋视觉 BoW成熟,室内文本场景强光照敏感,纯几何场景失效
全局点云描述子Scan Context无需训练,自带旋转估计,快对高度重叠的多层结构有投影歧义

Scan Context 最大的吸引力在于它的可解释性。每一步你都知道矩阵里那个格子是什么含义,匹配失败时也能通过可视化矩阵快速定位问题。这一点在工程排障时非常重要——深度学习描述子给个 256 维向量,你很难说清楚它为什么把两个地方判成同一个。

2. 从无序点云到有序二维矩阵的两次投影

2.1 极坐标下划分“环”和“扇区”

Scan Context 的第一项核心设计,是把以雷达自身为中心的周围空间,用极坐标划分成网格。

想象你站在一个圆形广场中央,先沿半径方向把地面切成若干圈,像靶纸上的一圈圈环;再把每一圈按角度切成若干份,像切披萨。于是每个格子看起来像一个扇形小块。设径向环数为 Nr,方位扇区数为 Ns,总共有 Nr × Ns 个格子,论文里叫 bin。

每个 bin 实际上是一块竖直的柱体空间。以雷达原点为轴,从最小半径 r_min 一直延伸到最大半径 r_max,同时覆盖一个角度区间。划分之后,点云里每一个三维点都能通过极坐标定位到唯一一个 bin:

  • 方位角:θ = atan2(y, x),范围 [-π, π]
  • 水平距离:ρ = sqrt(x² + y²)
  • 环编号:根据 ρ 落在哪个径向区间
  • 扇区编号:根据 θ 落在哪个角度区间

有一个细节值得留意:径向环不是必须等间隔。原文考虑的出发点是,近处点云密集,远处点云稀疏。如果近处和远处用同样的物理宽度划分 bin,近处一个 bin 里挤满了几百个点,远处一个 bin 却可能一个点都没有。常见做法是最小半径之后分段或按比例增大宽度,让最终每个 bin 里的点数量不至于悬殊。我自己测试时通常用 20 个环覆盖 2m 到 80m 的范围,效果比较稳定。

2.2 每个格子里为什么存“最大高度”而不是别的

划分完 bin 之后,每个 bin 内部可能包含若干个三维点,也可能一个点都没有。Scan Context 的做法是:取出这个 bin 内所有点的最大高度值 z,填入矩阵对应位置,得到一个二维矩阵 I,其中 I[i, j] 代表第 i 个环、第 j 个扇区内的最大高度。

那为什么不存平均高度,或者存点的数量?我的理解是这样的。

平均高度很容易被拉低。一个 bin 里只要有一点地面噪声,平均值就会显著下降,导致不同地点之间的矩阵差异被磨平。点的数量和点云密度强相关,不同距离、不同雷达型号下密度差别很大,稳定性差。最大高度则直接对应着一个环境里最突出的几何结构——墙角上沿、栏杆顶部、树冠、车顶、建筑立面转折。这些结构恰好在空间上有区分度,而且对雷达视角的细微变化不敏感。你弯腰走过去和站着走过去,同一堵墙的最大高度是一样的。

所以矩阵可视化出来,人眼一看就能认出场景轮廓:一条长的走廊会形成两条平行的亮线,一个开阔广场的矩阵边缘会比较亮,密集建筑区会呈现斑驳的高亮度块。

2.3 矩阵的旋转等价性质

这个设计里最巧妙的一点在于:激光雷达水平旋转时,扫描上下文矩阵不会变成一堆完全不同的数字,而只是“列位移”。

想象你把雷达原地转动 90 度。以前落在正前方的那堵墙,现在可能落到了左侧 90 度的扇区里。在矩阵中,这堵墙所在的列编号就变了,整体来看相当于矩阵沿着扇区维度循环平移了一段距离。也就是说,30 度的旋转对应着 Ns / 12 列的位移,180 度掉头对应着 Ns / 2 列的位移。

这个性质让旋转不变性的实现变得极其简单——不需要做任何旋转对齐预处理,只需要在匹配的时候测试各种列位移,找到相似度最高的那个位移量,就同时拿到了“是否回环”的答案和“相对偏航角”的估计值。

3. 回环匹配链路:环键、列匹配、旋转与粗位姿输出

3.1 环键:把匹配问题先粗筛一遍

真正的 SLAM 系统里,关键帧可能有几千上万帧,如果一个一个完整做矩阵比较,就算矩阵只有 20×60,几千帧也够喝一壶了。因此论文设计了一个极轻量的粗筛特征,叫环键(Ring Key)。

环键是一个二进制向量,长度等于环的数量 Nr。对每一行(即每一个环),统计该环内非空 bin 的数量占比,如果占比超过某个阈值,这一位就置 1,否则置 0。换句话说,它记录的是“这个距离范围上,环境中是否有东西存在”。

这个向量虽然极度精简,但很有效。不同地点在空间轮廓上的差异,往往在“哪些距离上有物体”这个维度就能反映出来。比如你站在一个空旷广场中央,近处环和远处环可能是空的,中间环有树和墙壁;你站在一条窄巷子里,几乎所有环都有点。环键比较只需要做二进制异或,统计差异位数,几百次比较几乎不耗时。

粗筛的目的不是找到最优候选,而是快速砍掉绝大多数明显不相关的帧。对剩下的少量候选,再做完整的列匹配。

3.2 按列计算余弦相似度,而不是矩阵整体距离

Scan Context 的相似性比较不是把两个矩阵拉平算欧氏距离,而是按列比较,每一列视为一个向量,计算余弦相似度,然后求平均。

为什么按列而不是按整个矩阵?原因是环境遮挡会影响矩阵中部分环区的值。当前帧远处被一堵墙挡住时,远处环全是 0;历史帧当时没有遮挡,远处环有值。如果算整体欧氏距离,一个列全是 0 的帧会被严重惩罚。但按列分开计算,只有那些两帧都有有效值的列才参与打分,缺失的列可以跳过或者扣少量分,鲁棒性更好。

打分过程对应这样一个场景:假设当前帧矩阵是 Iq,候选帧矩阵是 Ic,我先假设当前帧相对候选帧旋转了 k 个扇区,把 Iq 的第 j 列去跟 Ic 的第 (j + k) 列求余弦相似度,再把所有列的结果取平均。把所有可能的 k 都测一遍,最高分就是这两帧的匹配分数,对应的 k 就是要找的旋转位移。

伪代码如下:

def column_distance(I_q, I_c, shift): num_sectors = I_q.shape[1] total = 0.0 count = 0 for j in range(num_sectors): v1 = I_q[:, j] v2 = I_c[:, (j + shift) % num_sectors] # 跳过两个向量都太稀疏的列 if np.linalg.norm(v1) < 1e-6 or np.linalg.norm(v2) < 1e-6: continue total += np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) count += 1 return total / max(count, 1)

如果你把雷达原地转一圈,最优 shift 恰好就对应实际的偏航角偏移。这个角度可以直接作为后续 ICP 配准的旋转初值,省掉了 ICP 的全局搜索过程。

3.3 距离调整:让“近处的候选”更可信

论文里还加了一个容易被忽略的环节:距离调整。直觉上,两帧点云来自同一个地点的置信度,跟它们估计位置之间的距离有关。如果里程计或者 GPS 告诉你这两个关键帧距离只有半米,那它们相似度低有可能是遮挡导致的误判;如果距离差了二三十米,哪怕相似度挺高,也要怀疑是不是结构重复造成的假阳性。

实现上通常是引入一个随距离衰减的先验权重,把列匹配的原始分数跟“位置相差多大”结合成最终得分。具体公式各家实现略有不同,核心思想是一致的:匹配分数高只是必要条件,位置一致性是另一个独立证据。做工程集成的时候,这个距离先验很值得保留,它能在楼道、隧道这类自相似环境中明显降低误检率。

3.4 回环之后的校验与位姿输出

粗筛、列匹配、距离调整都走完,会得到几个分数靠前的候选帧。SLAM 系统通常不会直接接受最高分候选,而是再补一步几何校验:用当前帧和候选帧点云做一次 ICP 配准,看收敛残差。配准结果好,才把回环约束加入位姿图优化。

这里 Scan Context 的价值就体现出来了。ICP 最怕没有好初值,但 Scan Context 已经给了相对偏航角,平移初值可以直接用候选帧对应的地图位姿粗略换算。有了初值,ICP 收敛又快又稳。

4. 一个可以直接跑的 Python 最小实现

4.1 输入点云的预处理管线

要从原始点云得到 Scan Context 矩阵,前置处理很简单,三步就够:

  1. 体素滤波降采样。把点云分辨率控制到 0.2m ~ 0.5m,既保留几何轮廓,又避免单帧点数过多导致计算浪费。
  2. 地面点移除。Scan Context 的矩阵格值取的是最大高度,地面点本身不会成为最大值,但会加重点云密度,影响体素滤波效果。用 RANSAC 拟合平面然后滤掉地面点,可以提升整体稳定性。
  3. 限制有效距离和高度。去掉雷达底盘附近的点和过高的噪声点,避免车顶、飞鸟、传感器杂散点影响矩阵。

[ x = 雷达正前方方向, \quad y = 左侧方向, \quad z = 垂直向上 ]

坐标系不统一的问题在复现时挺常见。有些数据集用的是前左上,有些是右前上。atan2(y, x) 的结果会跟着坐标定义变化,所以整个系统里必须统一约定,不然匹配出来的旋转角度会差 90 度或 180 度。

4.2 构造 Scan Context 矩阵的代码

import numpy as np def make_scan_context(points, num_rings=20, num_sectors=60, r_min=2.0, r_max=80.0): """ points: Nx3 数组,每行为 (x, y, z) 返回: (num_rings, num_sectors) 的矩阵 """ x = points[:, 0] y = points[:, 1] z = points[:, 2] dist = np.sqrt(x**2 + y**2) theta = np.arctan2(y, x) # 1. 计算扇区索引 d_theta = 2.0 * np.pi / num_sectors sector_idx = np.floor((theta + np.pi) / d_theta).astype(int) sector_idx = np.mod(sector_idx, num_sectors) # 2. 计算环索引,这里用线性间隔,实际可按比例递增 ring_bounds = np.linspace(r_min, r_max, num_rings + 1) ring_idx = np.digitize(dist, ring_bounds) - 1 ring_idx = np.clip(ring_idx, 0, num_rings - 1) # 3. 遍历每个点,把 bin 内最大 z 写入矩阵 sc = np.zeros((num_rings, num_sectors)) for i in range(points.shape[0]): r_i = ring_idx[i] s_i = sector_idx[i] if z[i] > sc[r_i, s_i]: sc[r_i, s_i] = z[i] return sc

这段代码是教学版,用逐点循环便于理解。实际处理一帧几万点的点云时,可以优化成先按 (ring_idx, sector_idx) 做分组,再取每组最大值,速度会快很多。

4.3 环键提取与候选粗筛

环键用一句话就能写出来:每个环里非空 bin 的比例是否超过阈值。

def get_ring_key(sc, nonempty_threshold=0.2): # 每一行里大于 0 的格子占比 nonempty_ratio = (sc > 0).mean(axis=1) return (nonempty_ratio > nonempty_threshold).astype(np.uint8)

做候选搜索时,维护一个历史关键帧的环键数组,当前查询帧的环键跟所有历史环键做按位异或,统计 1 的数量,从小到大排序,只取前 N 个候选进入完整列匹配。N 一般取 20 到 50 就够了。这一步能把几千帧的搜索空间直接压缩到几十帧。

4.4 用列匹配选最优候选和旋转估计

def match_scan_context(sc_query, sc_candidates, shift_range=None): num_sectors = sc_query.shape[1] if shift_range is None: shifts = range(num_sectors) else: shifts = shift_range best_score = -1.0 best_shift = 0 for idx, sc_cand in enumerate(sc_candidates): best_score_cand = -1.0 best_shift_cand = 0 for shift in shifts: score = column_distance(sc_query, sc_cand, shift) if score > best_score_cand: best_score_cand = score best_shift_cand = shift if best_score_cand > best_score: best_score = best_score_cand best_shift = best_shift_cand return best_score, best_shift

注意,如果你事先知道里程计给出的两帧相对偏航,可以只搜索附近一个小区间,比如 ±30 度,进一步减少计算量。但正常做回环检测时建议全范围搜索,毕竟回环场景下里程计误差可能已经累积到不可信了。

5. 实测过程中的参数与误检调优经验

5.1 几个关键参数到底怎么定

复现的时候最常问的就是环数、扇区数、最大半径设多少。这里把我自己试过的组合和结论列出来:

参数典型值调参感受
扇区数 Ns60每扇区 6 度,区分度够用。加到 120 能提升一点精度,但差分检测的敏感性也会上升,噪声导致的误检变多
环数 Nr2020 环覆盖 80m,每环平均 4m 宽度,稳定。加到 40 对小场景更精细,但搜索和存储成本翻倍
最小半径 r_min2m ~ 5m近处包含车身边缘和行人,干扰大,跳过更稳
最大半径 r_max60m ~ 80m越大覆盖越广,但远处点云稀疏、动态物体也多,过大的 r_max 反而增加噪声
环键非空阈值0.2阈值太低环键几乎全是 1,粗筛失效;太高全是 0,也会失效

我的经验是先用 20 环、60 扇区、2m 到 80m 做基线,然后把整个测试轨迹上已知回环对的分数跟非回环对的分数分别统计出来,看重叠程度再针对性调整。

5.2 相似度阈值不能照搬论文

不同雷达、不同环境下的相似度分布差异很大。在一个开阔广场场景里,历史帧跟当前帧即使来自同一地点,远处环大量为空,匹配分数可能只有 0.5;而在一个结构丰富的停车场,不同地点的分数也可能高达 0.6 以上。

所以正确做法不是拿一个固定阈值直接套用,而是先跑一遍带真值标注的轨迹:

  1. 提取所有已知回环对,算一遍匹配分数;
  2. 抽取时间间隔较大的随机帧对,算匹配分数;
  3. 把两个分数分布画在一张图上,阈值选在两个分布之间偏高的位置。

这个方法虽然朴素,但在工程落地时比盲目抄参考值靠谱得多。我自己项目里最终阈值一般在 0.6 到 0.75 之间,但前提是已经做了环键粗筛和距离调整。如果不做距离调整,阈值还得往上提。

5.3 动态场景下的几个隐藏坑

第一个坑是动态物体改变了最大高度。一辆大型货车停在某个 bin 里,它的高度可能比环境里的墙还高,导致两帧点云虽然在同一地点,矩阵却出现显著差异。缓解办法是处理前用一个简单的“高度先验”过滤,比如把明显高于周围平均高度的孤立点剔除,或者在建图频率较高时做多帧合成一个子图再提取描述子。

第二个坑是同向重复路径与反向掉头。Scan Context 的列匹配理论上能统一处理,但反向进入同一区域时,雷达扫描到的可见面完全不同。比如一条两边有墙壁的走廊,正向走看到的是墙的内侧,反向走看到的是另一侧墙,矩阵形态差异比想象中大。遇到这类场景,我会额外建立“方向翻转”的候选集合,把当前帧矩阵做过一次完全翻转再匹配一遍。

第三个坑是上下层结构。高架桥、多层停车场会让 Scan Context 把不同高度的几何结构投影到同一个平面格子。这是纯二维投影方法的天然弱点。如果应用场景里大量存在多层结构,建议改用带高度层拆分的变体,或者同时保留多个高度区间的矩阵作为特征组。

6. 把 Scan Context 挂进 SLAM 系统的工程经验

6.1 回环约束如何接入图优化

识别到一个回环之后,不能只输出一个“是回环”的结论就结束。真正能消除漂移的是把回环提供的相对位姿作为约束写入后端优化。Scan Context 已经给出了粗偏航角,再用 ICP 精化的相对位姿,就可以直接构造回环边:

  • 节点:当前关键帧对应的位姿
  • 节点:候选关键帧对应的位姿
  • 边:两帧之间的相对位姿变换

回环边的信息矩阵可以比普通里程计边更严格,因为回环跨越时间长、约束价值高。需要注意的是,如果 Scan Context 阈值设得过分激进,引入了假回环,这种高权重约束会把整个地图搞坏。所以我在实现中基本都是“检测 + 几何校验”双保险,宁可漏检也不误检。

6.2 与 ICP 结合时的一点心得

Scan Context 最佳匹配的那一列 shift 对应偏航角,但它的精度只有 6 度(扇区数为 60 时)。直接把这个角度当作最终位姿肯定不够,通常的做法是把它作为 ICP 初值的旋转部分。平移部分则用候选关键帧位姿减去当前帧预估位姿得到。

这一步在实际跑的时候经常出现一个现象:如果初始旋转误差超过 10 度,ICP 很容易收敛到错误局部极小值。所以我会在 ICP 前先做一个更细的网格搜索,比如在 Scan Context 输出的 6 度附近,用 0.5 度步长再对矩阵做一次“子扇区插值匹配”,把偏航初值细化到 1 度以内,再交给 ICP。这个技巧能让回环配准成功率明显提升。

6.3 什么时候用手工描述子,什么时候换学习型方法

做技术选型时我一般这样判断:如果你的传感器标定可靠、场景结构特征清晰,Scan Context 这类手工描述子已经能解决大多数问题,而且出了问题你能一步一步排查。相反,如果场景包含大量语义变化,比如同一片区域在不同季节植被状态完全不同,或者雷达和视觉数据需要统一到一个同量级的特征空间里,那考虑学习型全局描述子更合适。

学习型方法的优点是把“最大程度利用数据区分性”这个任务交给了网络,但代价是需要足够多的标注数据,且特征不可解释。Scan Context 的另一个实用价值在于,它可以作为学习型方法的“几何先验”或弱监督标签来源:先用几何方法生成一批回环候选,人工抽检后作为训练样本,这个流程我实践过,效率很高。

最后说一个我反复踩过的坑:无论用哪种匹配方法,关键帧采样策略都直接影响回环检测效果。关键帧太密,描述子之间差异微弱,匹配噪声大;关键帧太疏,回环覆盖密度不够,容易漏检。Scan Context 本身对关键帧间距并不敏感,但整体系统最优效果一定是配合一个合理的“最小距离 + 最小转角 + 最大时间间隔”关键帧策略一起调出来的。

Scan Context 作为一篇几年前提出的方法,现在看依然能打。它没有花哨的网络结构,没有复杂的训练流程,却把一个“从哪来、到过哪、转了多大角度”的问题拆得清清楚楚。如果你也想把回环检测这块从“玄学调参”变成“可解释的确定性模块”,拿它当切入点,非常值得。

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

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

立即咨询