4. 日志读取入门:使用 pyulog 提取基本飞行参数
好,从这一章开始,我们正式动手操作日志。前面三章我们聊了 MAVLink 的协议结构、ULog 的格式,还有日志里到底存了哪些数据。现在,我们要把这些理论知识落地——用 Python 把日志里的时间、姿态、位置信息读出来。
我个人习惯用 pyulog 这个库。它轻量、稳定,而且就是 PX4 官方维护的。你想想看,官方出的工具,兼容性肯定是最好的。我在项目里遇到过几次用其他解析库读不全数据的情况,换成 pyulog 就一切正常了。
4.1 环境准备:安装 pyulog
安装很简单,一行命令搞定:
pip install pyulog
如果你用的是 Anaconda 或者虚拟环境,记得先激活环境再装。嗯,这里要注意:pyulog 依赖 numpy,如果没装它会自动帮你装上。但如果你网络不好,建议先手动装好 numpy。
小提示: 我建议在 Python 3.8 以上版本使用 pyulog。之前我在 Python 3.6 上跑过一个旧版本,遇到 Unicode 解析问题,升级 Python 后就解决了。
4.2 核心概念:ULog 的数据结构
在写代码之前,我们先搞清楚 pyulog 怎么组织数据。说白了,它把 ULog 文件解析成几个关键对象:
| 对象 | 说明 | 常用属性 |
|---|---|---|
ULog |
整个日志文件的入口 | .data_list, .initial_parameters |
ULogData |
单个话题(topic)的数据 | .name, .topic_data |
TopicData |
话题的具体时间序列数据 | .timestamps, .data |
你可以把 ULog 想象成一个箱子,里面装着很多个话题(比如 vehicle_attitude、vehicle_local_position)。每个话题又包含一条时间轴和对应的数据字段。
4.3 第一步:加载日志文件
我们先写一个最简单的脚本,把日志加载进来看看:
from pyulog import ULog
# 加载日志文件
ulog = ULog('flight_log.ulg')
# 看看日志里有哪些话题
print("话题列表:")
for d in ulog.data_list:
print(f" - {d.name}")
# 看看日志的起始时间和结束时间
print(f"\n日志时间范围:")
print(f" 起始: {ulog.start_timestamp} us")
print(f" 结束: {ulog.last_timestamp} us")
print(f" 时长: {(ulog.last_timestamp - ulog.start_timestamp) / 1e6:.2f} 秒")
运行这段代码,你会看到类似这样的输出:
话题列表:
- sensor_combined
- vehicle_attitude
- vehicle_local_position
- vehicle_gps_position
- battery_status
...
日志时间范围:
起始: 1234567890 us
结束: 1234598765 us
时长: 30.87 秒
关键点: pyulog 的时间戳单位是微秒(us)。如果你要转成秒,记得除以 1e6。我刚开始做分析时经常忘记这个,结果画出来的时间轴全是错的。
4.4 第二步:提取姿态数据
姿态数据存在 vehicle_attitude 话题里。它包含四元数(q[0]~q[4]),我们可以直接拿来用:
# 获取姿态话题
attitude_data = None
for d in ulog.data_list:
if d.name == 'vehicle_attitude':
attitude_data = d
break
if attitude_data is not None:
# 时间戳(微秒)
timestamps = attitude_data.timestamps
# 四元数数据
q0 = attitude_data.data['q[0]']
q1 = attitude_data.data['q[1]']
q2 = attitude_data.data['q[2]']
q3 = attitude_data.data['q[3]']
print(f"姿态数据点数量: {len(timestamps)}")
print(f"第一个数据点:")
print(f" 时间: {timestamps[0]} us")
print(f" 四元数: [{q0[0]:.4f}, {q1[0]:.4f}, {q2[0]:.4f}, {q3[0]:.4f}]")
else:
print("未找到 vehicle_attitude 话题")
这里要注意:attitude_data.data 是一个字典,键是字段名,值是 numpy 数组。每个数组的长度和 timestamps 数组的长度是一样的。
避坑指南: 我曾经遇到过一个情况——日志里
vehicle_attitude 话题存在,但 data 字典里没有 q[0] 这个键。后来发现是固件版本不同,字段名变成了 q[0] 和 q[1] 等。建议先打印 attitude_data.data.keys() 确认字段名。
4.5 第三步:提取位置数据
位置数据有两个来源:
- 局部位置(NED坐标系):
vehicle_local_position - GPS位置(经纬度):
vehicle_gps_position
我们先把局部位置读出来:
# 获取局部位置话题
local_pos_data = None
for d in ulog.data_list:
if d.name == 'vehicle_local_position':
local_pos_data = d
break
if local_pos_data is not None:
timestamps = local_pos_data.timestamps
x = local_pos_data.data['x'] # 北向位移(米)
y = local_pos_data.data['y'] # 东向位移(米)
z = local_pos_data.data['z'] # 向下位移(米,注意是负值)
print(f"位置数据点数量: {len(timestamps)}")
print(f"第一个位置: x={x[0]:.2f}, y={y[0]:.2f}, z={z[0]:.2f}")
else:
print("未找到 vehicle_local_position 话题")
再读 GPS 数据:
# 获取 GPS 话题
gps_data = None
for d in ulog.data_list:
if d.name == 'vehicle_gps_position':
gps_data = d
break
if gps_data is not None:
timestamps = gps_data.timestamps
lat = gps_data.data['lat'] # 纬度(度)
lon = gps_data.data['lon'] # 经度(度)
alt = gps_data.data['alt'] # 海拔高度(米)
print(f"GPS数据点数量: {len(timestamps)}")
print(f"第一个GPS点: lat={lat[0]:.6f}, lon={lon[0]:.6f}, alt={alt[0]:.2f}")
else:
print("未找到 vehicle_gps_position 话题")
小提示: 局部位置的 z 是向下为正,所以飞行器上升时 z 会变小(负值)。GPS 的 alt 是海拔高度,两者坐标系不同。我在做高度融合时经常需要做坐标系转换,这个坑踩过好几次。
4.6 完整示例:一键提取三大参数
下面我把上面的代码整合成一个函数,方便你直接复用:
from pyulog import ULog
import numpy as np
def extract_basic_params(ulg_file):
"""
从ULog文件中提取时间、姿态、位置数据
参数:
ulg_file: ULog文件路径
返回:
dict: 包含 'time', 'attitude', 'position' 三个键的字典
"""
ulog = ULog(ulg_file)
result = {}
# 1. 提取时间
result['start_time'] = ulog.start_timestamp
result['end_time'] = ulog.last_timestamp
# 2. 提取姿态
for d in ulog.data_list:
if d.name == 'vehicle_attitude':
result['attitude'] = {
'timestamps': d.timestamps,
'q': np.column_stack([
d.data['q[0]'],
d.data['q[1]'],
d.data['q[2]'],
d.data['q[3]']
])
}
break
# 3. 提取位置(优先局部位置,其次GPS)
for d in ulog.data_list:
if d.name == 'vehicle_local_position':
result['position'] = {
'timestamps': d.timestamps,
'x': d.data['x'],
'y': d.data['y'],
'z': d.data['z']
}
break
else:
for d in ulog.data_list:
if d.name == 'vehicle_gps_position':
result['position'] = {
'timestamps': d.timestamps,
'lat': d.data['lat'],
'lon': d.data['lon'],
'alt': d.data['alt']
}
break
return result
# 使用示例
data = extract_basic_params('flight_log.ulg')
print(f"日志时长: {(data['end_time'] - data['start_time']) / 1e6:.2f} 秒")
print(f"姿态数据点数: {len(data['attitude']['timestamps'])}")
print(f"位置数据点数: {len(data['position']['timestamps'])}")
4.7 知识体系:本章核心流程
下面这张图帮你理清整个提取流程:
4.8 常见问题与调试技巧
在实际使用中,你可能会遇到几个问题:
- 话题不存在: 有些固件版本可能不记录
vehicle_attitude,而是用vehicle_attitude_setpoint。建议先打印所有话题名确认。 - 字段名不一致: 不同固件版本字段名可能有差异。比如四元数可能是
q[0]也可能是q0。用.keys()检查最保险。 - 数据量太大: 一次飞行可能记录几十万条数据。如果内存不够,可以只取前 N 条,或者用
numpy的切片操作。
避坑指南: 我曾经在分析一次坠机日志时,发现位置数据全是 NaN。排查了半天,原来是 GPS 信号丢失导致数据无效。所以提取数据后,建议先检查一下有没有 NaN 值:
np.isnan(data).any()。