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 知识体系:本章核心流程

下面这张图帮你理清整个提取流程:

pyulog 提取基本飞行参数流程 加载 ULog 文件 遍历 data_list 提取时间戳 提取姿态四元数 提取位置数据 组装为字典返回 输出: time / attitude.q / position.x/y/z

4.8 常见问题与调试技巧

在实际使用中,你可能会遇到几个问题:

  1. 话题不存在: 有些固件版本可能不记录 vehicle_attitude,而是用 vehicle_attitude_setpoint。建议先打印所有话题名确认。
  2. 字段名不一致: 不同固件版本字段名可能有差异。比如四元数可能是 q[0] 也可能是 q0。用 .keys() 检查最保险。
  3. 数据量太大: 一次飞行可能记录几十万条数据。如果内存不够,可以只取前 N 条,或者用 numpy 的切片操作。
避坑指南: 我曾经在分析一次坠机日志时,发现位置数据全是 NaN。排查了半天,原来是 GPS 信号丢失导致数据无效。所以提取数据后,建议先检查一下有没有 NaN 值:np.isnan(data).any()。