做机器人导航的人多半经历过这种尴尬:机器人带着别GPS模块跑了一大圈,ROS日志里满是经纬度话题数据,但拿给需求方或者导师看的时候,对方只能干眨眨眼——你直接甩出一堆数字,人家根本不知道机器人到底走了哪条路。这时候,如果能让ROS的感知数据落地到一把全世界通用的地图工具里,把轨迹直接渲染成一条看得见、量得出的线,整个汇报的说服力会完全不同。这篇文章想说的,就是怎么把ROS里最常用的定位消息——sensor_msgs/NavSatFix——转成Google Earth Pro能认的KML文件,然后在三维地球上一帧一帧地把轨迹铺出来。整个过程不需要复杂的商业GIS软件,纯开源脚本加上几行Python就能打通,适合正在做户外移动机器人实验、自动驾驶平台数据回放、以及测绘无人机轨迹分析的同学直接照抄。
好,直接进入正题。
1. NavSatFix消息结构与坐标系基础
1.1 一个经纬度消息的背后
NavSatFix是一套被ROS生态普遍使用的卫星定位消息格式。它的定义并不是随便拍脑袋定的,而是参考了GPS领域常用的NMEA 0183协议、以及大地测量里的WGS-84椭球模型。消息内部主要有三组信息:header(时间戳与坐标系frame_id)、latitude(纬度)、longitude(经度)、altitude(海拔高度),再加上一组position_covariance(位置协方差)和对应的协方差类型字段。
很多初学者会忽略一个细节:header.frame_id默认应该是"gps"或者"gnss"这类与地理坐标系直接关联的frame,而不是机器人本体的"base_link"或"odom"。原因在于NavSatFix表达的永远是地球固连坐标系下的绝对位置,它的参照物是地球质心加椭球模型,不是机器人起步点。如果你在回放bag文件时发现轨迹飞到了另一个半球,八成就是frame_id和坐标变换没有对应好。
1.2 WGS-84坐标系与Google Earth的匹配点
Google Earth Pro使用的数据基准同样是WGS-84地理坐标系,这意味着NavSatFix里原始输出的经纬度精度,可以几乎不经过任何数学投影直接画在地球上。这和许多传统GIS应用要先做高斯-克吕格投影、或者UTM投影的做法完全不同。换句话说,跳过复杂的坐标转换环节正是这条链路最简单的原因——你要做的只是“搬运工”,把消息里的经纬度按KML语法组织起来。
这里我补充一个从业者容易踩的坑:原始GPS模块输出的海拔高度通常分两种,一种是椭球高(Ellipsoidal Height),一种是正高(Orthometric Height,一般由大地水准面模型换算而来)。NavSatFix里altitude字段的定义是相对于WGS-84椭球的高度,而Google Earth Pro展示的地表高度则基于EGM96大地水准面模型。如果对比机器人的海拔和地图上的海拔,常常会相差个十几米到几十米,这属于基准不同,不代表数据错了。画轨迹只看平面位置时无所谓,但如果要做高度剖面分析,就得意识到这个偏差的存在。
2. 工具链选型与整体方案设计
2.1 不是只有RTK才配得上轨迹可视化
有些同学一提到GPS可视化,第一反应就是去折腾专业GIS桌面软件,或者花大力气搭一套图传前端。实际上,Google Earth Pro本身免费、支持KML/KMZ直接导入、甚至能用时间轴面板逐帧回放轨迹,完全满足90%的机器人轨迹演示需求。再加上ROS本来就提供了话题录制与回放工具(rosbag),整套数据处理流水线只有“解析消息→组织数据→写出KML”这三步。
从方案对比上说,我见过有人用rviz里面的GPS显示插件直接看轨迹,这种方式调试起来很快,但对外沟通、跨平台分享、以及叠加卫星地图底图的能力都比较弱。另一些人会先把数据落成CSV,再拖到在线地图平台里生成路径,但这种方式一来受平台限制,二来不利于离线归档。综合来看,基于KML的路线在通用性、离线可用性、以及可复现性三个维度上都更有优势。
2.2 数据流环节拆解
整个链路可以用一句话概括:“话题订阅/数据读取 → 数据清洗 → KML模板填充 → Google Earth Pro加载”。从实现角度,这四个环节缺一不可:
- 数据读取:如果是在线跑机器人,
rostopic echo可以实时监听消息;如果是事后分析,rosbag文件则是最灵活的选择。 - 数据清洗:剔除无效定位(例如
status.status不是0或1的固定解、浮点解),去除漂移点和跳变点。 - 数据组织:按时间戳排序、按地理坐标去重、生成轨迹线和时间标签。
- 格式转换:将坐标点写入KML的
<Placemark>结构下,生成路径文件。
这里必须多提一句:很多人直接拿原始话题里的每一条消息去画线,结果轨迹线杂乱无章,因为低速机器人原地转向时会在同一个点附近产生几十个相近的坐标,这会让路径看起来像一团乱麻。确保输出前有必要的抽稀或滤波策略,是成果能不能直接见人的关键。
3. 核心代码实现:从ROS消息到KML文件
3.1 直接操作bag文件的数据提取脚本
如果手里有已经录好的rosbag文件,最简单的方式是用Python的rosbags库直接读取,不需要启动完整的ROS Master。好处是代码轻量、可集成进后台分析流程,也方便批量处理多个录制的数据包。下面这段代码实现的是:读取bag里的/gps/fix话题(具体话题名按你自己的配置调整),把NavSatFix的经纬度、海拔、时间戳全部取出来,再按时间顺序存入列表。
from rosbags.rosbag2 import Reader from rosbags.typesys import get_types_from_msg, get_typestore import sys # 注册自定义消息类型,如果没有自定义消息则只需要标准类型 TYPESTORE = get_typestore() with Reader('demo_route.bag') as reader: connections = [c for c in reader.connections if c.topic == '/gps/fix'] if not connections: sys.exit('未找到/gps/fix话题') gps_data = [] for connection, timestamp, rawdata in reader.messages(connections=connections): msg = TYPESTORE.deserialize_ros1(rawdata, connection.msgtype) lat = msg.latitude lon = msg.longitude alt = msg.altitude status = msg.status.status gps_data.append((timestamp, lat, lon, alt, status)) print(f'共读取{len(gps_data)}条GPS消息') for item in gps_data[:5]: print(item)如果你的环境是ROS 1,使用的库是rosbag,代码就会略微不同:
import rosbag bag = rosbag.Bag('demo_route.bag') gps_data = [] for topic, msg, t in bag.read_messages(topics=['/gps/fix']): if msg.status.status >= 0: # 简易过滤:只保留可用的定位状态 gps_data.append((t.to_sec(), msg.latitude, msg.longitude, msg.altitude)) bag.close()这里有个取舍问题:rosbags库跨ROS 1/ROS 2通用,但是需要额外通过get_types_from_msg注册自定义消息类型;而rosbag库在ROS 1的环境里零依赖安装,但对ROS 2的支持较弱。我个人建议,如果只在ROS 1环境里做分析,直接用rosbag更顺手。
3.2 数据清洗与抽稀策略
录完的数据一定不能直接丢进KML。最典型的三个方面问题:无效定位、漂移点、以及高频冗余。
- 无效定位过滤:
NavSatFixStatus里有status和service字段,status=0表示未修正的GPS定位,status=1表示SBAS修正,status=2表示差分GPS(DGPS)修正,status=4表示RTK固定解。通常来说,status>=1的数据才值得画轨迹。另外,service=1表示L波段GPS,service=2表示北斗/GLONASS等,不同卫星系统的混合定位一般不影响KML显示。 - 漂移点过滤:最粗暴但也最有效的方法是设置速度阈值。在相邻两条消息之间,如果计算出的平均移动速度超过某个物理上限(比如步行机器人上限设为5m/s,车载平台设为40m/s),直接判定为跳变点并丢弃。要计算速度,得先把经纬度差转换成平面距离,这里用等距圆柱近似已经足够:
距离公式(近似法)也可以用haversine公式,但既然不做长距离精确测量,直接在纬度方向用111320米/度、经度方向用111320 * cos(lat)米/度来折算,简单够用:
import math def distance_meters(lat1, lon1, lat2, lon2): lat_dist = (lat2 - lat1) * 111320.0 lon_dist = (lon2 - lon1) * 111320.0 * math.cos(math.radians((lat1 + lat2) / 2)) return math.sqrt(lat_dist * lon_dist + lon_dist * lon_dist) def filter_gps_points(gps_data, max_speed=5.0): cleaned = [] for i, (ts, lat, lon, alt, status) in enumerate(gps_data): if i == 0: cleaned.append((ts, lat, lon, alt, status)) continue prev_ts, prev_lat, prev_lon, _, _ = cleaned[-1] dt = ts - prev_ts if dt <= 0: continue dist = distance_meters(prev_lat, prev_lon, lat, lon) speed = dist / dt if speed <= max_speed: cleaned.append((ts, lat, lon, alt, status)) return cleaned- 高频抽稀:机器人原地打转或者慢速移动时,GPS会在一两米范围内反复跳动,形成一堆几乎重叠的点。路径在KML里如果塞进几千个点,Google Earth Pro加载速度会明显变慢,而且轨迹颜色层次不分明。一个简单的策略是道格拉斯-普克抽稀算法,但实现起来长度不小;更轻量、工程上够用的方式是按时间间隔抽稀——只保留每0.5秒或每1秒一个点。如果追求效率更优的运行效果,也可以把“与前一个点的直线距离大于0.5米”作为保留条件。
3.3 KML文件结构与轨迹生成
KML的全称是Keyhole Markup Language,本质上是基于XML的标记语言,Google Earth、Google Maps等地理浏览器都能直接解析。一个最简的轨迹点集合长这样:
<?xml version="1.0" encoding="UTF-8"?> <kml xmlns="http://www.opengis.net/kml/2.2"> <Document> <name>Robot GPS Track</name> <Placemark> <name>Track Path</name> <LineString> <tessellate>1</tessellate> <altitudeMode>relativeToGround</altitudeMode> <coordinates> 116.391,39.907,0 116.392,39.908,20 116.393,39.909,15 </coordinates> </LineString> </Placemark> </Document> </kml>注意<LineString>里面的coordinates节点,书写顺序必须是经度,纬度,海拔,用逗号分隔,点与点之间用空格或换行分隔。很多第一次写KML的开发者会把经纬度顺序写反,结果轨迹落到非洲或者南极,排查半天才发现是格式问题。
生成KML的Python脚本可以这样实现:
import xml.etree.ElementTree as ET def generate_kml(cleaned_data, output_file): ns = {'k': 'http://www.opengis.net/kml/2.2'} ET.register_namespace('', 'http://www.opengis.net/kml/2.2') kml_doc = ET.Element('kml', xmlns='http://www.opengis.net/kml/2.2') document = ET.SubElement(kml_doc, 'Document') name = ET.SubElement(document, 'name') name.text = 'Robot GPS Track' placemark = ET.SubElement(document, 'Placemark') placemark_name = ET.SubElement(placemark, 'name') placemark_name.text = 'Path Line' line_string = ET.SubElement(placemark, 'LineString') tessellate = ET.SubElement(line_string, 'tessellate') tessellate.text = '1' altitude_mode = ET.SubElement(line_string, 'altitudeMode') altitude_mode.text = 'relativeToGround' coordinates = ET.SubElement(line_string, 'coordinates') coord_text = [] for _, lat, lon, alt, _ in cleaned_data: coord_text.append(f'{lon:.8f},{lat:.8f},{alt:.2f}') coordinates.text = '\n'.join(coord_text) tree = ET.ElementTree(kml_doc) tree.write(output_file, encoding='UTF-8', xml_declaration=True)生成的KML里,tessellate=1告诉Google Earth Pro在曲线路径上自动加密插值点,让线条贴合地形起伏;altitudeMode选择relativeToGround则会让路径贴合地表高度,而不是贴着海平面。如果你的机器人跑到楼顶或山坡上,用absolute模式才能看到真实的海拔落差,这点可以按需调整。
4. Google Earth Pro软件操作与轨迹显示
4.1 导入KML文件的三种方式
拿到KML文件后,操作Google Earth Pro就非常顺手了。推荐三种方式:
- 菜单导入:依次点击“文件”→“打开”,在文件类型里选“KML/KMZ”,选中生成的
.kml文件即可。 - 直接拖拽:把KML文件从系统文件管理器直接拖进Google Earth Pro的三维视图区域,软件会自动识别并加载。
- 命令行打开:如果你经常做批量分析,甚至可以写一行脚本调用系统的文件关联命令,让KML默认用Google Earth Pro打开。
导入后左侧“位置”面板里会多出你命名的图层。双击它会快速飞行到轨迹所在区域,默认视角高度会自适应,方便直接截图或录屏。
4.2 用时间轴回放轨迹
Google Earth Pro最强的一个实用功能,其实是时间轴回放。KML的<Placemark>内部可以嵌套时间戳标签<TimeStamp>,也可以写带起止时间的<TimeSpan>,配合<gx:Track>元素能实现平滑的轨迹动画。不过实现完整的时间动画需要把数据写成更细致的KML结构,我建议在数据量不大时,可以直接为每个定位点生成一个独立的<Placemark>点,同时捆绑<TimeStamp>,Google Earth Pro就会在时间面板里自动显示一条可拖动的进度条。
单独的轨迹点KML片段长这样:
<Placemark> <TimeStamp> <when>2024-06-01T08:30:00Z</when> </TimeStamp> <Style> <IconStyle> <color>ff0000ff</color> <scale>0.8</scale> <Icon> <href>http://maps.google.com/mapfiles/kml/pal4/icon47.png</href> </Icon> </IconStyle> </Style> <Point> <coordinates>116.391,39.907,0</coordinates> </Point> </Placemark>这样每一帧都会对应一个时间标签,拖动时间轴就能看到机器人“走”起来。需要注意,时间字符串必须是ISO 8601格式,例如2024-06-01T08:30:00Z。如果bag文件里记录的是Unix秒时间戳,需要先转成UTC时间再格式化。
from datetime import datetime, timezone def format_time(unix_sec): return datetime.fromtimestamp(unix_sec, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')4.3 样式调整与多轨迹叠加对比
Google Earth Pro支持对图层做基础的样式定制。你可以给轨迹线设置不同的颜色和宽度,在KML里预置<Style>和<LineStyle>标签。比如红色轨迹表示第一次实验,绿色表示第二次实验,叠加在同一个地球场景里做对比分析。类似地,可以把多个KML文件同时导入,或者用一个KML文件内包含多个<Placemark>,Google Earth Pro会按图层归属显示出不同的样式。
颜色编码是KML里的一个经典容易出错点:它不像HTML用#RRGGBB,而是用aabbggrr,即透明度在前、然后是蓝绿红。比如蓝色线要写成ff0000ff,红色线要写成ffff0000,绿色线是ff00ff00。我最初写KML时总是按RGB习惯写,结果画出来的颜色完全不对,后来直接养成了“颜色反转再加透明度前缀”的习惯,才算彻底解决这个问题。
5. 常见问题与调试心得
5.1 轨迹完全脱离道路或出现大跳变
遇到这个问题先别急着怀疑KML生成逻辑。第一优先级是检查status.status字段,确认定位质量。如果是status=0或service=0,数据本身可能就不可靠。第二优先级是看bag文件里话题的频率,如果GPS话题在隧道或高架桥下短暂中断,重连后的第一帧数据往往会发生米级甚至十米级的漂移,这类数据要在清洗阶段就剔除。经验上,控制机器人运动速度在5m/s以内时,窗口内速度超过8m/s的点基本全是跳变点。
5.2 导入Google Earth Pro后路径不在预期位置
大概率是KML里coordinates的经纬度顺序写反了。KML标准是“经度,纬度”,而NavSatFix里的字段顺序是“纬度,经度”,这是一个能坑掉大多数新手的顺序陷阱。另一个可能原因是bag里存在多个GPS话题,比如同时有/gps/fix和/gps/rtkfix,你订阅错了话题,导致数据来自不同设备或不同坐标系。建议在数据读取阶段就把话题名与设备名做一一对应,必要时打印前5条消息检查。
5.3 时间轴回放失灵
如果在Google Earth Pro里看不到时间滑块,一般有两种原因:一是KML里没有<TimeStamp>或<TimeSpan>标签,二是在<Document>级没有启用时间可视化。Google Earth Pro对时间数据的组织要求比较严格,点状<Placemark>必须带时间戳,同一<Document>内不能混合“带时间戳”和“不带时间戳”的元素,否则时间轴机制会被整体关闭。所以要么全部打上时间戳,要么全部保持无时间戳,不要混用。
5.4 加载很卡、轨迹线密集缠绕
点过多时,Google Earth Pro的渲染压力会变大,尤其在高分辨率屏幕上拖动视角会明显掉帧。解决方案我已经在前面提到过:抽稀。但还要补充一条实用的工程建议——按距离阈值抽稀通常比按时间阈值抽稀效果更好,因为机器人静止时,时间抽稀也会保留大量几乎重合的点,而距离抽稀则能有效去除这类冗余。抽稀的距离阈值建议根据定位精度设置:普通GPS精度约2-5米,抽稀阈值至少设为1米以上,否则抽不了多少;如果是RTK厘米级定位,阈值可以设为0.2米左右,轨迹视觉上依然很光滑。
6. 扩展方向:从静态轨迹到实时动态显示
6.1 将KML实时输出并自动刷新
实验现场如果需要实时监控机器人轨迹,一种轻量做法是让脚本以在线模式订阅ROS话题,每收到一条定位消息就追加写KML文件,并让Google Earth Pro周期性地重新加载文件。Google Earth Pro本身不支持自动热更新,但你可以借助系统层的文件监视脚本,或者手动重新打开文件来刷新。这个方法虽然不如商业GIS平台那么平滑,但对实验汇报已经足够。
在线模式的代码结构并不复杂,只需要把之前封装好的清洗和KML写入函数包在一个循环里:
import rospy from sensor_msgs.msg import NavSatFix track_points = [] def gps_callback(msg): global track_points if msg.status.status < 1: return track_points.append((rospy.get_time(), msg.latitude, msg.longitude, msg.altitude)) if len(track_points) >= 10: cleaned = filter_gps_points(track_points) generate_kml(cleaned, '/tmp/live_track.kml') track_points = [] rospy.init_node('gps_to_kml_exporter') rospy.Subscriber('/gps/fix', NavSatFix, gps_callback) rospy.spin()这种“先攒点再刷新”的方式,能有效降低IO频率。注意,每次刷新如果都生成全量轨迹,点数量持续增长后依然会卡,建议在刷新时只输出最近N个点,既保持地图整洁,又能看到当前位置。
6.2 轨迹数据的联合分析
KML文件画完轨迹之后,它的价值其实还能再往上走一步。你可以在同一份KML里叠加多个数据图层,比如把机器人的航向角、电量、或者摄像头的关键帧时间点作为<Placemark>的备注信息放进去。Google Earth Pro的查看器支持点击点标记显示<description>里粘贴的文字描述,最适合做实验回放时的现场标注。配合Measure Tool,还能直接在三维地球上量出实际行驶距离,用来和里程计数据做误差对比,非常方便。
我个人在做户外小车实验时,习惯把GPS轨迹KML和激光雷达建图结果对照着看——地图上轨迹与建筑物边缘的相对位置,能快速判断建图漂移发生在哪个路段。这个方法不挑软件,任何支持KML导入的三维地球工具都能胜任。
6.3 用KMZ做压缩分发
如果KML文件里嵌入了大量图标、纹理或者多个轨迹点,文件体积可能很大。此时可以把它打包成KMZ格式——本质上就是Zip压缩包,把.kml文件和资源文件夹一起压缩并改成.kmz后缀。Google Earth Pro能直接读取。在Python里,你可以用标准库的zipfile完成打包:
import zipfile def compress_to_kmz(kml_path, kmz_path): with zipfile.ZipFile(kmz_path, 'w', zipfile.ZIP_DEFLATED) as zf: zf.write(kml_path, arcname='doc.kml') compress_to_kmz('track.kml', 'track.kmz')相比KML,KMZ在传输、归档、交付给甲方时都更省事,强烈建议在最终交付环节采用。
最后的几句实在话
把NavSatFix导入Google Earth Pro,对很多人来说不过是“解析消息、照模板输出”的练习,但它背后涉及的数据清洗、坐标顺序、时间戳格式和抽稀策略,每一样都是真正影响现场效果的经验细节。踩过几次坑之后,我自己的固定套路是先跑通离线bag链路,再扩展在线实时导出,这样出了问题能快速定位是数据源还是转换层的问题。如果你手头也正为“机器人跑完了,但轨迹画不出来”发愁,按这篇文章从头到尾走一遍,几乎不会再有意外。最后再提一句:如果实验环境里同时存在多个GPS接收器,记得先把话题名和物理设备对应清楚,否则后面所有分析都会建立在错误数据之上——这是最容易忽略、也最容易致命的一环。