复现那篇《Mean-shift exploration in shape assembly of robot swarms》,我前后花了两周多时间,中间废掉的调试版本比成功版本还多。核心思路一句话就能讲清楚:让一群没有编队、没有全局地图的机器人,靠 mean-shift 对局部密度做爬山,最终自组织地拼出预设的二维形状。听起来很玄,但仿真跑起来之后,你会看到机器人从随机位置慢慢聚成一片叶子、一个圆环或者任意不规则的轮廓,整个过程没有中央控制器,没有任务分配,每个机器人都只依赖邻居信息和目标形状的局部采样点。这篇文章适合两类人:一类是做集群智能、想找论文复现练手的人;另一类是正在做多机器人协作,却拿不准怎么把目标形状编码进局部规则的研究者。我自己从复现里学到的,比读十遍摘要都多。
1. 复现之前:先读懂论文在解决什么问题
1.1 形状组装的两条技术路线
做形状组装,最常见的做法是预设队形控制,比如虚拟结构法、leader-follower 或者人工势场法。虚拟结构法把期望队形当成一个刚体,机器人跟着这个刚体的位姿走;leader-follower 让一部分机器人当“领导”,其余机器人跟随。这些方法很成熟,但有一个共同点:需要明确指定机器人的角色、参考系和通信拓扑。队伍规模一旦放大到几百上千个机器人,全局参考系、故障节点、通信链路任何一个环节出问题,整个系统都会变得很脆。
自组织路线则更接近生物群体的行为。蚂蚁、蜜蜂、细菌菌落没有谁拿着一张图纸指挥,却最终呈现出结构。论文想证明的是:一个来自统计聚类的工具 mean-shift,可以直接作为机器人的局部运动规则,用来完成形状组装。它不要求全局定位,每台机器人只需要知道自己周围有哪些机器人、目标形状在附近的采样点分布在哪,就可以通过迭代收敛到目标结构的某个局部区域。这正是我复现它最感兴趣的地方——把“队形控制”从集中式变成涌现式。
1.2 Mean-shift 为什么会被用到这里
Mean-shift 是计算机视觉里常用的模式查找算法,输入一堆采样点,通过反复把样本向核密度估计的梯度方向移动,最终停留在概率密度最大的位置。早期它被用来做图像分割、目标跟踪和聚类,核心思想很简单:哪里密度高,就往哪里移动。
放到机器人集群场景里,有一个很自然的对应关系:机器人当前位置集合就是采样点,目标形状可以预先转成一组空间分布的目标样本点,那“形成目标形状”就被转换成了“让机器人群体在目标样本点定义的目标密度场中找到各自的模式并稳定下来”。这跟传统势场法相比,不是简单地对某个目标点计算引力,而是在局部范围做密度加权求和,形态上更柔,对噪声和局部扰动也更鲁棒。
而且 mean-shift 最大的好处是天然可分布式实现。每个机器人只需要自己在邻域内的邻居位置,加上目标形状预先广播给它的采样点,不需要知道群体全局坐标。这和集群机器人的通信约束完全匹配,所以论文把它定义成一种 exploration 策略,而不是单纯的控制律。
1.3 论文核心流程拆解
整个复现流程分两个阶段,我一开始没分清楚,导致后面的代码写得很乱。
预处理阶段把目标形状(可以是二值图、多边形或者带孔洞的复杂区域)离散成 S 个目标点。这些点并不是每个都要停一台机器人,而是作为虚拟采样点参与密度估计,相当于给每个机器人发一张“局部图纸”。执行阶段每个机器人反复执行三步:第一步通过通信或视觉感知获取通信半径内的邻居相对位置;第二步在自己当前位置附近收集目标形状采样点;第三步用邻居点和目标点一起计算 mean-shift 偏移量,再把这个偏移量转化为底盘速度命令。
这三个步骤循环执行,机器人就会不断向局部密度极大值靠近。这个极大值的位置由目标点和邻居点共同决定:如果你只看邻居点,机器人会往已有机器人的中心挤,形成几个团簇;如果你只看目标点,机器人会往目标形状的某个初始位置冲,不会铺开。所以关键就在怎么把两者混合,以及怎么选择核函数带宽。这个我后面会专门讲。
2. 仿真环境与工具链选择
2.1 为什么先用仿真而不是真机
论文里的许多假设在真实环境中是理想化的,比如机器人能够精确测量邻居的相对位置,通信距离固定且没有丢包,运动模型接近完美。现实中超声波、光流、轮子打滑都会带来一大堆脏数据,如果一开始就上真机,连“算法本身对不对”都很难判断。仿真的价值是能快速把算法参数的可行域摸出来,把收敛性、覆盖率这些核心指标验证完,再考虑迁移到真机上。
我这次复现的目标是论文的核心机理,不是工程落地,所以仿真平台足够逼真就行,不需要高保真物理引擎。
2.2 仿真器与编程语言的选择
我用的仿真器是一个常用的二维多机器人仿真器,里面提供圆形全向机器人模型、通信半径限制和简易碰撞检测。它的环境本身不写 UI,只提供物理更新接口,所有可视化和统计结果都是我自己用绘图库画的。编程语言选了 Python,主要原因是核密度计算和邻居搜索的操作可以借助数值计算库简洁实现,调试也直观。
如果你准备复现,建议把算法部分独立成不依赖仿真器 API 的包。我的做法是写了一个 core 目录,里面只放 mean-shift 更新、目标点采样、评价指标这些纯算法模块,仿真器只负责调用核心接口。这样后续哪怕是换一个仿真环境,或者迁移到某个机器人操作系统框架,核心逻辑都不用重写,这也是我踩了坑之后才体会到的好处。
2.3 物理引擎与通信模型需要注意的细节
这个实验对物理精度要求其实不高,机器人不是高动态的空中平台,重点是碰撞避免和通信拓扑。我的仿真步长设在 0.05 秒,控制周期 0.1 秒。通信模型用了最简单的圆形范围,后台直接查询半径内的邻居列表。
通信半径是最容易被忽略的参数。它如果比平均机器人间距小很多,每台机器人感知到的邻居数量太少,密度估计会产生巨大方差,形状边缘会像噪声一样抖动。反之半径太大,计算量会爆炸,而且机器人会“看到”远处很多不该影响自己的点,导致局部收敛速度变慢。我调试时把通信半径和 mean-shift 的带宽 h 绑定在一起,让通信半径保持在 h 的 2.5~3.5 倍。这个比例下,每台机器人平均能感知到 10~30 个邻居,密度估计的方差已经比较小。
初版复现我特意关掉了通信丢包和传感器噪声,先把“理想条件下的收敛性”跑通。要实现论文原文的容噪实验,可以再往模型里注入高斯噪声,但现在这一步的前提是算法骨架正确,不然根本无法区分是噪声影响还是参数没调好。
3. 算法核心模块的复现细节
3.1 目标形状的点采样与标识
为了让结果有区分度,我选了一个不规则的叶子形状,而不是对称图形。对称图形在验证算法时容易掩盖方向偏差,叶子形状有弯曲的轮廓和凹凸不平的边界,很容易看出收敛质量。
具体采样流程是:先把形状的闭合边界和内部区域用成熟的几何库统一转成多边形,在包围盒内均匀撒点,只保留落在多边形内部的点;然后对边界区域额外加密,因为形状最终的轮廓主要由边缘机器人撑起来,如果边缘采样点稀疏,收敛后外壳会模糊;之后把所有点归一化到长度约 10 米的仿真范围内。目标点数量 S 默认取 2000,个别实验会调整到 1000 或 4000 做对比。
采样时有一个特别容易错的细节:一定要剔除重复点。如果同一个空间位置出现多个目标点,该区域的目标密度会虚假升高,机器人会被过度吸引过去,最终形状局部堆积。我最初偷懒没有做去重,结果所有实验都在同一个位置多出一团机器人,花了一天才定位到问题。
3.2 Mean-shift 迭代公式与带宽选择
整个算法最核心的计算是 mean-shift 向量。对于当前机器人位置 x,先收集通信半径内邻居位置集合 N,以及当前位置附近一个范围(我取 2h 内)的目标形状采样点集合 T,然后用高斯核做加权,得到新的“模式”估计位置,减去当前位置就是 mean-shift 向量。
给一个我实际用的 Python 实现:
import numpy as np def gauss_kernel(r2, h): return np.exp(-r2 / (2.0 * h * h)) def mean_shift_step(x, neighbors, targets, h, lam=1.0): x = np.asarray(x, dtype=float) all_pts = [] weights = [] # 目标点:只取 2h 范围内,并乘以权重 lam if len(targets) > 0: d2 = np.sum((targets - x) ** 2, axis=1) keep = d2 < (2.0 * h) ** 2 if keep.any(): w_t = gauss_kernel(d2[keep], h) * lam all_pts.append(targets[keep]) weights.append(w_t) # 邻居:也限制在 2h 范围内 if len(neighbors) > 0: d2 = np.sum((neighbors - x) ** 2, axis=1) keep = d2 < (2.0 * h) ** 2 if keep.any(): w_n = gauss_kernel(d2[keep], h) all_pts.append(neighbors[keep]) weights.append(w_n) if not all_pts: return np.zeros_like(x) pts = np.vstack(all_pts) w = np.hstack(weights) s = np.sum(w) if s <= 1e-12: return np.zeros_like(x) mode = np.sum(pts * w[:, None], axis=0) / s return mode - x这个函数里的 lam 是目标点相对邻居点的吸引力权重。lam 很小时,机器人主要受邻居分布影响,最终会收缩成几个团簇;lam 很大时,机器人会直接朝目标点冲,但由于目标点永远存在,机器人不会停在中间过程,而是直接扎进形状内部,导致堆积。我试下来 lam 在 0.8~1.2 之间比较合适。
带宽 h 的影响就更大了。h 太小,密度估计噪声大,机器人在目标点之间来回切换位置,系统始终震荡;h 太大,密度近视非常平滑,形状的凹角、尖角这类细节全部丢失。我的经验是先用统计方法算一个初始值:统计所有机器人的平均最近邻距离 d_nn,然后令 h = 1.8 * d_nn。这个值可以在后续实验里乘以 0.5~1.5 做扫描。千万不要一上来就凭感觉定 h,否则结果会非常混乱。
3.3 机器人个体控制律
mean-shift 向量算出来之后,要把它转成机器人运动指令。在仿真器里,全向机器人模型是最方便的:直接把 mean-shift 向量乘以控制增益当作期望速度。如果使用差分驱动模型,还需要把期望速度投影到机器人当前朝向上,然后用滑动转向模型转换。我为了先验证算法,直接用了全向模型,没有加转动惯量和轮式约束。
但纯 mean-shift 会导致一个问题:机器人之间没有碰撞规避。当多个机器人被同一个目标区域吸引时,它们会互相重叠,最终在一个点堆成一团。解决方式是加一个近距离排斥项。最开始时我把排斥势场加得太大,整个形状像被吹散一样;后来把排斥作用半径设成 0.5h,排斥强度只做近距离保护,效果才正常。
我的控制更新伪代码如下:
for robot in robots: neighbors = get_neighbors(robot.pos, comm_radius) targets = get_targets_in_range(robot.pos, 2 * h, all_targets) m = mean_shift_step(robot.pos, neighbors, targets, h, lam) v_des = clip_speed(m * k_control, v_max) robot.pos += v_des * dt robot.pos += repulsion(robot.pos, neighbors, rep_radius)控制频率设置为 10Hz,速度上限 0.5m/s。如果 m 的长度已经很小,说明机器人处于局部稳定的位置,可以降低控制频率以节省算力。实际过程中,我还对点云的形心做了低通滤波,防止单步切换噪声过大。
3.4 停靠与收敛判定
仿真不能无限跑,需要明确什么时候算收敛。我设置了两个层级的判定条件:
第一,硬性最大步数,默认 5000 步。如果达到还没有收敛,就判定当前参数失败。第二,软性收敛条件:最近 100 步内所有机器人的平均 mean-shift 向量长度小于 0.01 米,并在这 100 步内覆盖率不再显著上升,才认为系统进入稳定状态。
评估指标用了两个,一个是形状覆盖率 C,以每个目标点为圆心,找最近的真实机器人,如果距离小于 r(我取 0.2 米)就认为该目标点被覆盖,覆盖率等于被覆盖的目标点数除以 S。另一个是平均误差 E,即所有真实机器人与最近目标点距离的平均值。这两个指标一个从目标视角看,一个从机器人视角看,可以避免单一指标被某一次偶然覆盖误导。
4. 实验调参与效果分析
4.1 多组实验对照
复现完成后,我做了一系列对比实验,验证不同参数组合对收敛结果的影响。这里选一组有代表性的对照:
| 机器人数量 N | 带宽 h (m) | 目标点数 S | 收敛步数 | 覆盖率 | 平均误差 (m) | 结果说明 |
|---|---|---|---|---|---|---|
| 200 | 0.4 | 2000 | 2100 | 0.92 | 0.09 | 收敛较慢,边缘完整 |
| 200 | 0.8 | 2000 | 800 | 0.87 | 0.13 | 收敛快但边缘模糊 |
| 400 | 0.4 | 2000 | 1700 | 0.96 | 0.07 | 覆盖好但有局部堆积 |
| 200 | 0.4 | 1000 | 1500 | 0.85 | 0.12 | 目标点不足,边缘空隙多 |
可以看到,h 从 0.4 增加到 0.8 时,收敛步数下降很明显,因为更大的带宽让机器人更快地感知到远处的密度中心,但这个收益是有代价的:边缘细节会丢失,覆盖率下降。这说明带宽本质上是一个在“收敛速度”和“形状精度”之间做权衡的参数,不存在一个万能的默认值。
4.2 关键参数的影响范围
我把主要参数对实验结果的影响整理成一个速查表,方便后续读者直接参考:
| 参数 | 影响方向 | 建议范围 | 备注 |
|---|---|---|---|
| 带宽 h | 太小震荡,太大丢细节 | 1.5~2.0 倍平均最近邻间距 | 优先扫描这个参数 |
| 目标点数 S | 太少形状空隙,太多计算量大 | 5~10 倍机器人数量 | 边界区域需要加密 |
| 目标吸引力 lam | 高则堆积,低则收敛到初始团簇 | 0.8~1.2 | 和 h 联合调整 |
| 通信半径 | 太小方差大,太大计算爆炸 | 2.5~3.5 倍 h | 至少要覆盖 10 个邻居 |
| 控制增益 k | 太大震荡,太小收敛慢 | 0.3~0.8 | 增益过大会导致过冲 |
| 排斥半径 | 太大破坏形状,太小重叠 | 0.3~0.5 倍 h | 只做近距离保护 |
这里要特别提醒的是,这些参数不是独立的。h 和 lam 组合在一起,决定了“机器人最终留在哪里”。我在实验中发现,如果 h 小、lam 大,机器人会一开始就被局部噪声吸引过去,形成很小的团簇,这些团簇之间不会合并,最终覆盖率很低。如果 h 大、lam 小,机器人更像在做群体聚类,形状特征不够明显。所以调参时不要单独调一个参数,要看联合效果。
4.3 复现过程中踩过的坑
第一个坑:把所有目标点全量放进 mean-shift 计算。我以为目标点越多越精确,结果每个机器人无论距离多远都会被所有目标点影响,最后所有机器人都被拉向形状的质心,整个系统坍缩成一团。解决方法是只取当前机器人周围 2h 范围内的目标点,并采用 KD 树近邻搜索加速。这个修改之后,计算量也大幅下降。
第二个坑:目标点采样不均匀。叶子形状的边界弯曲,我最初用均匀网格只保留内部点之后,边界处的采样点稀疏,而且有些边界线因为角度问题几乎没有目标点,导致收敛后机器人轮廓中间会缺一块。后来手工加了边界加密逻辑,在边界线上每隔很小间隔插点,最终的边缘形状干净了很多。
第三个坑:排斥项与 mean-shift 更新互相打架。我把排斥项直接加到 mean-shift 计算出的期望速度上,导致系统出现周期性振荡:机器人靠近邻居时被弹开,弹开后又被 mean-shift 拉回来,周而复始。后来我改成只在距离小于 0.15 米时触发排斥,并且把排斥力的上限限制在 0.3m/s,振荡问题才消失。这个经历也让我明白,集群算法里的“避碰”不应该和“编队”在同一层级耦合,应该作为底层保护机制。
5. 常见问题与排查实录
5.1 机器人震荡不收敛
表现是机器人到了某个区域后,位置在目标点附近来回跳,mean-shift 向量长度始终大于阈值。首先检查 h 是否过小,因为 h 小会让密度函数有很多局部峰值,机器人会不断从一个峰值跳到另一个。其次检查控制增益 k 是否过大,增益过大导致单步位移超过一个目标点的距离,形成过冲。解决办法是让 h 至少为最近邻间距的 1.5 倍,并把速度上限限制在 h 的一半左右。
5.2 形状边缘出现空洞
表现是整体轮廓大致成形,但某些边缘位置没有机器人。第一次遇到时我以为算法有 bug,后来逐一排查发现是目标点分布不均。特别是形状的凸出部分,如果采样点太少,就没有足够的密度吸引机器人过去。解决方法是增加 S 并对边界加密采样,同时把覆盖判定的半径 r 稍微调大,观察是“真没有机器人”还是“机器人离得有点远”。
5.3 群体分裂成多个独立小团
初始分布跨度太大时容易出现这种情况。机器人 A 和机器人 B 彼此距离很远,各自只能感知到自己那一块区域的目标点,于是 A 群体收敛到形状左边,B 群体收敛到形状右边,中间留下一片空区域。解决方法是采用“分阶段带宽”:前 1000 步用一个较大的 h 做粗聚类,让所有机器人先感知到全局的大尺度密度地貌,之后切换成较小的 h 做细节收敛。这个技巧在论文里没有明说,但是我在复现过程中反复尝试出的有效方案。
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 机器人在原地抖动 | h 太小或控制增益过大 | 增大 h 到最近邻间距的 1.5 倍以上,降低速度上限 |
| 形状边缘有空洞 | 目标点太少或边界采样不足 | 增加 S,边界线加密插点 |
| 群体分裂成多个小团 | 初始分布跨度过大 | 分阶段带宽,先大 h 再小 h |
| 中心位置堆积 | lam 太高或目标点去重失败 | 降低 lam,检查目标点重复 |
| 计算每步卡顿 | 全量目标点参与计算 | KD 树近邻搜索,只取 2h 内点 |
| 收敛后形状有缺口 | 通信半径太小,邻居信息不足 | 增大通信半径到 3 倍 h 以上 |
5.4 一个关于随机种子的提醒
多机器人仿真一定要固定随机种子,尤其是初始位置生成。我有一版代码没固定种子,跑出来的形状每次覆盖率在 0.88 到 0.93 之间波动,我还以为是参数不稳定,后来发现只是初始位置漂移太大。固定种子之后,同一参数重复五次实验的结果基本一致,这样调参才有意义。
6. 复现中的一点个人体会
这次复现最大的体会有两个。第一个是论文复现不等于抄公式。我按照原文的伪代码写出第一版之后,跑出来的形状惨不忍睹,后来翻附录才发现作者提了一句“目标点集合需要从形状骨架均匀采样”,这句话比正文任何公式都重要。一句话就能决定整个算法能不能出效果,这让我意识到读论文时不能只看数学表达,还要看那些藏在犄角旮旯的数据处理细节。
第二个体会是多机器人系统的调参思路和单智能体完全不一样。你不能盯着某一个机器人看,要看群体统计量。我后来写了一个实时的覆盖率曲线,它能告诉我当前系统处于扩张期、混乱期还是收敛期。比如覆盖率曲线若长时间停在 0.6 不动,说明系统陷入局部稳态,应该调整 h 而不是继续跑下去。这种“群体健康度”思维,比单点 debug 高效得多。
如果你也想复现这篇论文,我建议先从圆环这类对称形状开始。对称形状的期望覆盖分布容易预测,调参时如果结果不对,你能很快判断是参数问题还是算法骨架问题。我上来直接用了不规则的叶子形状,结果调参时根本分不清某个缺陷是叶子形状本身导致的,还是 lam 调高了。按“圆形、圆环、多边形、带凹面的复杂形状”这个顺序推进,每一步记录下最优 h 和 lam,后面换形状就有一组参考基线。最后再分享一个小技巧:把 mean-shift 向量的分布存成日志文件,一边跑一边打印,你会发现收敛过程不是线性的,而是先快速聚合、再慢慢修正边缘,这个过程中的统计特征,能帮助你判断参数是否适合当前任务,比只看最终覆盖率要有用得多。