写这篇东西之前,先交代一下背景。前阵子我一直在折腾一条小型水面无人艇的自主巡航方案,最初用的是廉价的GPS加固定航线,走直线倒还好,一旦港口里船多、浮标密,或者要绕开养殖区,固定航线就完全不够用了。后来把电子海图接入系统,配合全局路径规划,这才算把“能跑”变成“会跑”。这篇文章把我整个落地过程中踩过的坑、试过的参数、踩完坑之后的代码结构,一次性梳理出来,基本是按照“拿到项目以后该怎么一步步实现”的顺序来写的。如果你也在做无人艇相关的东西,或者说你想把电子海图用在自己的机器人平台上,这篇文章值得你花点时间看完。
1. 先搞清楚:电子海图到底给路径规划提供了什么
很多人一听到“基于电子海图”,第一反应是“用海图显示背景,在图上画路径”。这个理解不能说错,但离真正能用还差一大截。电子海图在这里扮演的其实是三个角色:环境信息的权威来源、坐标系的空间基准、以及离线可用的“先验地图”。对应到路径规划系统里,就是三个模块的事:地图解析、坐标转换、栅格化建模。这三个模块整不明白,后面RRT也好A*也好,跑出来的路径都是不可用的。
1.1 电子海图的“心动”与“心塞”
电子海图现在主流的数据格式是S-57,这是国际海道测量组织定的标准。它的核心思路是把海洋环境拆成一堆“物标”(feature),比如等深线、沉船、浮标、海底电缆、航道边界,每个物标都有自己固定的属性,属性里带着位置、形状、性质这些信息。这种数据设计的初衷是给船员看的,是给人做助航依据用的,所以它天然是面向显示的,而不是面向计算的。
我刚才说“心塞”就在这里:S-57格式里面各种物标被分为“点”“线”“面”三种空间对象,每种对象的位置信息都是用经纬度挂在属性里的。你要真正放到路径规划里用,首先得把它从“适合人看图”的状态,转成“适合机器算路”的状态。这个转换的过程,才是整个项目里最耗时、也最容易翻车的地方。
我自己的做法是先做一层过滤:把S-57里面的物标按照安全等级分成两类。一类是“绝对禁入区”,比如陆地、干出礁、沉船残骸、桥梁这种,路径规划网格里直接标成障碍物;另一类是“风险区”,比如浅滩、封闭水域、渔业养殖区,这些不直接标死,而是折算成规划代价的一部分,路径能躲开就躲开,实在躲不开也可以通过,但会有惩罚,避免算法顺着最短路一头扎进浅水区。
1.2 一个坐标很多个坐标系,别上来就写网格
电子海图本身用的坐标系是WGS84地理坐标系,经纬度是角度单位。但路径规划算法几乎无一例外是建立在平面笛卡尔坐标系下的。这就涉及到一个所有做无人艇规划的人都会遇到的问题:到底用墨卡托投影、高斯-克吕格还是直接用等距圆柱投影?
最简单的方案是用等距圆柱投影,把经纬度直接当平面坐标用。这种做法的好处是转换简单,坏处是在高纬度地区变形严重,航向会有误差。如果你只是在内河或者港湾里跑,问题不大;但如果是开放水域、纬度跨度大,那就要用墨卡托。我实测下来,在中低纬度的近海区域,等距圆柱投影带来的误差在几百米范围内基本可以忽略,所以我初期开发调试一直用等距圆柱,等到了要跑真实任务时再切到墨卡托。
另外还有一个坑是海图基准面和GPS的基准面可能不一致,导致海图上某个礁石的位置和GPS测到的实际位置差几十米。做路径规划的时候,如果船刚好贴着这个决策边界走,很可能实际开过去就撞上去了。所以我在建图的时候会统一做一个安全距离缓冲,把障碍物边缘向外膨胀。这个膨胀距离就是船的“最小安全距离”,取的是船宽的一半加转弯半径再加一个固定余量。
2. 从海图到栅格地图:技术选型与实现方案
路径规划的输入需要一张“谁可以走、谁不能走、走过去代价多高”的图。这一步相当于把上一步从S-57里提取出来的矢量信息,转换成一格一格的栅格地图。栅格地图最大的好处是直观:算法直接在这张图上搜索,不用再关心矢量图形的几何关系,所有判断都变成查表操作,快得很。
2.1 栅格大小怎么定:不是越小越好
栅格大小的选择是整个建图流程里最值得花时间权衡的参数。栅格太大,船的机动细节丢失,狭窄通道会被吃掉,出来的路径可能贴着障碍物但实际船过不去;栅格太小,地图尺寸大到内存爆炸,寻路算法算到天荒地老。
我自己的经验公式是:栅格尺寸取“船的长度”的1到2倍比较合适。比如说一条5米的小无人艇,栅格用5米到10米就非常舒服。港口避障的任务里,我常用的分辨率是5米一格,一张20公里乘20公里的近海图,栅格数量是4000乘4000,也就是1600万个格子,在计算机内存在几百MB级别,完全能接受,A*算法在这个规模上跑一次也就毫秒级。
如果你跑的船比较大,比如20米级别的无人艇,那你得把栅格放大到20米左右,否则算出来的路径一连串折线弯弯绕绕,船的实际控制根本执行不了。说到底,栅格尺寸就是一个计算复杂度与路径可用性之间的平衡点,没什么万能答案,要根据自己的平台参数来调。
2.2 障碍物怎么膨胀:算法安全的关键一步
膨胀是建图时最重要的步骤,直接决定一艘船会不会“擦着”障碍物走。膨胀半径至少要包含两个分量:一个是船本身的物理尺寸,比如船长5米、船宽2米,那就至少膨胀2到3米;另一个是运动学限制造成的横扫距离,这个要看船的转弯半径,比如我那条船的极限转弯半径大约在10米左右,那么障碍物边缘至少得往外扩10米才能保证船在转向过程中不会撞上。
这里分享一个踩过的坑:一开始我只按照船宽做了简单膨胀,没考虑巡航速度下的转弯半径。结果是规划出来的路径在纸面上看离障碍物有足够距离,但实际船以4节速度通过一个弯道时,由于转向不及时,船位已经压到了安全距离以内。后来我把膨胀半径改成“船宽一半 + 转弯半径 + 3米余量”,这个问题才算解决。3米这个余量是为了应对海流和风带来的漂移,你也可以根据你作业海况的流速适当加大。
2.3 陆地岛屿连接:我用图像形态学做了快速连通性检测
还有一个实际问题:海图里陆地、岛屿、防波堤这些区域画得并不规整,有时一条细长的人工堤坝被离散化成一行格子,会在一张很大的栅格图上“断掉”。如果断掉了,海和港口之间就“打通”了,路径规划会觉得船可以从堤坝中间穿过去,这就非常危险。
我的处理方法是,在建图完成之后,用图像形态学里的闭运算,把栅格地图上的细小断裂修补好,让同一片障碍物区域在栅格上变成一个完整的连通域。然后再做一次连通域标记,把所有与被标记为“不可航行陆地区域”连通的部分都统一标成障碍物。这样即便计算上某一格原本没有被覆盖到,只要它和陆地连在一起,就会被认为是不可通过的。这个小技巧解决了我当时很头疼的“路径穿堤坝”问题。
3. 全局路径规划算法:A*为主,Dijkstra为辅
算法选型方面,我最终选了A作为主算法,Dijkstra作为备用方案。原因很简单:在栅格地图上做全局路径规划,A无论是稳定性、实现难度还是运行效率,都是最均衡的选择。比A更复杂的算法,比如DLite,适合动态环境下的实时重规划,但本项目是全局路径规划,地图在出发前就固定好了,船在航行中主要靠局部避障来处理动态障碍物,所以全局算法用A*就够了。
3.1 A*的代价函数里加“晕船系数”
传统的A*代价函数是F = G + H,G是起点到当前点的实际代价,H是当前点到目标点的估计代价。这里“代价”一般就用欧几里得距离或者曼哈顿距离。但在我这个项目里,光有距离不够,我还给代价函数加了两个修正项:
第一个是危险区惩罚项。如果路径经过上一节提到的“风险区”(比如浅水区、养殖区),就会在代价上额外加一个惩罚值。这样A*在搜索过程中会优先选择绕开这些区域的安全路径,但万一绕路代价太大,它也允许穿过风险区,只是把这块区域的优先级压到最后。
第二个是转向惩罚项。这个是我个人最推荐的调整方向。不加这项的时候,A*经常算出“直角转弯、锯齿形走位”的路径——栅格地图上倒也没错,5米一格,连续几个直角转弯,纸面上路径长度最短,但船实际走起来必须不断减速、原地转向再加速,引擎磨损大不说,在流里还容易偏航。我加了一个小的转向惩罚之后,出来的路径平滑了很多,虽然总距离略长,但实际航行时间反而更短了。
我管这个惩罚项叫“晕船系数”——开过船的朋友都知道,一路左转右转会把人颠晕,船在地图上看起来也在“扭动”,这条路径在实际操控体验上非常糟糕。加了转向惩罚之后至少路径好看,人看着也舒心。
3.2 A*优化:二进制堆、三点共线压缩、贝塞尔平滑
A*算法本身不难写,真正决定性能的是数据结构实现。如果只用朴素数组来维护Open集合,在大地图上每次取最小F值的节点都要遍历一遍整个Open表,1600万个栅格的图根本跑不动。我这里直接上了二进制堆,也就是优先队列。这样每次弹出最优节点的时间是O(log n),一整轮搜索下来性能提升非常明显。
另外路径输出之后不要直接用,要先做一次“三点共线压缩”:如果路径上连续三个点是共线的,就把中间那个点删掉,因为直线段只需要头尾两个点就够了。这个操作能有效减少路径点的数量,方便后续给局部控制器下发指令。
最后一步是平滑。我用的方法很简单——贝塞尔曲线插值,把压缩后的折线路径变成曲线。注意这里贝塞尔的自由度不能太大,否则曲线会冲破安全距离贴到障碍物上。我在实现时对平滑结果做了安全校验,如果曲线上某一点落入障碍物网格,就自动降低平滑权重,宁可保留一点折角,也要保证安全。
3.3 初始可行解没找到?先跑一次快速“粗搜索”
我遇到过一种特殊情况:港区里浮标、缆桩、栈桥乱七八糟,障碍物非常密集,A在高分辨率地图上搜索很久都没找到可行路径。这个时候不要急着调参,先降分辨率——把10米分辨率的地图降到50米分辨率,跑一次A或者Dijkstra,只要能找到一条粗糙的可行通道,就能确认这个问题在数学上是有解的。然后再回到高分辨率地图做精细化搜索,加一些约束,比如只在上一步粗路径的带状缓冲区里搜索,这样计算量大大降低,搜索速度也能提上来。
如果你的场景和我类似,这个“粗搜索定通道、精搜索求最优”的思路值得一试。我后来把这条流程做成了自动化:每次A*超过设定时间还没找到路径,就先自动降分辨率跑粗搜索。实测下来,原来要卡好几秒甚至十几秒的密集障碍物场景,现在通常1秒之内就能给出一条可行路径。
4. 实操过程:我的A*实现与关键代码片段
讲完思路和方案,下面直接上实操部分。我的代码是在Python里写完测试,再翻译成C++部署到艇载的嵌入式设备上。你在自己的项目里可以直接参考这个结构,先用Python验证算法逻辑,再做性能优化。
4.1 数据结构定义
先定义一个栅格节点类。每个节点存坐标、代价、父节点指针。要注意的是,在C++工程里不要用这种带指针的Node去到处拷贝,否则会带来很大的内存开销。Python里因为引用语义,倒还好。如果你直接用C++重写,建议把Node改成int索引,用数组来存父节点。
import heapq import math class Node: def __init__(self, x, y, g=0, h=0, parent=None): self.x = x self.y = y self.g = g self.h = h self.parent = parent @property def f(self): return self.g + self.h def __lt__(self, other): # 堆排序的比较函数,注意当f相同时,倾向于h大的节点,这样搜索更快 if self.f != other.f: return self.f < other.f return self.h > other.h def __eq__(self, other): return self.x == other.x and self.y == other.y def __hash__(self): return hash((self.x, self.y))这里我特别说明一下__lt__里的那个小细节:当两个节点的f值一样时,我优先弹出h更小的节点。这样做虽然不会改变最终路径的最优性,却能有效减少A*在开阔水域上的搜索范围,让算法更快收敛。这个技巧是从一篇讲游戏寻路的文章里学来的,实测下来在大地图上性能提升有20%到30%。
4.2 核心A*搜索函数
我用的是8方向的搜索,即从当前节点可以朝上下左右以及四个对角方向移动。8方向比4方向生成的路径更自然,代价计算也更符合实际运动学。代价函数里包含:基础移动代价、危险区惩罚、转向惩罚,我把它们放在cost_extra这个字典里。
def astar_search(start, goal, grid_map, cost_extra=None): # grid_map: 2D numpy数组,0表示可通行,1表示障碍物 # cost_extra: 可选,风险区惩罚系数表,与grid_map同shape open_heap = [] start_node = Node(start[0], start[1], 0, heuristic(start, goal)) heapq.heappush(open_heap, start_node) came_from = {} g_score = { (start[0], start[1]): 0 } directions = [ (1, 0, 1.0), (-1, 0, 1.0), (0, 1, 1.0), (0, -1, 1.0), (1, 1, math.sqrt(2)), (1, -1, math.sqrt(2)), (-1, 1, math.sqrt(2)), (-1, -1, math.sqrt(2)) ] while open_heap: current = heapq.heappop(open_heap) if (current.x, current.y) == goal: return reconstruct_path(current) for dx, dy, move_cost in directions: nx, ny = current.x + dx, current.y + dy if not is_valid(nx, ny, grid_map): continue new_g = current.g + move_cost if cost_extra is not None: new_g += cost_extra[ny][nx] # 风险区惩罚 # 转向惩罚逻辑:判断当前节点与父节点方向差异 if current.parent is not None: prev_dx = current.x - current.parent.x prev_dy = current.y - current.parent.y if (prev_dx, prev_dy) != (dx, dy): new_g += 0.5 # 转向惩罚,量纲是网格距离单位 key = (nx, ny) if key not in g_score or new_g < g_score[key]: g_score[key] = new_g h = heuristic((nx, ny), goal) node = Node(nx, ny, new_g, h, current) heapq.heappush(open_heap, node) return None # 没找到路径这段代码里启发函数heuristic用的是欧几里得距离除以栅格尺寸,单位上要与移动代价对齐。is_valid函数要注意边界检查和障碍物检查,另外还要检查当前节点是否落在了地图外的无效区域上。
转向惩罚这个0.5的系数我是试出来的。如果你把系数调得过大,路径会为了“走直线”而绕非常远的路;调得太小,又起不到抑制锯齿的作用。这里给读者一个判断标准:如果最终路径上有大量的45度斜向走位并频繁切换方向,说明惩罚偏小;如果路径变成一条半径极大的弧线但总里程明显增加,说明惩罚偏大。
4.3 路径后处理:压缩和平滑
A*返回的路径是一串连续的栅格索引,直接下发肯定不行。我的后处理流程分两步:
def compress_path(path): """三点共线压缩,去掉处于直线上的中间点""" if len(path) < 3: return path compressed = [path[0]] for i in range(1, len(path) - 1): p0 = compressed[-1] p1 = path[i] p2 = path[i + 1] cross = (p1[0] - p0[0]) * (p2[1] - p1[1]) - (p1[1] - p0[1]) * (p2[0] - p1[0]) if abs(cross) > 1e-6: compressed.append(p1) compressed.append(path[-1]) return compressed这里判断共线的标准就是向量叉积是否接近0。如果三点共线,叉积为0,中间点就可以安全去除。经过压缩之后,一条几百个点的路径通常能压缩到几十个关键航路点,控制器的负担一下就小了。
平滑的逻辑我就不放完整代码了,思路是用三次贝塞尔曲线,把每两个关键航路点之间的折线段用贝塞尔过渡。平滑时需要逐点做障碍物检测,碰到障碍物就自动降低平滑力度。如果平滑算法没有安全校验,生成一条穿障碍物的“漂亮曲线”是很有可能的,那就是典型的“好看不能用”。
4.4 把路径从栅格坐标转回经纬度
这是最后一个环节,也是最容易出错的环节。计算完的路径全是栅格坐标,比如(row=123, col=456),你要发回给无人艇做导航,得先把栅格坐标转换为平面坐标,再逆投影成WGS84经纬度。如果这里只做了正向转换忘了反向转换,整个规划结果就废了。
具体流程:栅格坐标乘以栅格分辨率得到平面偏移,再在起始点经纬度基础上做墨卡托逆变换。注意墨卡托逆变换涉及纬度迭代计算,不少现成库都封装好了,不用自己手写。但要特别注意,逆变换和目标点是用的同一个坐标系,不要一会WGS84一会CGCS2000混着一通乱算。我早期就被这个坐标系混用坑过,路径在图上看着完全正常,实际船却偏了几百米,原因就是海图和GPS的坐标基准不一致,后来统一到WGS84基准才好。
5. 常见问题与排查技巧实录
这里整理了我做这个项目时遇到的各种问题,按“症状-原因-解决方案”梳理成表,给你排查时做个参考。
5.1 路径规划常见问题速查表
| 症状 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 路径穿过了地图里的大片陆地 | 陆地边界在栅格化时产生了空洞 | 先用闭运算修复细小断裂,再做连通域标记,把与陆地连通的所有区域统一标为障碍物 |
| 路径贴着障碍物边缘走,看着很“险” | 膨胀半径太小,没考虑转弯半径和海流 | 把膨胀半径改成船宽一半加转弯半径再加固定余量,测试时用不同膨胀值对比效果 |
| 路径全是锯齿,控制指令频繁翻转 | 缺少转向惩罚项 | 在代价函数里加转向惩罚,系数从0.3到1.0之间自测,找到锯齿较少且总里程可接受的值 |
| A*搜索极慢,大地图上卡几秒 | 栅格太细、Open表数据结构低效 | 换优先队列实现;或者用“粗搜索定通道、精搜索优化”的两阶段方案 |
| 路径算出来了,但船实际走得偏离很大 | 坐标系基准不一致 | 确认海图、GPS、转换库三者使用同一个坐标系基准,推荐统一用WGS84 |
| 海图上明明有航道,但栅格地图上却显示不可通行 | 等深线数据被误判成障碍物 | 检查物标过滤逻辑,确认哪些等深线等级被标成了禁入区 |
| 代码运行很快,但内存占用异常巨大 | 栅格地图尺寸没控制好 | 重新算栅格行列数,如果行列数乘积超过5000万,需要调大栅格尺寸或做分块处理 |
5.2 一个让我排查很久的“幽灵障碍物”问题
有一次我发现路径规划在一个固定的海区反复绕路,但海图上看那块区域明明是开阔水域。排查了很久,最后发现是海图里那个区域有一个用户自定义标记物,属于临时通告类物标,比如临时的演习区通告。S-57里这类物标也会被我的过滤逻辑识别成“不可穿越区域”,导致A*绕路。
解决方案是在物标过滤阶段增加一个“物标性质分类”的判断,把临时性的、非物理存在的公告类物标单独隔离出去,不参与最终的栅格化。正式通告如果有需要,可以保留,但要从“硬障碍物”降级为“警告区域”,只给规划器一个惩罚提示,不直接切断通道。
这类问题很难通过算法本身解决,需要在数据预处理环节对海图物标准确理解并建立合适的优先级。真正做过海图开发的朋友肯定有同感:S-57里的物标类型太多了,属性字段也是组合拳,光靠看文档很难把所有异常情况都照顾到,必须结合实际任务场景慢慢调规则表。
5.3 性能优化:从12秒到0.3秒的优化记录
项目测试阶段,我在一张1000乘1000的栅格地图上跑A*,最开始耗时大约12秒,这在实时系统里是完全不能接受的。我做了三件事,把耗时压到了300毫秒左右:
第一,把Open集合从列表改成优先队列。这个改动最立竿见影,直接减少了一个数量级的时间消耗。第二,给启发函数加了权重系数,把H乘以1.2左右。这样A*会更“激进”地往目标方向搜索,虽然不能保证绝对最优,但对于全局路径规划来说足够用了。第三,对地图做预处理,把所有不可通行的节点提前用一张bool数组存好,避免每次搜索时都去查表计算。
最终在C++版本里,加上多线程预计算和更高效的索引方式,搜索时间进一步压到了50毫秒以内。所以在方案可行、算法正确的前提下,工程优化带来的收益往往是数量级的。对无人艇来说,这个时间差足以让控制器提前好几秒开始动作,避障的安全感完全不一样。
6. 写在最后:关于规划与控制的衔接,以及一些个人经验
路径规划做完以后,别忘了无人艇不是只活在理想化的栅格地图里的。全局规划给出的是一条参考路径,真正的航行控制要靠底层控制器实时跟踪这条路径。在实际工程中,我见过很多团队把全局规划做得很漂亮,但一接上控制系统就露馅,原因是规划和控制之间少了一个“路径跟踪层”。
我的建议是:全局规划器输出的路径点不要太密,在关键拐点处保留航向信息;底层控制器接收路径后,对相邻路径点做前瞻距离判断,也就是常见的pure pursuit跟踪算法,给目标点的提取留出足够的提前量。这样即使海流突然把船推偏了几个船位,控制器也能以平滑的方式回到参考路径上,而不是急转猛拉。
另外,不要迷信“算法越复杂越好”。在做无人艇路径规划时,一味上强化学习或者各种前沿算法,很多时候反而不如一个调好参数的A好用。你真正需要的是稳定、可解释、能在边缘设备上实时跑起来的方案。A加上好的代价函数设计,再加上安全校验和后处理,已经能应对绝大多数实际作业场景了。先把基础方案做扎实,再考虑后续加局部规划器或者其他高级功能,这条路才算走得稳。