
水面无人艇这东西看着挺科幻真上手做起来第一个要解决的头疼事往往不是什么高深控制算法而是“船到底该往哪儿开”。陆地上的自动驾驶有车道线、有红绿灯、有高精地图兜底水面不一样一望无际的水面上没有路标连“路”这个概念都不存在。这时候电子海图就成了无人艇唯一可信的“道路模型”。我这个项目名字就叫“基于电子海图的水面无人艇全局路径规划”核心就四个字读图、找路。读图是把电子海图里那些水深点、暗礁、岸线、禁航区的矢量信息翻译成计算机能直接计算的栅格地图找路是在这张地图上用全局路径规划算法给无人艇算出一条从起点到目标点、既安全又经济的航线。这篇文章主要围绕整个技术链路展开包括海图数据怎么解析、坐标系怎么对齐、栅格图怎么构建、A*算法怎么设计以及我在实跑过程中踩过的一些坑。适合正在做无人艇、无人船路径规划或者想了解电子海图二次开发的朋友参考对刚入手水上机器人项目的人尤其友好——这些东西在论文里多半是几句话带过的但真正动手时每一步都是细节。1. 项目整体思路与设计拆解1.1 为什么全局路径规划一定离不开电子海图先聊一个基础问题水面无人艇为什么不能像扫地机器人那样随便撞一撞再绕开因为水面环境对安全性的要求高得多而且雷达、摄像头能感知的范围极其有限。船载传感器激光雷达、毫米波雷达、视觉本质上是“局部感知”只能在几十到几百米范围内发现障碍物但全局路径规划要解决的是几公里甚至几十公里的航路问题这已经远远超出了传感器能覆盖的范围。所以在整个决策链路里全局路径规划必须依赖先验环境信息也就是电子海图ENCElectronic Navigational Chart。电子海图的好处不在于它是一张“好看的图”而在于它是结构化、矢量化的数据。海洋里的陆地、暗礁、沉船、浅滩、电缆、禁航区都被编码成了标准的要素feature每个要素还带了一堆语义属性比如水深值、底质类型、灯光特征。这意味着我能直接告诉算法“这里水深只有1米船吃水0.6米不能走。”如果换成卫星影像我还得先做语义分割又慢又不可靠。我最初也考虑过用公开的岸线轮廓数据做简化版规划后来放弃了。原因很简单岸线数据只告诉你哪里是陆哪里是水但没告诉你“这片水域能不能走”。水面不像路面看着是水的地方底下可能有暗礁、有浅滩、有沉船。真正的全局规划必须把水深和碍航物一起考虑进去。这也就是为什么这个项目的输入必须是电子海图而不是普通地图。1.2 技术路线选型从海图到航线的总链路整个项目技术链路其实可以想象成一条流水线电子海图读取 → 要素解析与坐标转换 → 栅格化代价地图 → 全局路径搜索 → 路径平滑与输出。没有一步是可以省掉的。电子海图的读取国内项目用得比较多的是S-57IHO标准和S-101新一代标准文件后缀一般是.000内部用ISO/IEC 8211封装需要专门的库去解析比如GDAL/OGR、OpenS57或者一些商业组件。得到要素后并不能直接拿经纬度在平面上做路径规划因为地图投影和GPS用的坐标基准可能不是一回事。至少要把S-57里的经纬度统一到WGS84然后在局部作业区域转换到米制平面坐标比如高斯-克吕格投影或UTM投影后面拿这些坐标去算距离、算栅格才有意义。坐标搞定了下一个关键决策是怎么把矢量海图变成算法能算的地图。矢量要素本身是点、线、面不适合直接喂给搜索算法尤其对新手来说很别扭更通用、更工程化的做法是栅格化。把作业区域切分成固定大小的网格每个格子标记为“可通行”“障碍”或者“带代价的危险区”。这东西一铺开A*、Dijkstra这些经典寻路算法就能直接在网格上跑了。整个项目中栅格化这一步做得好不好基本决定了路径规划结果的质量比后面算法本身还重要。2. 电子海图数据的工程化读取与预处理2.1 看懂S-57海图的内容结构想用好电子海图必须先搞清楚它里面到底装的是什么。S-57数据模型分三层要素Feature、几何图元Geometry、属性Attribute。通俗地说要素是“地物是什么”几何是“地物在哪里”属性是“地物有多危险”。举个例子一个名叫OBSTRN的要素代表孤立碍航物它的几何可能是一个点属性里可能包含WTWCD对航行有影响的明/暗礁标识、QUASOU探测性质等。我实际解析时最常用到以下几类要素这里做成了速查表方便大家对照要素类别S-57首字母缩写含义路径规划中的处理方式水深区DEPARE一片区域的水深范围最重要生成可通行代价区域等深线DEPCNT连接相同水深的曲线提取安全等深线比如2米线、3米线陆地LNDARE陆地边界直接标为不可通行障碍暗礁UWTROC水下岩石点/面障碍物标为不可通行孤立的碍航物OBSTRN沉船、桩柱、养殖区等点/面障碍物需要膨胀处理岸线COALNE水陆分界辅助确认边界禁航区各种限带要素军事区、锚地边界等根据工程需求决定是否禁用有一个细节新手很容易踩坑S-57里的水深值是有符号的正值表示水深负值表示干出高度低潮时露出水面的高度。如果解析代码里没有处理这个符号直接把负值当成水深参与计算很可能会把一片干出滩涂当作可以走的水域后果不堪设想。我当时就是在解析DEPARE时忘了这一步导致规划路径横穿了一大片浅滩。2.2 坐标基准与投影转换电子海图里存的坐标默认是经纬度WGS84椭球但路径规划里的距离计算、膨胀半径、栅格偏移量这些都需要在米制坐标系里操作。你可能想说直接用经纬度算球面距离不行吗行但很麻烦尤其是要做网格化和邻域扩展时经纬度在每个纬度上对应的距离不一样代码写起来又乱又容易出错。我的做法是在规划前把作业区域统一转换到UTM投影坐标系。UTM把全球划成60个带每个带是6度经度范围在北纬84°到南纬80°之间都有较高的精度。选UTM而不是某个地方坐标系是为了通用性换一片水域也能直接用。转换可以依赖Proj库或者GDAL的osr模块代码几行就搞定。需要注意UTM的中央经线上没有长度变形但远离中央经线时会产生误差所以作业范围很大比如横跨两个UTM带时建议按带分割或者用兰勃特等角圆锥投影。水面无人艇的作业半径一般不会超过几十公里UTM完全够用。2.3 从海图要素到代价栅格图栅格化是整个项目里我最想详细讲的一块。直接说结论除非迫不得已不要用三维数组表达栅格状态一个二维numpy矩阵就够了。矩阵的每个格子存放一个整数或浮点数代表当前格子的状态或通行代价值。我自定义了一个简单的状态体系0自由通行水域1陆地/干出/暗礁等绝对障碍大于1的浮点代价值受水深影响的危险区域越界但可能可通过或者可通行但代价高构建流程是这样的先把作业区域按设定分辨率划分成网格网格边长我通常取海图显示比例尺对应精度的2~3倍。比如一张1:50000的海图图上0.1毫米对应地面5米那我就用15米或20米的网格。太细会让A*搜索空间爆炸太粗又会丢掉细窄水道和暗礁等关键信息。然后遍历解析出的矢量要素对于陆地LNDARE、禁航区、孤立碍航物用多边形或半近像素填充算法把覆盖到的格子标为1。对于点状暗礁UWTROC、OBSTRN除了点所在的格子还要按安全半径向外膨胀把周边一圈格子也标为1避免规划出的路径离危险物太近。对于水深区DEPARE读取最大水深DRVAL2有些实现用最小水深DRVAL1作为保守值把水深数据填到对应区域之后再统一换算成代价值。这一步做完你会得到一整张“外圈陆地包围水域、水域内部散布障碍物”的代价图。这张图一旦生成后续A*的搜索效率会非常高。我实测过一张大约20公里长、5公里宽的河道海图15米分辨率大概也就一百多万个格点在普通PC上构建耗时不到两秒。3. 全局路径规划算法实现与核心参数设计3.1 为什么我选择A*而不是Dijkstra或RRT算法选型这块我一开始就排除了RRT和RRT*。原因很简单RRT系算法适合高维连续空间或运动约束强的场景但在这个项目里环境已经是栅格化的且维度固定为2DA*能在离散网格上直接给出确定性最优解RRT的随机性反而会造成不必要的抖动。至于Dijkstra它在栅格地图上确实能找到最短路径但它是均匀向外扩展没有目标点方向引导搜索范围比A*大很多在百万级格点的地图上性能差异肉眼可见。所以最终选择的就是经典的A*配欧几里得距离启发函数。这里有个细节非常重要水面无人艇可以在任意方向航行不像四轮小车只能“上下左右”移动。如果用曼哈顿距离作为启发函数会高估路径代价导致A*扩展很多无效节点用欧几里得距离最快最稳出来的路径也更符合船的真实运动。3.2 代价函数设计不是“能走”就行是“好走”才走如果只把网格分成“能走”和“不能走”两类A*很容易给出贴着暗礁边缘、擦着浅滩边界走的危险航线——它确实最短但人不敢这么开船。为了解决这个问题我在代价值里加了三项基础距离代价移动一步增加的距离对角线移动按√2计算。水深代价当格子水深大于安全水深时代价为0当水深大于“最低可通行水深”但小于安全水深时代价按线性增长当水深小于最低可通行水深时直接设置为不可通行。这么做等效于让算法“宁绕远、不走浅”。障碍物距离代价在已知障碍物周围做距离场离障碍物越近代价值越高相当于给所有障碍物套了一个“风险衰减带”。这个策略很有用可以明显降低路径与暗礁、岸边贴太近的概率。代价值的组合公式大致是total_cost dist_cost depth_penalty obstacle_penalty其中depth_penalty和obstacle_penalty需要根据实际船型和作业环境调权重。我用的一条经验曲线是安全水深2米以上的水域完全不惩罚1.5~2米之间惩罚系数线性加到0.5倍距离代价1.5米以下直接禁行。这套参数在一个内河复杂水域场景里跑出来的效果很理想虽然路径比纯最短路径长了约20%但全程有富余水深心里踏实很多。3.3 吃水限制与安全水深判定逻辑安全水深的计算可能是这个项目里最“海事”的知识点了。很多人第一次做的时候以为吃水0.6米选1米水深的区域就够了真出事的往往就是这种想法。因为船在航行时会有下沉效应尤其是速度稍快一点船体尾部会下沉实际吃水可能比静态吃水多出0.3~0.5米再加上波浪造成的摇摆下沉以及观测误差和海图数据的年代问题必须留足富余水深。我用的规则是安全水深 静态吃水 下沉量 富余水深 误差补偿按我测试的船型艇长5米静态吃水0.6米计算下沉量取0.2米富余水深取0.5米误差补偿取0.2米最终安全水深约1.5米。也就是说电子海图里所有标称水深小于1.5米的区域在栅格地图里都会被打上较高的代价或者直接设为障碍。这个过程最好在解析海图时就算好而不是等A*运行的时候才临时判断能省很多内存和CPU时间。3.4 核心代码实现A*在栅格地图上的落地这里给出一个可以直接参考的A*核心结构基于Python实现。代码做了简化但主干逻辑完整适合在此基础上二次开发import heapq import math def heuristic(a, b): # 欧几里得距离作为启发函数允许任意方向航行 return math.hypot(a[0] - b[0], a[1] - b[1]) def a_star_search(grid, start, goal): # grid: 0自由, 1障碍, 1危险代价此处简化为0/1判断 rows, cols grid.shape open_set [] heapq.heappush(open_set, (0.0, start)) came_from {} g_score {start: 0.0} f_score {start: heuristic(start, goal)} directions [(1,0), (-1,0), (0,1), (0,-1), (1,1), (1,-1), (-1,1), (-1,-1)] while open_set: current heapq.heappop(open_set)[1] if current goal: path [] while current in came_from: path.append(current) current came_from[current] path.append(start) path.reverse() return path for dx, dy in directions: neighbor (current[0] dx, current[1] dy) if not (0 neighbor[0] rows and 0 neighbor[1] cols): continue if grid[neighbor] 1: continue step_cost math.hypot(dx, dy) tentative_g g_score[current] step_cost grid[neighbor] if tentative_g g_score.get(neighbor, float(inf)): came_from[neighbor] current g_score[neighbor] tentative_g f_score[neighbor] tentative_g heuristic(neighbor, goal) heapq.heappush(open_set, (f_score[neighbor], neighbor)) return None这是一个很标准的A*实现。需要注意的点有两个第一启发函数要满足可采纳性admissible也就是它给出的估值不能大于真实代价。在上面的场景里我们允许8方向移动且对角线代价约等于1.414所以欧几里得距离是安全的。如果把方向扩展成16邻域或者加入转向代价启发函数也要跟着调整否则结果可能不是最优。第二grid[neighbor]作为额外代价这一步只有在障碍和危险区代价远小于1时才能直接加。如果代价数值很大会导致A*过度避让路径绕得太远甚至把一条完全安全、只是稍微擦着边缘的航线彻底放弃。更稳妥的做法是先用0/1标记做一遍纯避障搜索再做一遍带水深代价的优化搜索两遍结果取优。3.5 实跑效果参考我在某个内河近岸测试水域跑了一组数据。起点在河道东侧码头目标点是上游约4公里处的一个开阔水域中间分布着三处浅滩和一条水下电缆。人工经验航线约4.8公里规划出的航线约5.6公里看似绕了将近一公里但全程最小水深都在2米以上且和浅滩边界保持了至少30米的安全间距。对比纯最短路径算法的结果A*的多绕行基本都花在绕浅滩和水下障碍上这个牺牲是值得的。4. 常见问题排查与工程避坑实录4.1 高频问题速查表问题现象可能原因解决思路路径穿过了明显是浅滩的区域海图水深符号解析错误负数被当作水深检查DRVAL1/DRVAL2符号干出区域单独设置路径贴着岸边或暗礁走没有做障碍物膨胀或距离代价增加膨胀半径或加obstacle_penalty风险带栅格图生成极慢分辨率过高或者要素裁剪不彻底先按外接矩形裁剪从20米分辨率起步试跑在开阔水域路径绕远过多水深代价权重过高降低depth_penalty权重或提高安全水深阈值路径出现锯齿状来回折16邻域/8邻域搜索后没有做平滑后端加Douglas-Peucker抽稀和贝塞尔平滑海图坐标系与GPS信标对不上基准面或投影带不一致统一到WGS84改用UTM投影4.2 最容易翻车的水深数据陷阱前面提过水深符号的问题这里专门展开说。电子海图的DEPARE要素里通常有DRVAL1和DRVAL2两个属性分别代表区域内的最浅水深和最深水深。在S-57标准里如果数值是负的表示该区域在理论最低潮面以上属于干出滩。我在调试时曾经打印出某一格的水深值发现是-0.3米下意识以为这是“水下0.3米”的意思实际它意味着那一片低潮时是裸露的泥滩。这种错误极其隐蔽因为数值看着很合理唯一能够暴露问题的方法就是把栅格地图可视化用色带标出水深区间肉眼看一遍。所以我强烈建议任何海图数据在进入规划算法前必须做人工目检。别嫌这一步“不自动化”它救过我至少三次。另外真实作业时千万不要忽略潮汐。电子海图上的水深是相对理论最低潮面的也就是说实际水深 海图水深 当时潮高。我测试的那个水域潮差最大能到2米以上。做全局路径规划时如果不代入潮汐预报数据按照海图水深算出的“安全航线”在高潮还行低潮时可能直接搁浅。项目里我把潮高作为一个全局偏移量在栅格化的同时统一叠加到水深代价值上一行代码就能解决但效果天差地别。4.3 图幅边界与孤立碍航物的特殊处理电子海图是按图幅发布的不同图幅之间会存在接边缝隙。如果作业区域正好跨越两幅图很容易出现一侧是干净水域、另一侧是“无数据区域”的情况。我的做法是把无数据区域默认标记为未知而不是可通行同时在边界处做5个格子的渐变缓冲。宁可让A*多绕一点也不要让它在一无所知的地方贸然穿行。这个思路也沿用到海图上那些只画了边界、内部没有任何水深信息的湖泊或水库区域。孤立碍航物则是另一个容易漏掉的点。A*搜索的本质是网格上的邻域扩展如果某个暗礁只占据了一个格子路径从它旁边斜着穿过时从几何上看并没有碰障碍但实际上船身不可能那么细。所以对点状碍航物必须额外做膨胀处理。膨胀半径我一般取前半宽加安全距离小型无人艇是5~10米大一点的船看情况调到20米。半径设大了窄水道会被封死设小了安全性又不够。这个参数多花点时间调值得。4.4 路径后处理让航线真正能跟着开A*直接搜出来的路径是网格折线存在大量45度锯齿尤其是8邻域搜索时看起来就像一串阶梯。这种路径直接发给底层控制器无人艇会频繁转向既费电又增加侧倾风险。我一般做两步后处理第一步用Douglas-Peucker算法抽稀路径点把几乎共线的点合并减少转折数量。阈值选一个栅格外加一点比如25米效果比较稳。第二步用三次贝塞尔曲线或者三次样条对转折点做平滑让航向连续变化。需要注意平滑后的曲线可能会“切弯”也就是从障碍物角落里穿过去所以平滑完必须逐点回查栅格地图把任何不安全的点拉回到膨胀边界之外。最终输出的航路点waypoints我会保存成带速度建议的JSON或者GPX格式直接喂给局部避障模块和制导控制模块。到这里全局路径规划的任务才算真正闭环。我个人在实际测试里最大的感悟是——这项目难的不是算法而是数据。A*的代码放到网上随便一搜就有三天能写完但把电子海图读明白、把坐标系对齐、把水深语义转成代价、把各种坑都避开可能要花三周。很多人一上来就钻进算法调参结果数据没处理好路径再漂亮也是纸面功夫。如果你正准备做类似的水面无人艇项目我的建议很直接先花大量时间把海图数据处理管线做得扎实、可视化做得直观等你在屏幕上看到一张干净、可解释的代价栅格图后面的路径规划反而是水到渠成的事。