博客
- MAVLink & FCU
- MAVLink Router-安装与使用指南
- 使用 pyulog 分析 PX4 飞控日志
- 基于 VRPN 的 PX4 EKF2 视觉融合基础调试指南
- 基于Pymavlink的任务规划实现
- MAVROS 与 MAVProxy:APM 平台上的使用场景、差异与取舍
- Backend
- Frontend
- Aircraft Simulation
- ROS
- Android
- Paradox Mods
- Dev Tools
- 在 Linux 中使用 Ventoy 制作 Windows 启动盘
- 使用 Whisper Tiny 模型实现快速语音转文字:Python 部署与实践指南
- 使用fuck-u-code优化代码质量
- Markdown 转 PDF 加水印工具
- QGroundControl
MAVLink & FCU
MAVLink Router-安装与使用指南
1. 概述
MAVLink Router 是一个用于路由 MAVLink 消息的工具,可以在不同的端点(UART、UDP、TCP)之间转发 MAVLink 数据包。在某些 Ubuntu 版本(如 Ubuntu 20.04 Focal)中,可能无法通过 apt 直接安装 mavlink-router 包,此时需要从源码编译安装。
2. 为什么使用 MAVLink Router
2.1 优势
MAVLink Router 解决了无人机系统中多个应用同时访问飞控的问题,优势包括:
多应用并行访问
在无人机系统中,通常需要多个应用同时与飞控通信:
- 地面站软件(如 QGroundControl)需要实时显示飞行状态
- 任务规划工具(如 pymavlink / MAVSDK-Python)需要发送航点任务
- 数据记录工具需要记录飞行数据
- 自定义应用需要执行特定功能
由于串口或单一网络连接通常只能被一个应用独占,MAVLink Router 可以将一个物理连接(如串口)转换为多个网络端点,让多个应用同时访问飞控数据。
注意:QGroundControl 的 MAVLink 转发功能可以实现从飞控接收数据,但无法向飞控发送数据,会提示超时错误。如果需要双向通信(如发送航点任务、参数设置等),必须使用 MAVLink Router、MAVProxy 或其他支持双向转发的工具。
协议转换与桥接
MAVLink Router 支持在不同传输介质之间桥接:
- UART ↔ UDP/TCP:将串口数据转换为网络数据,方便远程访问
- UDP ↔ TCP:在不同网络协议之间转换
- 多端点分发:将一条数据流分发到多个目标
消息路由与过滤
- 智能路由:根据系统 ID 和组件 ID 智能路由消息,避免消息循环
- 消息过滤:可以过滤特定类型的消息,减少网络负载
- 消息去重:自动去除重复消息,提高效率
灵活的网络配置
- 支持 IPv4 和 IPv6
- 支持单播、多播和广播
- 支持客户端和服务器模式
- 自动检测链路本地地址
2.2 典型部署场景
MAVLink Router 在实际应用中有多种部署方式,可以根据具体需求选择机载部署或地面站部署。以下是一个综合的典型部署架构:
graph TB
FC[飞控<br/>PX4/ArduPilot] -->|UART<br/>/dev/ttyACM0| MR[MAVLink Router]
subgraph CC["机载计算机"]
MR -->|UDP:14550| QGC[QGroundControl<br/>地面站]
MR -->|UDP:14540| MP[任务规划工具<br/>pymavlink / MAVSDK-Python]
MR -->|UDP:14560| MAVROS[MAVROS<br/>ROS/ROS2 节点]
MR -->|TCP:5760| REMOTE[其他应用<br/>可选]
MAVROS --> APP1[ROS/ROS2 节点]
MP --> APP2[Python 脚本]
REMOTE --> APP3[数据记录工具]
end
style FC fill:#e1f5ff
style CC fill:#fff4e1,stroke:#ff9800,stroke-width:3px
style MR fill:#e8f5e9
style QGC fill:#f3e5f5
style MAVROS fill:#f3e5f5
style MP fill:#f3e5f5飞控连接(UART)
飞控通过 UART(如 /dev/ttyACM0)连接到机载计算机,波特率通常为 1500000(1.5Mbps)或 921600。需要确保串口权限正确配置(将用户加入 dialout 组)。一个串口只能被一个 MAVLink Router 实例使用。如果需要在机载计算机和地面站同时访问,需要在机载计算机上部署 MAVLink Router。串口连接延迟低,适合实时控制应用。
QGroundControl 连接(UDP:14550)
QGroundControl 通过 UDP:14550 连接到 MAVLink Router,可以在同一网络内或通过 WiFi/4G 远程连接。QGroundControl 主要用于实时监控飞行状态,接收飞控数据。如果需要发送航点任务,建议使用 pymavlink 或 MAVSDK-Python(通过其他端口)。
注意:QGroundControl 的 MAVLink 转发功能只能接收数据,无法发送数据,会提示超时错误。如果需要双向通信(如发送航点任务、参数设置等),必须使用 MAVLink Router、MAVProxy 或其他支持双向转发的工具。
任务规划工具连接(UDP:14540)
pymavlink 或 MAVSDK-Python 通过 UDP:14540 连接到 MAVLink Router,支持双向通信,可以发送航点任务、参数设置等。注意:一个 UDP 端口通常只能被一个应用独占接收使用。如果需要多个应用并行,为每个应用分配不同端口(如 14540、14550、14600)。pymavlink 和 MAVSDK-Python 可以同时使用,但需要连接不同的端口。
配置示例:
# MAVLink Router 配置
mavlink-routerd /dev/ttyACM0:1500000 -e 127.0.0.1:14540 -e 127.0.0.1:14550
# pymavlink 连接
connection = mavutil.mavlink_connection('udp:127.0.0.1:14540')
# MAVSDK-Python 连接
drone = System()
await drone.connect(system_address="udp://:14550")
MAVROS 连接(UDP:14560)
MAVROS 通过 UDP:14560 连接到 MAVLink Router。MAVROS 是 ROS/ROS2 与 MAVLink 协议之间的桥接节点。配置方式如下:
- MAVLink Router 配置:
mavlink-routerd /dev/ttyACM0:1500000 -e 127.0.0.1:14560
- MAVROS 启动(使用命令行参数):
ros2 launch mavros px4.launch fcu_url:=udp://:14560@127.0.0.1:14560
如果使用 TCP 连接(MAVLink Router 默认监听 TCP:5760),可以使用:
ros2 launch mavros px4.launch fcu_url:=tcp://127.0.0.1:5760
127.0.0.1:14560 配置说明:该配置开放给本地的 MAVROS 使用。当外部连接到伴随计算机的 14560 端口时,MAVLink Router 会自动转发 MAVLink 消息到飞控,实现通过 MAVROS 对飞控的直接操作。这样既支持本地 MAVROS 节点访问,也支持远程通过该端口连接并控制飞控。
使用场景:
- ROS/ROS2 节点通信:通过 MAVROS 发布/订阅飞控话题
- 传感器融合:将机载传感器数据与飞控数据融合
- 自主导航:ROS 导航栈通过 MAVROS 控制无人机
- 视觉处理:视觉 SLAM 节点获取位置信息并发送控制指令
注意事项:
- MAVROS 需要独占一个 UDP 端口
- 可以同时运行多个 ROS 节点共享飞控数据
- 与 ROS 生态系统无缝集成,支持标准的 ROS 话题和服务接口
其他应用连接(TCP:5760)
TCP 服务器默认启用,监听端口 5760,支持多个 TCP 客户端同时连接。TCP 连接比 UDP 更可靠,但延迟略高,适合数据记录、远程监控等对可靠性要求高的应用。可以动态连接和断开,不影响其他端点。
3. 安装方式选择
3.1 使用包管理器安装
在支持的 Ubuntu 版本中,可以直接使用包管理器安装:
sudo apt update
sudo apt install mavlink-router
3.2 从源码编译安装
如果在 Ubuntu Focal 等版本中遇到以下错误:
E: Unable to locate package mavlink-router则需要从源码编译安装。
4. 从源码编译安装
4.1 安装依赖
首先安装编译所需的依赖包:
sudo apt install git meson ninja-build pkg-config gcc g++ systemd
4.2 克隆源码
git clone https://github.com/mavlink-router/mavlink-router.git
cd mavlink-router
git submodule update --init --recursive
4.3 检查并安装 Meson
检查 Meson 版本:
meson --version
如果版本低于 0.55,需要升级:
pip3 install meson==0.55
4.4 编译和安装
meson setup build . && ninja -C build
sudo ninja -C build install
5. 验证安装
5.1 验证安装成功
安装完成后,可以通过以下命令验证 MAVLink Router 是否安装成功:
mavlink-routerd --help
如果能够正常显示帮助信息,说明安装成功。
5.2 配置串口权限 - 将用户加入 dialout 组
在使用 MAVLink Router 连接飞控串口之前,需要配置串口权限。
Linux 下所有串口 /dev/ttyACM*,/dev/ttyUSB* 默认属于 dialout 组。将用户加入该组可以一次性解决所有串口权限问题:
sudo usermod -a -G dialout $USER
重要:执行上述命令后,必须重启或重新登录系统才能生效:
reboot
重启后检查权限:
ls -l /dev/ttyACM0
应该能看到类似输出:
crw-rw---- 1 root dialout ...
此时用户已经在 dialout 组里,MAVLink Router 就能访问串口了。
6. 命令行使用方法
6.1 参数说明
- TCP 服务器默认启用(监听端口 5760)
- TCP 和 UDP 端点可以多次添加
- 使用
-e选项添加的 UDP 端点以普通模式启动(发送数据到指定地址和端口) - 最后一个参数(无键值)可以是 UART 设备或 UDP 连接,UDP 端点将以服务器模式启动(等待传入连接)
6.2 使用示例
从 UART 路由到 UDP
将 MAVLink 数据包从 UART ttyS1 路由到 2 个 UDP 端点:
mavlink-routerd -e 192.168.7.1:14550 -e 127.0.0.1:14550 /dev/ttyS1:1500000
其中 1500000 是 UART 波特率。
从 UDP 路由到 UDP
也可以从传入的 UDP 连接路由 MAVLink 数据包:
mavlink-routerd -e 192.168.7.1:14550 -e 127.0.0.1:14550 0.0.0.0:24550
IPv6 地址格式
IPv6 地址必须用方括号括起来,例如:
mavlink-routerd -e [::1]:14550 /dev/ttyS1:1500000
单播和多播地址都能正确处理,链路本地地址的接口会自动检测。
7. 详细说明
7.1 端点类型
MAVLink Router 支持三种基本端点类型:UART, UDP 链接和 TCP 客户端。此外,它还可以作为 TCP 服务器为动态客户端提供服务(除非明确禁用)。
7.1.1 UART
- 用途:用于遥测无线电或其他串行链接
- 配置:UART 设备路径/名称和波特率
- 行为:无需等待传入数据即可接收和发送数据
7.1.2 UDP
- 配置:模式(客户端或服务器)、IP 地址和端口
- 客户端模式行为:端点配置有目标 IP 和端口组合。启动后可以直接发送 MAVLink 消息,但只有在远程端发送第一条消息后才会接收数据(否则远程端不知道我们的 IP 和端口)
- 服务器模式行为:端点配置有监听端口和 IP 地址。启动后可以直接接收消息,但只有在收到第一条消息后才能发送消息(否则不知道远程 IP 和端口)。MAVLink 消息总是发送到最后一条传入消息的 IP 和端口
7.1.3 TCP 客户端
- 配置:目标 IP 地址和端口,断开连接时的重连间隔
- 行为:TCP 会话建立后立即接收和发送数据
7.2 端点定义
端点通过以下方式创建:
- 在配置文件中定义端点
- 通过相应的命令行选项定义端点
- TCP 客户端连接到 TCP 服务器端口
端点在以下情况下被销毁:
- TCP 客户端从 TCP 服务器端口断开连接
- MAVLink Router 终止
(这意味着 UART、UDP 和 TCP 客户端端点在运行期间永远不会被销毁)
7.3 消息路由
7.3.1 基本路由规则
一般来说,在一个端点上接收的每条消息都会被传递到所有已看到该目标系统/组件的端点。如果是广播消息,则传递到所有端点。消息永远不会发送回它来源的同一端点。
7.3.2 详细路由规则
- 每个端点会记住在其整个生命周期内从哪些系统(系统 ID 和组件 ID)接收过消息
- 在一个端点上接收的消息会被提供给除接收端点外的所有端点。端点将:
- 如果消息的发送者地址在此端点的已连接系统列表中,则拒绝该消息(防止消息循环)
- 根据出站消息过滤器拒绝消息(如果启用)
- 如果消息的目标是此端点已连接系统列表中的任何系统,则接受该消息。广播规则在检查目标是否可通过此端点到达时适用。没有目标地址的消息视为广播
- 如果已连接系统列表为空,则只发送系统 ID 广播消息,不发送组件 ID 广播(因为目标系统未知是否可通过此端点到达)
- 拒绝所有其他消息
7.4 消息过滤
在每个端点上有两个位置可以过滤消息:
- In(入站):在此端点上从外部接收的消息在路由到其他端点之前,根据相应的过滤规则被丢弃或允许
- Out(出站):在传输之前,根据端点的过滤规则丢弃或允许消息。这是在内部路由之后(见上面的
路由规则章节)
消息过滤器可以基于以下消息标识符之一:
- MsgId:基于 MAVLink 消息 ID(如 HEARTBEAT)过滤消息
- SrcSys:基于 MAVLink 源系统 ID 过滤消息
- SrcComp:基于 MAVLink 源组件 ID 过滤消息
消息过滤器可以是阻止列表或允许列表:
- Block(阻止):丢弃所有匹配相应标识符的消息(允许所有其他消息)
- Allow(允许):允许所有匹配相应标识符的消息(丢弃所有其他消息)
注意:在同一标识符上同时使用 Allow 和 Block 过滤器没有意义,但在不同标识符上使用它们可能很有用(例如,只允许特定的出站系统 ID,并阻止该系统发送一些不需要的消息 ID)。
7.5 消息去重
如果启用,每条传入消息都会被检查,是否在过去 DeduplicationPeriod 毫秒内已经收到过另一个副本。如果已知,消息将被丢弃,就像从未收到过一样,该消息的超时计数器将被重置。消息通过包括其标头在内的完整 MAVLink 消息的 std::hash 值进行标识。
只要在配置的周期内没有收到具有完全相同标头序列号和内容的消息,一切正常。最关键的消息是心跳,因为它主要包含静态数据。因此,周期短于最快静态消息的更新周期在任何情况下都可以(对于 1 Hz 心跳,小于 1000 毫秒)。
7.6 端点组
可以将多个端点配置为在同一端点组中。同一组中的端点将共享相同的已连接系统列表。
当使用两个(或更多)并行数据链路时(例如 LTE 和遥测无线电),端点必须在两侧都分组。否则,由于路由规则 1,一个链路将不再被使用。
7.7 消息嗅探
可以通过设置 SnifferSysID 来定义嗅探器。这将把所有流量转发到连接了此 MAVLink 系统 ID 的端点。这可用于记录或查看流经 mavlink-router 的所有消息。
参考文档
使用 pyulog 分析 PX4 飞控日志
本文档以实际案例 log_284_2025-11-25-01-15-04.ulg 为例,系统介绍如何使用 Python 的 pyulog 库分析 PX4 飞控生成的 ULog 格式日志文件,通过提取关键数据并生成可视化图表来诊断飞行过程中的潜在问题。
本案例展示了系统性的日志分析流程:从数据探索(识别关键主题)→ 数据提取与预处理 → 理解数据意义 → 可视化分析(生成专业图表)→ 问题诊断(建立因果链条)→ 解决方案(问题排查清单)。这种方法具有系统性、可复现性和实用性,适用于各种飞行日志分析场景,能够帮助开发者快速定位和解决飞行过程中的问题,提升飞行器的安全性和性能。
你可以从以下链接下载该日志文件进行实践:
1. 从 ULog 中提取有效的数据条目
ULog 是 PX4 飞控系统采用的二进制日志格式,记录了飞行过程中的传感器数据、系统状态、控制指令等丰富信息。要解析 ULog 文件,需要使用 pyulog 库。
1.1 安装 pyulog
首先,确保已安装 Python 环境(推荐 Python 3.7+),然后使用 pip 安装 pyulog:
pip install pyulog
同时,为了进行数据分析和可视化,还需要安装以下依赖:
pip install numpy matplotlib pandas
1.2 读取 ULog 文件
使用 pyulog 读取 ULog 文件的基本方法:
from pyulog import ULog
# 读取 ULog 文件(使用实际案例日志)
ulog = ULog('log_284_2025-11-25-01-15-04.ulg')
# 获取所有消息名称(uORB 主题)
message_names = ulog.get_message_names()
print(f"日志中包含的消息类型: {message_names}")
1.3 解析日志
在实际分析中,我们首先需要了解日志中包含哪些主题,然后根据分析目标识别关键主题。以下脚本可以帮助我们系统地探索日志内容:
from pyulog import ULog
import sys
# 读取日志文件
ulog_file = 'log_284_2025-11-25-01-15-04.ulg'
ulog = ULog(ulog_file)
# 获取所有消息名称
message_names = [dataset.name for dataset in ulog.data_list]
print(f"日志文件: {ulog_file}")
print(f"总共包含 {len(message_names)} 个主题\n")
print("=" * 80)
print("所有主题列表:")
print("=" * 80)
# 按类别分类主题
categories = {
'飞行状态': ['vehicle_status', 'commander_state', 'vehicle_control_mode'],
'姿态控制': ['vehicle_attitude', 'vehicle_attitude_setpoint', 'vehicle_rates_setpoint'],
'位置导航': ['vehicle_local_position', 'vehicle_global_position', 'vehicle_gps_position'],
'EKF2 融合': ['estimator_local_position', 'estimator_status', 'estimator_innovations'],
'传感器数据': ['sensor_combined', 'sensor_accel', 'sensor_gyro', 'sensor_mag'],
'电机/舵机': ['actuator_outputs', 'actuator_controls'],
'外部定位': ['vehicle_vision_position', 'vehicle_odometry'],
'其他': []
}
# 分类显示
for category, keywords in categories.items():
matched = []
for name in sorted(message_names):
if any(keyword in name for keyword in keywords):
matched.append(name)
elif category == '其他' and not any(name in m for m in categories.values() if m != categories['其他']):
if name not in [item for sublist in [v for k, v in categories.items() if k != '其他'] for item in sublist]:
matched.append(name)
if matched:
print(f"\n【{category}】")
for name in matched:
try:
dataset = ulog.get_dataset(name)
field_count = len(dataset.data.keys())
sample_count = len(list(dataset.data.values())[0]) if dataset.data else 0
print(f" - {name:40s} (字段数: {field_count:3d}, 样本数: {sample_count:6d})")
except:
print(f" - {name:40s} (无法读取)")
运行后返回
日志文件: log_284_2025-11-25-01-15-04.ulg
总共包含 97 个主题
================================================================================
所有主题列表:
================================================================================
【飞行状态】
- vehicle_control_mode (字段数: 16, 样本数: 114)
- vehicle_status (字段数: 39, 样本数: 114)
【姿态控制】
- vehicle_attitude (字段数: 11, 样本数: 1124)
- vehicle_attitude_setpoint (字段数: 11, 样本数: 1124)
- vehicle_rates_setpoint (字段数: 8, 样本数: 2807)
【位置导航】
- vehicle_local_position (字段数: 55, 样本数: 563)
- vehicle_local_position_setpoint (字段数: 15, 样本数: 563)
【EKF2 融合】
- estimator_innovations (字段数: 33, 样本数: 112)
- estimator_innovations (字段数: 33, 样本数: 112)
- estimator_local_position (字段数: 55, 样本数: 112)
- estimator_local_position (字段数: 55, 样本数: 112)
- estimator_status (字段数: 40, 样本数: 282)
- estimator_status (字段数: 40, 样本数: 282)
- estimator_status_flags (字段数: 71, 样本数: 72)
- estimator_status_flags (字段数: 71, 样本数: 72)
【传感器数据】
- sensor_accel (字段数: 12, 样本数: 56)
- sensor_accel (字段数: 12, 样本数: 56)
- sensor_combined (字段数: 14, 样本数: 11528)
- sensor_gyro (字段数: 12, 样本数: 56)
- sensor_gyro (字段数: 12, 样本数: 56)
- sensor_mag (字段数: 8, 样本数: 56)
【电机/舵机】
- actuator_outputs (字段数: 18, 样本数: 1)
- actuator_outputs (字段数: 18, 样本数: 1)
- actuator_outputs (字段数: 18, 样本数: 1)
【其他】
- action_request (字段数: 4, 样本数: 5)
- actuator_armed (字段数: 8, 样本数: 114)
- actuator_motors (字段数: 15, 样本数: 563)
- battery_status (字段数: 52, 样本数: 282)
- can_interface_status (字段数: 5, 样本数: 551)
- can_interface_status (字段数: 5, 样本数: 551)
- config_overrides (字段数: 6, 样本数: 115)
- control_allocator_status (字段数: 26, 样本数: 282)
- cpuload (字段数: 3, 样本数: 113)
- distance_sensor_mode_change_request (字段数: 2, 样本数: 1)
- estimator_aid_src_ev_hgt (字段数: 14, 样本数: 112)
- estimator_aid_src_ev_hgt (字段数: 14, 样本数: 112)
- estimator_aid_src_ev_pos (字段数: 21, 样本数: 112)
- estimator_aid_src_ev_pos (字段数: 21, 样本数: 112)
- estimator_aid_src_ev_yaw (字段数: 14, 样本数: 112)
- estimator_aid_src_ev_yaw (字段数: 14, 样本数: 112)
- estimator_aid_src_gravity (字段数: 28, 样本数: 112)
- estimator_aid_src_gravity (字段数: 28, 样本数: 112)
- estimator_aid_src_mag (字段数: 28, 样本数: 112)
- estimator_aid_src_mag (字段数: 28, 样本数: 112)
- estimator_attitude (字段数: 11, 样本数: 112)
- estimator_attitude (字段数: 11, 样本数: 112)
- estimator_event_flags (字段数: 20, 样本数: 60)
- estimator_event_flags (字段数: 20, 样本数: 60)
- estimator_innovation_test_ratios (字段数: 33, 样本数: 112)
- estimator_innovation_test_ratios (字段数: 33, 样本数: 112)
- estimator_innovation_variances (字段数: 33, 样本数: 112)
- estimator_innovation_variances (字段数: 33, 样本数: 112)
- estimator_odometry (字段数: 28, 样本数: 112)
- estimator_odometry (字段数: 28, 样本数: 112)
- estimator_selector_status (字段数: 46, 样本数: 589)
- estimator_sensor_bias (字段数: 32, 样本数: 56)
- estimator_sensor_bias (字段数: 32, 样本数: 56)
- estimator_states (字段数: 52, 样本数: 112)
- estimator_states (字段数: 52, 样本数: 112)
- estimator_status_flags (字段数: 71, 样本数: 72)
- estimator_status_flags (字段数: 71, 样本数: 72)
- event (字段数: 29, 样本数: 25)
- failsafe_flags (字段数: 40, 样本数: 104)
- failure_detector_status (字段数: 11, 样本数: 114)
- home_position (字段数: 13, 样本数: 3)
- input_rc (字段数: 30, 样本数: 112)
- magnetometer_bias_estimate (字段数: 21, 样本数: 3)
- manual_control_setpoint (字段数: 17, 样本数: 282)
- manual_control_switches (字段数: 15, 样本数: 58)
- navigator_status (字段数: 3, 样本数: 114)
- parameter_update (字段数: 9, 样本数: 2)
- position_setpoint_triplet (字段数: 70, 样本数: 1)
- px4io_status (字段数: 77, 样本数: 57)
- rate_ctrl_status (字段数: 5, 样本数: 282)
- rtl_status (字段数: 6, 样本数: 28)
- rtl_time_estimate (字段数: 4, 样本数: 28)
- sensor_baro (字段数: 6, 样本数: 56)
- sensor_selection (字段数: 3, 样本数: 4)
- sensors_status_imu (字段数: 35, 样本数: 282)
- system_power (字段数: 17, 样本数: 112)
- takeoff_status (字段数: 3, 样本数: 11)
- telemetry_status (字段数: 38, 样本数: 56)
- timesync_status (字段数: 6, 样本数: 56)
- trajectory_setpoint (字段数: 15, 样本数: 282)
- transponder_report (字段数: 40, 样本数: 1)
- vehicle_acceleration (字段数: 5, 样本数: 1124)
- vehicle_air_data (字段数: 9, 样本数: 282)
- vehicle_angular_velocity (字段数: 8, 样本数: 2807)
- vehicle_command (字段数: 15, 样本数: 3)
- vehicle_command_ack (字段数: 8, 样本数: 4)
- vehicle_constraints (字段数: 4, 样本数: 56)
- vehicle_imu (字段数: 16, 样本数: 112)
- vehicle_imu (字段数: 16, 样本数: 112)
- vehicle_imu_status (字段数: 32, 样本数: 56)
- vehicle_imu_status (字段数: 32, 样本数: 56)
- vehicle_land_detected (字段数: 13, 样本数: 62)
- vehicle_local_position_setpoint (字段数: 15, 样本数: 563)
- vehicle_magnetometer (字段数: 7, 样本数: 112)
- vehicle_thrust_setpoint (字段数: 5, 样本数: 2807)
- vehicle_torque_setpoint (字段数: 5, 样本数: 2807)
我们可以清楚地看到日志中包含的所有主题,并根据分析目标选择关键主题。对于 log_284 这个案例,在此文档中我们特别关注:
- 飞行模式:
vehicle_status- 了解何时切换到 Position 模式 - EKF2 融合状态:
estimator_status- 检查融合器是否正常工作 - 位置估计:
estimator_local_position和vehicle_local_position- 对比分析位置跳变问题 - 姿态数据:
vehicle_attitude- 评估飞行器姿态稳定性
2. 关键数据条目
基于 log_284 案例中识别出的关键主题,本节详细讲解每个数据条目的物理意义、数据单位和提取方法。
2.1 飞行模式与系统状态
vehicle_status - 飞行器系统状态,包含飞行模式、安全状态等关键信息。
| 字段名 | 类型 | 单位 | 说明 |
|---|---|---|---|
timestamp | uint64 | 微秒 (μs) | 时间戳 |
nav_state | uint8 | - | 导航状态(飞行模式):0=MANUAL(手动), 1=ALTCTL(高度控制), 2=POSCTL(位置控制), 3=AUTO_MISSION(自动任务), 4=AUTO_LOITER(自动悬停), 5=AUTO_RTL(自动返航), 6=ACRO(特技), 7=OFFBOARD(外部控制) |
arming_state | uint8 | - | 解锁状态:0=未解锁, 1=已解锁 |
hil_state | uint8 | - | HIL 仿真状态 |
failsafe | bool | - | 故障保护是否激活 |
vehicle_status = ulog.get_dataset('vehicle_status')
timestamps = np.array(vehicle_status.data['timestamp']) / 1e6
nav_state = np.array(vehicle_status.data['nav_state'])
arming_state = np.array(vehicle_status.data['arming_state'])
# 查找模式切换时间点
mode_changes = np.where(np.diff(nav_state) != 0)[0]
for idx in mode_changes:
print(f"时间 {timestamps[idx]:.2f} s: 切换到模式 {nav_state[idx+1]}")
2.2 姿态控制数据
vehicle_attitude - 飞行器姿态信息,使用四元数表示。
| 字段名 | 类型 | 单位 | 说明 |
|---|---|---|---|
timestamp | uint64 | 微秒 (μs) | 时间戳 |
q[0] | float | - | 四元数 w 分量(标量部分) |
q[1] | float | - | 四元数 x 分量(对应横滚轴) |
q[2] | float | - | 四元数 y 分量(对应俯仰轴) |
q[3] | float | - | 四元数 z 分量(对应偏航轴) |
vehicle_attitude_setpoint - 姿态设定值,用于评估控制器跟踪性能。
| 字段名 | 类型 | 单位 | 说明 |
|---|---|---|---|
roll_body | float | 弧度 (rad) | 横滚角设定值 |
pitch_body | float | 弧度 (rad) | 俯仰角设定值 |
yaw_body | float | 弧度 (rad) | 偏航角设定值 |
thrust_body[0] | float | - | X 轴推力设定值(归一化) |
thrust_body[1] | float | - | Y 轴推力设定值(归一化) |
thrust_body[2] | float | - | Z 轴推力设定值(归一化) |
2.3 位置与导航数据
vehicle_local_position - 本地坐标系(NED)下的位置、速度和加速度信息,这是对外发布的位置估计。
| 字段名 | 类型 | 单位 | 说明 |
|---|---|---|---|
timestamp | uint64 | 微秒 (μs) | 时间戳 |
x | float | 米 (m) | X 轴位置(北向,NED 坐标系) |
y | float | 米 (m) | Y 轴位置(东向,NED 坐标系) |
z | float | 米 (m) | Z 轴位置(地向,NED 坐标系,向上为负) |
vx | float | 米/秒 (m/s) | X 轴速度 |
vy | float | 米/秒 (m/s) | Y 轴速度 |
vz | float | 米/秒 (m/s) | Z 轴速度 |
ax | float | 米/秒² (m/s²) | X 轴加速度 |
ay | float | 米/秒² (m/s²) | Y 轴加速度 |
az | float | 米/秒² (m/s²) | Z 轴加速度 |
xy_valid | bool | - | XY 平面位置是否有效 |
z_valid | bool | - | Z 轴位置是否有效 |
v_xy_valid | bool | - | XY 平面速度是否有效 |
v_z_valid | bool | - | Z 轴速度是否有效 |
estimator_local_position - EKF2 内部位置估计,用于分析融合算法性能。字段与 vehicle_local_position 相同,但这是 EKF2 的原始输出,未经过位置控制器的处理。
注意:在 log_284 案例中,对比这两个位置数据源可以发现 Position 模式下的位置跳变问题。
2.4 EKF2 融合状态数据
estimator_status - EKF2 融合器状态,包含融合质量指标和故障标志。
| 字段名 | 类型 | 单位 | 说明 |
|---|---|---|---|
timestamp | uint64 | 微秒 (μs) | 时间戳 |
gps_check_fail_flags | uint16 | - | GPS 检查失败标志位 |
filter_fault_flags | uint16 | - | 滤波器故障标志位 |
innovation_check_flags | uint16 | - | 残差检查标志位 |
solution_status_flags | uint16 | - | 解算状态标志位 |
pos_horiz_accuracy | float | 米 (m) | 水平位置精度估计 |
pos_vert_accuracy | float | 米 (m) | 垂直位置精度估计 |
vel_accuracy | float | 米/秒 (m/s) | 速度精度估计 |
estimator_innovations - EKF2 融合残差(innovation),用于诊断融合质量。
| 字段名 | 类型 | 单位 | 说明 |
|---|---|---|---|
timestamp | uint64 | 微秒 (μs) | 时间戳 |
ev_hvel[0] | float | 米/秒 (m/s) | 外部视觉水平速度残差 X |
ev_hvel[1] | float | 米/秒 (m/s) | 外部视觉水平速度残差 Y |
ev_hpos[0] | float | 米 (m) | 外部视觉水平位置残差 X |
ev_hpos[1] | float | 米 (m) | 外部视觉水平位置残差 Y |
ev_vpos | float | 米 (m) | 外部视觉垂直位置残差 |
ev_hvel_test_ratio[0] | float | - | 水平速度 X 测试比率(test_ratio) |
ev_hvel_test_ratio[1] | float | - | 水平速度 Y 测试比率 |
ev_hpos_test_ratio[0] | float | - | 水平位置 X 测试比率 |
ev_hpos_test_ratio[1] | float | - | 水平位置 Y 测试比率 |
test_ratio 说明:当 test_ratio > 1.0 时,EKF2 会拒绝该次测量数据。在 log_284 案例中,过小的 EKF2_EV_POS_X/Y 参数导致 test_ratio 频繁超过阈值,视觉数据被拒绝,造成位置跳变。
2.5 传感器数据
sensor_combined - 综合传感器数据,包含加速度计、陀螺仪和磁力计的融合读数。
| 字段名 | 类型 | 单位 | 说明 |
|---|---|---|---|
timestamp | uint64 | 微秒 (μs) | 时间戳 |
accelerometer_m_s2[0] | float | 米/秒² (m/s²) | X 轴加速度 |
accelerometer_m_s2[1] | float | 米/秒² (m/s²) | Y 轴加速度 |
accelerometer_m_s2[2] | float | 米/秒² (m/s²) | Z 轴加速度 |
gyro_rad[0] | float | 弧度/秒 (rad/s) | X 轴角速度 |
gyro_rad[1] | float | 弧度/秒 (rad/s) | Y 轴角速度 |
gyro_rad[2] | float | 弧度/秒 (rad/s) | Z 轴角速度 |
magnetometer_ga[0] | float | 高斯 (G) | X 轴磁力计读数 |
magnetometer_ga[1] | float | 高斯 (G) | Y 轴磁力计读数 |
magnetometer_ga[2] | float | 高斯 (G) | Z 轴磁力计读数 |
合理范围:
- 加速度计:±20 m/s²(正常飞行),±9.8 m/s²(静止时重力)
- 陀螺仪:±10 rad/s(正常飞行)
- 磁力计:±0.5 G(地球磁场强度)
3. 通过绘图分析关键数据
基于 log_284 案例,本节展示如何将提取的关键数据绘制成图表,直观展示数据变化趋势,为问题诊断提供可视化支持。
读者可以自行下载该日志文件,使用本文提供的脚本进行复现分析。
3.1 飞行模式时间线
通过飞行模式随时间变化的曲线,可以直接观测整段飞行中飞机经历的模式阶段(起飞、悬停、手动介入、稳高/定点、降落等),这是后续所有细节分析的时间框架。
脚本下载:plot_flight_mode.py

图表含义:
- 横轴:时间(秒),从日志开始后的相对时间,范围 0-55 秒。
- 纵轴:飞行模式(Flight Mode),显示 PX4 中的各个模式,包括 MANUAL, ALTCTL, POSCTL, AUTO_MISSION, AUTO_LOITER, AUTO_RTL, ACRO, OFFBOARD 等。
- 数据系列:蓝色线条和圆形标记表示当前激活的飞行模式,模式切换时线条发生垂直跳变。
- 关键信息:每一次模式切换的精确时间点、模式持续时长,以及模式切换的频率和模式。
分析:
- 数据观察:
- 飞行模式序列(0-55秒):
- 0-13.5秒:ALTCTL(高度控制)模式:飞行开始阶段,飞机处于高度控制模式,此阶段对水平位置估计的依赖较弱,主要依赖气压计和加速度计进行高度控制。
- 13.5-17.5秒:POSCTL(位置控制)模式:首次切换到位置控制模式,持续约4秒。这是第一个关键时间点,飞机开始依赖水平位置估计进行控制。
- 17.5-25.5秒:ALTCTL(高度控制)模式:切回高度控制模式,持续约8秒。可能由于位置控制出现问题,飞手或系统自动切回高度控制模式。
- 25.5-37.5秒:POSCTL(位置控制)模式:再次切换到位置控制模式,持续约12秒。这是第二个关键时间点,也是持续时间最长的位置控制阶段。
- 37.5-55秒:ALTCTL(高度控制)模式:最后切回高度控制模式,直到日志结束。
- 模式切换特征:
- 飞机仅在 ALTCTL 和 POSCTL 之间切换,没有进入其他模式(如 MANUAL, AUTO_MISSION, OFFBOARD 等),说明飞行过程相对简单。
- 在约55秒的飞行过程中,发生了4次模式切换(ALTCTL→POSCTL→ALTCTL→POSCTL→ALTCTL),频繁切换往往意味着飞手在尝试使用位置控制模式但遇到问题,或系统检测到异常自动触发保护逻辑。
- 两次 POSCTL 模式分别持续约4秒和12秒,持续时间较短,特别是第一次仅4秒,可能表明位置控制模式启动后很快出现问题。
- 飞行模式序列(0-55秒):
- 关键发现:
- 时间锚点与问题关联:
- 13.5秒(首次切入 POSCTL):这是第一个关键时间点,结合后续分析可以发现,此时开始出现水平位置漂移和姿态控制异常。
- 25.5秒(再次切入 POSCTL):这是第二个关键时间点,也是持续时间最长的位置控制阶段,可能对应最严重的异常阶段(如位置对比图中35-45秒的异常峰值期)。
- 模式切换与异常的关系:每次从 ALTCTL 切换到 POSCTL 后,飞机开始依赖水平位置估计,若位置估计本身存在问题(如 EKF2 对外发布位置跳变),就会导致位置控制异常,表现为水平位置漂移和姿态控制异常;切回 ALTCTL 后,对水平位置估计的依赖减弱,异常现象可能暂时缓解。
- 时间锚点与问题关联:
- 结论验证:
这一飞行模式时间线为后续所有细节分析提供了明确的时间框架。通过将姿态角异常、位置跳变、加速度计波动等数据与模式切换时间点对齐,可以清晰地建立模式切换 → 位置估计异常 → 控制失效的因果链条,为问题诊断提供关键的时间锚点。
- 数据观察:
3.2 姿态角时间序列
姿态角(Roll/Pitch/Yaw)时间序列是最直观反映飞控控制性能的图表之一。通过对比问题飞行(log_284)和正常飞行(log_287),可以很快判断控制回路是否处于健康工作区间。
脚本下载:plot_attitude.py
问题飞行

正常飞行

图表含义:
- 横轴:时间(秒),从日志开始后的相对时间。
- 纵轴:
- 姿态角图:角度(度),显示 Roll(横滚角,绕 X 轴,蓝色)、Pitch(俯仰角,绕 Y 轴,橙色)、Yaw(偏航角,绕 Z 轴,绿色)随时间的变化。
- 角速度图:角速度(deg/s),显示 Roll Rate(蓝色)、Pitch Rate(橙色)、Yaw Rate(绿色)随时间的变化。
- 数据系列:
- 问题飞行(log_284):显示约52秒的飞行数据,包含姿态角和角速度两个子图。
- 正常飞行(log_287):显示约125秒的飞行数据,包含姿态角和角速度两个子图。
- 关键标记:蓝色点标记快速角度变化(>10°/s),用于突出异常行为。
分析:
- 数据观察:
log_284(问题飞行):- Roll 和 Pitch 轴:姿态角在整个约52秒的飞行过程中保持相对稳定,角度值基本在 $\pm 10^\circ$ 范围内小幅波动,接近水平状态;角速度主要波动在 $\pm 500$ deg/s 范围内,属于正常的控制响应范围。
- Yaw 轴(严重异常):姿态角出现频繁、剧烈的瞬时反转,在多个时间段(3-8秒、15-18秒、20-22秒、25-28秒、30-38秒、40-42秒)内,Yaw 角在 $-170^\circ$ 和 $+170^\circ$ 之间快速跳变,在图表上呈现为近垂直的线条,表明航向在短时间内发生 $340^\circ$ 的巨大变化;角速度出现极端峰值,幅度可达 $\pm 7500$ deg/s,与 Yaw 角的快速跳变完全对应;蓝色点标记集中在 Yaw 角的跳变区间,进一步突出了异常行为的严重性;42秒后 Yaw 角稳定在 $+170^\circ$ 附近。
log_287(正常飞行):- Roll 和 Pitch 轴:姿态角在整个约125秒的飞行过程中持续振荡,角度值在 $-5^\circ$ 到 $+10^\circ$ 范围内波动,围绕 $0^\circ$ 进行小幅调整,这是正常飞行中为保持姿态稳定而进行的持续修正;角速度显示高频、高幅振荡,频繁达到 $\pm 40$ deg/s 的峰值,甚至超出显示范围,表明系统在积极进行姿态修正,这是健康控制系统的正常表现。
- Yaw 轴(非常稳定):姿态角在整个125秒内极其稳定,始终保持在 $95-96^\circ$ 附近,波动极小,几乎呈水平直线,表明航向估计和控制非常可靠;角速度在整个飞行过程中非常接近 0 deg/s,波动仅在 $\pm 2$ deg/s 范围内,表明几乎没有绕 Z 轴的旋转运动,航向保持稳定。
- 关键发现:
- Yaw 轴的极端对比:这是最关键的发现——
log_284中 Yaw 角出现频繁、剧烈的瞬时反转(角速度峰值达 $\pm 7500$ deg/s),而log_287中 Yaw 角极其稳定(角速度接近 0),这种极端对比直接证明了问题出在航向估计或控制逻辑,而非机械结构。 - Roll/Pitch 表现差异:
log_284中 Roll/Pitch 相对稳定但缺乏主动修正,而log_287中 Roll/Pitch 持续振荡但处于健康控制状态,这种差异可能与飞行模式、控制参数或外部干扰有关,但不是导致问题的根本原因。 - 问题定位:Roll/Pitch 在
log_284中基本正常,而 Yaw 出现严重异常,说明问题不在基本姿态控制环路本身,而在更高层(如位置/航向估计或上层控制模式)。Yaw 的频繁跳变通常意味着位置/航向估计不稳定(EKF2 融合质量差),导致航向参考频繁跳变;或控制器在错误反馈信号驱动下不断反向调整,形成失控感。
- Yaw 轴的极端对比:这是最关键的发现——
- 结论验证:
通过这一对比,可以直接观察到:同一架飞机、相似的飞行场景,仅仅因为参数/配置不同,Yaw 控制就可以从稳定可控变为频繁反转、接近失控,从而证明问题根源在于位置估计与控制逻辑的配置不当(特别是 EKF2 航向估计和 Position 模式下的控制逻辑),而非机械结构或硬件故障。
- 数据观察:
3.3 EKF2 内部位置 vs 对外发布位置
位置对比图是 log_284 案例中最关键的证据之一:它将 EKF2 内部估计的位置(来自 estimator_local_position)与飞控对外发布的位置(如 vehicle_local_position)叠加到同一坐标系中,直观展示 EKF2 内部位置估计与对外发布位置之间的差异。
脚本下载:plot_position.py

图表含义:
- 横轴:时间(秒),从日志开始后的相对时间,范围 0-55 秒。
- 纵轴:位置(米),三个子图分别显示 X 轴(前后方向)、Y 轴(左右方向)、Z 轴(高度方向)的位置随时间的变化。
- 数据系列:
- 蓝色线:
estimator_local_position(EKF2 内部估计位置),表示 EKF2 滤波器内部的位置估计值。 - 橙色线:
vehicle_local_position(对外发布位置),表示飞控对上层控制和外部模块发布的位置值。
- 蓝色线:
- 关键标记:红色虚线标记 “Position Mode Start”(位置模式启动时间点,约14秒),用于标识模式切换的关键时刻。
分析:
- 数据观察:
- X 轴位置对比(前后方向):
- Position 模式启动前(0-14秒):两条曲线紧密重合,在 4-6 米范围内平滑波动,表明此阶段位置估计整体可信。
- Position 模式启动后(14秒起):橙色线(对外发布位置)立即开始剧烈波动,与蓝色线(内部估计)出现明显分离。
- 异常峰值期(35-45秒):橙色线出现极端跳变,峰值接近 10 米,最低降至 2 米以下,呈现锯齿状、高幅度的来回抖动;蓝色线在此期间也出现振荡,但幅度和频率明显小于橙色线,整体更平滑。
- 恢复期(45秒后):两条曲线重新收敛,从约 6 米平滑下降至 4 米并趋于稳定,表明系统恢复正常。
- Y 轴位置对比(左右方向):
- Position 模式启动前(0-14秒):两条曲线紧密重合,在 12.5-14.5 米范围内稳定波动,与 X 轴表现一致。
- Position 模式启动后(14秒起):橙色线开始显著偏离蓝色线,波动加剧。
- 异常峰值期(35-45秒):橙色线出现极端跳变,峰值接近 15 米,最低降至 10 米以下,与 X 轴同步出现大幅锯齿状抖动;蓝色线同样显示振荡但幅度更小。
- 恢复期(45秒后):两条曲线收敛,从约 12 米平滑上升至 13 米并稳定,系统恢复正常。
- Z 轴位置对比(高度方向):
- 整体一致性:Z 轴两条曲线的一致性明显优于 X/Y 轴,即使在 Position 模式启动后,橙色线的波动也相对较小。
- 0-14秒:两条曲线基本重合,从 -0.2 米平滑下降至 -0.55 到 -0.6 米,保持稳定。
- 14秒后:橙色线波动稍大于蓝色线,但偏差远小于 X/Y 轴。
- 38-42秒:两条曲线同步出现快速下降至 -0.85 米,随后快速上升至 -0.2 米(48秒),橙色线在此期间略显锯齿状,但整体趋势一致。
- X 轴位置对比(前后方向):
- 关键发现:
- X/Y 轴异常跳变:Position 模式启动后,对外发布的 X/Y 位置(橙色线)出现极端跳变和锯齿状抖动,而 EKF2 内部估计(蓝色线)虽然也有振荡,但幅度和频率明显更小,说明问题出在 EKF2 对外发布位置的处理环节,而非内部估计本身。
- Z 轴相对稳定:Z 轴两条曲线的一致性明显好于 X/Y 轴,表明高度估计和发布机制相对正常,问题主要集中在水平位置(X/Y)的发布环节。
- 异常峰值期(35-45秒):这是整个飞行过程中最严重的异常阶段,X/Y 轴对外发布位置出现极端跳变,峰值可达正常值的 2-3 倍,这直接导致 Position 控制器基于错误反馈产生剧烈修正动作。
- 恢复机制:45秒后两条曲线重新收敛,可能与模式切换、飞手介入或 EKF2 重新收敛有关,说明系统具备恢复能力,但异常期间已造成位置控制失效。
- 结论验证:
- 这一对比图与前文对 EKF2
test_ratio和参数EKF2_EV_POS_X/Y过于严格的分析结论形成闭环:EKF2 外部位置数据被拒绝 → 内部估计与对外发布位置分离 → 对外发布位置出现跳变 → Position 控制器基于错误反馈输出错误控制量 → 飞机在 Position 模式下出现失控漂移。 - 在 PX4 的 Position 模式中,飞控会在切入该模式时将当前飞机所在的 X/Y 位置视为新的保持目标点,若对外发布的位置本身发生跳变,控制器会基于错误的位置反馈持续调整控制量,导致飞机出现缓慢甚至剧烈漂移;当退出 Position 模式,改为 Altitude 或 Manual 并由飞手直接控制姿态/油门时,对位置估计的依赖减弱,漂移现象立即消失。
- 这一对比图与前文对 EKF2
- 数据观察:
3.4 加速度计时域对比
加速度计 X 轴和 Y 轴时域对比图,将问题飞行(log_284)与正常飞行(log_287)在同一时间标度下叠加,帮助我们从机体振动和动态响应的角度,验证系统是否处在一个合理的工作环境中。
脚本下载:plot_accel.py

图表含义:
- 横轴:时间(秒),分别对两段日志做相对时间对齐,范围 0-130 秒。
- 纵轴:加速度(m/s²),两个子图分别显示 X 轴(前后方向,通常是机头指向)和 Y 轴(左右方向)的加速度随时间的变化。
- 数据系列:
- 蓝色线:
log_284 accel X/Y(问题飞行),显示存在问题的飞行中的加速度数据。 - 橙色线:
log_287 accel X/Y(正常飞行),显示参数/配置优化后的稳定飞行中的加速度数据。
- 蓝色线:
- 关键信息:通过对比两条曲线可以直观判断系统是否工作在健康的振动水平,识别异常振动和动态响应问题。
分析:
- 数据观察:
log_284(问题飞行):- 在前约50秒内,X 轴和 Y 轴加速度均显示出更大的振幅波动,振荡范围约在 $\pm 6$ 到 $\pm 7.5$ m/s² 之间,明显高于正常水平。
- 曲线呈现不规则、高频的抖动特征,表明此阶段机体振动水平确实偏高。
- 数据在大约50秒后停止记录,这与飞行模式时间线中显示的飞行终止时间点一致。
log_287(正常飞行):- 整体振幅显著更小,大部分时间加速度值稳定在 $\pm 2.5$ m/s² 范围内,基线接近 $0$ m/s²。
- 曲线平滑度明显优于
log_284,表明系统工作在健康的振动环境中。 - 存在少量尖锐的峰值(如45秒、65秒、95秒、105秒附近),这些可能是正常的机动动作(如快速转向、急停等),峰值后迅速恢复到稳定状态。
- 数据持续记录整个测量期间(约130秒),飞行过程完整。
- 关键发现:
- 振动水平对比:
log_284在前50秒内确实存在更高的振动水平,这可能与 Position 模式下的位置控制异常导致的频繁修正动作有关,而非单纯的机械振动问题。 - 系统优化效果:
log_287的平滑曲线证明,在相同的机械结构下,通过优化 EKF2 参数和位置控制逻辑,系统可以工作在低振动、稳定的状态。 - 问题根源验证:通过对比两张图中曲线的整体平滑度和峰值大小,可以直观判断系统是否工作在一个健康的振动水平,为问题诊断提供重要的参考依据。
- 振动水平对比:
- 结论验证:
这一对比进一步验证了前文结论:问题的根源在于 EKF2 配置不当导致的位置估计跳变,进而引发 Position 控制器的异常响应和频繁修正,表现为加速度数据的剧烈波动;而非机械结构或硬件故障导致的振动问题。
- 数据观察:
3.5 Test Ratio 与位置跳变关联分析
Test Ratio 与位置跳变关联分析图是 log_284 案例中最关键的诊断图表之一:它将 EKF2 的 test ratio(测试比率)、数据拒绝事件与位置跳变、Yaw 角异常叠加在同一时间轴上,直观展示 EKF2 数据拒绝机制与位置控制异常之间的因果关系。

图表含义:
- 横轴:时间(秒),从日志开始后的相对时间,范围 0-55 秒。
- 纵轴:三个子图分别显示不同的数据指标。
- 上子图:X 位置(米,左轴)和 Yaw 角(度,右轴),显示 EKF2 内部估计位置(蓝色)、对外发布位置(橙色)和 Yaw 角(绿色虚线)。
- 中子图:X Test Ratio(无单位),显示 EKF2 对 X 轴位置数据的测试比率(红色实线)和拒绝阈值(黑色虚线,值为 1.0)。
- 下子图:数据拒绝事件(二进制指标),红色点表示数据被拒绝的时刻。
- 关键标记:红色虚线标记 “POSCTL” 和 “ALTCTL”,用于标识飞行模式切换的时间点。
- 关键信息:通过对比三个子图,可以直观观察 test ratio 峰值、数据拒绝事件与位置跳变、Yaw 角异常之间的时间对应关系。
分析:
- 数据观察:
- ALTCTL 模式阶段(0-13.5秒、17.5-25.5秒、37.5-55秒):
- X 位置:EKF2 内部估计(蓝色)与对外发布位置(橙色)紧密重合,在 4-6 米范围内稳定波动,位置估计整体可信。
- Yaw 角:稳定在约 $10^\circ$ 附近,波动较小,航向控制正常。
- X Test Ratio:大部分时间保持在拒绝阈值(1.0)以下,表明传感器数据质量良好,EKF2 正常接受数据。
- 数据拒绝事件:几乎没有或极少,系统运行稳定。
- POSCTL 模式阶段(13.5-17.5秒、25.5-37.5秒):
- X 位置:切换到 POSCTL 模式后,对外发布位置(橙色)开始出现波动和偏差,与内部估计(蓝色)出现分离。
- Yaw 角:在模式切换时刻出现急剧变化,随后在 POSCTL 模式下波动加剧。
- X Test Ratio:出现峰值,在约 13.5 秒、20 秒、32 秒等时刻超过拒绝阈值(1.0),最高峰值可达 30 以上。
- 数据拒绝事件:在 test ratio 超过阈值时出现,与位置跳变和 Yaw 角异常的时间点高度一致。
- 异常峰值期(35-45秒):
- X 位置:对外发布位置出现极端跳变,从约 6 米跳至 9 米,随后降至 2 米以下,呈现锯齿状、高幅度的来回抖动,与内部估计位置严重分离。
- Yaw 角:出现频繁、剧烈的瞬时反转,在 $-150^\circ$ 和 $+150^\circ$ 之间快速跳变,航向控制完全失效。
- X Test Ratio:出现连续多次峰值,数值在 10-15 之间,持续超过拒绝阈值,表明 EKF2 在此阶段频繁拒绝传感器数据。
- 数据拒绝事件:在 35-40 秒期间出现密集的拒绝事件,红色点集中分布,与位置跳变和 Yaw 角异常完全对应。
- 恢复期(45秒后):
- 切回 ALTCTL 模式后,X 位置两条曲线重新收敛,稳定在约 4 米;Yaw 角稳定在 $0^\circ$ 附近;test ratio 降至阈值以下;数据拒绝事件消失,系统恢复正常。
- ALTCTL 模式阶段(0-13.5秒、17.5-25.5秒、37.5-55秒):
- 关键发现:
- 时间对应关系:test ratio 峰值、数据拒绝事件与位置跳变、Yaw 角异常在时间上高度一致,特别是在 POSCTL 模式下和异常峰值期(35-45秒),这种对应关系清晰地表明 EKF2 数据拒绝机制是导致位置跳变的直接原因。
- 模式切换触发:每次从 ALTCTL 切换到 POSCTL 时,test ratio 都会出现峰值并超过阈值,导致数据拒绝事件,随后位置跳变和 Yaw 角异常开始出现;切回 ALTCTL 后,test ratio 恢复正常,位置和 Yaw 角也趋于稳定。
- 异常峰值期的严重性:35-45 秒期间,test ratio 连续多次超过阈值,数据拒绝事件密集出现,对应最严重的位置跳变(峰值可达正常值的 2-3 倍)和 Yaw 角失控($340^\circ$ 的巨大变化),这是整个飞行过程中最危险的阶段。
- EKF2 数据拒绝机制的影响:当 test ratio > 1.0 时,EKF2 拒绝外部位置数据(如视觉定位数据),只能依赖 IMU 预测,位置逐渐漂移;下次数据被接受时,位置突然"跳回",形成锯齿状跳变;这种不稳定的位置反馈导致 Position 控制器产生错误的控制指令,进而引发 Yaw 角异常和水平位置漂移。
- 结论验证:
- 这一关联分析图与前文对 EKF2
test_ratio和参数EKF2_EV_POS_X/Y过于严格的分析结论形成完整的因果链条:EKF2 参数设置过小(EKF2_EV_POS_X/Y过小) → innovation test 过于严格 → test ratio 频繁超过阈值 → 外部位置数据被拒绝 → 内部估计与对外发布位置分离 → 对外发布位置出现跳变 → Position 控制器基于错误反馈输出错误控制量 → Yaw 角异常和水平位置漂移 → 飞机在 Position 模式下出现失控漂移。 - 在 ALTCTL 模式下,系统对水平位置估计的依赖较弱,即使出现数据拒绝,影响也较小;但在 POSCTL 模式下,位置估计的准确性直接决定控制性能,数据拒绝导致的位置跳变会立即引发控制异常,这解释了为什么问题只在 POSCTL 模式下暴露出来。
- 这一关联分析图与前文对 EKF2
- 数据观察:
4. 问题诊断
基于 log_284 案例的可视化分析,本节系统性地讲解如何通过图表识别和诊断飞行过程中的常见问题,并提供相应的解决方案。
4.1 位置估计异常分析
基于 log_284 案例的完整分析,位置估计异常问题具有以下典型特征:
问题症状:
estimator_local_position(EKF2 内部估计位置)与vehicle_local_position(对外发布位置)出现明显偏差- X/Y 轴位置出现锯齿状跳变,位置在相邻采样之间快速来回抖动
- Z 轴(高度)相对稳定,问题主要集中在水平位置(X/Y)
- Yaw 角出现频繁、剧烈的瞬时反转
- 问题仅在 Position 模式下暴露,在 Altitude 模式下表现正常
诊断方法:
问题原因分析(基于 log_284):
EKF2 数据拒绝机制触发:
- 当
test_ratio > 1.0时,EKF2 拒绝外部位置数据(如视觉定位数据),只能依赖 IMU 预测 - 位置逐渐漂移;下次数据被接受时,位置突然"跳回",形成锯齿状跳变
- 在
log_284中,test ratio 峰值可达 30 以上,大部分在 5-20 之间
- 当
参数设置过小导致过度拒绝:
EKF2_EV_POS_X/Y设置过小(如 0.1),导致 innovation test 过于严格- 视觉数据频繁被拒绝,特别是在 Position 模式启动时和异常峰值期(35-45秒)
- 数据拒绝事件集中在模式切换时刻和异常峰值期,与位置跳变完全对应
控制回路反馈振荡:
- Position 模式下,位置控制器基于不稳定的位置反馈产生控制指令
- 位置跳变导致控制器不断尝试修正"错误"的位置,形成振荡反馈
- 这种振荡反馈进一步加剧了位置估计的不稳定性,形成恶性循环
模式依赖性问题:
- 在 Altitude 模式下,系统对水平位置估计的依赖较弱,即使出现数据拒绝,影响也较小
- 在 Position 模式下,位置估计的准确性直接决定控制性能,数据拒绝导致的位置跳变会立即引发控制异常
- 这解释了为什么问题只在 Position 模式下暴露出来
4.2 解决方案
基于 log_284 案例的诊断结果,建议按照以下问题排查清单逐步检查和调整:
问题排查清单:
检查并调整 EKF2 参数:
- 检查
EKF2_EV_POS_X和EKF2_EV_POS_Y的当前值,如果过小(如 0.1),建议调整到 0.3-0.5- 这些参数定义了外部位置数据(如视觉定位)的标准偏差
- 增大这些值可以放宽 innovation test 的严格程度,减少数据被过度拒绝的情况
- 检查
EKF2_EVP_GATE(innovation gate 阈值),如果默认值为 3.0,可以尝试调整到 5.0- 这个参数控制 innovation test 的拒绝阈值,增大后可以减少数据拒绝
- 调整后重新飞行并记录日志,检查 test ratio 是否降低,数据拒绝事件是否减少,确认位置跳变和 Yaw 角异常是否消失
- 检查
检查外部定位数据质量:
- 验证视觉定位数据(VRPN)的噪声水平
- 检查数据转发脚本的过滤逻辑是否合理
- 确认数据更新频率是否在合理范围内(建议 20-50 Hz)
- 验证数据是否包含异常值或跳变
- 优化数据预处理流程
- 在数据转发脚本中添加异常值过滤
- 实现数据平滑滤波,减少噪声
- 确保数据时间戳同步准确
- 验证视觉定位数据(VRPN)的噪声水平
验证传感器校准状态:
- 重新校准 IMU(加速度计、陀螺仪)
- 使用 PX4 的校准工具进行完整校准
- 确保传感器数据质量,减少 IMU 预测误差
- 检查传感器安装情况
- 确认传感器安装牢固,无松动
- 检查是否存在电磁干扰
- 验证传感器数据是否在合理范围内
- 重新校准 IMU(加速度计、陀螺仪)
考虑固件和配置优化:
- 检查是否有可用的固件升级
- 考虑升级/降级 PX4 固件版本,可能包含位置处理逻辑的优化
- 查看固件更新日志,了解 EKF2 相关的改进
- 根据实际飞行环境调整其他相关参数
EKF2_AID_MASK:控制哪些辅助数据源被使用EKF2_HGT_MODE:高度估计模式选择- 根据实际飞行环境和传感器配置进行优化
- 检查是否有可用的固件升级
调整飞行策略(临时方案):
- 避免频繁模式切换
- 在 Position 模式下保持稳定飞行,减少模式切换频率
- 如果必须切换,确保在 Altitude 模式下停留足够时间,让 EKF2 重新收敛
- 如果问题持续存在,考虑使用其他飞行模式
- 可以使用 Altitude 模式进行飞行
- 或者使用 Manual 模式,由飞手直接控制姿态和油门
- 避免频繁模式切换
验证和测试注意事项:
- 每次参数调整后,先进行地面测试和短时间悬停测试
- 记录日志并对比调整前后的 test ratio、数据拒绝事件和位置稳定性
- 逐步调整参数,避免一次性大幅修改导致其他问题
- 参考
log_287(正常飞行)的参数配置,作为调整的参考基准
4.3 小结
本文档以 log_284 为实际案例,展示了使用 pyulog 库分析 PX4 飞控日志的完整流程。通过系统性的数据提取、可视化和诊断分析,成功定位了 Position 模式下位置跳变问题的根本原因。
log_284 案例要点:
- 问题现象:切换到 Position 模式后,X/Y 轴出现锯齿状位置跳变,Yaw 角频繁反转
- 根本原因:EKF2 参数(
EKF2_EV_POS_X/Y)设置过小,导致 innovation test 过于严格,视觉数据频繁被拒绝 - 诊断方法:通过对比 EKF2 内部估计位置与对外发布位置、分析 test ratio 和数据拒绝事件、观察飞行模式切换与异常的时间对应关系,建立了完整的因果链条
- 解决方案:调整
EKF2_EV_POS_X/Y(从 0.1 调整到 0.3-0.5)和EKF2_EVP_GATE(从 3.0 调整到 5.0),问题得到解决
参考文档
基于 VRPN 的 PX4 EKF2 视觉融合基础调试指南
本文档不包含 ROS 2, MAVROS, Gazebo 等工具的基础使用方法,也没有如何安装、编译或运行这些软件的具体步骤;
而是专注于:VRPN 位姿数据是如何进入系统、在各个设备中如何被处理与融合、以及如何通过日志和脚本分析这些数据。
- 阅读本文档默认你已经具备以下基础能力:
- 能够在自己的环境中安装并使用
ROS 2,MAVROS和Gazebo - 能够启动
PX4飞控,QGroundControl,并、完成连接 - 能够运行简单的 Python 脚本和
ROS/ROS 2节点命令
1. 飞控的数据融合流程:从 VRPN 到位置融合数据
1.1 完整数据流
VRPN 动捕系统的位置和姿态数据经过多个环节处理,最终融合到飞控的位置估计中。整个系统涉及三个主要设备:动捕系统,伴随计算机 和 飞控。
graph TD
subgraph 动捕系统["动捕系统设备"]
A[VRPN动捕系统<br/>位置和姿态测量]
end
subgraph 伴随计算机["伴随计算机"]
B[VRPN转发脚本<br/>get_pose.py<br/>数据预处理和过滤]
C[MAVROS<br/>ROS与PX4通信桥梁]
end
subgraph 飞控["飞控设备"]
E[IMU传感器<br/>加速度计/陀螺仪]
D[PX4 EKF2融合器<br/>多传感器融合]
F[PX4位置控制器<br/>位置控制算法]
end
A -->|1. /vrpn_mocap/drone1/pose<br/>PoseStamped原始数据| B
B -->|2. 数据有效性检查<br/>位置跳变过滤| C
C -->|3. /mavros/vision_pose/pose<br/>转换为MAVLink并发送给PX4| D
E -->|4. IMU原始数据| D
D -->|5. estimator_local_position<br/>融合后的位置估计| F
F -->|6. vehicle_local_position<br/>作为控制回路反馈| C
C -->|7. /mavros/local_position/pose<br/>提供给上层应用使用| G[外部应用<br/>MAVSDK/QGC等]
style 动捕系统 fill:#e8f5e9,stroke:#009900,stroke-width:3px
style 伴随计算机 fill:#fff4e1,stroke:#ff9900,stroke-width:3px
style 飞控 fill:#e1f5ff,stroke:#0066cc,stroke-width:3px
style D fill:#e1f5ff,stroke:#0066cc,stroke-width:3px
style C fill:#fff4e1,stroke:#ff9900,stroke-width:2px
style B fill:#fff4e1,stroke:#ff9900,stroke-width:2px
style G fill:#fce4ec,stroke:#cc0066,stroke-width:2px设备功能说明:
| 设备 | 运行的功能 | 说明 |
|---|---|---|
| 动捕系统 | VRPN动捕系统 | 提供高精度位置和姿态测量数据 |
| 伴随计算机 | VRPN转发脚本 | 数据预处理、有效性检查、位置跳变过滤 |
| 伴随计算机 | MAVROS | ROS话题与MAVLink消息转换,与飞控通信 |
| 飞控 | IMU传感器 | 提供加速度和角速度数据(硬件) |
| 飞控 | PX4 EKF2融合器 | 融合VRPN和IMU数据,输出位置估计 |
| 飞控 | PX4位置控制器 | 基于位置估计进行飞行控制 |
1.2 关键话题说明
| 话题名称 | 类型 | 说明 | 数据来源 |
|---|---|---|---|
/vrpn_mocap/drone1/pose | geometry_msgs/PoseStamped | VRPN原始数据 | VRPN动捕系统 |
/mavros/vision_pose/pose | geometry_msgs/PoseStamped | 转发给PX4的VRPN数据 | MAVROS |
/mavros/local_position/pose | geometry_msgs/PoseStamped | EKF2融合后的最终位置 | PX4 EKF2 |
1.3 视觉转发脚本
在伴随计算机上,需要一个“视觉转发脚本”get_pose.py,负责把动捕系统输出的位姿数据转换成飞控可以理解的形式,并转发给MAVROS。
可以简单理解为:MAVROS是 ROS ↔ PX4 的桥,而 get_pose.py 则是 VRPN ↔ MAVROS 的桥——负责在进入 MAVROS 之前,把视觉数据清洗、规范好。
其功能可以概括为:
- 订阅动捕系统的位姿话题(例如
/vrpn_mocap/drone1/pose,类型为geometry_msgs/PoseStamped)。 - 检查数据有效性:过滤掉包含 NaN、无穷大或严重不合法四元数的数据。
- 限制位置跳变:如果相邻两帧的位置差异超过阈值(如 0.5 m),认为数据异常并丢弃。
- 可选:限制数据超时,如果长时间收不到新数据,则暂停转发,避免向飞控提供过期数据。
- 转发到MAVROS:将清洗后的位姿发布到
/mavros/vision_pose/pose,并将frame_id统一设为map坐标系,供飞控 EKF2 使用。 - 可选:记录日志,将实际转发的数据写入日志文件,方便之后用 Python 分析。
下面是一个基于 rospy 的简化示例,展示 get_pose.py 的核心逻辑(省略异常处理和完整启动代码):
#!/usr/bin/env python3
import rospy
from geometry_msgs.msg import PoseStamped
import math
class VisionRelay(object):
def __init__(self):
# 最大允许位置跳变(米)
self.max_position_change = rospy.get_param("~max_position_change", 0.5)
self.last_pose = None
# 订阅 VRPN 动捕位姿
self.sub = rospy.Subscriber(
"/vrpn_mocap/drone1/pose",
PoseStamped,
self.pose_callback,
queue_size=10,
)
# 发布到 MAVROS,供飞控 EKF2 使用
self.pub = rospy.Publisher(
"/mavros/vision_pose/pose",
PoseStamped,
queue_size=10,
)
def _is_pose_valid(self, pose):
"""检查位置和四元数是否有效"""
p = pose.position
q = pose.orientation
# 1)检查 NaN / Inf
for v in (p.x, p.y, p.z, q.x, q.y, q.z, q.w):
if math.isnan(v) or math.isinf(v):
return False
# 2)检查四元数近似归一化
q_norm = math.sqrt(q.x**2 + q.y**2 + q.z**2 + q.w**2)
if abs(q_norm - 1.0) > 0.1:
return False
return True
def _position_jump_too_large(self, new_pose):
"""检查与上一帧相比是否位置跳变过大"""
if self.last_pose is None:
return False
p1 = self.last_pose.pose.position
p2 = new_pose.pose.position
dx = p2.x - p1.x
dy = p2.y - p1.y
dz = p2.z - p1.z
dist = math.sqrt(dx * dx + dy * dy + dz * dz)
return dist > self.max_position_change
def pose_callback(self, msg):
"""接收 VRPN 位姿并进行过滤,再转发到 MAVROS"""
if not self._is_pose_valid(msg.pose):
# 丢弃无效数据
return
if self._position_jump_too_large(msg):
# 丢弃位置跳变过大的数据
return
# 通过检查,更新 last_pose
self.last_pose = msg
# 转发到 MAVROS,这里主动将 frame_id 规范到 \"map\" 坐标系
# 这样可以避免动捕系统原始 frame_id (如 \"world\" / 机体名等) 与 PX4/MAVROS 期望的全局坐标系不一致,
# 方便在下游(EKF2、本地位置话题、可视化工具)中进行统一的坐标变换和调试。
out = PoseStamped()
out.header.stamp = msg.header.stamp
out.header.frame_id = "map"
out.pose = msg.pose
self.pub.publish(out)
if __name__ == "__main__":
rospy.init_node("vision_relay")
node = VisionRelay()
rospy.spin()
无论使用 ROS1(rospy)还是 ROS2(rclpy),关键点都是:订阅 VRPN 位姿 → 进行数据过滤 → 以 /mavros/vision_pose/pose 的形式转发给 MAVROS,从而让飞控的 EKF2 接收到可靠的视觉位姿输入。
1.4 PX4 参数配置:使用视觉+IMU 进行EKF2融合
要让飞控真正使用视觉位置和高度,需要在 PX4 中正确配置 EKF2 相关参数,使其融合 External Vision(EV)和自身 IMU,并在需要时停用 GPS 融合。下面给出一个典型的配置思路(以 QGroundControl 参数界面为例):
启用视觉位置融合(EKF2_AID_MASK)
- 在 QGC 中搜索参数
EKF2_AID_MASK。 - 确保勾选/包含:
- Vision position(视觉位置)
- (可选)Vision yaw(视觉航向),如果动捕系统提供可靠的 yaw。
- 如果不希望使用 GPS:取消 GPS position、GPS yaw 等相关选项,或者直接在飞控硬件上不接 GPS。
- 在 QGC 中搜索参数
选择高度来源(EKF2_HGT_MODE / EKF2_EV_CTRL)
- 将
EKF2_HGT_MODE设置为 Vision / External Vision(具体名称取决于 PX4 版本)。 - 在
EKF2_EV_CTRL中启用高度相关融合选项(position + height)。
- 将
合理设置视觉噪声参数(EKF2_EV_POS_X / EKF2_EV_POS_Y 等)
- 在日志分析中,如果发现视觉数据经常被拒绝(test_ratio 很大),说明噪声参数过小或门限过严。
- 通常可以将:
EKF2_EV_POS_X/EKF2_EV_POS_Y从 0.1 调整到 0.3–0.5。EKF2_EVP_NOISE调整到与 EV_POS_X/Y 相近的数值。EKF2_EVP_GATE调整到 5–7 之间,允许合理的残差。
只保留需要的传感器融合
- 确保
EKF2_AID_MASK中只启用你实际在用的传感器(IMU 必须,视觉按需,GPS 可停用)。 - 这样可以避免 EKF2 在 GPS 和视觉之间来回切换,导致位置跳变。
- 确保
保存参数并重启飞控
- 修改完参数后,在 QGC 中保存参数并重启飞控,让新的融合配置生效。
完成以上配置后,EKF2 应当可以以 IMU 为高频预测源,以视觉位姿为慢速绝对参考,在无 GPS 的环境中完成稳定的位置和高度融合。
1.5 EKF2融合过程
EKF2(Extended Kalman Filter 2)是PX4的核心融合算法,将VRPN数据与IMU数据融合:
graph LR
A[VRPN位置数据] -->|External Vision| C[EKF2融合器]
B[IMU数据] -->|高频预测| C
C -->|融合算法| D[位置估计]
D -->|输出| E[local_position/pose]
style C fill:#e1f5ff,stroke:#0066cc,stroke-width:3px
style E fill:#fce4ec,stroke:#cc0066,stroke-width:2px融合步骤:
- IMU预测阶段:使用IMU数据(加速度计、陀螺仪)以高频(~200Hz)预测位置和姿态
- VRPN更新阶段:接收VRPN数据,计算innovation(预测值与测量值的差)
- 数据有效性检查:计算test_ratio,如果超过阈值则拒绝数据
- 状态更新:融合有效数据,更新位置和姿态估计
1.6 验证融合
检查 /mavros/local_position/pose 话题输出:
# 检查话题是否存在
rostopic list | grep local_position
# 检查话题频率(应该>10Hz)
rostopic hz /mavros/local_position/pose
# 查看话题内容
rostopic echo /mavros/local_position/pose
正常输出示例:
header:
seq: 12345
stamp:
secs: 1234567890
nsecs: 123456789
frame_id: "map"
pose:
position:
x: 1.234
y: 2.345
z: -0.567
orientation:
x: 0.0
y: 0.0
z: 0.0
w: 1.0
如果话题有正常输出且频率>10Hz,说明VRPN数据已成功融合到飞控位置信息中。
只要你使用 MAVROS 并持续发布:
rostopic echo /mavros/vision_pose/pose
MAVROS 会自动将其转换为 MAVLink 的 VISION_POSITION_ESTIMATE,在 EKF2 参数配置正确(例如 EKF2_AID_MASK 中启用 Vision 相关选项)的前提下,EKF2 就会融合这些视觉数据,并通过 /mavros/local_position/pose 话题输出。
2. 融合结果验证与快速排查
2.1 使用 MAVROS 话题快速确认 EKF2 输出
当怀疑 EKF2 没有正确融合 VRPN 视觉数据时,可以先用 MAVROS 话题做一个“最小自检”:
# 直接查看局部位置输出
rostopic echo /mavros/local_position/pose
# 检查话题频率(建议 >10Hz)
rostopic hz /mavros/local_position/pose
# 查看话题信息,确认发布者与消息类型
rostopic info /mavros/local_position/pose
如果 /mavros/local_position/pose 话题不存在、频率明显偏低,或者数据长时间不更新,说明 EKF2 融合过程存在问题,需要进一步检查 PX4 内部状态和日志。
如果 /mavros/vision_pose/pose 话题不存在,说明 get_pose.py 脚本运行存在问题,需要进一步检查和调试。
2.2 检查 PX4 EKF2 状态与常见失败原因
PX4 内部通过 uORB 话题提供 EKF2 的详细状态与告警信息,可以直接在 ROS2 环境下查看:
# 查看 EKF2 的时间戳信息(是否在正常更新)
ros2 topic echo /fmu/out/ekf2_timestamps
# 查看 EKF2 估计器状态(融合标志、test_ratio 等)
ros2 topic echo /fmu/out/estimator_status
在 estimator_status 中,重点关注以下几个方面:
- Innovation Test Ratios:各传感器的残差检验值,如果长期明显大于 1,说明该传感器数据经常被拒绝,可能存在噪声模型或数据质量问题。
- Control Status / Fusion Flags:哪些传感器被真正纳入融合(如 GPS、视觉位置、气压高度等),可用来确认视觉融合是否已实际启用。
- 告警与错误信息:PX4 会通过状态位和日志给出融合失败的直接原因。
常见 EKF2 融合失败原因包括:
- GPS 信号丢失或不稳定(但参数中仍启用 GPS 融合,导致状态在 GPS/视觉间频繁切换)。
- IMU 数据异常(震动过大、传感器损坏或安装不当)。
- 磁力计干扰或偏差过大(航向不可靠,影响位置估计)。
- 参数配置错误(例如
EKF2_AID_MASK未启用 Vision Position,或高度来源与实际数据来源不一致)。
2.3 结合日志与 Flight Review 做进一步确认
如果通过话题和 uORB 状态仍无法快速判断问题根因,可以进一步查看 PX4 记录的 .ulg 日志:
- 使用 QGroundControl 下载
.ulg日志文件(参考后文“4.1 QGroundControl日志下载”)。 - 使用 QGroundControl 自带的日志浏览器或 Flight Review 网站打开日志。
- 在日志中重点查看
Estimator Status / EKF2相关曲线:- Innovation Test Ratios:确认视觉位置、气压高度、IMU 等传感器的残差是否在合理范围。
- Control Status / Fusion Flags:确认 External Vision 是否真正被纳入融合,而不是一直处于“待用”或“拒绝”状态。
- Warning / Error 信息:例如
GPS quality insufficient,Mag fusion failed,Bad IMU data等。
通过“话题自检 + uORB 状态 + 日志回放”这一套组合排查,可以较快定位是传感器数据本身的问题(如动捕数据不稳定、IMU 噪声过大)、参数配置错误,还是EKF2 内部对某类数据一直处于拒绝状态。
3. 可视化验证:Gazebo中的位置监控
3.1 系统架构
使用Gazebo仿真环境和ROS2 bridge,可以实时可视化飞机的位置和姿态,对比实机位置数据与仿真显示:
graph TD
A[实机飞控] -->|MAVLink| B[MAVROS]
B -->|/mavros/local_position/pose| C[ROS2 Bridge]
C -->|ROS2话题| D[Gazebo插件]
D -->|可视化| E[Gazebo场景]
F[VRPN数据] -->|/vrpn_mocap/drone1/pose| G[转发脚本]
G -->|/mavros/vision_pose/pose| B
style E fill:#fce4ec,stroke:#cc0066,stroke-width:2px
style C fill:#fff4e1,stroke:#ff9900,stroke-width:2px
style B fill:#e1f5ff,stroke:#0066cc,stroke-width:2px3.3 Gazebo可视化插件
创建Gazebo插件订阅飞机位置话题,在Gazebo场景中显示飞机模型:
<!-- Gazebo模型文件示例 -->
<model name="drone1">
<pose>0 0 0 0 0 0</pose>
<static>false</static>
<plugin name="pose_visualizer" filename="libpose_visualizer.so">
<ros2_topic>/mavros/local_position/pose</ros2_topic>
<update_rate>50</update_rate>
</plugin>
</model>
3.4 位置数据对比
在Gazebo中可以同时显示:
- 实机位置:来自
/mavros/local_position/pose(EKF2融合后的位置) - VRPN原始位置:来自
/vrpn_mocap/drone1/pose(动捕系统原始数据) - 期望位置:来自位置控制器setpoint
graph LR
A[实机位置<br/>mavros/local_position/pose] -->|对比| D[Gazebo显示]
B[VRPN原始位置<br/>vrpn_mocap/drone1/pose] -->|对比| D
C[期望位置<br/>setpoint] -->|对比| D
style D fill:#fce4ec,stroke:#cc0066,stroke-width:3px观察要点:
- 位置差异:实机位置与VRPN原始位置的差异反映了EKF2融合的效果
- 姿态差异:如果姿态显示不一致,可能是EKF2融合或IMU数据问题
- 延迟:观察数据更新的延迟,如果延迟过大可能影响控制性能
- 跳变:如果位置突然跳变,可能是数据过滤失效或EKF2融合异常
4. 异常诊断:使用 pyulog 库分析飞控日志
4.1 QGroundControl日志下载
当发现位置异常或融合问题时,需要下载飞行日志进行分析。
4.1.1 连接飞控
- 打开QGroundControl(QGC)
- 通过USB或无线连接飞控
- 等待连接建立(状态栏显示"Connected")
4.1.2 下载日志文件
graph TD
A[QGC连接飞控] -->|连接成功| B[Vehicle Setup]
B -->|Log Files| C[查看日志列表]
C -->|选择日志| D[下载.ulg文件]
D -->|保存到本地| E[日志文件]
style E fill:#e8f5e9,stroke:#009900,stroke-width:2px操作步骤:
- 在QGC主界面,点击左上角菜单 → Vehicle Setup
- 选择 Log Files 标签页
- 查看日志列表,选择需要分析的日志(通常是最新的)
- 点击 Download 按钮下载
- 日志文件保存为
.ulg格式
日志文件命名:
- 格式:
log_XXX_YYYY-MM-DD-HH-MM-SS.ulg - 例如:
log_287_2025-11-25-05-30-36.ulg
4.2 Python脚本分析工具
使用Python脚本解析.ulg文件,提取关键数据并生成可视化图表。
4.2.1 安装依赖
# 安装pyulog库(用于解析.ulg文件)
pip install pyulog
# 安装其他依赖
pip install numpy matplotlib pandas
4.2.2 基础分析脚本
下面是一个简单的分析脚本示例:
从 .ulg 日志中读取 EKF 内部估计位置(estimator_local_position)和融合后对外发布的位置(vehicle_local_position)。
在一张图上绘制二者的 X/Y/Z 三轴曲线对比图并保存为PNG文件,同时在终端打印保存信息。
from pyulog import ULog
import matplotlib.pyplot as plt
# 这里直接指定需要分析的 ulog 文件
ulog_file = 'log_284_2025-11-25-01-15-04.ulg'
# 从文件名中提取「ulog 编号」(例如 log_0_2025-... -> log_0)
base_name = ulog_file.split('.')[0]
ulog_id = '_'.join(base_name.split('_')[:2])
output_png = f'{ulog_id}.png'
# 读取日志
ulog = ULog(ulog_file)
# 获取 EKF 估计位置
try:
elp = ulog.get_dataset('estimator_local_position0')
except Exception:
elp = ulog.get_dataset('estimator_local_position')
# 获取 vehicle_local_position
vlp = ulog.get_dataset('vehicle_local_position')
# 时间戳(秒)
t_el = elp.data['timestamp'] / 1e6
t_vl = vlp.data['timestamp'] / 1e6
# 位置数据
x_el = elp.data['x']
y_el = elp.data['y']
z_el = elp.data['z']
x_vl = vlp.data['x']
y_vl = vlp.data['y']
z_vl = vlp.data['z']
# 只做 XYZ 对比并输出图片
fig, axes = plt.subplots(3, 1, figsize=(12, 10), sharex=True)
# X
ax = axes[0]
ax.plot(t_el, x_el, label='estimator_local_position.x', alpha=0.7)
ax.plot(t_vl, x_vl, label='vehicle_local_position.x', alpha=0.7)
ax.set_ylabel('X (m)')
ax.legend()
ax.grid(True, alpha=0.3)
# Y
ax = axes[1]
ax.plot(t_el, y_el, label='estimator_local_position.y', alpha=0.7)
ax.plot(t_vl, y_vl, label='vehicle_local_position.y', alpha=0.7)
ax.set_ylabel('Y (m)')
ax.legend()
ax.grid(True, alpha=0.3)
# Z
ax = axes[2]
ax.plot(t_el, z_el, label='estimator_local_position.z', alpha=0.7)
ax.plot(t_vl, z_vl, label='vehicle_local_position.z', alpha=0.7)
ax.set_ylabel('Z (m)')
ax.set_xlabel('Time (s)')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(output_png, dpi=150)
print(f'图表已保存为 {output_png}')
plt.show()
4.3 执行分析
graph TD
A[飞行测试 / 采集数据] -->|QGC下载| B[.ulg 日志文件]
B -->|Python基础分析脚本<br/>main.py| C[提取关键数据]
C -->|生成图表| D[位置 / 高度时间序列图]
C -->|生成图表| E[EV融合状态 / test_ratio 图]
D -->|对比| F[定位异常时间与位置跳变]
E -->|对比| F
F -->|在 Gazebo 中对比| G[实机 / VRPN / 期望轨迹可视化]
G -->|综合判断| H[确定问题原因]
H -->|调整参数 / 修改脚本| I[EKF2参数 / VRPN转发脚本 / 控制逻辑]
I -->|重新起飞并采集新日志| A
style A fill:#e8f5e9,stroke:#009900,stroke-width:2px
style B fill:#e8f5e9,stroke:#009900,stroke-width:2px
style C fill:#fff4e1,stroke:#ff9900,stroke-width:2px
style D fill:#fff4e1,stroke:#ff9900,stroke-width:2px
style E fill:#fff4e1,stroke:#ff9900,stroke-width:2px
style F fill:#fff4e1,stroke:#ff9900,stroke-width:2px
style G fill:#e1f5ff,stroke:#0066cc,stroke-width:2px
style H fill:#fce4ec,stroke:#cc0066,stroke-width:2px
style I fill:#fce4ec,stroke:#cc0066,stroke-width:2px在图示流程中,当通过日志分析、Gazebo 可视化完成参数调整或问题修复后,应当重新回到流程起点(从新一轮飞行测试与日志采集开始),形成闭环迭代的调试过程。
4.4 分析调试过程案例
本节展示的是同一套系统在同一场景下的三轮重复测试与分析过程:每次起飞都记录 .ulg 日志,用前文的 Python 脚本生成三轴位置对比图,逐步调整参数与脚本,直到 estimator_local_position 与 vehicle_local_position 的 X/Y/Z 曲线基本重合。
第一次测试:log_284——初步评估
log_284 是该案例中的第一次测试飞行,通过 Python 分析脚本生成的三轴位置对比图如下:

你可以下载对应的原始 .ulg 日志文件以便自行复现分析过程:
在 log_284 中可以看到:在切换到 Position 模式之前,estimator_local_position 与 vehicle_local_position 的三轴曲线基本重合,XYZ 一致性良好,说明 EKF2 内部状态与对外发布的位置估计在非 Position 模式下工作正常。
当通过 QGC 将飞控切换到 Position 模式后,图中的 X/Y 轴曲线开始出现明显的锯齿状位置跳变,飞控开始拒绝超范围的位置输入,平面位置估计在相邻采样之间快速来回抖动,最终导致控制回路输出剧烈变化,飞机进入失控状态。
问题原因分析:
Position 模式下的控制回路反馈振荡:在
Position模式下,位置控制器会读取vehicle_local_position作为反馈信号,计算位置误差并生成控制指令。当位置估计不稳定时,会形成反馈循环:EKF2 内部估计(estimator_local_position)出现波动 → 位置控制器基于不稳定的位置反馈产生控制指令 → 控制指令导致飞机实际运动,进一步影响位置估计 → 形成振荡反馈,导致锯齿状跳变。这解释了为什么问题只在切换到Position模式后才暴露出来。EKF2 数据拒绝机制导致的间歇性跳变:EKF2 的 innovation test 会计算预测值与测量值的残差(innovation),如果
test_ratio超过门限(如EKF2_EVP_GATE),会拒绝该次视觉数据。视觉数据被拒绝时,EKF2 只能依赖 IMU 进行预测,位置逐渐漂移;下次视觉数据被接受时,位置突然"跳回"到正确值。这种"拒绝-接受"的切换导致位置曲线出现锯齿状跳变。在log_284中,由于视觉噪声参数(EKF2_EV_POS_X/Y)设置过小,视觉数据频繁被拒绝,导致锯齿状跳变非常明显。两个位置数据源的不同处理逻辑:
estimator_local_position是 EKF2 内部状态,相对平滑但可能有延迟;vehicle_local_position是经过位置控制器和范围限制处理后的对外发布值。当 EKF2 内部估计与控制器期望不一致时,vehicle_local_position可能被限制或修正,导致两者出现偏差。在log_284中,两个曲线"打架"的情况正是这种处理逻辑差异的体现。
这一轮测试为后续调试建立了清晰的"问题基线"——问题只在 Position 模式下暴露,且主要集中在平面位置 X/Y 的估计质量上。
第二次测试:log_286——参数调整
在第二次测试 log_286 中,在飞行前对 EKF2_EV_POS_X,EKF2_EV_POS_Y 等视觉噪声参数以及相关 innovation gate(EKF2_EVP_GATE)进行了调整,希望减少视觉数据被拒绝的次数,并改善 EKF2 对外发布的位置输出质量:

对应的原始日志文件为:
对比 log_284 与 log_286 的分析图可以看到:在切换到 Position 模式时,X/Y 轴上虽然依然存在大量锯齿状跳变,但 estimator_local_position 与 vehicle_local_position 在两个平面轴上的轨迹已经基本重合,不再出现前一轮测试中那种明显彼此"打架"的情况。
改进原因分析:
通过调整 EKF2_EV_POS_X/Y 和 innovation gate(EKF2_EVP_GATE),减少了视觉数据被 EKF2 拒绝的频率。在 log_284 中,由于视觉噪声参数设置过小,EKF2 的 innovation test 频繁拒绝视觉数据,导致位置在"IMU 预测漂移"和"视觉数据跳回"之间快速切换,形成锯齿状跳变。参数调整后,虽然仍有跳变(说明问题尚未完全解决),但两个位置曲线已经基本重合,说明视觉数据被拒绝的频率显著降低,EKF2 内部状态与对外发布的位置估计已经趋于一致。
这表明视觉测量与 EKF2 内部状态本身是一致的,问题更可能来自当前 PX4 固件版本或其他高级参数配置,而不是 VRPN 数据链路或噪声模型本身。
第三次测试:log_287——融合效果收敛
第三次测试 log_287 展示的是在前两次测试基础上,更换 PX4 固件版本、重新配置 EKF2 相关参数并完成电机与遥控器校准之后,系统运行较为稳定的一次飞行:

你可以下载该次飞行的日志文件进行进一步分析:
在这次飞行中,从姿态控制到切换到 Position 模式的整个过程中,XYZ 三轴的 estimator_local_position 与 vehicle_local_position 曲线始终平滑且高度重合,X/Y 轴不再出现锯齿状位置跳变,Position 模式下飞行轨迹稳定可控,高度曲线也未出现明显漂移或突变。
通过三次测试的逐步改进,验证了问题的根本原因和解决方案:
参数调整的作用(log_286):通过调整
EKF2_EV_POS_X/Y和 innovation gate(EKF2_EVP_GATE),减少了视觉数据被拒绝的频率,使两个位置曲线基本重合,证明了参数配置的重要性。固件升级与完整校准的协同效果(log_287):更换 PX4 固件版本、重新配置 EKF2 参数并完成传感器校准后,彻底消除了锯齿状跳变。这说明问题不仅来自参数配置,也可能与固件版本的位置处理逻辑有关。新固件版本可能修复了 Position 模式下的位置处理逻辑问题,或者优化了 EKF2 与位置控制器之间的接口,使得两个位置数据源能够更好地同步。
控制回路反馈振荡的消除:在
log_287中,由于位置估计稳定,Position 模式下的控制回路不再出现振荡反馈。位置控制器能够基于稳定的位置反馈产生平滑的控制指令,避免了锯齿状跳变。数据拒绝机制的优化:通过参数调整和固件升级,EKF2 的 innovation test 能够更准确地评估视觉数据的质量,减少了不必要的拒绝,使得位置估计更加连续和平滑。
结合 Gazebo 可视化,对比 /mavros/local_position/pose 与 /vrpn_mocap/drone1/pose 可以看到,VRPN 融合后的整体轨迹与动捕原始轨迹保持一致。
通过这一个案例的三次测试与分析,可以将"日志采集 → Python 分析 → 参数/脚本调整 → 重新飞行验证"的闭环流程具体化,直到最终实现三轴位置曲线的高度重合,方便在后续调试中快速套用同样的方法。
参考文档
基于Pymavlink的任务规划实现
1. 概述
本文档使用MAVLink协议和pymavlink库进行航点发送,具有以下特点:
- 直接通信: 直接使用MAVLink协议,与QGC完全一致
- 灵活控制: 可以精确控制消息发送和错误处理
- 高效稳定: 直接通信,性能更好,减少潜在问题
- 完全兼容: 支持与QGroundControl、MAVSDK-Python等应用同时使用
2. 主要功能
2.1 航点管理
- 标准MAVLink航点格式
- 支持航点参数配置(接受半径、停留时间等)
- 自动序列号管理
2.2 任务发送
- 完整的MAVLink任务上传流程
- 支持任务确认和错误处理
- 实时进度监控
- 多航点批量上传: 一次性上传多个航点,具有原子性
2.3 端口兼容性
- 一端口一应用: 一个UDP端口通常只能被一个应用独占接收使用
- 多应用并行: 为每个应用分配不同端口,例如14540、14550,或自定义UDP/TCP端口
- 示例建议: 将
QGroundControl使用一个端口,MAVSDK-Python与pymavlink分别使用其他端口
连接配置
# QGroundControl 通常使用一个端口(示例:14540)
# MAVSDK-Python 连接使用另一个端口(示例:14550)
System().connect(system_address="udp://:14550")
# pymavlink 再使用第三个端口(示例:14600,或任意未占用端口)
PyMAVLinkWrapper("drone1")
# 关键点:不同应用使用不同UDP/TCP端口,避免端口冲突
实际使用场景
PX4飞控
├─ 14540/udp ←→ QGroundControl
├─ 14550/udp ←→ MAVSDK-Python应用
└─ 14600/udp ←→ pymavlink应用(或用户自定义端口/使用tcp)
3. MAVLink 协议流程
3.1 任务上传流程
- 发送
MISSION_COUNT消息告知航点数量 - 等待飞控发送
MISSION_REQUEST_INT或MISSION_REQUEST请求 - 根据请求类型发送对应的航点消息:
MISSION_REQUEST_INT→MISSION_ITEM_INTMISSION_REQUEST→MISSION_ITEM
- 等待
MISSION_ACK确认消息
3.2 多航点批量上传
- 原子性: 所有航点要么全部成功,要么全部失败
- 高效性: 一次连接完成所有航点上传
- 一致性: 保证航点序列的完整性
- 性能: 减少网络往返次数
3.3 消息类型
MISSION_COUNT: 任务数量MISSION_REQUEST_INT: 航点请求(INT格式)MISSION_REQUEST: 航点请求(普通格式)MISSION_ITEM_INT: 航点数据(INT格式)MISSION_ITEM: 航点数据(普通格式)MISSION_ACK: 任务确认MISSION_CURRENT: 当前航点
4. 航点格式
waypoint = {
'sequence': 0, # 序列号
'command': 16, # MAV_CMD_NAV_WAYPOINT
'frame': 3, # MAV_FRAME_GLOBAL_RELATIVE_ALT
'current': 0, # 是否为当前航点
'autocontinue': 1, # 自动继续
'param1': 0, # 停留时间
'param2': 5, # 接受半径
'param3': 0, # 飞越半径
'param4': 0, # 航向角
'param5': lat, # 纬度
'param6': lon, # 经度
'param7': alt, # 高度
'mission_type': 0 # 任务类型
}
5. MAVLink 命令说明
5.1 常用导航命令
| 命令值 | 命令名称 | 说明 | 参数说明 |
|---|---|---|---|
| 16 | MAV_CMD_NAV_WAYPOINT | 导航到航点 | param1: 停留时间, param2: 接受半径, param3: 飞越半径, param4: 航向角 |
| 20 | MAV_CMD_NAV_RETURN_TO_LAUNCH | 返回起飞点 | param1: 高度, param2: 空, param3: 空, param4: 空 |
| 21 | MAV_CMD_NAV_LAND | 降落 | param1: 中止高度, param2: 降落方向, param3: 空, param4: 空 |
| 22 | MAV_CMD_NAV_TAKEOFF | 起飞 | param1: 最小俯仰角, param2: 空, param3: 空, param4: 偏航角 |
| 82 | MAV_CMD_NAV_SPLINE_WAYPOINT | 样条航点 | param1: 停留时间, param2: 接受半径, param3: 飞越半径, param4: 航向角 |
| 84 | MAV_CMD_NAV_LOITER_UNLIM | 无限盘旋 | param1: 半径, param2: 空, param3: 空, param4: 空 |
| 85 | MAV_CMD_NAV_LOITER_TURNS | 指定圈数盘旋 | param1: 圈数, param2: 半径, param3: 空, param4: 空 |
| 86 | MAV_CMD_NAV_LOITER_TIME | 指定时间盘旋 | param1: 时间(秒), param2: 半径, param3: 空, param4: 空 |
5.2 特殊命令
| 命令值 | 命令名称 | 说明 | 使用场景 |
|---|---|---|---|
| 0 | MAV_CMD_NAV_WAYPOINT | 空命令 | 占位符 |
| 1 | MAV_CMD_NAV_LOITER_UNLIM | 无限盘旋 | 待机、观察 |
| 2 | MAV_CMD_NAV_LOITER_TURNS | 指定圈数盘旋 | 精确盘旋 |
| 3 | MAV_CMD_NAV_LOITER_TIME | 指定时间盘旋 | 定时盘旋 |
| 4 | MAV_CMD_NAV_RETURN_TO_LAUNCH | 返回起飞点 | 紧急返航 |
| 5 | MAV_CMD_NAV_LAND | 降落 | 任务结束 |
| 6 | MAV_CMD_NAV_TAKEOFF | 起飞 | 任务开始 |
5.3 坐标系说明
| 坐标系值 | 坐标系名称 | 说明 | 使用场景 |
|---|---|---|---|
| 0 | MAV_FRAME_GLOBAL | 全球坐标系(绝对高度) | 全球任务 |
| 1 | MAV_FRAME_LOCAL_NED | 本地NED坐标系 | 本地任务 |
| 2 | MAV_FRAME_MISSION | 任务坐标系 | 任务相关 |
| 3 | MAV_FRAME_GLOBAL_RELATIVE_ALT | 全球坐标系(相对高度) | 推荐使用 |
| 4 | MAV_FRAME_LOCAL_ENU | 本地ENU坐标系 | 本地任务 |
| 6 | MAV_FRAME_GLOBAL_INT | 全球坐标系(整数格式) | 高精度任务 |
| 7 | MAV_FRAME_GLOBAL_RELATIVE_ALT_INT | 全球坐标系(相对高度整数) | 高精度相对高度 |
6. 使用示例
6.1 基本使用 - 创建航点任务
# 创建发送器
sender = PyMAVLinkWrapper("drone1")
# 连接到飞控
await sender.connect("udp:127.0.0.1:14540")
# 创建正方形航点任务
side_length = 0.001 # 约100米
base_lat, base_lon, base_alt = 39.9042, 116.4074, 50.0 # 基准位置
# 第一个航点:基准位置
sender.add_waypoint(base_lat, base_lon, base_alt)
# 第二个航点:向东
sender.add_waypoint(base_lat + side_length, base_lon, base_alt)
# 第三个航点:东北
sender.add_waypoint(base_lat + side_length, base_lon + side_length, base_alt)
# 第四个航点:北
sender.add_waypoint(base_lat, base_lon + side_length, base_alt)
# 发送任务
success = await sender.send_mission()
6.2 关键特性
- 异步操作: 支持异步连接和任务发送
- 双格式支持: 自动处理MISSION_REQUEST和MISSION_REQUEST_INT消息
- 系统ID处理: 自动检测和设置系统ID和组件ID
- 多航点批量上传: 一次性上传多个航点,具有原子性
- 高效性能: 减少网络往返,提高上传效率
- 错误处理: 完善的超时和重试机制
- QGC兼容: 与QGroundControl完全兼容
- 灵活配置: 支持所有MAVLink航点参数
6.3 高级配置
自定义航点参数
# 创建发送器并连接
sender = PyMAVLinkWrapper("drone1")
await sender.connect("udp:127.0.0.1:14540")
# 自定义航点参数
sender.add_waypoint(
lat=39.9042,
lon=116.4074,
alt=50.0,
acceptance_radius=10 # 接受半径10米
)
不同命令类型示例
标准航点 (MAV_CMD_NAV_WAYPOINT)
# 创建发送器并连接
sender = PyMAVLinkWrapper("drone1")
await sender.connect("udp:127.0.0.1:14540")
# 标准航点,默认命令16
sender.add_waypoint(lat=39.9042, lon=116.4074, alt=50.0)
返回起飞点 (MAV_CMD_NAV_RETURN_TO_LAUNCH)
# 返回起飞点,命令20
sender.add_waypoint(
lat=0, lon=0, alt=0, # 坐标会被忽略
command=20, # MAV_CMD_NAV_RETURN_TO_LAUNCH
frame=3
)
降落命令 (MAV_CMD_NAV_LAND)
# 降落命令,命令21
sender.add_waypoint(
lat=39.9042, lon=116.4074, alt=0,
command=21, # MAV_CMD_NAV_LAND
frame=3,
hold_time=10.0 # 中止高度10米
)
起飞命令 (MAV_CMD_NAV_TAKEOFF)
# 起飞命令,命令22
sender.add_waypoint(
lat=39.9042, lon=116.4074, alt=50.0,
command=22, # MAV_CMD_NAV_TAKEOFF
frame=3,
hold_time=15.0, # 最小俯仰角15度
yaw=90.0 # 偏航角90度
)
无限盘旋 (MAV_CMD_NAV_LOITER_UNLIM)
# 无限盘旋,命令84
sender.add_waypoint(
lat=39.9042, lon=116.4074, alt=50.0,
command=84, # MAV_CMD_NAV_LOITER_UNLIM
frame=3,
hold_time=50.0 # 盘旋半径50米
)
指定时间盘旋 (MAV_CMD_NAV_LOITER_TIME)
# 指定时间盘旋,命令86
sender.add_waypoint(
lat=39.9042, lon=116.4074, alt=50.0,
command=86, # MAV_CMD_NAV_LOITER_TIME
frame=3,
hold_time=60.0, # 盘旋时间60秒
acceptance_radius=30.0 # 盘旋半径30米
)
调查任务示例
# 创建发送器并连接
sender = PyMAVLinkWrapper("drone1")
await sender.connect("udp:127.0.0.1:14540")
# 调查任务 - 多航点批量上传
survey_waypoints = [
# 起飞点
{"lat": 39.9042, "lon": 116.4074, "alt": 0, "command": 22, "hold_time": 0.0},
# 调查点1
{"lat": 39.9052, "lon": 116.4084, "alt": 50.0, "command": 16, "hold_time": 30.0},
# 调查点2
{"lat": 39.9062, "lon": 116.4094, "alt": 50.0, "command": 16, "hold_time": 30.0},
# 调查点3
{"lat": 39.9072, "lon": 116.4104, "alt": 50.0, "command": 16, "hold_time": 30.0},
# 返回起飞点
{"lat": 0, "lon": 0, "alt": 0, "command": 20, "hold_time": 0.0}
]
# 批量添加调查航点
for wp in survey_waypoints:
sender.add_waypoint(
lat=wp["lat"],
lon=wp["lon"],
alt=wp["alt"],
command=wp["command"],
hold_time=wp["hold_time"],
acceptance_radius=5.0
)
# 一次性上传所有调查航点
success = await sender.send_mission()
8. 依赖要求
pip install pymavlink
9. 注意事项
- 确保无人机已连接并GPS定位正常
- 检查MAVLink连接参数
- 航点坐标必须在有效范围内
- 高度设置要合理(避免碰撞)
- 接受半径要适中(避免过严导致盘旋)
- 必须先调用
await connect()方法建立连接 - 系统ID和组件ID会自动检测和设置
- 异步操作: 所有方法都是异步的,需要使用
await关键字 - 多航点上传: 大量航点(>100个)建议分批处理
- 原子性: 多航点上传具有原子性,全部成功或全部失败
- 性能: 多航点上传比单航点上传更高效
- 连接字符串: 默认使用
udp:127.0.0.1:14540,可根据需要修改 - 端口独占: 一个UDP端口通常只能被一个应用接收使用;多应用并行时请为每个应用分配不同端口(例如:QGC→14540,MAVSDK→14550,pymavlink→14600),或为其中一部分应用使用TCP端口。
10. 与 QGC 兼容性
本实现完全兼容QGroundControl:
- 使用相同的MAVLink消息格式
- 支持相同的航点参数
- 可以相互导入导出任务文件
- 实时同步任务状态
11. 命令参考表
11.1 快速参考
| 命令值 | 命令名称 | 常用场景 | 关键参数 |
|---|---|---|---|
| 16 | MAV_CMD_NAV_WAYPOINT | 标准航点导航 | param2: 接受半径 |
| 20 | MAV_CMD_NAV_RETURN_TO_LAUNCH | 紧急返航 | 无关键参数 |
| 21 | MAV_CMD_NAV_LAND | 降落 | param1: 中止高度 |
| 22 | MAV_CMD_NAV_TAKEOFF | 起飞 | param1: 俯仰角, param4: 偏航角 |
11.2 坐标系快速参考
| 坐标系值 | 坐标系名称 | 推荐使用场景 |
|---|---|---|
| 3 | MAV_FRAME_GLOBAL_RELATIVE_ALT | 标准任务(推荐) |
| 0 | MAV_FRAME_GLOBAL | 全球绝对高度任务 |
| 1 | MAV_FRAME_LOCAL_NED | 本地NED任务 |
| 6 | MAV_FRAME_GLOBAL_INT | 高精度全球任务 |
| 7 | MAV_FRAME_GLOBAL_RELATIVE_ALT_INT | 高精度相对高度任务 |
参考文档
MAVROS 与 MAVProxy:APM 平台上的使用场景、差异与取舍
本文围绕 ArduPilot/APM 飞控平台,系统梳理 MAVROS 与 MAVProxy 的定位、工作原理、典型使用场景、差异点与组合策略,并评估在不同任务形态下它们的必要性以及可替代方案。
flowchart TB
A["APM/ArduPilot 飞控<br/>(MAVLink)"] --> RP["MAVProxy(路由/桥接)"]
P["PX4 飞控<br/>(MAVLink)"] --> RR["MAVLink Router(路由)"]
RP --> O("MAVLink 扇出")
RR --> O
O --> Q["QGC/地面站"]
O --> S["MAVSDK 服务/后端"]
O --> M
%% ROS 子图(组合与数据流)
subgraph ROS
direction TB
M["MAVROS"]
RA["ROS 算法/感知/规划"]
Tpub["状态/位置话题<br/>(/mavros/state, /local_position, /imu 等)"]
Tsub["控制/Setpoint 话题<br/>(/setpoint_position, /setpoint_raw 等)"]
Tsensor["传感器话题<br/>(IMU/里程计/相机/视觉定位)"]
M --> Tpub
Tpub --> RA
RA --> Tsub
Tsub --> M
Tsensor --> RA
Tsensor --> M
end
%% 可选:飞控直连地面站(仅人控/参数/任务管理)
- 只需“把数据给多人看/多服务用”:用 MAVProxy(或 MAVLink Router)。
- 需要“在 ROS 内做算法并控制飞机”:用 MAVROS;若还要多路分发,再叠加 MAVProxy。
- 面向 APM 的通用稳健方案:APM → MAVLink 路由工具 →(QGC | MAVROS | MAVSDK…)。
1. 概念
- MAVLink:飞控与外部系统之间的轻量级消息协议。消息是结构化的二进制帧,包含系统/组件 ID、消息 ID、序号、时间戳等;通过串口或 UDP/TCP 承载。关键点是“谁与谁在对话、以何种传输介质与频率对话”。
- APM/ArduPilot:在飞控端负责生成/消费 MAVLink 消息(心跳、状态、位置、参数、模式、任务、RCIO 等);可配置消息流速率与输出端口。
- 典型链路:飞控串口/UDP → 路由/桥接(可选) → 地面站/算法/服务。路由关注“扇入/扇出与可靠转发”,桥接关注“协议域间的语义映射(如 MAVLink↔ROS)”。
2. MAVProxy:MAVLink 路由工具
2.1 概念
- 定位:轻量“地面站/路由工具”。核心目标是“从一个或多个输入稳定扇出到多个输出”,不改变 MAVLink 协议语义;由 ArduPilot 官方维护。
- 职责边界:以路由/转发为核心能力,其他能力按需启用。
2.2 工作机制
- 输入(master):显式指定串口或 UDP/TCP 源,只转发声明的输入。
- 输出(out):可并行配置多个 UDP/TCP 目的端,逐帧复制转发。
- 流控(streamrate):向飞控申请消息组频率;需与其他客户端协同,避免相互抢写导致抖动。
- 插件化:按需加载日志、地形、地理围栏、仿真辅助等模块。
- 功能要点:
- 稳定扇出与可观测:输出目的端清晰,异常易于定位(心跳/计数异常等)。
- 低侵入与易部署:无需 ROS/DDS 等环境,常驻机载计算机做统一扇出。
- 直接交互飞控:读写参数、切换模式、解锁/上锁。
- 任务/航点:任务上传下载与流程管理。
- 速率管理:统一申请与调整消息组频率,控制链路负载。
- 日志记录:事件/日志输出,便于复现与追踪。
- 地理与仿真:地理围栏、地形数据、SITL/联调辅助。
注意:关于 QGC 的 MAVLink 转发
QGC 的“MAVLink 转发”仅会将“QGC 接收到的 MAVLink 帧”单向转发到目标地址;它不会把来自该目标地址的控制/参数写入等指令再回送给飞控。因此,经由 QGC 转发链路发送控制命令通常会因无法到达飞控而超时。若需要可控的双向链路,请在飞控侧或近端使用 MAVProxy、MAVLink Router 等路由工具建立端到端会话。
2.3 典型使用场景
- 多下游并发:同一飞控流同时提供给 QGC、MAVSDK 服务、日志录制、状态监控。
- 网络解耦:在不改飞控输出的前提下,本机扇出多路流。
- 安全隔离:结合端口控制与防火墙/ACL 管控可达范围。
- 多飞控集中接入:一台伴随计算机统一接入多块飞控,集中扇出与会话管理。
- 蜂群/编队分发:为编队提供稳定的遥测与指令分发通道,易于规模化监控。
- 异构链路聚合:串口/UDP/蜂窝/以太网等多链路统一转发与限流。
- 伴随端常驻:作为“入口与分发”常驻机载,向地面站、算法、日志稳定供数。
- 诊断与复现:配合日志/事件与可观测性指标,快速定位断流、环路与高负载。
2.4 优势与局限
- 优势:简单可靠、部署门槛低、与 APM 生态契合度高、职责边界清晰。
- 局限:无语义映射与高层控制 API;需配合其他组件完成算法/定位/融合。
2.5 MAVProxy 与 PX4
- MAVProxy 是基于 MAVLink 协议层的路由/地面站工具,由 ArduPilot 社区维护;并非 APM 专属。任何产出/消费 MAVLink 的系统(APM、PX4、SITL、外设网关等)都可接入与转发。
- PX4 官方推荐在机载计算机侧采用其自有框架与工具链进行路由/桥接,例如 MAVLink Router(推荐)或基于 microROS/uXRCE-DDS 的通信栈,而非 MAVProxy。本推荐见 PX4 官方“机载电脑”文档。
- MAVProxy 能够稳定转发 PX4 的遥测与指令流;实际工程中仍应优先评估 PX4 推荐方案,只有在需要命令行交互、插件生态与现场诊断等特性时再考虑使用 MAVProxy。
- 建议:
- 以 PX4 官方支持路径、长期维护与系统服务形态为优先:倾向 MAVLink Router 或 uXRCE-DDS。
- 需要命令行交互、插件能力与快速诊断:可选 MAVProxy(注意协调 SYSID/COMPID、速率与签名/隔离策略)。
- 注意:对 PX4 而言,MAVProxy“可用但非官方推荐/非必要”;对 APM 生态契合度最高,但并非唯一可选的路由工具。
3. MAVROS:ROS↔MAVLink
3.1 概念
- MAVROS 是 ROS 的一个软件包,用于在 ROS 与支持 MAVLink 的飞控之间进行通信与语义映射;是连接 ROS 与 PX4/APM 的关键桥梁,使开发者可直接在 ROS 中以话题/服务的形式使用飞控能力。
- 若不使用 MAVROS,开发者需自行解析 MAVLink 并构建框架,无法复用 ROS 的大量工具链与第三方算法生态;使用 MAVROS 可显著降低集成成本与学习曲线。
3.2 工作机制
- 插件化:如 state、setpoint_position、setpoint_raw、global_position、param、cmd 等,分别负责子域消息的解码、校验与发布/订阅。
- 语义映射:将 MAVLink 原语映射为 ROS 常用消息类型(geometry_msgs、sensor_msgs 等)。
- 控制与时间/坐标:提供 Offboard 控制接口;可启用时间同步与 TF/外参管理以保证闭环稳定。
3.3 典型使用场景
- Offboard/伴随计算:在 ROS 中执行路径规划、轨迹跟踪、任务编排,通过 setpoint 接口控制飞控。
- 传感器/定位融合:将里程计/SLAM/视觉定位等结果映射到飞控或用于外环控制。
- 仿真闭环:在 Gazebo/Ignition 等仿真环境中与 PX4/APM 形成闭环,快速迭代算法与任务逻辑。
3.4 优势与局限
- 优势:强语义桥接、算法栈与工具链丰富、成熟的 Offboard 接口与可视化支持(RViz、rqt 等)。
- 局限:引入 ROS 运行时的资源与复杂度;需管理话题队列、调度与时延;多客户端需协调参数写入与消息速率,避免竞争。
3.5 MAVROS 与 PX4/APM
- 同时支持 PX4 与 APM,是两者与 ROS 之间的主流桥接路径之一。
- 在 PX4 侧,亦可选用 uXRCE-DDS(ROS2)等官方通信路径;选择取决于算法栈与系统架构。
- 与 MAVProxy 的关系:二者互补。常见组合为“APM/PX4 → 路由(MAVProxy/Router)→ MAVROS/算法栈”。
4. 场景对比与实践策略
- 定位差异:
- MAVProxy:面向链路与路由,最小可行转发;不改变消息语义。
- MAVROS:面向算法与应用,完成协议域到 ROS 域的语义投射与控制接口。
- 部署复杂度:MAVProxy < MAVROS(需要 ROS 运行时与插件管理)。
- 资源占用:MAVProxy 轻;MAVROS 随插件与话题规模增长。
- 实时性/时延:
- 路由层(MAVProxy)时延极低,主要受网络与系统负载影响。
- ROS 桥接(MAVROS)受调度、话题队列、时间同步影响,需工程化调优(CPU 亲和、QoS/队列深度、发布频率)。
- 容错与观测:
- MAVProxy 输出侧可快速定位断流或下游异常。
- MAVROS 通过 ROS 工具链(rqt、roswtf、tf 树)进行问题定位,粒度更细但系统更复杂。
4.1 使用建议
- 何时用 MAVProxy:
- 需要“把飞控数据转给若干消费方”,且无 ROS 依赖。
- 需要在机载端稳定地“一个输入、多输出”扇出,减少对飞控端改动。
- 何时用 MAVROS:
- 主要运行环境在 ROS 内:路径规划、定位融合、任务执行、仿真闭环等。
- 不需要对外再扇出多条原始 MAVLink 流,或扇出可由 ROS 内部其他方式满足。
- 何时组合使用:
- 在机载端用 MAVProxy 统一从飞控读取并扇出:一支给 QGC/监控,另一支给本机或远端的 MAVROS/算法栈。
- 这样可以把“链路可用性/端口安全”与“算法语义处理”解耦,减少相互干扰。
4.2 可替代/互补方案对比
- MAVLink Router(mavlink-routerd):
- 职能与 MAVProxy 的“路由/扇出”类似,C 实现,资源占用更低,配置文件式管理,常见于 PX4,但同样适用 ArduPilot。
- 优点:性能优、守护进程形态、基于规则的管控;缺点:交互/调试能力不如 MAVProxy 命令行直观。
- MAVSDK / MAVSDK-Server:
- 面向应用开发的高层 API(C++/Python/Go 等),便于快速构建控制/任务逻辑。
- 与 MAVProxy/MAVROS 并不冲突:常见架构是“APM→路由→MAVSDK、QGC 等并列消费”。
- cmavnode / 自研网关:
- 轻量桥接/转发工具;适合极小型系统或定制需求,但生态/文档沉淀一般。
- 直接地面站(QGC/Mission Planner):
- 仅人控与参数/任务管理时可以直连;不解决“多消费者并发”“程序化接口”问题。
4.3 工程选择建议
- 若主要诉求是“稳定扇出”与“运维可控”,首选 MAVProxy 或 MAVLink Router(择其一)。
- 若主要诉求是“ROS 内算法闭环”,首选 MAVROS;是否叠加路由视是否有多消费者与安全隔离需求而定。
5. 项目实践的思考
- 链路梳理:明确输入端(串口/UDP 单播/广播)、下游清单(QGC、服务、算法端)。
- 频率与带宽:
- 统一在路由侧申请/调整流速,避免多个客户端抢写导致消息抖动。
- 关注高频消息(姿态/位置/IMU),在无线链路下控制总体带宽与丢包敏感度。
- ID 与签名:
- 多客户端写参数/控制时,确保系统/组件 ID 唯一;在需要时启用 MAVLink 签名与网络隔离。
- 资源与实时性:
- 在机载电脑上为路由与关键节点设定 CPU 亲和与优先级,保持时延稳定。
- 可观测性与排障:
- 路由侧:定期检查下游端口存活状态、丢包与心跳计数;
- ROS 侧:关注时间同步、话题队列、TF 树一致性与控制回路频率。
参考文档
Backend
Frontend
自定义 NiceGUI 中 Leaflet 的 marker 样式和旋转
本文档详细描述了在NiceGUI框架中实现JavaScript Bridge架构的方法,该架构通过Python封装JavaScript代码,实现对Leaflet地图插件的样式定制和功能扩展。最终实现了替换NiceGUI中Leaflet标记样式,新增无人机和人的标记,并通过对象管理所有标记。
![]()
技术栈
- 前端: NiceGUI + Leaflet + JavaScript
- 后端: Python
- 通信: JavaScript Bridge
- 样式: CSS + SVG图标
架构设计
整体架构图
graph TB
subgraph "Python 应用层"
A[MarkerManager] --> B[状态管理]
A --> C[方法封装]
A --> D[事件处理]
end
subgraph "NiceGUI Bridge Layer"
E[ui.run_javascript] --> F[ui.leaflet]
F --> G[事件监听]
end
subgraph "JavaScript 层"
H[marker.js] --> I[标记管理]
H --> J[样式定制]
H --> K[事件分发]
end
subgraph "Leaflet 插件层"
L[地图渲染] --> M[标记显示]
M --> N[交互处理]
end
A --> E
E --> H
H --> L
G --> A
K --> G文件关联关系
graph LR
subgraph "项目根目录"
A[config.yaml]
B[ui/config.yaml]
end
subgraph "UI模块"
C[ui/main_page.py]
D[ui/map.py]
E[ui/marker.py]
F[ui/styles.py]
end
subgraph "静态资源"
G[ui/static/marker.js]
H[ui/static/drone.svg]
I[ui/static/person.svg]
end
A --> C
B --> C
C --> D
C --> E
C --> F
D --> G
E --> G
F --> H
F --> I数据流图
sequenceDiagram
participant P as Python应用
participant N as NiceGUI
participant J as JavaScript
participant L as Leaflet
P->>N: 创建地图组件
N->>L: 初始化地图
L-->>N: 地图就绪
N-->>P: 返回地图引用
P->>N: 创建MarkerManager
N->>J: 调用initMarkers()
J->>L: 创建FeatureGroup
L-->>J: 返回FeatureGroup
J-->>N: 初始化完成
N-->>P: 包装器就绪
P->>N: 调用move_to()
N->>J: 执行moveMarker()
J->>L: 更新标记位置
L-->>J: 更新完成
J-->>N: 操作完成
N-->>P: 状态同步核心实现
JavaScript层实现 (marker.js)
状态管理架构
classDiagram
class MarkersState {
+boolean initialized
+FeatureGroup featureGroup
+DivIcon icon
+Object byId
+Map map
}
class MarkerFunctions {
+initMarkers(mapId)
+addMarker(mapId, id, lat, lng, heading, z, className, label)
+updateMarker(id, lat, lng, heading, z, label)
+deleteMarker(id)
+setClass(id, className)
}
MarkersState --> MarkerFunctionsPython包装器实现 (marker.py)
类设计架构
classDiagram
class MarkerManager {
+ui.leaflet map_element
+str marker_id
+float lat
+float lng
+float heading
+str class_name
+str label
+int z_index
+boolean _added
+__init__(map_element, marker_id, lat, lng, heading, class_name, label, z_index)
+add()
+move_to(lat, lng, heading)
+rotate_to(heading)
+set_class(class_name)
+remove()
+create_drone()
+create_person()
}
class MarkerFactory {
+create_drone(map_element, marker_id, lat, lng, heading, label, z_index)
+create_person(map_element, marker_id, lat, lng, heading, label, z_index)
}
MarkerManager --> MarkerFactory样式系统实现 (styles.py)
样式注入流程
flowchart TD
A[应用启动] --> B[调用apply_global_styles]
B --> C[ui.add_body_html]
C --> D[注入CSS样式]
D --> E[定义图标样式]
E --> F[设置容器样式]
F --> G[配置响应式设计]
G --> H[样式生效]使用示例
批量操作示例
# 创建多个标记
markers = {}
for i in range(3):
marker_id = f"drone_{i:03d}"
markers[marker_id] = MarkerManager.create_drone(
map_element=map_element,
marker_id=marker_id,
lat=39.9042 + i * 0.001,
lng=116.4074 + i * 0.001,
heading=i * 45.0,
label=f"无人机{i+1}"
)
# 批量更新
for marker in markers.values():
marker.move_to(new_lat, new_lng, new_heading)
扩展性设计
新标记类型扩展
扩展流程
flowchart TD
A[设计新图标] --> B[创建SVG文件]
B --> C[添加CSS样式]
C --> D[扩展JavaScript函数]
D --> E[扩展Python工厂方法]
E --> F[测试验证]
F --> G[文档更新]样式扩展示例
.target-icon {
width: 32px;
height: 32px;
background-image: url('/static/target.svg');
background-size: contain;
background-repeat: no-repeat;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
}
.target-icon-animated {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
动画效果扩展
动画系统架构
graph TB
subgraph "动画类型"
A[脉冲动画]
B[旋转动画]
C[闪烁动画]
D[缩放动画]
end
subgraph "控制层"
E[CSS动画]
F[JavaScript控制]
G[Python接口]
end
subgraph "触发条件"
H[状态变化]
I[用户交互]
J[定时触发]
end
A --> E
B --> E
C --> E
D --> E
E --> F
F --> G
H --> G
I --> G
J --> G事件系统扩展
事件流架构
sequenceDiagram
participant U as 用户
participant L as Leaflet
participant J as JavaScript
participant N as NiceGUI
participant P as Python
U->>L: 鼠标悬停
L->>J: 触发mouseover事件
J->>J: handleMarkerHover()
J->>N: emitEvent('marker-hovered')
N->>P: 事件回调
P->>P: 处理业务逻辑配置管理
配置层次结构
graph TB
subgraph "全局配置 config.yaml"
A[地图配置]
B[无人机配置]
C[系统配置]
end
subgraph "UI配置 ui/config.yaml"
D[界面设置]
E[样式配置]
F[调试选项]
end
subgraph "运行时配置"
G[动态参数]
H[用户偏好]
I[性能设置]
end
A --> G
D --> G
B --> H
E --> H
C --> I
F --> I配置示例
# config.yaml
map:
center_lat: 39.9042
center_lng: 116.4074
zoom_level: 12
bing_key: "your_bing_maps_key"
markers:
default_size: [32, 32]
default_anchor: [16, 16]
animation_enabled: true
batch_update_threshold: 10
# ui/config.yaml
ui:
mouse_debug_window: false
marker_animations: true
performance_mode: false
styles:
drone_icon_size: [32, 32]
person_icon_size: [28, 28]
target_icon_size: [24, 24]
代码段参考
1. JavaScript层核心实现 (marker.js)
自定义图标创建
function addMarker(mapId, id, lat, lng, heading = 0, z = 0, className = 'drone-icon', label = '') {
// 创建自定义DivIcon
var IconCtor = L.DivIcon.extend({
options: {
className: className, // CSS类名控制样式
iconSize: [32, 32], // 图标尺寸
iconAnchor: [16, 16], // 锚点位置
popupAnchor: [0, -16], // 弹窗位置
html: label // 动态标签
}
});
var iconx = new IconCtor();
var marker = L.marker([lat, lng], {
icon: iconx,
zIndexOffset: z,
rotationAngle: heading, // 旋转角度
rotationOrigin: '16px 16px', // 旋转中心点
}).addTo(markers.featureGroup);
marker._id = id;
markers.byId[id] = marker;
}
标记旋转更新
function updateMarker(id, lat, lng, heading = null, z = null, label = null) {
var marker = markers.byId[id];
if (!marker) return;
if (lat !== null && lng !== null) marker.setLatLng([lat, lng]);
if (heading !== null && typeof marker.setRotationAngle === 'function')
marker.setRotationAngle(heading); // 更新旋转角度
if (z !== null) marker.setZIndexOffset(z);
if (label !== null) {
// 更新图标标签
var currentIcon = marker.options.icon;
var newIcon = L.divIcon({
className: currentIcon.options.className,
iconSize: currentIcon.options.iconSize,
iconAnchor: currentIcon.options.iconAnchor,
popupAnchor: currentIcon.options.popupAnchor,
html: label
});
marker.setIcon(newIcon);
}
}
2. Python包装器实现 (marker.py)
样式类切换
def set_class(self, class_name: str):
"""切换样式类"""
self.class_name = class_name
with self.map_element:
ui.run_javascript(f"setClass('{self.marker_id}', '{self.class_name}')")
def rotate_to(self, heading: float):
"""仅更新朝向"""
self.heading = heading
with self.map_element:
ui.run_javascript(f"updateMarker('{self.marker_id}', null, null, {self.heading})")
工厂方法 - 预设样式
@classmethod
def create_drone(cls, map_element: ui.leaflet, marker_id: str, lat: float, lng: float,
heading: float = 0, label: str = '', z_index: int = 0):
"""创建无人机标记 - 使用drone-icon样式"""
return cls(map_element, marker_id, lat, lng, heading, 'drone-icon', label, z_index)
@classmethod
def create_person(cls, map_element: ui.leaflet, marker_id: str, lat: float, lng: float,
heading: float = 0, label: str = '', z_index: int = 0):
"""创建人员标记 - 使用person-icon样式"""
return cls(map_element, marker_id, lat, lng, heading, 'person-icon', label, z_index)
3. 样式系统实现 (styles.py)
图标样式定义
def apply_global_styles():
"""应用全局样式"""
ui.add_body_html('''
<style>
/* 无人机图标样式 */
.drone-icon {
width: 32px;
height: 32px;
background-image: url('/static/drone.svg');
background-size: contain;
background-repeat: no-repeat;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
}
/* 人员图标样式 */
.person-icon {
width: 28px;
height: 28px;
background-image: url('/static/person.svg');
background-size: contain;
background-repeat: no-repeat;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
}
/* 激活状态样式 */
.drone-icon-active {
filter: drop-shadow(0 2px 4px rgba(255,0,0,0.5)) brightness(1.2);
}
/* 动画效果 */
.drone-icon-animated {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
</style>
''')
4. 地图组件封装 (map.py)
旋转插件集成
def create_full_page_map(self):
"""创建支持旋转的地图"""
additional_resources = [
'https://unpkg.com/leaflet-rotatedmarker@0.2.0/leaflet.rotatedMarker.js', # 旋转插件
]
map_element = ui.leaflet(
center=[self.center_lat, self.center_lng],
zoom=self.zoom_level,
additional_resources=additional_resources # 加载旋转插件
).classes('map-container')
return map_element
5. 使用示例
样式和旋转操作
# 创建带样式的标记
drone = MarkerManager.create_drone(
map_element=map_element,
marker_id="drone_001",
lat=39.9042,
lng=116.4074,
heading=45.0, # 初始朝向
label="无人机1"
)
# 更新旋转角度
drone.rotate_to(90.0)
# 切换样式类
drone.set_class("drone-icon-active") # 激活状态
drone.set_class("drone-icon-animated") # 动画效果
6. 样式配置
图标尺寸配置
styles:
drone_icon_size: [32, 32] # 无人机图标尺寸
person_icon_size: [28, 28] # 人员图标尺寸
icon_anchor: [16, 16] # 图标锚点
rotation_origin: "16px 16px" # 旋转中心点
参考文档
Aircraft Simulation
AirSim Agent 大模型驱动无人机
AirSim Agent来源于微软开源项目PromptCraft-Robotics ,提供了由大模型驱动机器人的解决方案。
1. AirSim 安装、编译和使用尝试
1.1 开发环境与编译平台建议
- 硬件/系统
- 建议 ≥16GB 内存;Windows 11 优先。Mac/Linux 需自行编译 AirSim。
- Python 与工具
- 使用 conda 与 JupyterLab,IDE 推荐 PyCharm
- 创建/启用环境与安装:
conda create -n airsim_agent python=3.10
conda activate airsim_agent
pip install jupyterlab
- 克隆本仓库后,用 PyCharm 打开项目根目录。
- 大模型 API
- 任选兼容 OpenAI SDK 的平台(如火山方舟、阿里云、腾讯云等)。
- 依赖冲突提示
- AirSim 的 tornado 与 JupyterLab 可能冲突,不建议 pip install airsim。
- 采用本地包引入:
import sys
sys.path.append('../external-libraries') # 或绝对路径
import airsim
- 编译与平台建议
- Windows:优先使用现成可执行场景(无需源码编译),上手最快。 - Linux/macOS:按官方文档编译 AirSim 与 UE 插件,或参考 UE5 社区分支(如 Cosys-AirSim、Colosseum)以适配新平台。
- 文档参考:https://github.com/Microsoft/AirSim/blob/main/docs/
1.2 AirSim 仿真场景搭建
简介:基于 Unreal Engine,支持无人机/车辆与多传感器,适配 HIL/SIL;适合数据生成与高风险场景复现。
现状:官方仓库已归档但可用;可参考 UE5 社区分支(Cosys-AirSim、Colosseum)。
推荐场景:论文《ChatGPT for Robotics: Design Principles and Model Abilities》配套环境
- 下载:https://github.com/microsoft/PromptCraft-Robotics/releases/tag/1.0.0
- 解压后直接运行,适合快速上手。
- 参考链接
- Releases:https://github.com/microsoft/airsim/releases
- 文档:https://microsoft.github.io/AirSim/
1.3 无人机基本控制
- 连接与初始化
import sys
sys.path.append'../external-libraries')
import airsim
client = airsim.MultirotorClient() # ip 不写是本地
client.confirmConnection()
client.enableApiControl(True)
client.armDisarm(True)
- 起降与轨迹
client.takeoffAsync().join()
client.moveToZAsync(-3, 1).join() # NED 坐标,Z 负向为上
client.moveToPositionAsync(5, 0, -3, 1).join() # 航点飞行
client.moveOnPathAsync([airsim.Vector3r(5,0,-3), ...], 1).join()
client.landAsync().join()
client.armDisarm(False)
client.enableApiControl(False)
- 状态获取对比
- simGetVehiclePose():传感器级位姿,可能含噪声;拟真。
- simGetGroundTruthKinematics():物理引擎真值(含速度/加速度);用于控制/验证。
- 注意事项
- 异步 API 多为 ...Async(),需 .join() 串联确保动作顺序。
- 坐标系为 NED:moveToZAsync(-3, ...) 表示上升到 3 米(Z 取负)。
1.4 视觉感知与图像采集
- 相机与类型
- 位置:front_center/front_right/front_left/bottom_center/back_center(兼容旧 ID "0"~"4")。
- 类型:Scene,DepthPlanar,DepthPerspective,DepthVis,Segmentation,SurfaceNormals,Infrared 等。
- 采集示例(OpenCV + Matplotlib)
import cv2, time, numpy as np, matplotlib.pyplot as plt
from airsim import ImageType
client = airsim.MultirotorClient(); client.confirmConnection()
client.enableApiControl(True); client.armDisarm(True)
client.takeoffAsync().join()
camera_name = '0' # 或 'front_center'
image_type = ImageType.Scene
resp = client.simGetImage(camera_name, image_type)
if resp:
img_bgr = cv2.imdecode(np.frombuffer(resp, np.uint8), cv2.IMREAD_UNCHANGED)
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
plt.imshow(img_rgb); plt.axis('off'); plt.show()
client.landAsync().join()
client.armDisarm(False); client.enableApiControl(False)
- 注意事项
- 并发:多线程/多进程均可,但每个线程/进程需独立创建 MultirotorClient,不可共享。
- 显示:Notebook 用 Matplotlib;桌面窗口显示可用 a_cv2_imshow_thread。
1.5 多无人机控制
- 多机配置
- 将 1-airsim_basic/settings.json 复制为:
- Windows:C:\Users\<用户名>\Documents\AirSim\settings.json
- 重启模拟器生效。
- 控制要点
- 在 API 调用中通过 vehicle_name="UAV1"(或 "UAV2", "UAV3")区分不同无人机。
- 示例(并发起飞/定高):
client = airsim.MultirotorClient()
for i in range(3):
name = f"UAV{i+1}"
client.enableApiControl(True, name)
client.armDisarm(True, name)
client.takeoffAsync(vehicle_name=name)
for i in range(3):
name = f"UAV{i+1}"
client.moveToZAsync(-3, 1, vehicle_name=name)
- 注意事项
- 名称需与 settings.json 保持一致;多机并发建议为每机建立独立控制流程。
- 协同/编队时注意全局 NED 与机体坐标的转换。
1.6 快速上手流程
conda 创建
airsim_agent(Python 3.10),安装jupyterlab。克隆Airsim Agent仓库,打开项目根目录。
下载 PromptCraft-Robotics 场景并解压运行:
https://github.com/microsoft/PromptCraft-Robotics/releases/tag/1.0.0在
1-airsim_basic/3-airsim_basic.ipynb执行连接/起飞/轨迹/降落。在
1-airsim_basic/4-airsim_camera.ipynb采集与显示图像。复制
1-airsim_basic/settings.json至用户目录,按需多机控制。
2. 指令封装和OpenAI SDK调用
2.1 SDK封装设计与规则
目标:将底层 AirSim API 语义化、原子化,便于大模型/自然语言直接调用
封装规则
- 语义化接口:内部隐藏 NED 坐标 转换,对外使用直观语义(如 fly_to([x,y,正高度]) 自动处理 Z 符号)
- 功能原子化:每个方法只做一件事(如 takeoff,land,set_yaw,fly_to)
- 参数/返回标准化:统一使用简单类型(如 get_drone_position()->[x,y,z],get_yaw()->角度)
- 异步转同步:统一 .join(),保证顺序执行,减少并发不确定性
- 异常与缺省:适度重试/返回安全缺省值(如 get_position 等待对象可用)
- 单位一致性:内部弧度/角度转换,对外一律角度(如 get_yaw() 返回“度”)
- 对象别名映射:objects_dict 统一自然语言到 UE 对象名(如 "car"->"StaticMeshActor_10")
对象映射
{"turbine1":"BP_Wind_Turbines_C_1","car":"StaticMeshActor_10","solarpanels":"StaticMeshActor_146",...}典型方法(节选)
class AirSimWrapper:
def __init__(self):
self.client = airsim.MultirotorClient()
self.client.confirmConnection()
self.client.enableApiControl(True)
self.client.armDisarm(True)
def takeoff(self):
self.client.takeoffAsync().join()
def land(self):
self.client.landAsync().join()
def fly_to(self, point: list[float]):
z = -point[2] if point[2] > 0 else point[2]
self.client.moveToPositionAsync(point[0], point[1], z, 5).join()
def get_drone_position(self) -> list[float]:
pose = self.client.simGetVehiclePose()
return [pose.position.x_val, pose.position.y_val, pose.position.z_val]
def set_yaw(self, yaw_degree: float):
self.client.rotateToYawAsync(yaw_degree, 5).join()
- 注意事项
- NED 坐标统一语义:向上飞 Z 减小;封装中已处理,外部仅关注“正高度”表达
- 顺序控制:连续动作务必 .join();复杂流程拆小步更稳健
2.2 OpenAI 等 SDK 调用
- 客户端初始化(OpenAI 协议兼容,含国产云)
import os
from openai import OpenAI
API_KEY = os.getenv("ARK_API_KEY")
client = OpenAI(
base_url="https://ark.cn-beijing.volces.com/api/v3",
api_key=API_KEY,
)
- 非流式调用示例
completion = client.chat.completions.create(
model="doubao-1-5-pro-32k-250115",
messages=[{"role":"user","content":"常见无人机仿真系统有哪些?"}],
temperature=0.1,
)
print(completion.choices[0].message.content)
- 角色与多轮对话
- system:设定身份/约束(如仅输出 Python 代码块)
- assistant:历史回复纳入 messages 维护上下文
- 截断策略:messages = chat_history[-10:] 控制 Token 消耗
- 代码片段抽取
import re
def extract_python_code(content: str) -> str|None:
blocks = re.findall(r"```(.*?)```", content, flags=re.DOTALL)
if not blocks: return None
code = "\n".join(blocks)
return code[7:] if code.startswith("python") else code
- 参数建议与注意
- temperature 低值(0.1~0.3)更稳健;配合 max_tokens 控制成本;必要时 top_p 微调多样性
- 密钥安全:使用环境变量;避免明文
- 长对话优化:定期截断/摘要,降低成本与延迟
- 错误处理:区分 API 错误/限流并退避重试
2.3 提示词工程与函数调用
- 核心做法
- 结构化提示:角色设定(system)+ 能力边界 + 输出格式约束(仅代码块)
- 函数白名单:明确可调用函数/参数/返回,降低幻觉与越权
- 错误模板:参数校验/错误码标准化返回,便于自动化处理
- 函数描述模板(简版)
aw.takeoff() - 起飞无人机
aw.land() - 无人机着陆
aw.get_drone_position() -> [x,y,z]
aw.fly_to([x,y,z]) - 飞到目标点(NED 已封装)
aw.get_position(object_name) -> [x,y,z]
- 方式对比(要点)
- Prompt 直控:快、灵活,但易失控
- Function Calling:可控性强、工程化好,需前期函数设计
- MCP:多模型/多工具协作标准化,复杂度更高
2.4 知识库构建
- 目录与角色
- system_prompts/:系统级角色设定(行为边界、输出格式、可用库、禁止事项)
- prompts/:领域知识/功能清单(函数白名单、对象名称映射、坐标与运动语义、示例)
- 内容关键点
- 角色设定:仅输出 Python 代码块;允许 math,numpy;使用已定义的 aw 对象
- 函数白名单:列出 aw.takeoff/land/fly_to/get_position/get_drone_position/set_yaw/... 的参数与返回
- 对象映射:提供“中文目标名 ↔ 英文内部名”(与 objects_dict 同步),如“汽车 ↔ car”
- 坐标语义:声明 NED 规则与“向上/Z 减小”的高度表达;给出相对运动示例(YZ 平面、三角函数)
- 输出格式:仅代码块,或“代码 + 一句话用途说明”
- 风控约束:禁止文件/网络/系统命令;只调用白名单函数
- 示例(节选,
prompts/aisim_lession24.txt)
以下函数可用:
aw.takeoff();aw.land();aw.get_drone_position()->[x,y,z];
aw.fly_to([x,y,z]);aw.get_position(object_name)->[x,y,z];aw.set_yaw(yaw_deg)
对象映射(中文→英文):
汽车→car;风力发电机1→turbine1;太阳能电池板→solarpanels;塔1→tower1 …
坐标/运动规则:
使用 NED;向上飞 Z 减小;YZ 平面相对位移可用三角函数计算
输出格式:
```python
aw.takeoff()
- 加载方式
- AirSimAgent(knowledge_prompt="prompts/aisim_lession24.txt", system_prompts="system_prompts/airsim_basic_cn.txt")
- 更新知识库后,重新初始化 AirSimAgent 生效
- 编写建议
- 精炼:避免冗长描述,控制在数百行内,降低 Token 压力
- 可维护:分文件管理场景/任务;统一对象命名与坐标规则
- 一致性:与 airsim_wrapper.py 方法签名、objects_dict 保持一致
2.4 基本飞行控制示例
- Agent 封装(
airsim_agent.py)
from openai import OpenAI
class AirSimAgent:
def __init__(self, system_prompts="system_prompts/airsim_basic_cn.txt",
knowledge_prompt="prompts/aisim_basic_cn.txt", chat_history=None):
self.client = OpenAI(base_url="https://ark.cn-beijing.volces.com/api/v3", api_key=API_KEY)
self.chat_history = []
# 省略:载入 system 与知识库提示
def ask(self, prompt: str) -> str:
# 省略:构造 messages 并调用 chat.completions.create
...
def extract_python_code(self, content: str) -> str|None:
# 同上文
...
def process(self, command: str, run_python_code=False) -> str|None:
resp = self.ask(command)
code = self.extract_python_code(resp)
if run_python_code and code:
exec(code)
return code
- 飞到汽车上方(示例)
car_pos = aw.get_position("car")
target = [car_pos[0], car_pos[1], car_pos[2] - 3] # NED:上方 3m -> Z 减小
aw.fly_to(target)
- 注意事项
- 知识库中声明 对象中英映射 与 NED 规则
- 执行模式可切换:调试(只生成)/实操(生成并执行)
2.5 复杂指令:风力发电机检查
- 任务模式
- 单步指令 + 等待下一步(人工在回路)、简单 Workflow、Agent 自主执行
- 典型步骤
- 靠近 turbine1/turbine2,沿 X 轴保持距离;设置高度(Z 为负表示上方)
- 叶片检查:上/右下/左下等相对位移,使用三角函数在 YZ 平面 规划
- 机头朝向:set_yaw(角度)
- 注意事项
- 相同类型对象需澄清(两台涡轮机、三座塔);未指明时请求澄清,避免假设
- 始终以 NED 为基准描述相对运动,确保高度/方向一致性
2.6 完整任务:太阳能发电矩阵巡检
目标:在阵列上方 5m 执行“割草机”航迹(蛇形扫掠),覆盖 10 行
关键设定
- 面板宽 30m(X)、长 50m(Y),按长度分 10 行
- 规则:行末换向,右端下移一行再左扫;左端下移一行再右扫,直到完成
- 示例要点
- 基于当前位置,明确“向右/向左/向前/向后”的坐标增减规则(在知识库中定义)
- 保持 恒定高度 5m(NED:Z 固定为 -5)
- 注意事项
- 逐段生成与执行更稳健:便于监控、容错与现场修订
- 若引入视觉/定位闭环,可在入列/行末对齐处加入校正点
3. 思考:历史对话数据处理的可能性
3.1 目标与约束
目标:在保证无人机控制上下文正确性的前提下,尽量减少对话上下文体积,提升响应速度与吞吐。
约束:AirSim 场景知识与 API 清单相对稳定,宜在初始化阶段外置加载,运行期不重复注入。
3.2 核心策略
- 上下文最小化
- 仅保留最近 K 轮有效对话(如 4~8 轮),并固定保留首条 system。
- 将长历史进行状态化摘要(如 pos、goal、step、alt、yaw 等短键 JSON),以后续请求用“状态+新指令”替代完整历史。
- 消息归一与去重:术语固定表达(如“向上飞 Z 减小”),移除重复确认/日志,减少冗余。
- 知识外置与结构化输出
- 将函数白名单、对象映射、NED 规则等固定知识放入 system_prompts/ 与 prompts/,仅初始化注入一次。
- 强制仅输出代码块或结构化 JSON,弱化自然语言解释,减少无效 Token。
- 函数调用优先:用参数承载上下文,减少长文本描述;统一 aw.* 代码风格,压缩差异化开销。
- 缓存与执行控制
- 会话/跨会话缓存:对“同指令→同代码”结果命中即复用;将“后空翻/割草机”等常用流程抽为宏指令。
- 分步生成与执行:长流程拆分多步,降低单次上下文体积与失败成本。
- 参数与响应控制:低温度(0.1~0.3)、限制 max_tokens,必要时小幅 top_p;区分 API 错误/限流并做退避重试。
4. 大模型知识库构建
4.1 通用层级与加载策略
- 分层组织
- system:角色、能力边界、输出格式、可用库与风控
- domain:领域知识(函数白名单、对象/概念映射、坐标/规则、任务宏)
- 加载与调用
- 初始化一次加载 system+domain
- 运行期仅携带“状态摘要 JSON + 当前指令”
- 输出约束
- 仅输出代码块/结构化 JSON,参数承载语义
4.2 对象/概念映射(静态范式)
# 静态映射(示例)
OBJECTS = {
"turbine1": "UE_Turbine_A",
"turbine2": "UE_Turbine_B",
"solarpanels": "UE_Solar_Array",
"car": "UE_Car_A",
}
def resolve_position(env, name: str) -> list[float]:
"""根据通用名称解析环境内部对象并返回位姿[x,y,z]"""
query = OBJECTS[name] + ".*"
cand = []
while not cand:
cand = env.list_objects(query)
pose = env.get_pose(cand[0])
return [pose.x, pose.y, pose.z]
4.3 多模态与语音集成(视觉/ASR/TTS)
视觉:拍图 → VLM
这是最直接的多模态应用,将无人机摄像头捕获的图像交由视觉语言模型(VLM)进行理解,可以用于场景描述、目标清点等任务。
# 视觉:拍图→VLM
img = camera.capture() # bytes
b64 = base64.b64encode(img).decode()
resp = llm.chat.completions.create(
model="vision-model",
messages=[{"role":"user","content":[
{"type":"text","text":"列出清晰可见目标"},
{"type":"image_url","image_url":{"url":f"data:image/png;base64,{b64}"}}]}],
temperature=0.01
)
print(resp.choices[0].message.content) # 目标列表
视觉:深度相机定位
原理与实现
在 AirSim 中,我们无需模拟复杂的双目立体匹配,因为其渲染引擎能直接生成像素级的深度图,这极大地简化了定位过程。AirSim 提供两种主要的深度图类型:DepthPlanar 和 DepthPerspective。
DepthPlanar:图中每个像素的值代表该点到相机投影平面的垂直距离。DepthPerspective:图中每个像素的值代表该点到相机光心(即相机本身)的直线距离。
结合这两种深度图,我们可以精确计算出任何一个像素点相对于相机的三维坐标。具体流程是:
使用目标检测模型(如 GroundingDINO)在彩色图中找到目标的边界框(Bbox)。
取边界框的中心点
(cx, cy)作为目标在图像上的位置。从
DepthPlanar图中查询(cx, cy)处的深度值d_plane。从
DepthPerspective图中查询(cx, cy)处的深度值d_cam(这即是目标到相机的直线距离)。利用
d_plane和d_cam,通过三角关系计算出目标相对于相机中心线的偏航角。
# 视觉:单目深度估计 → 距离/角度
scene, depth_planar, depth_persp = sensor.capture_multi()
(xmin,ymin,xmax,ymax) = detect_bbox(scene)
cx, cy = (xmin+xmax)//2, (ymin+ymax)//2
d_plane = depth_planar[cy, cx]; d_cam = depth_persp[cy, cx]
angle = math.degrees(math.acos(max(1e-6, min(1.0, d_plane/max(1e-6, d_cam)))))
angle = -angle if cx < scene.shape[1]/2 else angle
result = {"name": "target", "distance": float(d_cam), "angle_deg": float(angle)}
双目定位原理
尽管 AirSim 提供了捷径,了解传统的双目定位原理依然重要,因为它在真实世界的机器人中广泛应用。其核心是模拟人类双眼,通过两个有固定间距(基线 Baseline)的相机拍摄同一场景。同一物体在左右图像中的水平像素位置差称为 视差 (Disparity)。物体越近,视差越大。根据相机 焦距 (Focal Length)、基线和测得的视差,利用公式 深度 = (焦距 * 基线) / 视差 即可计算深度。
# 视觉:双目定位(Stereo Localization)
# 假设双目相机参数已知(来自 settings.json 或标定)
FOCAL_LENGTH = 320 # 焦距(像素单位)
BASELINE = 0.25 # 基线,即双目相机间距(米)
# 1. 获取左右相机图像
responses = client.simGetImages([
airsim.ImageRequest("front_left", airsim.ImageType.Scene),
airsim.ImageRequest("front_right", airsim.ImageType.Scene)
])
img_left = cv2.imdecode(np.frombuffer(responses[0].image_data_uint8, np.uint8), 1)
img_right = cv2.imdecode(np.frombuffer(responses[1].image_data_uint8, np.uint8), 1)
# 2. 在左图检测目标,获得其中心点 (cx_left, cy)
(xmin, ymin, xmax, ymax) = detect_bbox(img_left)
cx_left, cy = (xmin + xmax) // 2, (ymin + ymax) // 2
# 3. 在右图中找到对应点 (cx_right, cy)(需立体匹配算法)
cx_right = find_corresponding_point(img_left, img_right, (cx_left, cy))
# 4. 计算视差和深度
disparity = cx_left - cx_right
if disparity > 0:
depth = (FOCAL_LENGTH * BASELINE) / disparity
# 5. 反算三维坐标 (相对于左相机)
X = (cx_left - img_left.shape[1] / 2) * depth / FOCAL_LENGTH
Y = (cy - img_left.shape[0] / 2) * depth / FOCAL_LENGTH
Z = depth
result = {"name": "target", "position_relative": [X, Y, Z]}
else:
result = None
5. 思考:知识库构建
5.1 总览
- 在缺少固定对象映射的真实环境中,可以“感知绑定—语义解析—世界状态内存”为主线贯通知识库与执行体系,确保在对象无先验ID、目标可移动、感知噪声与语义歧义下,仍能稳定、低成本地完成环境交互。
5.2 感知
- 目标与方法
- 检测/分割/多模态检索结合跟踪与 ReID,完成对象发现、连续追踪与在线绑定。
- 首次出现的实例分配临时 ID(如 car#1),持久化位姿、外观特征与别名。
- 端到端链路
- 视觉/多模态采集 → 检测(类别/框/特征)→ 跟踪 + ReID(跨帧一致)→ 实例库更新(ID/pose/feature)。
5.3 语义解析
- 目标与方法
- 将“离我最近/左边第二个/黄色箱子”等自然表述解析为空间与属性约束(距离、排序、颜色/形状/尺寸/OCR 等)。
- 基于实例库筛选候选目标,不唯一则返回缩略图或方位距离进行澄清。
- 端到端链路
- 自然语句 → 约束解析(空间/属性/数量)→ 候选检索与排序 → 歧义澄清(小图/方位距离)→ 最终实例。
5.4 世界模型、控制与优化
- 世界状态内存
- 轻量语义图维护 id、类别、别名、NED 位姿、2D 框、ReID、空间关系(near/left_of),随时间与可见性迭代更新与清理。
- 控制链路
- 最终实例位姿 → 动作 API(aw.*)→ 执行结果(位姿/成功态/异常)回写内存,形成“感知-控制”闭环。
- 工程化优化
- 知识外置与一次加载:固定规则/术语/动作 API 在初始化注入;请求仅携带状态摘要 JSON(pos/goal/step/alt/yaw)与当次指令。
- 输出结构化:仅代码或 JSON,低温度与 max_tokens 控制长度;函数调用优先,用参数承载语义。
- 复用与拆解:常见序列宏与缓存复用;长流程拆步执行;图片/日志采用缩略或哈希减少上下文。
- 治理与安全:版本化与一致性校验、异常路径与回退策略、权限与密钥安全(环境变量/脱敏日志)、可观测埋点闭环优化。
5.5 最小实现示例
# 感知与实例化
dets = detect("wind turbine, car, tower") # [{cls,bbox,feat,pose?}, ...]
tracks = tracker.update(dets) # [{cls,reid,pose}, ...]
# 在线绑定(首次见面分配实例ID)
for t in tracks:
if t.reid not in memory:
num = sum(v["cls"] == t.cls for v in memory.values()) + 1
memory[t.reid] = {"id": f"{t.cls}#{num}", "cls": t.cls, "pose": t.pose,
"aliases": [], "last_seen": now()}
# 指令解析为约束
cons = parse_constraints("左边第二个风机,上方3米") # {"cls":"wind_turbine","order":"left@2","offset":{"z":-3}}
# 实例选择或澄清
cand = select(memory, cons) # 结合方位/距离/外观筛选
if len(cand) != 1:
ask_user_disambiguation(preview(cand)) # 附缩略图/距离/方位
else:
p = cand[0]["pose"]
aw.fly_to([p.x, p.y, p.z - 3]) # NED:上方3m
5.6 世界状态内存示例
{
"id": "car#1",
"class": "car",
"name_aliases": ["汽车", "那辆红色车"],
"pose_ned": [12.0, -3.5, -1.2],
"bbox_2d": [100, 200, 240, 360],
"reid": "2f91...c8",
"last_seen": 1736390000,
"relations": {"near": ["tower#1"], "left_of": ["turbine#1"]}
}
6. 基于功能框架自动构建知识库
6.1 思路与范式
- 代码即知识(Code as Knowledge)
- 手工维护知识库与代码同步易出错且低效。核心范式是将功能代码本身作为唯一真实源(Single Source of Truth),知识库应从此自动生成。
- 自动化构建流程
- 装饰器标记:以 @tool 等装饰器标记需暴露给大模型的函数,作为自动化扫描的入口。
- 静态/动态解析:通过代码内省(Introspection)或静态分析,提取函数签名、Type Hint、结构化文档字符串(Docstrings)与对象映射字典。
- 模板化生成:将解析出的结构化信息填入预设的 Markdown 模板,生成最终的 SYSTEM_PROMPT 与 KNOWLEDGE_PROMPT。
6.2 自动化实现示例
- 第一步:规范化功能代码(
airsim_smol_wrapper.py的范式)
- 使用 @tool 装饰器、类型提示和结构化文档字符串。
# smolagents 的 @tool 或自定义装饰器
from smolagents import tool
from typing import Tuple
# 对象映射字典
objects_dict = {
"可乐": "airsim_coca",
"小鸭子": "airsim_duck",
}
@tool
def get_position(object_name: str) -> Tuple[float, float, float, float]:
"""
获取指定对象的位置与偏航角。
Args:
object_name (str): 需查询的对象名称(中文)。
Returns:
Tuple[float, float, float, float]: 包含三维坐标(x,y,z)与偏航角(角度制)的元组。
"""
# ... 实现代码 ...
query_string = objects_dict[object_name] + ".*"
# ...
return [pose.position.x_val, pose.position.y_val, pose.position.z_val, yaw_degree]
@tool
def fly_to(point: Tuple[float, float, float, float]) -> str:
"""
控制无人机飞至目标点。
Args:
point (Tuple[float, float, float, float]): 目标点,含三维坐标(x,y,z)与偏航角(角度制)。
Returns:
str: 成功状态描述,如 "成功"。
"""
# ... 实现代码,含 NED 坐标转换 ...
return "成功"
- 第二步:编写知识库生成脚本(
generate_kb.py)
- 使用 inspect 解析模块,docstring_parser 解析文档。
import inspect
import docstring_parser
import airsim_smol_wrapper as tools_module # 导入功能模块
def build_knowledge_base():
functions_info = []
# 遍历模块成员
for name, member in inspect.getmembers(tools_module):
# 检查是否为被 @tool 装饰的函数
if inspect.isfunction(member) and hasattr(member, '_is_tool'):
# 解析函数签名与文档
sig = inspect.signature(member)
docstring = docstring_parser.parse(member.__doc__)
# 提取信息
params = [f"{p.name}: {p.annotation}" for p in sig.parameters.values()]
func_info = {
"name": name,
"params_str": ", ".join(params),
"return_type": sig.return_annotation,
"description": docstring.short_description,
"args": [{"name": p.arg_name, "desc": p.description} for p in docstring.params]
}
functions_info.append(func_info)
# 读取对象映射
object_mapping = tools_module.objects_dict
# 使用模板生成 Markdown (此处用 f-string 简化)
kb_md = "## 可用函数\n\n"
for func in functions_info:
kb_md += f"- **{func['name']}**(`{func['params_str']}`) -> `{func['return_type']}`\n"
kb_md += f" - **描述**: {func['description']}\n"
for arg in func['args']:
kb_md += f" - **参数** `{arg['name']}`: {arg['desc']}\n"
kb_md += "\n## 对象映射(中文→内部名)\n\n"
for key, value in object_mapping.items():
kb_md += f"- `{key}` → `{value}`\n"
# 写入文件
with open("KNOWLEDGE_BASE.md", "w", encoding="utf-8") as f:
f.write(kb_md)
if __name__ == "__main__":
build_knowledge_base()
6.3 示例输出
## 可用函数
- **get_position**(`object_name: <class 'str'>`) -> `typing.Tuple[float, float, float, float]`
- **描述**: 获取指定对象的位置与偏航角。
- **参数** `object_name`: 需查询的对象名称(中文)。
- **fly_to**(`point: typing.Tuple[float, float, float, float]`) -> `<class 'str'>`
- **描述**: 控制无人机飞至目标点。
- **参数** `point`: 目标点,含三维坐标(x,y,z)与偏航角(角度制)。
## 对象映射(中文→内部名)
- `可乐` → `airsim_coca`
- `小鸭子` → `airsim_duck`
6.4 可能性
- 优势
- 一致性:知识库与代码实现永久同步,减少人工维护错误。
- 效率:新增功能只需按规范编写代码与文档,知识库自动更新。
- 可扩展性:轻松管理成百上千个工具,支撑复杂 Agent 框架。
- 展望
- CI/CD 集成:代码提交时自动运行生成脚本,确保知识库实时更新。
- 原生 Function Calling:自动生成符合 OpenAI/LangChain 等框架的 JSON Schema,提升调用效率与准确性。
- 代码自省:进一步分析函数体(如 NED 转换逻辑),自动在描述中添加“坐标系已处理”等隐性规则。
7. 模糊指令与代码智能体(Code agents)
7.1 从用户通用语言到精确代码
- 用户的视角:用户不了解底层代码,倾向于使用高层、模糊的自然语言下达指令。
- 模糊指令示例:“找到小鸭子”、“检查一下风力发电机有没有问题”、“飞到太阳能板那边看看”。
- 机器的视角:功能框架与知识库只接受精确、结构化的函数调用。
- 精确指令示例:aw.fly_to([10.5, -3.2, -5.0]),aw.detect("duck")。
- 核心矛盾:如何将用户的模糊意图,自动翻译并分解为一系列精确、有序的原子操作(函数调用)集合。
7.2 代码智能体的多步推理与规划
代码智能体 (Code Agent):一个以大模型为核心的系统,它能理解自然语言,查询知识库,选择并编排工具(函数),生成并执行代码来完成复杂任务。
核心流程:分解模糊命令为精确指令集
1. 意图与实体识别:解析用户指令,识别核心意图(如“查找”、“检查”、“移动”)和关键实体(“小鸭子”、“风力发电机”、“太阳能板”)。
2. 知识库查询与工具选择:基于识别出的意图和实体,在知识库中检索最匹配的可用函数/工具。
- “查找” → 匹配到 look(),detect(),ob_objects()
- “小鸭子” → 匹配到对象映射 小鸭子: airsim_duck
3. 任务分解与规划(宏组合):当单一工具无法完成任务时,Agent 需将模糊指令分解为一系列有序的、可执行的原子步骤。
- 这是将复杂命令分解为“宏组合”的关键,Agent 在此充当了任务规划师。
4. 代码生成与执行:将规划好的步骤序列,翻译成符合知识库规范的 Python 代码,并按需执行。
5. 结果观察与迭代:执行代码后,观察返回结果。如果任务未完成(如未找到目标),则基于新状态重新规划,形成闭环。
7.3 示例1:分解模糊指令(“找到小鸭子”)
用户指令:“找到小鸭子”
**Agent 的推理与规划
1. 意图/实体:意图=找到,实体=小鸭子。
2. 知识库查询:我知道 小鸭子 对应 duck。我有 detect(object_names) 工具可以检测目标,turn_left() 和 turn_right() 可以改变朝向。
3. 任务分解(宏组合)
- Plan A:原地旋转搜索
- (a) 以当前位置为中心,向左旋转 30 度。
- (b) 调用 detect("duck") 查看是否在视野内。
- (c) 如果检测到,记录其位置信息,任务成功。
- (d) 如果未检测到,重复 (a) 和 (b) 直到旋转一圈。
- (e) 如果一圈后仍未找到,报告“在当前位置未找到目标”。
4. 代码生成
# Agent 生成并执行的代码
duck_found = False
for _ in range(12): # 360/30 = 12 steps
aw.turn_left(30) # 假设 aw.turn_left() 已封装
time.sleep(1) # 等待姿态稳定
# 调用视觉检测工具
detected_objects, locations = aw.detect("duck")
if "duck" in detected_objects:
print(f"找到小鸭子,位置信息: {locations[0]}")
duck_found = True
break
if not duck_found:
print("在当前位置附近未找到小鸭子。")
7.4 示例2:分解复杂指令
用户指令:“飞过去检查一下第一个风力发电机”
Agent 的推理与规划
1. 意图/实体:意图=检查,实体=第一个风力发电机。
2. 知识库查询:第一个风力发电机 映射到 turbine1。检查 是个复杂动作,没有直接对应的工具。但我有 get_position() 和 fly_path()。我可以规划一个环绕飞行的路径来实现“检查”。
3. 任务分解(宏组合)
- (a) 调用 get_position("turbine1") 获取目标中心点 (cx, cy, cz)。
- (b) 定义一个环绕半径(如 15 米)和检查高度(如目标高度)。
- (c) 在目标点周围生成一个由多个航点组成的圆形或方形路径。
- (d) 调用 fly_to() 飞到第一个航点。
- (e) 调用 fly_path() 按顺序飞越所有航点,完成环绕检查。
- (f) (可选) 在飞行过程中,周期性调用 watch("详细描述叶片状态") 进行多模态观察。
4. 代码生成
# Agent 生成并执行的代码
import math
# Step a: 获取目标位置
target_pos = aw.get_position("turbine1")
cx, cy, cz = target_pos[0], target_pos[1], target_pos[2]
# Step b & c: 生成环绕路径
radius = 15.0
height = cz - 20 # 假设在目标上方20米检查
waypoints = []
for i in range(8): # 8个航点
angle = math.radians(i * 45) # 45度间隔
x = cx + radius * math.cos(angle)
y = cy + radius * math.sin(angle)
waypoints.append(airsim.Vector3r(x, y, height))
# Step d & e: 执行飞行
aw.fly_to(waypoints[0])
aw.fly_path(waypoints + [waypoints[0]]) # 飞一圈并回到起点
print("已完成对风力发电机1的环绕检查。")
7.5 代码智能体的价值
降低使用门槛:用户无需学习复杂的 API 或编程,用自然语言即可与系统交互。
提升任务上限:通过任务分解和宏组合,Agent 能完成远超单个工具能力的复杂、多步任务。
动态适应性:通过观察-规划-执行的闭环,Agent 能根据环境变化和执行结果调整策略,表现出更高的智能和鲁棒性。
框架支持:
smol-agents,LangChain,AutoGen等框架为此类智能体的构建提供了强大的工程支持,包括工具注册(如@tool),规划逻辑和执行循环。
8. 语音指令
8.1 必要性
自然与直观:语音是人类最自然的沟通方式,极大地降低了操作门槛,用户无需学习复杂的编程或遥控器操作。
解放双手(Hands-Free):在真实作业场景中,操作员可能需要同时处理其他事务或手持设备,语音指令实现了“解放双手”,提升了操作安全性与效率。
表达复杂意图:相比于按钮或摇杆,语言能更灵活地表达复杂、多步骤的任务意图,例如“先飞到那边的太阳能板,然后从左到右扫描一遍”,这背后蕴含了目标识别、路径规划和连续动作。
多模态协同:语音是多模态交互的核心。用户可以结合视觉说出“看看那个红色的车”,系统需要融合视觉定位与语音指令才能准确执行,实现更智能的交互。
8.3 实现原理与架构
语音指令的端到端实现,是一个整合了 语音处理,语言理解,任务规划 和 代码执行 的完整链路。本项目核心架构如下:
语音识别 (ASR - Audio Speech Recognition):将用户的原始语音流转换成文本字符串。
代码智能体 (LLM Agent):接收文本指令,作为系统的“大脑”。
知识库查询与任务规划:智能体查询已构建的知识库,理解文本指令的意图,并将其分解为一系列精确的、可执行的原子步骤(宏组合)。
代码生成:智能体将规划好的步骤序列,生成符合功能框架(如
aw.*)的Python代码。代码执行:执行生成的代码,通过已封装的
AirSimWrapper或smol-agent tools与无人机仿真环境交互。(可选)语音合成 (TTS - Text to Speech):将执行结果或需要澄清的问题,合成为语音,向用户播报,形成交互闭环。
graph TD
A[用户语音输入] -->|麦克风/UI| B(语音识别 ASR)
B -->|文本指令| C{代码智能体 LLM Agent}
C -- 查询 --> D[知识库(函数/对象/规则)]
D -- 返回可用工具 --> C
C -->|生成代码| E[Python 代码执行]
E -->|调用| F[功能框架 aw.*]
F -->|控制| G[AirSim 无人机]
G -- 状态反馈 --> F
F -- 结果 --> E
E -- 执行状态 --> C
C -->|生成回复文本| H(语音合成 TTS)
H -->|语音输出| I[用户]8.4 案例
from fake.user_app.recognition_module import process_mp3
from fake.five.user_app.voice_module import process_text2mp3
from fake.four.agent_app.airsim_agent import AirSimAgent # 假设使用一个统一的Agent
# --- 初始化 ---
# 1. 初始化代码智能体,加载最全的知识库
# 该知识库应包含飞行控制、多模态视觉工具、对象映射等
print("正在初始化智能体...")
my_agent = AirSimAgent(knowledge_prompt="prompts/knowledge_base_full.txt")
# --- 模拟一次完整的语音交互 ---
# 2. 语音输入 (在真实应用中,这会来自麦克风录音)
# 这里我们使用一个预先录制好的音频文件
audio_file_url = "https://your-online-storage/fly_to_car_and_look.mp3"
print(f"接收到语音指令,正在识别...")
# 3. 语音识别 (ASR)
command_text = process_mp3(audio_file_url)
print(f"识别结果: '{command_text}'")
# 4. (可选) TTS 确认指令
feedback_text = f"收到指令: {command_text}。正在规划..."
process_text2mp3(feedback_text)
# play_audio("feedback.mp3") # 播放确认语音
# 5. 代码智能体处理指令
# Agent会进行任务分解和代码生成
print("智能体正在处理指令...")
# 设置为 True 以直接执行生成的代码
generated_code = my_agent.process(command_text, run_python_code=True)
print("\n--- 智能体生成的代码 ---")
print(generated_code)
print("------------------------\n")
# 6. (可选) TTS 报告任务结果
# 真实的 Agent 会根据代码执行的返回结果来生成报告
final_report_text = "任务已完成。已到达汽车上方并完成观察。"
process_text2mp3(final_report_text)
# play_audio("report.mp3") # 播放最终报告
print("语音指令流程结束。")
参考文档
在 Linux 上使用 Epic Asset Manager 管理 UE 资源库
1. 安装 Flatpak 和 Flathub
如果系统尚未安装 Flathub,需要先进行安装:
1.1 安装 Flatpak
sudo apt install flatpak
sudo apt install gnome-software-plugin-flatpak
1.2 添加 Flathub 仓库
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
安装完成后,需要重启系统以使配置生效。
2. 安装 Epic Asset Manager
访问 Flathub 上的 Epic Asset Manager 页面 进行安装,或使用命令行安装。
安装完成后,可以通过以下命令启动:
flatpak run io.github.achetagames.epic_asset_manager

3. 登录和授权
3.1 浏览器授权
启动应用后,按照提示点击 Open In Browser 按钮。

3.2 复制授权码
在浏览器中完成 Epic Games 账号登录后,会显示授权码。

复制授权码字段,粘贴到应用中的授权框内完成授权。
4. 浏览和下载资产
授权成功后,可以在资产库中浏览 Unreal Engine 的资产。

选择需要的资产后,可以下载到本地。

5. 创建和管理项目
5.1 创建项目
点击 创建项目 按钮,可以根据当前场景创建新项目。

5.2 项目操作选项
在项目界面中,可以看到以下操作选项:
- 下载资产
- 添加到现有项目
- 创建新项目
在顶部可以看到下载进度。
6. 启动项目
在顶部的 Project 分页中,选择已下载的场景,在右侧选择合适的 Unreal Engine 版本,点击 Launch 按钮启动项目。

参考文档
Airsim + PX4 SITL + MAVSDK 系统集成踩坑记录
1. 网络拓扑
graph TB
subgraph "WSL2"
subgraph "地面站"
MAVSDK[MAVSDK<br/>发出UDP:14540]
AirSimAPI[AirSim API<br/>TCP:41451]
end
PX4SITL[PX4 SITL<br/>接收UDP:14560]
end
subgraph "Windows"
AirSim[AirSim<br/>RPC:41451<br/>TCP:4560]
AirSimCamera[AirSim相机]
end
MAVSDK -->|MAVLink| PX4SITL
AirSimAPI -->|RPC| AirSim
PX4SITL -->|TCP| AirSim
AirSimAPI -->|RPC| AirSimCamera
style MAVSDK fill:#f3e5f5
style AirSimAPI fill:#fff3e0
style PX4SITL fill:#e8f5e8
style AirSim fill:#e1f5fe
style AirSimCamera fill:#ffebee1.1 端口配置详解
核心端口分配与说明
WSL2: MAVSDK
- 地址:
udpin://127.0.0.1:14540(MAVLink) - 说明: MAVSDK 默认监听 PX4 SITL 的 MAVLink 数据流(14540 为 MAVLink 默认端口),因为PX4 SITL无法广播UDP,MAVSDK应放到PX4同一环境运行(参见2)
- 方向: PX4 SITL → 地面站
WSL2: AirSim API
- 地址:
0.0.0.0:41451(RPC) - 说明: AirSim 提供的默认RPC 接口,用于外部程序(Python/C++ 脚本等)调用控制 API
- 方向: 地面站 ↔ AirSim
Windows: AirSim (PX4 桥接)
- 地址:
0.0.0.0:4560(TCP) - 说明: PX4 SITL 运行在 WSL2 中,通过 PX4 桥接接口与 Windows 侧 AirSim 交互,
0.0.0.0监听的IP实际为宿主机在 WSL2 网络中的可访问地址(通常是 192.168.x.x 或/etc/resolv.conf里的网关地址,在settings.json中也要同步修改地址 - 方向: PX4 SITL ↔ AirSim
1.2 AirSim Settings.json 配置示例
{
"SettingsVersion": 1.2,
"SimMode": "Multirotor",
"RpcEnabled": true,
"RpcServerPort": 41451,
"ControlIp": "0.0.0.0",
"Vehicles": {
"Drone1": {
"VehicleType": "PX4Multirotor",
"X": 0, "Y": 0, "Z": -5,
"UseSerial": false,
"UseTcp": true,
"TcpPort": 4560,
"LocalHostIp": "0.0.0.0",
"ExternalIp": "0.0.0.0",
"Cameras": {
"front_center": {
"X": 0.5, "Y": 0, "Z": 0,
"Pitch": 0, "Roll": 0, "Yaw": 0,
"CaptureSettings": [
{
"Width": 1920,
"Height": 1080,
"ImageType": 0,
"FOV_Degrees": 90
}
]
}
},
"Parameters": {
"NAV_RCL_ACT": 0,
"NAV_DLL_ACT": 0,
"COM_OBL_ACT": 1,
"LPE_LAT": 47.641468,
"LPE_LON": -122.140165
}
}
}
}
2. 坑之一:PX4 SITL 广播和配置更改限制
问题描述
- 现象: PX4 SITL不支持动态配置更改和广播通信
- 原因: SITL模式下的PX4固件功能受限,无法像真实硬件一样支持所有MAVLink命令,param 等命令并不会真的保存
解决方案
- 在同一环境下使用PX4 SITL和MAVSDK
class PX4SITLManager:
def __init__(self):
self.drone = System()
# 使用本地连接
self.connection_string = "udpin://127.0.0.1:14540"
def connect(self):
await self.drone.connect(system_address=self.connection_string)
async for state in self.drone.core.connection_state():
if state.is_connected:
break
3. 坑之二:AirSim 相机集成问题
3.1 RPC通信与MAVLink不一致
问题描述
- 现象: PX4 SITL 无法直接调用 AirSim 相机
- 原因: PX4使用MAVLink(WSL2),AirSim相机走RPC(Windows),协议与位置均不同步
解决方案:双API架构
import airsim
from mavsdk import System
class DualAPIManager:
def __init__(self):
# MAVSDK用于飞行控制
self.drone = System()
# AirSim API用于相机控制
self.airsim_client = airsim.MultirotorClient()
async def setup_connections(self):
# 连接MAVSDK
await self.drone.connect(system_address="udpin://127.0.0.1:14540")
# 连接AirSim (Windows RPC 41451)
self.airsim_client.confirmConnection()
# 此处相机名称一定要填settings中设置的相机名称,否则会导致仿真崩溃,如果不填名称默认使用低分辨率相机“0”
def take_photo(self, camera_name: str = "front_center"):
# 使用AirSim API拍照 (Windows RPC 41451)
responses = self.airsim_client.simGetImages([
airsim.ImageRequest(camera_name, airsim.ImageType.Scene)
])
return responses[0]
async def fly_and_capture(self, waypoints):
# 使用MAVSDK控制飞行
for wp in waypoints:
await self.drone.action.goto_location(wp.lat, wp.lng, wp.alt)
# 使用AirSim API拍照
photo = self.take_photo()
# 处理照片...
3.2 相机 API Bug 与解决方案
问题描述
- 现象: AirSim
simGetCameraInfo()API导致仿真进程崩溃 - 原因:
- AirSim相机信息获取API存在内存泄漏问题
- 当调用无效相机名称或其他未知问题时,会导致仿真进程崩溃
问题API详情:
def simGetCameraInfo(self, camera_name, vehicle_name = '', external=False):
"""
Get details about the camera
Args:
camera_name (str): Name of the camera
vehicle_name (str, optional): Vehicle which the camera is associated with
external (bool, optional): Whether the camera is an External Camera
Returns:
CameraInfo: Camera information object
"""
return CameraInfo.from_msgpack(self.client.call('simGetCameraInfo', str(camera_name), vehicle_name, external))
崩溃原因:
- 当
camera_name参数无效或不存在时,RPC调用失败 CameraInfo.from_msgpack()解析失败导致内存访问错误- 其他未知原因
解决方案
- 直接使用settings.json中的相机名进行拍照,可以对相机参数进行有效调整
def take_photo(self, camera_name: str = "front_center", image_type: str = "Scene"):
if camera_name not in self.cameras:
raise ValueError(f"Unknown camera: {camera_name}")
request = airsim.ImageRequest(camera_name, airsim.ImageType.Scene)
responses = self.client.simGetImages([request])
return responses[0]
def take_multiple_photos(self, camera_names: list = None):
if camera_names is None:
camera_names = ["front_center", "downward"]
requests = []
for camera in camera_names:
if camera in self.cameras:
requests.append(airsim.ImageRequest(camera, airsim.ImageType.Scene))
return self.client.simGetImages(requests)
3.3 AirSim Settings 相机配置
4. 坑之三:Windows Defender 防火墙端口配置
- 如果不放开对应端口,会导致PX4 SITL和Airsim通信失败
4.1 防火墙端口配置步骤
步骤1:打开 Windows Defender 防火墙高级设置
# 方法1:通过控制面板
控制面板 → 系统和安全 → Windows Defender防火墙 → 高级设置
# 方法2:通过运行命令
wf.msc
步骤2:添加入站规则
- 选择"入站规则" → “新建规则”
- 规则类型:选择"端口"
- 协议和端口:
- 选择"TCP"
- 选择"特定本地端口"
- 输入端口:
14540,14550,14560,41451,4560
- 操作:选择"允许连接"
- 配置文件:勾选"域"、“专用”、“公用”
- 名称:输入"AirSim PX4 SITL 端口"
步骤3:添加出站规则
- 选择"出站规则" → “新建规则”
- 重复上述步骤,但选择"出站规则"
4.2 验证端口连接
# 在WSL2中测试端口连通性
telnet <Windows_IP> 14540
telnet <Windows_IP> 41451
telnet <Windows_IP> 4560
# 使用netstat检查端口监听状态
netstat -an | findstr :14540
netstat -an | findstr :41451
netstat -an | findstr :4560
4.3 常见问题解决
- 端口被占用:使用
netstat -ano | findstr :端口号查看占用进程 - 防火墙阻止:检查Windows Defender防火墙规则是否正确配置
- WSL2网络问题:重启WSL2服务
wsl --shutdown
5. 环境配置与调试指南
5.1 PX4 SITL 环境变量配置
# 配置PX4与AirSim的连接地址
export PX4_SIM_HOSTNAME=<Windows_IP>
export PX4_SIM_HOST_ADDR=<Windows_IP>
# 启动PX4 SITL
make px4_sitl_default none_iris
# 临时打开端口与QGC连接
mavlink start -p -o 14550
5.2 WSL2 双虚拟环境配置
- 为了在Windows下和WSL2下快速开发,可以使用双虚拟环境配置的方式
# Windows下WSL2的双虚拟环境激活命令
cd /mnt/c/Users/Username/Documents/ProjectLocation
source venv_wsl/bin/activate
# 环境说明:
# - Windows中使用.venv
# - WSL2中使用新的虚拟环境venv_wsl
5.3 快速修复与调试命令
端口配置问题
# 检查端口占用
netstat -ano | findstr :14540 # Windows
lsof -i :14540 # Linux/macOS
# 快速修复:重启PX4 SITL
pkill -f px4_sitl
make px4_sitl gazebo
参考文档
ROS
Ubuntu 24.04 安装 ROS 2 Jazzy 完整指南

什么是 ROS 2?
ROS 2(Robot Operating System 2)是 ROS 的下一代版本,是一个开源的机器人操作系统框架。ROS 2 专为现代机器人应用设计,提供了分布式计算、实时性能、多平台支持和更好的安全性等特性。
ROS 2 的主要特点
- 分布式架构:支持多机器人和多计算机之间的通信
- 实时性能:提供实时通信和调度能力
- 跨平台支持:支持 Linux、Windows 和 macOS
- 多种编程语言:支持 C++、Python、Java 等
- 模块化设计:通过节点(Node)和话题(Topic)实现松耦合的通信
- 丰富的工具链:提供命令行工具、可视化工具(rqt)和调试工具
ROS 2 Jazzy 是 ROS 2 的一个长期支持(LTS)版本,支持 Ubuntu Noble 24.04 的 amd64 和 arm64 架构。在网络通畅的情况下,安装较为顺利,未发现明显问题。
1. 系统环境准备
1.1 设置 Locale
设置系统 locale 为 UTF-8 编码,确保 ROS 2 正常运行:
# 检查当前 locale
locale
# 如果不是 UTF-8,先安装 locales
sudo apt update && sudo apt install locales
# 生成 en_US.UTF-8 locale
sudo locale-gen en_US en_US.UTF-8
# 更新系统 locale
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
# 设置当前会话的 locale
export LANG=en_US.UTF-8
# 再次检查 locale 确认设置成功
locale
1.2 添加 ROS 2 APT 源
确保启用 Ubuntu Universe 仓库,并添加 ROS 2 的 APT 源:
# 安装软件属性管理工具
sudo apt install software-properties-common
# 启用 Universe 仓库
sudo add-apt-repository universe
# 安装 curl(如果尚未安装)
sudo apt update && sudo apt install curl -y
# 获取最新版本的 ROS APT 源包
export ROS_APT_SOURCE_VERSION=$(curl -s https://api.github.com/repos/ros-infrastructure/ros-apt-source/releases/latest | grep -F "tag_name" | awk -F\" '{print $4}')
# 下载 ROS 2 APT 源 deb 包
curl -L -o /tmp/ros2-apt-source.deb "https://github.com/ros-infrastructure/ros-apt-source/releases/download/${ROS_APT_SOURCE_VERSION}/ros2-apt-source_${ROS_APT_SOURCE_VERSION}.$(. /etc/os-release && echo ${UBUNTU_CODENAME:-${VERSION_CODENAME}})_all.deb"
# 安装 APT 源包
sudo dpkg -i /tmp/ros2-apt-source.deb
1.3 安装开发工具
# 更新包列表并安装 ROS 开发工具
sudo apt update && sudo apt install ros-dev-tools
2. 安装 ROS 2
2.1 安装 ROS 2 Desktop
# 更新系统包
sudo apt update
sudo apt upgrade
# 安装 ROS 2 Jazzy Desktop(包含可视化工具)
sudo apt install ros-jazzy-desktop
2.2 设置环境变量
# 临时设置当前会话的环境变量
source /opt/ros/jazzy/setup.bash
3. 测试 FastDDS 通信
FastDDS 是 ROS 2 的默认通信中间件。通过以下步骤测试安装是否成功:
- 在第一个终端中启动 talker 节点:
ros2 run demo_nodes_cpp talker
- 在第二个终端中启动 listener 节点:
# 先设置环境变量
source /opt/ros/jazzy/setup.bash
# 运行 listener
ros2 run demo_nodes_py listener
如果两个终端都能正常打印消息,说明安装成功:

4. 配置 Shell 启动脚本
ROS 2 的 shell 工作区概念允许在同一台电脑上同时安装多个不同版本的 ROS,通过启动脚本来切换版本。如果不加载安装文件,您将无法访问 ROS 2 命令,也无法查找或使用 ROS 2 软件包。
4.1 添加到 ~/.bashrc
# 编辑 ~/.bashrc 文件(使用您喜欢的编辑器,如 nano、vim 或 subl)
nano ~/.bashrc
# 或者使用 Sublime Text
subl ~/.bashrc
在文件末尾添加以下内容:
source /opt/ros/jazzy/setup.bash
4.2 使配置生效
# 重新加载配置
source ~/.bashrc
4.3 验证配置
打开一个新的命令行窗口,直接输入 ros2,如果显示如下内容,说明配置成功:

5. 使用 Turtlesim
Turtlesim 是一个轻量级的 ROS 2 学习模拟器。它以最基本的层面演示了 ROS 2 的功能,让您了解以后使用真实机器人或机器人模拟器时会遇到的问题。
5.1 安装 Turtlesim
sudo apt update
sudo apt install ros-jazzy-turtlesim
5.2 验证安装
ros2 pkg executables turtlesim
如果显示以下内容,说明安装成功:
turtlesim draw_square
turtlesim mimic
turtlesim turtle_teleop_key
turtlesim turtlesim_node
5.3 运行 Turtlesim
- 启动 Turtlesim 节点:
ros2 run turtlesim turtlesim_node
- 在另一个终端中启动键盘控制节点:
ros2 run turtlesim turtle_teleop_key
保持 turtle_teleop_key 的窗口处于激活状态,通过方向键可以控制乌龟移动。此时乌龟指代的是无人机的移动和控制方式。

6. 安装和使用 rqt
rqt 是 ROS 2 的图形用户界面 (GUI) 工具。rqt 中的所有操作都可以在命令行中完成,但 rqt 提供了一种更友好的方式来操作 ROS 2 元素。
6.1 安装 rqt
sudo apt update
sudo apt install '~nros-jazzy-rqt*'
6.2 运行 rqt
rqt
6.3 使用 rqt 调用服务
在 Turtlesim 运行时,可以通过 rqt 调用服务:
- 从下拉菜单选择 Plugins → Services → Service Caller
- 选择服务
/spawn - 在此处可以直接调整生成位置(x、y 坐标)、实例名称等参数
- 点击 Call 按钮

如果服务调用成功,您应该会看到一只新的海龟(同样是随机设计的)在您输入的 x 和 y 坐标处生成。

6.4 控制新创建的海龟实例
如果要控制新创建的实例 turtle2,可以在新的终端中执行:
ros2 run turtlesim turtle_teleop_key --ros-args --remap turtle1/cmd_vel:=turtle2/cmd_vel
然后在这个终端控制即可。
如果您刷新 rqt 中的服务列表,您还会看到除了 /turtle1/... 之外,现在还有与新海龟相关的服务 /turtle2/...。
参考文档
理解 ROS 2 的 node 和 topic
1. 理解 node
node 是 ROS 2 实现模块化的基本组件。ROS 中的每个 node 都应负责单一的模块化功能,例如控制车轮电机或发布来自激光测距仪的传感器数据。每个 node 都可以通过 topic、服务、动作或参数与其他 node 发送和接收数据。
一个完整的机器人系统由许多协同工作的 node 组成。在 ROS 2 中,一个可执行文件(C++ 程序、Python 程序等)可以包含一个或多个 node。

上图展示了两个 ROS 2 node 是如何交互的。
1.1 ROS 2 启动命令
使用以下命令启动 node:
ros2 run <package_name> <executable_name>
例如:
ros2 run demo_nodes_cpp talker
其中 demo_nodes_cpp 是包名字,talker 是可执行文件名。
1.2 查看 node 列表
node 名称可以使用以下命令查找:
ros2 node list

可以看到 /talker 已经显示出来了。
1.3 node 名称重映射(Remapping)
通过 --remap 参数可以重映射 node 名称:
ros2 run turtlesim turtlesim_node --ros-args --remap __node:=my_turtle
上述命令中 --remap __node:=my_turtle 将 node 名称定义为 my_turtle,通过 ros2 node list 应该也可以看到这个新建的实例 /my_turtle。
1.4 查看 node 信息
使用以下命令查看 node 的详细信息:
ros2 node info <node_name>
例如:
ros2 node info /talker
注意要加斜杠。

2. 理解 topic
在上个章节的图片中,我们已经可以看到,publisher node 通过 topic 将信息发送给 subscriber 的过程,只要一个 node 订阅了 topic,就可以收到对应的消息。
ROS 2 将复杂的系统分解为许多模块化 node。topic 是 ROS 图的重要元素,充当 node 交换消息的总线。

一个 node 可以将数据发布到任意数量的 topic,并同时订阅任意数量的 topic。

topic 是在 node 之间以及系统不同部分之间移动数据的主要方式之一。
2.1 使用 rqt_graph 检查通信状态
通过 rqt_graph 可以可视化检查当前 ROS 2 系统的通信状态:
- 打开第一个终端,启动 talker node:
ros2 run demo_nodes_cpp talker
- 打开第二个终端,启动 listener node:
ros2 run demo_nodes_cpp listener
- 使用以下命令打开 rqt_graph:
ros2 run rqt_graph rqt_graph
可以看到 talker 和 listener 通过 /chatter 这个 topic 建立通信。node 正在向 topic 发布数据,并且该 node 订阅了该 topic 以接收数据。
rqt_graph 的突出显示功能在检查具有许多 node 和 topic 以多种不同方式连接的更复杂的系统时非常有用。

2.2 查看 topic 列表
在新终端中运行以下命令将返回系统中当前活动的所有 topic 的列表:
ros2 topic list
重要:使用 -t 参数可以在括号中附加 topic 类型:
ros2 topic list -t
输出示例:
/chatter [std_msgs/msg/String]
/parameter_events [rcl_interfaces/msg/ParameterEvent]
/rosout [rcl_interfaces/msg/Log]
topic 类型是本文档的重要内容,后续对 topic 的命令行应用都基于类型进行。
2.3 查看 topic 信息
topic 不必只是一对一的交流;它们可以是一对多、多对一或多对多。通过以下命令来查看当前订阅数量:
ros2 topic info /chatter
返回示例:
Type: std_msgs/msg/String
Publisher count: 1
Subscription count: 1
2.4 查看 topic 数据
使用以下命令查看正在发布的 topic 数据:
ros2 topic echo <topic_name>
例如:
ros2 topic echo /chatter
注意 topic 名称前要加斜杠。

现在返回 rqt_graph 并取消选中 “debug” 框,可以见到新增的订阅 /_ros2cli_100752,也就是刚才我们通过命令行命令 echo 创建的 node。

2.5 查看接口/数据结构
在前面运行过了 ros2 topic list -t 后,得知了 /chatter 的接口为 [std_msgs/msg/String]。
运行以下命令查看接口定义:
ros2 interface show std_msgs/msg/String
返回:
# This was originally provided as an example message.
# It is deprecated as of Foxy
# It is recommended to create your own semantically meaningful message.
# However if you would like to continue using this please use the equivalent in example_msgs.
string data
可以得知为字符串数据结构,字段为 data。
2.6 发布 topic 消息
得知消息结构后,通过以下命令可以直接从终端发送命令数据到 topic 中:
ros2 topic pub <topic_name> <msg_type> '<args>'
<args> 是要传递给 topic 的实际数据,采用正确的数据结构。如上 interface 为 string data 时,data 就是构建 YAML 字符串的 key。
如下几种方式都可以发布:
- 构建 YAML 字符串发布:
ros2 topic pub /chatter std_msgs/msg/String "{data: 'Hello from manual pub.'}"
可以看到 subscriber node 同时收到了两个 node 的信息。


- 发布空数据:
ros2 topic pub /chatter std_msgs/msg/String
因为很少会使用到手动 pub 消息,其他两种自动构建数据结构并发布的方式暂时不考虑。
消息的时间戳
当发布带时间戳的消息时,pub 有两种方法可以自动填充当前时间。对于带有 std_msgs/msg/Header 的消息,可以将 header 字段设置为 auto 来填充 stamp 字段。
ros2 topic pub /chatter std_msgs/msg/String "{header: \"auto\", data: 'Hello from manual pub.'}"
此时会报错,因为 std_msgs/msg/String 类型没有 header 字段。
2.7 查询 topic 发布频率
使用以下命令来得知对应 topic 的发布频率:
ros2 topic hz <topic>
例如:
ros2 topic hz /chatter
在检测后会返回:
average rate: 1.000
min: 1.000s max: 1.000s std dev: 0.00021s window: 3
2.8 查询 topic 带宽
使用以下命令查询 topic 的带宽使用情况:
ros2 topic bw <topic>
例如:
ros2 topic bw /chatter
输出示例:
Subscribed to [/chatter]
49 B/s from 2 messages
Message size mean: 28 B min: 28 B max: 28 B
39 B/s from 3 messages
Message size mean: 28 B min: 28 B max: 28 B
返回带宽利用率和发布到 topic 的消息数量。
2.9 查询指定类型的 topic
列出给定类型的可用 topic 列表:
ros2 topic find <topic_type>
根据前文可以得知,topic_type 为 ros2 topic list -t 返回的括号中的内容,例如 std_msgs/msg/String。
执行:
ros2 topic find std_msgs/msg/String
输出:
/chatter
3. 总结
node 通过 topic 发布信息,允许任意数量的其他 node 订阅和访问该信息。笔记中使用 rqt_graph 和命令行工具检查了 topic 上多个 node 之间的连接。由此,可以初步理解数据如何在 ROS 2 系统中移动。
4. 参考文档
理解 ROS 2 的 Services, Params 和 Actions
1. 理解 services
services 是 ROS 图中节点通信的另一种方法。services 基于调用和响应模型,而不是主题的pub-sub模型。虽然主题允许节点订阅数据流并获取持续更新,但 services 仅在 client 专门调用时才提供数据。
节点可以使用 ROS 2 中的 services 进行通信。与主题(一种单向通信模式,其中节点发布可供一个或多个订阅者使用的信息)不同,services 是一种请求/响应模式,其中 client 向提供 services node发出请求,services 处理该请求并生成响应。


1.1 查看 ROS 2 当前 services
通过 ros2 service list 返回系统中当前活动的所有 services 的列表。
在两个终端内分别运行 subscriber 和 publisher node:
ros2 run demo_nodes_cpp talker
ros2 run demo_nodes_cpp listener
执行 ros2 service list,返回:
$ ros2 service list
/listener/describe_parameters
/listener/get_parameter_types
/listener/get_parameters
/listener/get_type_description
/listener/list_parameters
/listener/set_parameters
/listener/set_parameters_atomically
/rqt_gui_py_node_103317/describe_parameters
/rqt_gui_py_node_103317/get_parameter_types
/rqt_gui_py_node_103317/get_parameters
/rqt_gui_py_node_103317/get_type_description
/rqt_gui_py_node_103317/list_parameters
/rqt_gui_py_node_103317/set_parameters
/rqt_gui_py_node_103317/set_parameters_atomically
/talker/describe_parameters
/talker/get_parameter_types
/talker/get_parameters
/talker/get_type_description
/talker/list_parameters
/talker/set_parameters
/talker/set_parameters_atomically
可以观察到节点中有相同名称的多个服务,ROS 2 的大部分节点都有这些基础设施服务。
1.2 查看 ROS 2 services 类型
服务具有描述服务的请求和响应数据的结构的类型。服务类型的定义与主题类型类似,不同之处在于服务类型有两部分:一个用于请求的消息,另一个用于响应。
查看 talker 的 services 类型:
ros2 service type /talker/set_parameters
返回:
rcl_interfaces/srv/SetParameters
也可以通过在 services 列表中增加 -t 参数来直接显示所有当前 services 对应的类型:
ros2 service list -t
返回:
/listener/describe_parameters [rcl_interfaces/srv/DescribeParameters]
/listener/get_parameter_types [rcl_interfaces/srv/GetParameterTypes]
/listener/get_parameters [rcl_interfaces/srv/GetParameters]
/listener/get_type_description [type_description_interfaces/srv/GetTypeDescription]
/listener/list_parameters [rcl_interfaces/srv/ListParameters]
/listener/set_parameters [rcl_interfaces/srv/SetParameters]
/listener/set_parameters_atomically [rcl_interfaces/srv/SetParametersAtomically]
/rqt_gui_py_node_103317/describe_parameters [rcl_interfaces/srv/DescribeParameters]
/rqt_gui_py_node_103317/get_parameter_types [rcl_interfaces/srv/GetParameterTypes]
/rqt_gui_py_node_103317/get_parameters [rcl_interfaces/srv/GetParameters]
/rqt_gui_py_node_103317/get_type_description [type_description_interfaces/srv/GetTypeDescription]
/rqt_gui_py_node_103317/list_parameters [rcl_interfaces/srv/ListParameters]
/rqt_gui_py_node_103317/set_parameters [rcl_interfaces/srv/SetParameters]
/rqt_gui_py_node_103317/set_parameters_atomically [rcl_interfaces/srv/SetParametersAtomically]
/talker/describe_parameters [rcl_interfaces/srv/DescribeParameters]
/talker/get_parameter_types [rcl_interfaces/srv/GetParameterTypes]
/talker/get_parameters [rcl_interfaces/srv/GetParameters]
/talker/get_type_description [type_description_interfaces/srv/GetTypeDescription]
/talker/list_parameters [rcl_interfaces/srv/ListParameters]
/talker/set_parameters [rcl_interfaces/srv/SetParameters]
/talker/set_parameters_atomically [rcl_interfaces/srv/SetParametersAtomically]
1.3 查看 ROS 2 services 信息
ros2 service info <service_name>
返回 services 类型以及 services clients 和服务器的计数。
ros2 service info /talker/set_parameters
返回:
Type: rcl_interfaces/srv/SetParameters
Clients count: 0
Services count: 1
1.4 查询指定类型的 services
ros2 service find <type_name>
例如:
ros2 service find rcl_interfaces/srv/DescribeParameters
返回:
/listener/describe_parameters
/rqt_gui_py_node_103317/describe_parameters
/talker/describe_parameters
1.5 查看 services 的接口/协议
与 topic 的接口查询一样,只是查询主体换成了services 类型。例如:
ros2 interface show rcl_interfaces/srv/DescribeParameters
返回:
# A list of parameters of which to get the descriptor.
string[] names
---
# A list of the descriptors of all parameters requested in the same order
# as they were requested. This list has the same length as the list of
# parameters requested.
ParameterDescriptor[] descriptors
string name
uint8 type
string description
#
string additional_constraints
bool read_only false
bool dynamic_typing false
#
FloatingPointRange[<=1] floating_point_range
float64 from_value
float64 to_value
#
#
#
#
float64 step
IntegerRange[<=1] integer_range
int64 from_value
int64 to_value
#
#
#
uint64 step
--- 分隔符上面是请求结构,即调用 services 需要输入的参数,下面是响应结构,即 services 返回的数据结构。注意:同一个 services 类型使用相同的数据结构/协议。
1.6 ROS 2 services 调用
从上文已经得知了调用 services 所需的数据结构,现在可以通过以下命令来调用 services:
ros2 service call <service_name> <service_type> <arguments>
这里尝试调用没有参数的 services:
ros2 service call /talker/list_parameters rcl_interfaces/srv/ListParameters
返回:
waiting for service to become available...
requester: making request: rcl_interfaces.srv.ListParameters_Request(prefixes=[], depth=0)
response:
rcl_interfaces.srv.ListParameters_Response(result=rcl_interfaces.msg.ListParametersResult(names=['qos_overrides./parameter_events.publisher.depth', 'qos_overrides./parameter_events.publisher.durability', 'qos_overrides./parameter_events.publisher.history', 'qos_overrides./parameter_events.publisher.reliability', 'start_type_description_service', 'use_sim_time'], prefixes=['qos_overrides./parameter_events.publisher']))
列出了当前节点拥有的参数。
1.7 ROS 2 services echo
查看 services clients 和 services 服务器之间的数据通信,使用命令 echo 对应的 services:
ros2 service echo <service_name | service_type> <arguments>
echo service_name 或者 service_type 都可以。
ros2 service echo 依赖于 services clients 和 services 服务器的 services 自省功能,该功能默认是禁用的。要启用它,用户必须在创建 services clients 或服务器后调用 configure_introspection。
启动 services 自省演示:
ros2 launch demo_nodes_cpp introspect_services_launch.py
然后打开一个新的终端,启用 services 自省:
ros2 param set /introspection_service service_configure_introspection contents
ros2 param set /introspection_client client_configure_introspection contents
在新终端内,继续执行:
ros2 service echo --flow-style /add_two_ints
可以看到 introspection_client 和 introspection_service 的通信情况,REQUEST_SENT,REQUEST_RECEIVED,RESPONSE_SENT,RESPONSE_RECEIVED 都被展示出来了:
---
info:
event_type: REQUEST_SENT
stamp:
sec: 1762498810
nanosec: 268090535
client_gid: [1, 15, 178, 36, 79, 84, 235, 99, 0, 0, 0, 0, 0, 0, 21, 3]
sequence_number: 387
request: [{a: 2, b: 3}]
response: []
---
info:
event_type: REQUEST_RECEIVED
stamp:
sec: 1762498810
nanosec: 268466526
client_gid: [1, 15, 178, 36, 79, 84, 235, 99, 0, 0, 0, 0, 0, 0, 20, 4]
sequence_number: 387
request: [{a: 2, b: 3}]
response: []
---
info:
event_type: RESPONSE_SENT
stamp:
sec: 1762498810
nanosec: 268538042
client_gid: [1, 15, 178, 36, 79, 84, 235, 99, 0, 0, 0, 0, 0, 0, 20, 4]
sequence_number: 387
request: []
response: [{sum: 5}]
---
info:
event_type: RESPONSE_RECEIVED
stamp:
sec: 1762498810
nanosec: 268637789
client_gid: [1, 15, 178, 36, 79, 84, 235, 99, 0, 0, 0, 0, 0, 0, 21, 3]
sequence_number: 387
request: []
response: [{sum: 5}]
---
2. 理解参数
参数就是 node 的配置,node 将参数存储为不同的数据类型,ROS 2 每个 node 都维护自己的参数。
节点使用参数来定义其默认配置值。您可以从命令行获取和设置参数值。您还可以将参数设置保存到文件中,以便在将来的会话中重新加载它们。
2.1 查看 ROS 2 的参数列表
启动 subscriber 和 publisher node:
ros2 run demo_nodes_cpp talker
ros2 run demo_nodes_cpp listener
查看参数列表:
ros2 param list
返回示例:
/introspection_client:
client_configure_introspection
qos_overrides./parameter_events.publisher.depth
qos_overrides./parameter_events.publisher.durability
qos_overrides./parameter_events.publisher.history
qos_overrides./parameter_events.publisher.reliability
start_type_description_service
use_sim_time
/introspection_service:
qos_overrides./parameter_events.publisher.depth
qos_overrides./parameter_events.publisher.durability
qos_overrides./parameter_events.publisher.history
qos_overrides./parameter_events.publisher.reliability
service_configure_introspection
start_type_description_service
use_sim_time
/listener:
start_type_description_service
use_sim_time
/talker:
qos_overrides./parameter_events.publisher.depth
qos_overrides./parameter_events.publisher.durability
qos_overrides./parameter_events.publisher.history
qos_overrides./parameter_events.publisher.reliability
start_type_description_service
use_sim_time
可以看到节点的 namespace 和其参数,注意:每个 node 都有 use_sim_time。
2.2 查看参数类型
ros2 param get <node_name> <parameter_name>
返回类型和当前值。查看 start_type_description_service 的当前值和类型:
ros2 param get /listener start_type_description_service
返回:
Boolean value is: True
2.3 修改参数
ros2 param set <node_name> <parameter_name> <value>
尝试修改 listener 为 false:
ros2 param set /listener start_type_description_service False
返回:
Setting parameter failed: Trying to set a read-only parameter: start_type_description_service.
传参成功了,但是因为这个参数是只读的,所以无法修改。
2.4 导出 ROS 2 参数
ros2 param dump <node_name>
运行:
ros2 param dump /talker
返回:
/talker:
ros__parameters:
qos_overrides:
/parameter_events:
publisher:
depth: 1000
durability: volatile
history: keep_last
reliability: reliable
start_type_description_service: true
use_sim_time: false
默认将在命令行打印,也可以通过以下命令指定输出文件到当前终端的工作目录:
ros2 param dump /talker > talker.yaml
2.5 加载 ROS 2 参数
使用命令加载参数文件到运行中的 node:
ros2 param load <node_name> <parameter_file>
例如:
ros2 param load /talker talker.yaml
返回:
Set parameter qos_overrides./parameter_events.publisher.depth failed: parameter 'qos_overrides./parameter_events.publisher.depth' cannot be set because it is read-only
Set parameter qos_overrides./parameter_events.publisher.durability failed: parameter 'qos_overrides./parameter_events.publisher.durability' cannot be set because it is read-only
Set parameter qos_overrides./parameter_events.publisher.history failed: parameter 'qos_overrides./parameter_events.publisher.history' cannot be set because it is read-only
Set parameter qos_overrides./parameter_events.publisher.reliability failed: parameter 'qos_overrides./parameter_events.publisher.reliability' cannot be set because it is read-only
Set parameter start_type_description_service failed: parameter 'start_type_description_service' cannot be set because it is read-only
Set parameter use_sim_time successful
可以看到,所有的参数都被尝试设置了,仅 use_sim_time 这个非 read-only 的参数覆写成功了。只读参数只能在启动时修改,而不能在启动后修改,这就是为什么 “qos_overrides” 参数会出现一些警告。
2.6 node 启动时加载参数
ros2 run demo_nodes_cpp talker --ros-args --params-file talker.yaml
命令行不会有额外打印信息,但是 read-only 的参数此时会生效。
3. 理解 action
动作(Actions)是 ROS 2 中的通信类型之一,用于长时间运行的任务。它们由三个部分组成:目标(goal)、反馈(feedback)和结果(result)。
action 基于主题和 services 构建。它们的功能类似于 services,但 action 可以被取消。它们还提供稳定的反馈,这与返回单个响应的 services 不同。

如图所示,action 使用 client-server 模型,与 pub sub 模型类似。action client node 将信息发送到 action server node,然后 server node 处理并返回结果。
仔细看图,client 先发一个 goal request 到 server,得到 server 的 response 后,client 发送 result request 到 server,此时 server 通过 feedback topic 与 client 保持连接,同步状态等,直到处理完成后,通过 result response 返回处理后的信息给 client。这个架构比仅用 topic 或者 services 都要复杂,但是强大、更加现代化,对复杂系统兼容性强,易于管理。
3.1 使用 action
打开两个终端,分别运行:
ros2 run turtlesim turtlesim_node
ros2 run turtlesim turtle_teleop_key
启动控制 node 后,看到提示信息:Use g|b|v|c|d|e|r|t keys to rotate to absolute orientations. 'f' to cancel a rotation.
其中 g|b|v|c|d|e|r|t 围绕键盘的 f 按键形成一个圆圈,按下对应方向的按键即命令乌龟转向对应的方向,比如按 t,是让乌龟朝向右上方。
按下 t 后,node 窗口会打印:
[INFO] [1762503823.706663289] [turtlesim]: Rotation goal completed successfully
如果在转向的过程中按下另一个方向,会打印:
[WARN] [1762503902.170788364] [turtlesim]: Rotation goal received before a previous goal finished. Aborting previous goal.
该 action 服务器选择中止第一个目标,因为它有了一个新目标。它可以选择其他目标,例如拒绝新目标或在第一个目标完成后执行第二个目标。
通过 ros2 node info /turtlesim 可以发现返回值中存在:
Service Clients:
Action Servers:
/turtle1/rotate_absolute: turtlesim/action/RotateAbsolute
Action Clients:
可以得知这是基于 action 实现的复杂功能,而不是每个使用了 action 的 node 就天然具备的功能。
3.2 查看 ROS 2 action 列表
ros2 action list
返回:
/turtle1/rotate_absolute
和其他的 list 命令一样,可以通过 -t 来查看 action 的类型:
ros2 action list -t
返回:
/turtle1/rotate_absolute [turtlesim/action/RotateAbsolute]
3.3 查看 ROS 2 action 类型
ros2 action type <action>
例如:
ros2 action type /turtle1/rotate_absolute
返回:
turtlesim/action/RotateAbsolute
3.4 查看 ROS 2 action 信息
ros2 action info <action>
例如:
ros2 action info /turtle1/rotate_absolute
返回:
Action: /turtle1/rotate_absolute
Action clients: 1
/teleop_turtle
Action servers: 1
/turtlesim
可以看到 action clients 和 servers 都直接打印出来了。
3.5 查看 ROS 2 action 的接口
ros2 interface show <action_type>
例如:
ros2 interface show turtlesim/action/RotateAbsolute
返回:
# The desired heading in radians
float32 theta
---
# The angular displacement in radians to the starting position
float32 delta
---
# The remaining rotation in radians
float32 remaining
第一部分(第一个 --- 上方)是 goal 的结构,第二部分(第一个 --- 和第二个 --- 之间)是 result 的结构,最后一部分(第二个 --- 下方)是 feedback 的结构,如上图所示。
3.6 ROS 2 action 发送 goal
ros2 action send_goal <action_name> <action_type> <values>
<values> 需要是 YAML 格式。在终端输入:
ros2 action send_goal /turtle1/rotate_absolute turtlesim/action/RotateAbsolute "{theta: -1.57}" --feedback
可以看到海龟开始旋转。返回:
Waiting for an action server to become available...
Sending goal:
theta: -1.57
Goal accepted with ID: e6092c831f994afda92f0086f220da27
Feedback:
remaining: -3.1268222332000732
Feedback:
remaining: -3.1108222007751465
...
Result:
delta: 3.1200008392333984
Goal finished with status: SUCCEEDED
Feedback 展示的是剩余的弧度,直到完成。action client 通过 send goal 到 action server 来实现对海龟的旋转。
4. 总结
本文介绍了 ROS 2 中三种重要的通信和配置机制:
服务(Services):基于请求/响应模型的通信方式,适用于需要即时响应的操作。服务允许客户端向服务器发送请求并接收响应,与主题的单向通信不同,服务提供了双向通信能力。
参数(Parameters):节点的配置值,用于定义节点的默认行为。参数可以在运行时查询和修改(非只读参数),也可以保存到文件中以便在将来的会话中重新加载。只读参数只能在节点启动时设置。
动作(Actions):用于长时间运行任务的通信机制,由目标、反馈和结果三部分组成。动作基于主题和服务构建,提供了可取消的任务执行和定期反馈功能,非常适合需要长时间运行且需要进度更新的任务,如机器人导航。
机器人系统可能会使用 action 进行导航。action 目标可以告诉机器人前往某个位置。当机器人导航到该位置时,它可以沿途发送更新(即反馈),然后在到达目的地后发送最终结果消息。
通过理解这三种机制,可以更好地设计和实现 ROS 2 机器人系统,选择合适的通信方式来处理不同的任务需求。
参考文档
Android
在 Ubuntu 使用 scrcpy 进行 Android 桌面调试
scrcpy 是一个开源的 Android 屏幕镜像工具,允许用户通过 USB 或 WiFi 将Android 设备屏幕镜像到 Ubuntu 桌面,并支持鼠标键盘控制、文件传输、剪贴板同步等功能。
1. 环境准备
1.1 安装依赖
# 更新包列表
sudo apt update
# 安装基础依赖
sudo apt install -y adb scrcpy
# 安装额外工具(可选)
sudo apt install -y android-tools-adb android-tools-fastboot
1.2 验证安装
# 检查ADB版本
adb version
# 检查scrcpy版本
scrcpy --version
# 检查设备连接
adb devices
2. Android设备配置
2.1 启用开发者选项
- 进入设置 → 关于手机
- 连续点击"版本号"7次,直到出现"您已处于开发者模式"
- 返回设置 → 开发者选项
2.2 启用USB调试
在开发者选项中启用:
- USB调试
- USB调试(安全设置)
- USB安装
- USB调试(安全设置)
3. ADB设备连接与管理
3.1 USB连接方式
基础USB连接
# 通过USB连接设备
adb devices
# 预期输出示例:
# List of devices attached
# 1234567890ABCDEF device
# 9876543210FEDCBA device
USB连接故障排查
# 检查USB连接
lsusb
# 重启ADB服务
adb kill-server
adb start-server
# 检查设备权限
ls -la /dev/bus/usb/
# 添加udev规则(权限被拒绝时)
sudo nano /etc/udev/rules.d/51-android.rules
# 添加以下内容(替换VENDOR_ID)
SUBSYSTEM=="usb", ATTR{idVendor}=="VENDOR_ID", MODE="0666", GROUP="plugdev"
# 重新加载规则
sudo udevadm control --reload-rules
sudo udevadm trigger
3.2 WiFi连接方式
WiFi调试原理
WiFi调试通过TCP/IP协议实现ADB连接,无需物理USB连接。这种方式特别适合:
- 设备距离计算机较远
- 需要同时连接多个设备
- 避免频繁插拔USB线
- 在设备充电时进行调试
基础WiFi调试设置
方法一:通过USB初始化(推荐)
# 1. 通过USB连接设备
adb devices
# 2. 启用TCP/IP调试(端口5555)
adb tcpip 5555
# 3. 断开USB连接
# 4. 通过WiFi连接(需要知道设备IP)
adb connect 192.168.1.100:5555
# 5. 验证连接
adb devices
方法二:通过WiFi直接连接(Android 11+)
# 1. 在Android设备上启用"无线调试"
# 设置 → 开发者选项 → 无线调试
# 2. 点击"使用配对码配对设备"
# 3. 在Ubuntu上配对设备
adb pair 192.168.1.100:37017
# 4. 输入配对码
# 5. 连接设备
adb connect 192.168.1.100:5555
获取设备IP地址
方法一:通过Android设备查看
# 在Android设备上查看IP
# 设置 → WiFi → 点击已连接的网络 → 查看IP地址
方法二:通过ADB命令查看
# 通过USB连接时查看IP
adb shell ip route | grep wlan
# 或者
adb shell ifconfig wlan0 | grep "inet addr"
方法三:通过网络扫描
# 安装网络扫描工具
sudo apt install nmap
# 扫描局域网中的Android设备
nmap -sn 192.168.1.0/24
# 扫描特定端口
nmap -p 5555 192.168.1.0/24
3.3 多设备管理
查看连接的设备
# 列出所有连接的设备
adb devices
# 详细设备信息
adb devices -l
# 查看设备属性
adb shell getprop ro.product.model
多设备操作
# 指定设备执行命令
adb -s 1234567890ABCDEF shell ls /sdcard/
# 向指定设备推送文件
adb -s 1234567890ABCDEF push local_file.txt /sdcard/
# 从指定设备拉取文件
adb -s 1234567890ABCDEF pull /sdcard/remote_file.txt ./
# 安装APK到指定设备
adb -s 1234567890ABCDEF install app.apk
混合连接管理
# 同时管理USB和WiFi连接的设备
adb devices
# 预期输出示例:
# List of devices attached
# 1234567890ABCDEF device # USB设备
# 192.168.1.100:5555 device # WiFi设备
# 192.168.1.101:5555 device # WiFi设备
# 对USB设备操作
adb -s 1234567890ABCDEF shell ls /sdcard/
# 对WiFi设备操作
adb -s 192.168.1.100:5555 shell ls /sdcard/
3.5 连接故障排查
USB连接问题
# 检查USB连接
lsusb
# 重启ADB服务
adb kill-server
adb start-server
# 检查设备权限
ls -la /dev/bus/usb/
WiFi连接问题
问题1:无法发现设备
# 检查网络连通性
ping 192.168.1.100
# 检查端口是否开放
telnet 192.168.1.100 5555
# 检查防火墙设置
sudo ufw status
sudo ufw allow 5555
问题2:连接不稳定
# 检查网络质量
ping -c 10 192.168.1.100
# 调整ADB超时设置
export ADB_LOCAL_TRANSPORT_MAX_PORT=5585
export ADB_LOCAL_TRANSPORT_MIN_PORT=5585
# 重启ADB服务
adb kill-server
adb start-server
问题3:配对失败
# 清除配对信息
adb pair --clear
# 重新配对
adb pair 192.168.1.100:37017
# 检查配对码是否正确
4. scrcpy使用指南
4.1 基础使用
# 基本启动(自动选择第一个设备)
scrcpy
# 指定设备启动
scrcpy -s 1234567890ABCDEF
# 指定设备名称启动
scrcpy -s "Galaxy S21"
4.2 常用参数配置
# 设置窗口大小
scrcpy --max-size 1920
# 设置比特率(提高画质)
scrcpy --bit-rate 8M
# 设置帧率
scrcpy --max-fps 60
# 全屏启动
scrcpy --fullscreen
# 保持屏幕常亮
scrcpy --stay-awake
# 关闭屏幕(仅镜像)
scrcpy --turn-screen-off
4.3 高级配置
# 自定义配置启动
scrcpy \
--max-size 1920 \
--bit-rate 8M \
--max-fps 60 \
--stay-awake \
--disable-screensaver \
--window-title "Android调试" \
--always-on-top
4.4 文件传输功能
通过ADB传输文件
# 推送文件到设备
adb push /path/to/local/file.txt /sdcard/Download/
# 从设备拉取文件
adb pull /sdcard/Download/file.txt /path/to/local/
# 批量传输
adb push /path/to/folder/ /sdcard/Download/
通过scrcpy拖拽传输
- 启用文件拖拽功能:
scrcpy --push-target /sdcard/Download/
- 拖拽文件到scrcpy窗口,文件会自动传输到Android设备
4.5 剪贴板同步
启用剪贴板同步
# 启用剪贴板同步
scrcpy --clipboard-autosync
# 双向剪贴板同步
scrcpy --clipboard-autosync --forward-all-clipboard
剪贴板操作
- Ubuntu → Android:在Ubuntu中复制,在Android应用中粘贴
- Android → Ubuntu:在Android中复制,在Ubuntu应用中粘贴
5. 故障排查
5.1 常见问题
问题1:设备未识别
# 检查USB连接
lsusb
# 重启ADB服务
adb kill-server
adb start-server
# 检查设备权限
ls -la /dev/bus/usb/
问题2:scrcpy启动失败
# 检查设备连接
adb devices
# 检查scrcpy版本
scrcpy --version
# 使用详细模式启动
scrcpy --verbose
5.2 性能优化
# 降低画质提高性能
scrcpy --max-size 1280 --bit-rate 2M
# 关闭音频(如果不需要)
scrcpy --no-audio
# 使用硬件加速
scrcpy --encoder h264
参考文档
使用 Termux 在 Android 运行 Python 源码
1. 为什么是 Termux
- 构建原生 Android App 成本高:需要完整的 SDK/NDK、Gradle、签名、打包与多 ABI 适配,调试周期长。
- 直接验证业务逻辑:很多场景只需在设备上跑 Python 后端/脚本(算法、接口、数据处理),无须先做 APK 封装。
- 环境接近真实设备:在手机本机 I/O、网络、性能与权限模型下验证代码,比纯模拟器/PC 更接近真实表现。
- 快速迭代:通过 ADB/SSH 同步代码,立即运行与观察日志,缩短问题定位与修复时间。
注意事项
- 环境差异:Termux 基于 Android/Linux 用户空间,和标准 Linux 发行版存在差异,某些系统调用/路径不可用。
- Python 包兼容性:依赖原生扩展(C/C++/Fortran)的包在 Termux 上可能无法编译或运行(如依赖特定 glibc/系统接口)。
- 优先选择纯 Python 包或提供 aarch64 预编译 wheels 的发行版。
- 必要时安装
clang,rust,make,pkg-config再尝试编译,但仍可能失败。
- 官方不保证兼容:部分上游项目明确不支持 Termux/Android 平台,出现问题时官方可能不修复。
- 版本固定:建议在
requirements.txt固定依赖版本,避免因上游升级导致不可预期的构建/运行失败。
2. Termux 安装和配置
安装 Termux
方法一:通过应用商店
- 从 F-Droid 或 Google Play 安装 Termux
- 打开 Termux 应用
方法二:通过 ADB 安装 APK
# 下载 Termux APK 文件
# 从 https://f-droid.org/packages/com.termux/ 下载最新版本
# 通过 ADB 安装
adb install termux.apk
# 或者强制安装(覆盖现有版本)
adb install -r termux.apk
# 检查安装是否成功
adb shell pm list packages | grep termux
ADB 连接与 scrcpy 远程桌面
USB 连接(推荐)
# 在开发机上执行
# 1. 使用 USB 线连接设备
# 2. 在设备上启用 USB 调试
# 3. 检查连接
adb devices
# 如果显示设备,说明连接成功
# 运行scrcpy打开安卓桌面
scrcpy
无线 ADB 连接
# 在开发机上执行
# 1. 确保两个设备在同一网段下,通过 USB 连接并启用无线调试
adb tcpip 5555
adb connect 192.168.1.100:5555
# 4. 检查连接
adb devices
# 运行scrcpy打开安卓桌面
scrcpy
基础环境配置
1. 更新包管理器
# 在 Termux 中
# 更新包列表和系统
pkg update && pkg upgrade
# 清理缓存
pkg clean
2. 一键安装所有依赖
# 在 Termux 中
# 安装核心依赖(纯 Python 项目,无需编译工具)
pkg install -y python python-pip git curl wget openssh iproute2 net-tools htop procps rsync tree neofetch android-tools rust clang make pkg-config
3. 项目部署
克隆项目
# 在 Termux 中
git clone url-to-project
cd url-to-project
安装依赖
# 在 Termux 中
# 安装 Python 依赖
pip install -r requirements.txt
运行项目
# 在 Termux 中
# 后台运行
nohup python start_backend.py > drone.log 2>&1 &
# 检查运行状态
ps aux | grep python
curl http://localhost:8000/health
文件传输
# 在开发机上执行
# 通过 ADB 传输文件
adb push local_file.txt /data/data/com.termux/files/home/
# 从设备拉取文件
adb pull /data/data/com.termux/files/home/log.log ./
4. 使用 Termius 进行 SSH/SFTP 管理(推荐)
Termius 是一款跨平台的 SSH 客户端,适合管理 Termux 主机:
- 可以为常用主机保存连接信息(主机、端口、用户名、密码/密钥)。
- 支持命令片段(Snippets),可一键执行常用指令(如重启服务、查看日志)。
- 内置 SFTP 文件管理,可直接浏览
~与项目目录、上传/下载、查看与编辑文件。 - 云同步(可选),多设备共享配置;也可本地仅存储,避免隐私外泄。
示例配置:
新建 Host:
- Address:
localhost(或手机局域网 IP) - Port:
8022(对应adb forward tcp:8022 tcp:22) - Username:
u0_a...(Termux 默认交互用户,或留空用密码登录) - Password: 设置为你在 Termux 中的密码(如
termux123,建议修改)
- Address:
新建 Snippet(命令片段):
- 查看服务日志:
tail -f ~/path-to-project/log.log - 重启服务:
pkill -f "python start_backend.py" && nohup python ~/path-to-project/start_backend.py > ~/path-to-project/log.log 2>&1 &
- 查看服务日志:
使用 SFTP:
- 直接进入
~/path-to-project/,上传/下载requirements.txt,log.log等文件。 - 可视化查看目录结构,便于排查路径与权限问题。
- 直接进入
参考文档
Paradox Mods
维多利亚3 战争赔款恶名优化mod
概述
本Mod解决了维多利亚3中战争赔款系统的不平衡问题,相比征服领土等其他战争目标,要求战争赔款会产生过高的恶名。Mod提供了灵活可定制的解决方案,在保持游戏平衡的同时改善外交策略选择。
功能特性
- 可调节恶名生成: 较低的divide值会增加恶名生成,可自定义最小/最大值限制
- 5倍更快恶名衰减: 年衰减率从5.0提升到25.0
- 战争赔款优化: 减少战争赔款的过度恶名,同时保持战略平衡
安装
推荐:Steam创意工坊
- 访问 Steam创意工坊页面
- 点击"订阅"自动下载和安装
- 启动维多利亚3 - Mod将自动启用
注意:Steam创意工坊安装是最可靠的方法。手动安装可能导致Mod无法正常工作。
手动安装
- 下载Mod文件:GitHub仓库
- 放置到维多利亚3 Mod目录
- 在游戏启动器中启用Mod
文件结构
common/
├── defines/
│ └── 99_mwid_infamy_fix.txt # 恶名阈值和衰减率
└── treaty_articles/
└── 05_transfer_money.txt # 战争赔款恶名计算
自定义
您可以通过修改 common/treaty_articles/05_transfer_money.txt 中的以下值来调整恶名生成:
divide = 10000 # 较低值 = 更高恶名
min = 0.5 # 最小恶名 (最大值: 5)
max = 20 # 最大恶名 (最大值: 50)
开发方法
本Mod使用热补丁方法:
- 完整文件覆盖: 复制并修改整个
05_transfer_money.txt条约条款文件 - 选择性值更改: 仅调整特定参数 (
divide,min,max),同时保留所有其他功能 - 最小影响: 精确控制战争赔款恶名,不影响其他外交行动或与其他Mod冲突
核心恶名计算位于:
game/common/treaty_articles/05_transfer_money.txt - money_transfer.wargoal.infamy
兼容性
- 游戏版本: 维多利亚3 v1.9.8
- 多人游戏: 同步
- 其他Mod: 由于高加载优先级,与大多数Mod兼容
支持
如有问题或建议,请参考Mod讨论页面或在仓库中创建问题。
Dev Tools
在 Linux 中使用 Ventoy 制作 Windows 启动盘
Ventoy 是一个开源的多系统启动 U 盘解决方案,支持直接从 ISO/WIM/VHD(x)/EFI 文件启动,无需反复格式化 U 盘。
1. 准备工作
1.1 获取 Windows ISO 镜像
三选一,建议使用非官方源(Microsoft 官网可能会有神秘报错):
- OS.click(推荐):https://os.click/en
- UUP dump:https://uupdump.net/
- Microsoft 官网:https://www.microsoft.com/software-download/windows11
下载完成后校验文件:
sha256sum windows11.iso
2. 安装 Ventoy
2.1 下载 Ventoy
访问 Ventoy 官网 下载 Linux 版本安装包,例如 ventoy-1.0.00-linux.tar.gz。
解压安装包:
tar -xzf ventoy-1.0.00-linux.tar.gz
cd ventoy-1.0.00
2.2 使用 WebUI 制作 Windows 启动盘
Ventoy 提供了 WebUI 图形化界面,推荐使用此方式安装,GUI 版本可能导致未响应。
- 启动 WebUI 服务器:
sudo ./VentoyWeb.sh
启动后会显示以下提示信息:
===============================================================
Ventoy Server 1.1.07 is running ...
Please open your browser and visit http://127.0.0.1:24680
===============================================================
################## Press Ctrl + C to exit #####################
- 打开浏览器访问:
在浏览器中打开 http://127.0.0.1:24680。
- 在 WebUI 中安装:
WebUI 界面如下图所示:

界面说明:
- 设备选择:
Device下拉框选择目标 U 盘设备,右侧绿色刷新按钮可重新扫描设备 - 版本信息:
- Ventoy In Package:显示安装包中的 Ventoy 版本(如 1.1.07)和分区格式(MBR/GPT)
- Ventoy In Device:显示设备中已安装的 Ventoy 版本,如果为空则表示未安装
- Option 菜单:
- Secure Boot Support:启用安全启动支持,建议勾选以兼容支持 UEFI Secure Boot 的计算机,在一些品牌笔记本电脑安装中,如果不启用安全会导致无法安装系统
- Partition Style:选择分区格式,可选择
MBR(Legacy BIOS)或GPT(UEFI) - Partition Configuration:分区配置选项,可设置在U盘内的保留空间
- Clear Ventoy:清除设备中的 Ventoy
- Show All Devices:显示所有设备
- 状态显示:
Status - READY表示准备就绪 - 操作按钮:
Install:安装 Ventoy 到 U 盘Update:升级设备中的 Ventoy 版本
在 WebUI 中执行以下操作:
- 选择目标 U 盘设备
- 选择分区格式(MBR 或 GPT)
- 选择文件系统类型(exFAT, NTFS, FAT32 等)
- 点击
安装按钮
注意: 安装会格式化 U 盘,清除所有数据。普通 U 盘建议使用 exFAT 文件系统,大容量移动硬盘或 SSD 建议使用 NTFS 文件系统。
3. 拷贝镜像文件
安装完成后,U 盘会被分成两个分区:
- 第 1 个分区(镜像分区):容量较大,用于存放 ISO 文件
- 第 2 个分区(VTOYEFI 分区):32MB,存放 Ventoy 启动文件
将下载的 Windows ISO 镜像文件直接拷贝到第 1 个分区(大一点的分区)中即可。可以将文件放在任意目录及子目录下,Ventoy 会自动遍历所有目录,按字母顺序显示在启动菜单中。
注意: 安装完成后,镜像分区也可以手动重新格式化为其他支持的文件系统(exFAT/FAT32/NTFS/UDF/XFS/Ext2/3/4),不影响 Ventoy 功能。
4. 启动安装
4.1 设置启动顺序
- 重启计算机,进入 BIOS/UEFI 设置
- 将 U 盘设置为第一启动项
- 保存并退出
4.2 选择镜像启动
从 U 盘启动后,Ventoy 会显示镜像文件列表,选择要安装的 Windows ISO 文件即可开始安装。
4.3 目标分区格式要求
重要提示: 如果目标安装位置(通常是系统盘)使用 GPT 分区表且格式为 NTFS,安装过程中可能会出现错误。建议在安装 Windows 前,将目标安装位置格式化为 GPT 格式,以避免安装失败。
5. 注意事项
- 安装 Ventoy 会格式化 U 盘,清除所有数据,请提前备份重要文件
- U 盘容量需至少 8GB(推荐 16GB 或更大)
- 可以将 U 盘当作普通存储设备使用,存放普通文件不影响 Ventoy 功能
- 支持同时存放多个 ISO 文件,启动时可以选择
- MBR/GPT 分区格式选项只在安装时有效,升级不会改变现有分区格式
参考文档
使用 Whisper Tiny 模型实现快速语音转文字:Python 部署与实践指南
1. 环境准备
1.1 系统要求
- 操作系统:Ubuntu / Debian / WSL / macOS / Windows
- Python:3.10 或更高版本
- 系统依赖:
ffmpeg(Whisper 处理音频文件必需)
1.2 安装系统依赖
Ubuntu/Debian:
sudo apt-get update
sudo apt-get install -y ffmpeg
macOS:
brew install ffmpeg
Windows: 从 ffmpeg.org 下载并安装
1.3 安装 Python 依赖
pip install openai-whisper
或者使用项目 requirements.txt:
pip install -r requirements.txt
2. Whisper 模型选择
Whisper 提供多种模型,可根据需求选择:
| 模型 | 参数量 | 速度 | 准确度 | 推荐场景 |
|---|---|---|---|---|
tiny | 3900万 | 最快 | 较低 | 实时交互、低延迟需求 |
base | 7400万 | 较快 | 中等 | 平衡速度和准确度 |
small | 2.44亿 | 中等 | 较好 | 一般应用 |
medium | 7.69亿 | 较慢 | 较高 | 高准确度需求 |
large | 15.5亿 | 最慢 | 最高 | 专业转录 |
本项目使用 tiny 模型,适合实时语音交互场景。
3. 核心功能实现
3.1 模型管理(单例模式)
import whisper
import warnings
_whisper_model = None
def get_whisper_model():
"""获取或加载Whisper模型(单例模式)"""
global _whisper_model
if _whisper_model is None:
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="FP16 is not supported on CPU")
_whisper_model = whisper.load_model("tiny", device="cpu")
return _whisper_model
特点:
- 延迟加载:首次使用时才加载模型
- 单例模式:全局只加载一次,节省内存
- 自动处理 CPU/GPU:根据设备自动选择
注意:首次运行时会自动下载模型文件(tiny 模型约 75MB),下载完成后会缓存到本地,后续运行会直接使用缓存,无需重新下载。
3.2 音频文件处理
async def process_audio_file(audio_content: bytes, filename: str):
"""处理音频文件并转写"""
# 1. 验证文件格式
validate_audio_file(filename, audio_content)
# 2. 保存到临时文件
temp_path = create_temp_file(audio_content, filename)
# 3. 转写音频
result = transcribe_audio_file(temp_path, filename)
# 4. 清理临时文件
cleanup_temp_file(temp_path)
return result
支持的音频格式:
.mp3,.wav,.flac,.m4a,.ogg,.webm,.mpeg,.mp4
注意:Whisper 会自动通过 ffmpeg 处理各种音频格式,无需手动转换。确保系统已安装 ffmpeg。
3.3 API 接口
可以使用 FastAPI 等 Web 框架封装 Whisper 功能,提供 HTTP API 接口。典型的接口设计包括:
语音转文字接口示例:
from fastapi import FastAPI, File, UploadFile
import whisper
app = FastAPI()
model = whisper.load_model("tiny")
@app.post("/transcribe")
async def transcribe_audio(audio: UploadFile = File(...)):
"""语音转文字接口"""
# 保存上传的音频文件
temp_path = save_temp_file(audio)
# 转写音频
result = model.transcribe(temp_path, language="zh")
# 清理临时文件
cleanup_temp_file(temp_path)
return {
"text": result["text"],
"language": result["language"]
}
4. 快速开始
4.1 最小示例
import whisper
# 加载模型(首次会自动下载)
model = whisper.load_model("tiny")
# 转写音频文件
result = model.transcribe("audio.wav", language="zh")
print(result["text"])
4.2 使用 FastAPI 接口
启动服务:
uvicorn main:app --reload
测试接口:
# 转写接口示例
curl -X POST \
-F "audio=@test.wav" \
http://localhost:8000/transcribe
4.3 Python 代码调用
import whisper
# 加载模型
model = whisper.load_model("tiny")
# 转写音频文件
result = model.transcribe("audio.wav", language="zh")
# 输出结果
print(f"转写结果: {result['text']}")
print(f"检测语言: {result['language']}")
print(f"处理时间: {result.get('processing_time', 'N/A')}")
5. 配置说明
5.1 修改模型类型
修改模型名称:
# 使用 tiny 模型(推荐,速度快)
_whisper_model = whisper.load_model("tiny", device="cpu")
# 或使用其他模型
_whisper_model = whisper.load_model("base", device="cpu")
_whisper_model = whisper.load_model("small", device="cpu")
注意:如需提高转写准确度,可以:
- 使用更大的模型(small/medium/large),但速度会变慢
- 确保音频质量良好,减少背景噪音
- 指定正确的语言参数,避免自动检测带来的延迟
5.2 设备选择
# CPU 推理(默认)
model = whisper.load_model("tiny", device="cpu")
# GPU 推理(需要 CUDA)
model = whisper.load_model("tiny", device="cuda")
注意:Tiny 模型在 CPU 上运行速度已经很快,适合大多数场景。如需进一步提升速度,可以考虑:
- 使用 GPU 推理(需要安装 CUDA 版本的 PyTorch)
- 使用更小的模型(但准确度会降低)
5.3 语言指定
# 自动检测语言(默认)
result = model.transcribe("audio.wav")
# 指定语言(更快)
result = model.transcribe("audio.wav", language="zh") # 中文
result = model.transcribe("audio.wav", language="en") # 英文
参考文档
使用fuck-u-code优化代码质量
fuck-u-code 是一款专门揭露屎山代码的质量分析工具,能够评估代码的"屎山等级"并输出美观的报告,可以输出md格式报告,供大模型分析使用。
项目介绍
项目地址: https://github.com/Done-0/fuck-u-code
项目描述: Legacy-Mess Detector – assess the “legacy-mess level” of your code and output a beautiful report | 屎山代码检测器,评估代码的"屎山等级"并输出美观的报告
核心特性
- 多语言支持: Go、JS/TS、Python、Java、C/C++
- 屎山指数: 0~100 分,越高越烂
- 七维度检测: 复杂度 / 函数长度 / 注释率 / 错误处理 / 命名 / 重复度 / 结构
- 彩色终端报告: 批评也能笑着听
- Markdown 输出: 方便 AI 分析与文档集成
- 灵活配置: 摘要 / 详细模式,多语言报告
- 全程本地运行: 不上传代码,安全无忧
安装方法
方法一:Go 安装(推荐)
go install github.com/Done-0/fuck-u-code/cmd/fuck-u-code@latest
方法二:源码构建
git clone https://github.com/Done-0/fuck-u-code.git
cd fuck-u-code && go build -o fuck-u-code ./cmd/fuck-u-code
方法三:Docker 构建
docker build -t fuck-u-code .
基本使用方法
分析本地项目
# 基本分析 - 本地项目
fuck-u-code analyze /path/to/project
# 或
fuck-u-code /path/to/project
# 默认分析当前目录
fuck-u-code analyze
分析 Git 仓库
# 分析 Git 仓库(自动克隆)
fuck-u-code analyze https://github.com/user/repo.git
# 或
fuck-u-code https://github.com/user/repo
Docker 运行
docker run --rm -v "/path/to/project:/build" fuck-u-code analyze
常用选项
| 选项 | 简写 | 描述 |
|---|---|---|
--verbose | -v | 显示详细报告 |
--top N | -t | 最烂的前 N 个文件 |
--issues N | -i | 每文件显示 N 个问题 |
--summary | -s | 只看总结,不看过程 |
--markdown | -m | 输出 Markdown 格式报告 |
--lang | -l | 报告语言 (zh-CN/en-US/ru-RU) |
--exclude | -e | 排除指定目录或文件 |
--skipindex | -x | 跳过 index.js/ts 文件 |
使用示例
fuck-u-code analyze --verbose
fuck-u-code analyze --top 3
fuck-u-code analyze --lang en-US
fuck-u-code analyze --summary
fuck-u-code analyze --exclude "**/test/**"
fuck-u-code analyze --markdown > report.md
代码质量分析脚本
基于 fuck-u-code 工具,我编写了一个简单的代码质量分析脚本,用于自动生成 Markdown 格式的代码质量报告。
脚本内容
创建 analyze_code_quality.sh 文件:
#!/bin/bash
# 代码质量分析脚本
fuck-u-code analyze --markdown --lang zh-CN --skipindex --exclude "**/tests/**" > code_quality_report.md
echo "代码质量分析完成,报告已生成:code_quality_report.md"
使用方法
# 1. 创建脚本文件
nano analyze_code_quality.sh
# 将上述脚本内容复制到文件中
# 2. 赋予执行权限
chmod +x analyze_code_quality.sh
# 3. 运行脚本
./analyze_code_quality.sh
脚本参数说明
--markdown: 输出 Markdown 格式报告--lang zh-CN: 使用中文语言--skipindex: 跳过 index.js/ts 文件--exclude "**/tests/**": 排除测试目录
输出文件
code_quality_report.md- 代码质量分析报告
Markdown 输出
适合 AI 分析、文档集成、CI/CD、团队协作
fuck-u-code analyze --markdown
fuck-u-code analyze --markdown > report.md
fuck-u-code analyze --markdown --top 10 --lang en-US > report.md
默认排除路径
- 前端:
node_modules,dist,build,*.min.js等 - 后端:
vendor,bin,target,logs,migrations等
疑难解答
常见问题
command not found错误
# 把 Go bin 路径加到 PATH
export PATH="$PATH:$(go env GOPATH)/bin"
# 并写入 .bash_profile / .zshrc 等
- 权限错误
chmod +x analyze_code_quality.sh
chmod +x quick_analyze.sh
- fuck-u-code 未安装
go install github.com/Done-0/fuck-u-code/cmd/fuck-u-code@latest
- 不在项目根目录
- 确保在包含
requirements.txt和app/目录的根目录运行
- 分析失败
- 检查
logs/analysis_error.log文件 - 确保项目代码没有语法错误
报告解读
评分系统
- 分数范围: 0~100 分
- 分数含义: 越高越烂,欢迎"高分大佬"上榜
- 七维度检测:
- 复杂度分析
- 函数长度检查
- 注释率统计
- 错误处理评估
- 命名规范检查
- 重复度检测
- 代码结构分析
生成的 Markdown 报告包含
- 📊 整体质量评分
- 📈 各项指标详情
- 🔍 问题文件列表
- 💡 改进建议
改进重点
根据报告中的优先级进行代码改进,重点关注:
- 高复杂度函数
- 重复代码
- 缺少注释
- 错误处理
Markdown 转 PDF 加水印工具
一个功能强大的Markdown转PDF工具,支持GitHub风格的Markdown语法、代码文档、Mermaid图表等,并自动添加水印。专为技术文档和代码项目设计,支持中文字体渲染和批量处理。
功能特性
- Markdown → PDF(GitHub样式):表格、代码块、链接、图片;多语言代码高亮
- Mermaid 支持:流程图、时序图、甘特图自动渲染
- 水印能力:文本/图片水印、透明度/角度/密度可调,亦可输出无水印
- 中文字体:自动识别常见中文字体,渲染稳定
- 批量处理:
input/全目录一键处理,结果输出至output/ - 交互/默认双模式:交互式最少参数配置;快速模式开箱即用
- 多语言界面:中英自动/手动切换
适用:README/设计与架构(Mermaid)/API与代码文档、技术博客归档、项目报告与教学讲义;支持批量加水印或生成纯净PDF,便于团队分发协作。
快速开始
运行项目
git clone https://github.com/pinyinjj/Markdown-to-PDF-Tool.git
cd md-pdf-watermark
python main.py
程序会引导您选择操作模式:
1. 处理PDF文件(添加水印)
- 为现有PDF文件添加水印
- 支持批量处理多个PDF文件
2. 转换Markdown到PDF(添加水印)
- 将Markdown文件转换为PDF并添加水印
- 支持Mermaid图表和代码高亮
- 自动检测中文字体
3. 仅生成图片水印
- 只生成水印图片,不处理任何文件
- 适合批量生成水印素材
- 支持文本和图片两种水印类型
4. 转换Markdown到PDF(无水印)
- 将Markdown文件转换为PDF,不添加水印
- 支持Mermaid图表和代码高亮
- 适合需要纯净PDF的场景
详细安装说明
Windows 安装
安装Python
- 从 python.org 下载Python 3.8+
- 安装时勾选"Add Python to PATH"
安装依赖
pip install -r requirements.txt playwright install系统依赖(如果需要)
playwright install-deps
Linux 安装
安装Python
# Ubuntu/Debian sudo apt update sudo apt install python3 python3-pip python3-venv # CentOS/RHEL sudo yum install python3 python3-pip安装依赖
pip install -r requirements.txt playwright install系统依赖
sudo playwright install-deps
国际化支持
语言设置
程序支持英文和中文两种界面语言:
自动语言检测
程序会自动检测系统语言环境:
- 检测系统
locale设置 - 检查环境变量
LANG - 默认使用英文作为后备语言
手动语言设置
# 使用英文界面
python main.py --lang en --interactive
# 使用中文界面
python main.py --lang zh --interactive
# 自动检测(默认)
python main.py --interactive
语言切换
- 在交互式模式中,语言设置会影响所有界面文本
- 包括菜单选项、提示信息、错误消息等
- 不影响处理的文件内容
配置说明
水印配置
程序使用WatermarkConfig类管理所有配置:
class WatermarkConfig:
# 文本水印设置
GENERATE_IMAGE_FROM_TEXT = True # 是否从文本生成图片水印
TEXT_WATERMARK_FILE = "watermarks/text_watermark.png" # 文本水印图片文件名
FONT_SIZE = 36 # 字体大小
TEXT_COLOR = (68, 68, 68, 220) # 文本颜色RGBA
PADDING = 20 # 内边距
# PDF水印参数
WATERMARK_TYPE = "grid" # 水印类型:grid/insert
OPACITY = 0.2 # 透明度
ANGLE = 45 # 旋转角度
IMAGE_SCALE = 1.0 # 图片缩放
HORIZONTAL_BOXES = 3 # 水平网格数
VERTICAL_BOXES = 6 # 垂直网格数
字体配置
程序会自动检测系统中文字体,支持:
- Windows: 微软雅黑、黑体、宋体、楷体、仿宋等
- Linux: Noto Sans CJK等
如需指定字体,可设置环境变量:
export WATERMARK_FONT="/path/to/your/font.ttf"
使用方法
基本使用
- 准备文件:将PDF或Markdown文件放入
input/目录 - 运行程序:执行
python main.py - 查看结果:处理后的文件在
output/目录
调整水印样式
修改WatermarkConfig中的相关参数:
# 调整透明度
OPACITY = 0.3
# 调整角度
ANGLE = 30
# 调整网格密度
HORIZONTAL_BOXES = 4
VERTICAL_BOXES = 8
故障排除
常见问题
- Playwright浏览器未安装
playwright install
- 中文字体未找到
- 确保系统已安装中文字体
- 或设置
WATERMARK_FONT环境变量
- watermark命令未找到
- 确保已安装
pdf-watermark包 - 检查虚拟环境是否正确激活
- 确保已安装
- 权限问题
# Linux
sudo playwright install-deps
调试模式
程序会输出详细的处理信息,包括:
- 文件处理状态
- 水印生成过程
- 错误信息
依赖包说明
| 包名 | 版本 | 用途 |
|---|---|---|
| Pillow | >=9.0.0 | 图像处理,生成文本水印图片 |
| markdown | >=3.4.0 | Markdown文件处理 |
| playwright | >=1.30.0 | 浏览器自动化,PDF渲染 |
| pdf-watermark | >=0.1.0 | PDF水印添加 |
许可证
本项目采用 GPL-3.0-or-later 许可证发布。
注意:首次运行可能需要下载Playwright浏览器,请确保网络连接正常。
QGroundControl
MavSDK和QGC航点规划兼容性问题的解决方案
1. 问题背景
1.1 QGC 5.0.6 稳定版航点管理问题
版本兼容性说明
本解决方案针对以下版本进行了测试和优化:
QGroundControl (QGC) 5.0.6 稳定版在航点管理方面存在一些已知问题,这些问题主要影响无人机任务的规划和执行:
主要问题表现
航点数量异常增长
- 用户上传5个航点,QGC显示15-20个航点
- 系统自动生成额外的航点,导致任务执行异常
- 航点序列号不连续,影响任务逻辑
航点执行异常
- 无人机在航点处"徘徊"不继续执行
- 航点接受半径设置无效
- 任务执行顺序混乱
界面显示问题
- 航点列表显示不准确
- 航点属性显示错误
- 任务状态更新延迟
问题影响
- 任务可靠性下降:航点数量异常导致任务执行不可预测
- 操作效率降低:需要手动清理多余航点
- 安全风险增加:航点执行异常可能导致飞行安全问题
1.2 MAVSDK-Python MissionItem 与 QGC 兼容性问题
MAVSDK-Python 的 MissionItem 数据结构与 QGC 的航点解析机制之间存在兼容性问题,主要体现在数据结构差异和协议层面问题两个方面。在数据结构方面,MAVSDK-Python 使用 latitude_deg,longitude_deg 字段格式,而 QGC 期望 lat,lng 格式,同时 MAVSDK-Python 使用枚举类型(如 CameraAction,VehicleAction),而 QGC 期望字符串或数值,导致类型转换失败和解析错误。此外,MAVSDK-Python 的参数范围与 QGC 期望不匹配,超出范围的参数被 QGC 忽略或错误处理。
在协议层面,MAVSDK-Python 使用 MISSION_ITEM_INT 消息格式,但 QGC 5.0.6 对某些字段处理不当,存在消息序列号管理问题。同时,经纬度精度处理存在差异,高度参考系统不一致,坐标系转换错误导致航点位置计算偏差。
1.3 航点数量异常增长的根本原因分析
航点数量异常增长主要由三个核心问题导致:QGC 5.0.6 在解析航点时自动生成额外的航点,系统认为某些航点需要"连接"或"优化",但自动生成的航点没有正确的序列号;QGC 对 MISSION_ITEM_INT 消息解析不完整,某些字段被错误解释为新的航点,导致消息序列号管理混乱;上传新任务前没有完全清除旧任务,新旧航点混合导致数量异常,任务状态管理不当。
在技术实现层面,QGC 5.0.6 的解析逻辑存在缺陷,当遇到某些特殊字段时会错误地创建新航点,同时 QGC 期望连续的序列号,但 MAVSDK-Python 生成的序列号可能不连续,导致 QGC 认为有"缺失"的航点。此外,经纬度精度处理不当和坐标系转换算法错误导致航点位置计算偏差。
解决方案需要采用 MissionRaw 接口直接使用 MISSION_ITEM_INT 消息格式,避免 QGC 的自动解析和优化,确保航点数据的一致性。同时需要在上传前完全清除旧任务,确保任务状态重置,避免新旧任务混合,并使用正确的坐标系转换和参数范围控制,避免触发 QGC 的自动优化机制。
2. 技术原理
2.1 MAVSDK-Python MissionItem 数据结构
MAVSDK-Python 的 MissionItem 是航点任务的核心数据结构,包含了无人机执行任务所需的所有信息:
核心字段
class MissionItem:
# 位置信息
latitude_deg: float # 纬度(度,范围:-90 到 +90)
longitude_deg: float # 经度(度,范围:-180 到 +180)
relative_altitude_m: float # 相对起飞高度(米)
# 飞行参数
speed_m_s: float # 飞行速度(米/秒)
is_fly_through: bool # 是否飞越航点(True=飞越,False=停留)
acceptance_radius_m: float # 航点接受半径(米)
yaw_deg: float # 偏航角(度)
# 云台控制
gimbal_pitch_deg: float # 云台俯仰角(度)
gimbal_yaw_deg: float # 云台偏航角(度)
# 相机控制
camera_action: CameraAction # 相机动作
camera_photo_interval_s: float # 拍照间隔(秒)
camera_photo_distance_m: float # 拍照距离(米)
# 飞行控制
vehicle_action: VehicleAction # 飞行器动作
loiter_time_s: float # 盘旋时间(秒)
枚举类型
class CameraAction(Enum):
NONE = "NONE" # 无动作
TAKE_PHOTO = "TAKE_PHOTO" # 拍照
START_VIDEO = "START_VIDEO" # 开始录像
STOP_VIDEO = "STOP_VIDEO" # 停止录像
START_PHOTO_INTERVAL = "START_PHOTO_INTERVAL" # 开始定时拍照
STOP_PHOTO_INTERVAL = "STOP_PHOTO_INTERVAL" # 停止定时拍照
START_PHOTO_DISTANCE = "START_PHOTO_DISTANCE" # 开始距离拍照
STOP_PHOTO_DISTANCE = "STOP_PHOTO_DISTANCE" # 停止距离拍照
class VehicleAction(Enum):
NONE = "NONE" # 无动作
TAKEOFF = "TAKEOFF" # 起飞
LAND = "LAND" # 降落
HOLD = "HOLD" # 悬停
RETURN_TO_LAUNCH = "RETURN_TO_LAUNCH" # 返航
TRANSITION_TO_FW = "TRANSITION_TO_FW" # 切换到固定翼模式
TRANSITION_TO_MC = "TRANSITION_TO_MC" # 切换到多旋翼模式
2.2 MissionRaw 与 Mission 接口的区别
MAVSDK-Python 提供了两个不同的任务接口,它们在数据格式和用途上有重要区别:
Mission 接口(高层接口)
# 使用 Mission 接口
from mavsdk.mission import MissionItem, MissionPlan
# 创建航点
mission_item = MissionItem(
latitude_deg=47.641627578463165,
longitude_deg=122.14016532897949,
relative_altitude_m=10.0,
speed_m_s=5.0,
is_fly_through=True,
# ... 其他参数
)
# 创建任务计划
mission_plan = MissionPlan([mission_item])
# 上传任务
await drone.mission.upload_mission(mission_plan)
特点:
- 使用高层数据结构
- 自动处理数据转换
- 适合简单的航点任务
- 与 QGC 兼容性较差
MissionRaw 接口(底层接口)
# 使用 MissionRaw 接口
from mavsdk.mission_raw import MissionItem as RawMissionItem
# 创建原始航点
raw_item = RawMissionItem(
seq=0, # 序列号
frame=6, # 坐标系(MAV_FRAME_GLOBAL_RELATIVE_ALT_INT)
command=16, # 命令(MAV_CMD_NAV_WAYPOINT)
current=1, # 当前航点标志
autocontinue=1, # 自动继续标志
param1=0.0, # 参数1(盘旋时间)
param2=5.0, # 参数2(接受半径)
param3=0.0, # 参数3(未使用)
param4=0.0, # 参数4(偏航角)
x=476416275, # 纬度(度*1e7)
y=1221401653, # 经度(度*1e7)
z=10.0, # 高度(米)
mission_type=0 # 任务类型
)
# 上传原始任务
await drone.mission_raw.upload_mission([raw_item])
特点:
- 直接使用 MAVLink 消息格式
- 完全控制数据格式
- 与 QGC 兼容性更好
- 需要手动处理数据转换
2.3 QGC 航点解析机制
QGroundControl 5.0.6 的航点解析机制存在一些已知问题:
解析流程
接收 MAVLink 消息
# QGC 接收 MISSION_ITEM_INT 消息 def parse_mission_item(message): seq = message.seq frame = message.frame command = message.command # ... 解析其他字段数据验证和转换
# QGC 的数据验证逻辑(存在问题) if command == MAV_CMD_NAV_WAYPOINT: # 错误:某些条件下会创建额外航点 if param1 > 0: # 问题条件 create_additional_waypoint()航点显示和存储
- 将解析的航点添加到任务列表
- 更新界面显示
- 存储到本地数据库
已知问题
自动航点生成
- QGC 5.0.6 在某些条件下会自动生成额外航点
- 这些航点没有正确的序列号
- 导致任务执行异常
参数解析错误
- 某些参数被错误解释
- 导致航点属性不正确
- 影响任务执行逻辑
坐标系转换问题
- 经纬度精度处理不当
- 坐标系转换算法错误
- 导致航点位置偏移
2.4 MAVLink 协议层面的兼容性
MISSION_ITEM_INT 消息格式
# MAVLink MISSION_ITEM_INT 消息结构
class MissionItemInt:
target_system: int # 目标系统ID
target_component: int # 目标组件ID
seq: int # 序列号
frame: int # 坐标系
command: int # 命令类型
current: int # 当前航点标志
autocontinue: int # 自动继续标志
param1: float # 参数1
param2: float # 参数2
param3: float # 参数3
param4: float # 参数4
x: int # 纬度(度*1e7)
y: int # 经度(度*1e7)
z: float # 高度(米)
mission_type: int # 任务类型
关键参数说明
坐标系 (frame)
MAV_FRAME_GLOBAL_RELATIVE_ALT_INT = 6- 使用相对高度的全球坐标系
- 经纬度精度为 1e7
命令类型 (command)
MAV_CMD_NAV_WAYPOINT = 16:普通航点MAV_CMD_NAV_TAKEOFF = 22:起飞命令MAV_CMD_NAV_LAND = 21:降落命令
参数含义
param1:盘旋时间(秒)param2:接受半径(米)param3:未使用param4:偏航角(度)
兼容性要求
序列号连续性
- 航点序列号必须连续
- 从0开始递增
- 不能有重复或跳跃
参数范围
- 经纬度范围:-90 到 +90(纬度),-180 到 +180(经度)
- 高度范围:0 到 1000 米
- 速度范围:0.1 到 50 米/秒
命令兼容性
- 使用标准的 MAVLink 命令
- 避免使用非标准命令
- 确保命令参数正确
3. 解决方案架构
3.1 双层数据转换架构
本解决方案采用双层数据转换架构,将高层 MissionItem 数据转换为底层 MissionRaw 格式,确保与 QGC 5.0.6 的完美兼容:
架构组件
graph TD
A[User Data Input] --> B[Field Validation & Mapping]
B --> C[MissionItem Construction]
C --> D[Enum Type Conversion]
D --> E[MissionRaw Conversion]
E --> F[Flight Controller Upload]
F --> G[QGC Display]第一层:高层 MissionItem 数据组织
# 高层 MissionItem 数据构造
class MissionItemConstructor:
def __init__(self):
self.required_fields = [
'latitude_deg', 'longitude_deg', 'relative_altitude_m',
'speed_m_s', 'is_fly_through'
]
self.optional_fields = [
'gimbal_pitch_deg', 'gimbal_yaw_deg', 'camera_action',
'loiter_time_s', 'camera_photo_interval_s', 'acceptance_radius_m',
'yaw_deg', 'camera_photo_distance_m', 'vehicle_action'
]
def validate_and_construct(self, waypoint_data: dict) -> MissionItem:
"""验证并构造 MissionItem 对象"""
# 字段验证
self._validate_required_fields(waypoint_data)
# 数据类型转换
converted_data = self._convert_data_types(waypoint_data)
# 枚举类型转换
converted_data = self._convert_enums(converted_data)
# 构造 MissionItem
return MissionItem(**converted_data)
def validate_mission_data(self, mission_data: dict) -> List[MissionItem]:
"""验证并构造多个 MissionItem 对象"""
mission_items = []
for waypoint_data in mission_data['waypoints']:
mission_item = self.validate_and_construct(waypoint_data)
mission_items.append(mission_item)
return mission_items
第二层:底层 MissionRaw 转换器
# 底层 MissionRaw 转换器
class MissionRawConverter:
def __init__(self):
self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT = 6
self.MAV_CMD_NAV_WAYPOINT = 16
self.MAV_CMD_NAV_TAKEOFF = 22
self.MAV_CMD_NAV_LAND = 21
def convert_items_to_raw(self, items: List[MissionItem]) -> List[RawMissionItem]:
"""将 MissionItem 列表转换为 RawMissionItem 列表"""
raw_items = []
seq_counter = 0
for item in items:
# 处理特殊命令(TAKEOFF/LAND)
if item.vehicle_action == VehicleAction.TAKEOFF:
raw_items.append(self._create_takeoff_item(item, seq_counter))
seq_counter += 1
elif item.vehicle_action == VehicleAction.LAND:
raw_items.append(self._create_land_item(item, seq_counter))
seq_counter += 1
continue # LAND 后不再添加 NAV_WAYPOINT
# 创建普通航点
raw_items.append(self._create_waypoint_item(item, seq_counter))
seq_counter += 1
return raw_items
3.2 数据流程设计
完整数据流程
数据输入阶段
# 用户代码数据输入示例(符合航点输出协议) mission_data = { "selected_drone_id": "drone_001", "waypoints": [ { "latitude_deg": 47.39804, "longitude_deg": 8.54557, "relative_altitude_m": 30.0, "speed_m_s": 5.0, "is_fly_through": True, "gimbal_pitch_deg": 0.0, "gimbal_yaw_deg": 0.0, "camera_action": "NONE", "loiter_time_s": 0.0, "camera_photo_interval_s": 0.0, "acceptance_radius_m": 5.0, "yaw_deg": 0.0, "camera_photo_distance_m": 0.0, "vehicle_action": "NONE" } ] }数据验证阶段
# 字段验证 def validate_mission_data(data: dict) -> dict: # 检查必填字段 if 'selected_drone_id' not in data: raise ValueError("Missing required field: selected_drone_id") if 'waypoints' not in data: raise ValueError("Missing required field: waypoints") # 验证航点数据 for i, waypoint in enumerate(data['waypoints']): required_fields = ['latitude_deg', 'longitude_deg', 'relative_altitude_m', 'speed_m_s'] for field in required_fields: if field not in waypoint: raise ValueError(f"Missing required field in waypoint {i}: {field}") # 数据类型验证 waypoint['latitude_deg'] = float(waypoint['latitude_deg']) waypoint['longitude_deg'] = float(waypoint['longitude_deg']) # ... 其他字段验证 return data数据转换阶段
# MissionItem 构造 mission_items = [] for waypoint_data in mission_data['waypoints']: mission_item = MissionItem( latitude_deg=waypoint_data['latitude_deg'], longitude_deg=waypoint_data['longitude_deg'], relative_altitude_m=waypoint_data['relative_altitude_m'], speed_m_s=waypoint_data['speed_m_s'], is_fly_through=waypoint_data.get('is_fly_through', True), gimbal_pitch_deg=waypoint_data.get('gimbal_pitch_deg', 0.0), gimbal_yaw_deg=waypoint_data.get('gimbal_yaw_deg', 0.0), camera_action=CameraAction(waypoint_data.get('camera_action', 'NONE')), loiter_time_s=waypoint_data.get('loiter_time_s', 0.0), camera_photo_interval_s=waypoint_data.get('camera_photo_interval_s', 0.0), acceptance_radius_m=waypoint_data.get('acceptance_radius_m', 5.0), yaw_deg=waypoint_data.get('yaw_deg', 0.0), camera_photo_distance_m=waypoint_data.get('camera_photo_distance_m', 0.0), vehicle_action=VehicleAction(waypoint_data.get('vehicle_action', 'NONE')) ) mission_items.append(mission_item)MissionRaw 转换阶段
# 转换为 MissionRaw 格式 raw_items = [] for i, mission_item in enumerate(mission_items): raw_item = RawMissionItem( seq=i, frame=6, # MAV_FRAME_GLOBAL_RELATIVE_ALT_INT command=16, # MAV_CMD_NAV_WAYPOINT current=1 if i == 0 else 0, autocontinue=1 if mission_item.is_fly_through else 0, param1=mission_item.loiter_time_s, # 盘旋时间 param2=mission_item.acceptance_radius_m, # 接受半径 param3=0.0, # 未使用 param4=mission_item.yaw_deg, # 偏航角 x=int(mission_item.latitude_deg * 1e7), # 纬度*1e7 y=int(mission_item.longitude_deg * 1e7), # 经度*1e7 z=mission_item.relative_altitude_m, # 高度 mission_type=0 ) raw_items.append(raw_item)任务上传阶段
# 上传到飞行控制器 await drone.mission_raw.clear_mission() # 清除旧任务 await drone.mission_raw.upload_mission(raw_items) # 上传新任务
3.3 兼容性保证机制
QGC 兼容性保证
使用 MissionRaw 接口
- 直接使用
MISSION_ITEM_INT消息格式 - 避免 QGC 的自动解析和优化
- 确保航点数据的一致性
- 直接使用
正确的任务清除
# 上传前清除旧任务 async def upload_mission_with_clear(self, items: List[MissionItem]): # 清除旧任务 await self.drone.mission_raw.clear_mission() # 等待清除完成 await asyncio.sleep(0.5) # 转换并上传新任务 raw_items = self._convert_items_to_raw(items) await self.drone.mission_raw.upload_mission(raw_items)参数范围控制
# 确保参数在 QGC 兼容范围内 def validate_parameters(self, item: MissionItem) -> MissionItem: # 经纬度范围检查 if not (-90 <= item.latitude_deg <= 90): raise ValueError("Latitude out of range") if not (-180 <= item.longitude_deg <= 180): raise ValueError("Longitude out of range") # 高度范围检查 if not (0 <= item.relative_altitude_m <= 1000): raise ValueError("Altitude out of range") # 速度范围检查 if not (0.1 <= item.speed_m_s <= 50): raise ValueError("Speed out of range") return item
4. 核心实现
4.1 MissionItem 数据构造
字段映射和验证
# 完整的字段映射和验证实现
class MissionItemConstructor:
def __init__(self):
# 必填字段定义
self.required_fields = {
'latitude_deg': {'type': float, 'range': (-90, 90)},
'longitude_deg': {'type': float, 'range': (-180, 180)},
'relative_altitude_m': {'type': float, 'range': (0, 1000)},
'speed_m_s': {'type': float, 'range': (0.1, 50)},
'is_fly_through': {'type': bool, 'range': None}
}
# 可选字段定义
self.optional_fields = {
'gimbal_pitch_deg': {'type': float, 'range': (-90, 90), 'default': 0.0},
'gimbal_yaw_deg': {'type': float, 'range': (-180, 180), 'default': 0.0},
'camera_action': {'type': str, 'range': None, 'default': 'NONE'},
'loiter_time_s': {'type': float, 'range': (0, 3600), 'default': 0.0},
'camera_photo_interval_s': {'type': float, 'range': (0, 3600), 'default': 0.0},
'acceptance_radius_m': {'type': float, 'range': (0.1, 100), 'default': 5.0},
'yaw_deg': {'type': float, 'range': (-180, 180), 'default': 0.0},
'camera_photo_distance_m': {'type': float, 'range': (0, 1000), 'default': 0.0},
'vehicle_action': {'type': str, 'range': None, 'default': 'NONE'}
}
def validate_and_construct(self, waypoint_data: dict) -> MissionItem:
"""验证并构造 MissionItem 对象"""
try:
# 验证必填字段
self._validate_required_fields(waypoint_data)
# 处理可选字段
processed_data = self._process_optional_fields(waypoint_data)
# 数据类型转换
converted_data = self._convert_data_types(processed_data)
# 枚举类型转换
converted_data = self._convert_enums(converted_data)
# 构造 MissionItem
return MissionItem(**converted_data)
except Exception as e:
logger.error(f"MissionItem construction failed: {e}")
raise ValueError(f"Invalid waypoint data: {e}")
def _validate_required_fields(self, data: dict):
"""验证必填字段"""
for field, config in self.required_fields.items():
if field not in data:
raise ValueError(f"Missing required field: {field}")
# 类型检查
if not isinstance(data[field], config['type']):
try:
data[field] = config['type'](data[field])
except (ValueError, TypeError):
raise ValueError(f"Invalid type for {field}: expected {config['type'].__name__}")
# 范围检查
if config['range'] is not None:
min_val, max_val = config['range']
if not (min_val <= data[field] <= max_val):
raise ValueError(f"{field} out of range: {data[field]} not in [{min_val}, {max_val}]")
def _process_optional_fields(self, data: dict) -> dict:
"""处理可选字段"""
processed = data.copy()
for field, config in self.optional_fields.items():
if field not in processed:
processed[field] = config['default']
else:
# 类型转换
if not isinstance(processed[field], config['type']):
try:
processed[field] = config['type'](processed[field])
except (ValueError, TypeError):
processed[field] = config['default']
# 范围检查
if config['range'] is not None:
min_val, max_val = config['range']
if not (min_val <= processed[field] <= max_val):
processed[field] = config['default']
return processed
枚举类型转换
# 枚举类型转换实现
class EnumConverter:
def __init__(self):
self.camera_action_map = {
'NONE': CameraAction.NONE,
'TAKE_PHOTO': CameraAction.TAKE_PHOTO,
'START_VIDEO': CameraAction.START_VIDEO,
'STOP_VIDEO': CameraAction.STOP_VIDEO,
'START_PHOTO_INTERVAL': CameraAction.START_PHOTO_INTERVAL,
'STOP_PHOTO_INTERVAL': CameraAction.STOP_PHOTO_INTERVAL,
'START_PHOTO_DISTANCE': CameraAction.START_PHOTO_DISTANCE,
'STOP_PHOTO_DISTANCE': CameraAction.STOP_PHOTO_DISTANCE
}
self.vehicle_action_map = {
'NONE': VehicleAction.NONE,
'TAKEOFF': VehicleAction.TAKEOFF,
'LAND': VehicleAction.LAND,
'HOLD': VehicleAction.HOLD,
'RETURN_TO_LAUNCH': VehicleAction.RETURN_TO_LAUNCH,
'TRANSITION_TO_FW': VehicleAction.TRANSITION_TO_FW,
'TRANSITION_TO_MC': VehicleAction.TRANSITION_TO_MC
}
def convert_camera_action(self, action_str: str) -> CameraAction:
"""转换相机动作枚举"""
action_upper = str(action_str).upper()
if action_upper in self.camera_action_map:
return self.camera_action_map[action_upper]
else:
logger.warning(f"Unknown camera action: {action_str}, using NONE")
return CameraAction.NONE
def convert_vehicle_action(self, action_str: str) -> VehicleAction:
"""转换飞行器动作枚举"""
action_upper = str(action_str).upper()
if action_upper in self.vehicle_action_map:
return self.vehicle_action_map[action_upper]
else:
logger.warning(f"Unknown vehicle action: {action_str}, using NONE")
return VehicleAction.NONE
数据完整性检查
# 数据完整性检查实现
class DataIntegrityChecker:
def __init__(self):
self.coordinate_precision = 1e7 # 经纬度精度
self.max_waypoint_distance = 1000 # 最大航点距离(米)
def check_waypoint_integrity(self, waypoints: List[MissionItem]) -> bool:
"""检查航点数据完整性"""
try:
# 检查航点数量
if len(waypoints) == 0:
raise ValueError("No waypoints provided")
if len(waypoints) > 100:
raise ValueError("Too many waypoints (max 100)")
# 检查航点间距
self._check_waypoint_distances(waypoints)
# 检查高度一致性
self._check_altitude_consistency(waypoints)
# 检查速度合理性
self._check_speed_consistency(waypoints)
return True
except Exception as e:
logger.error(f"Waypoint integrity check failed: {e}")
return False
def _check_waypoint_distances(self, waypoints: List[MissionItem]):
"""检查航点间距"""
for i in range(1, len(waypoints)):
prev_wp = waypoints[i-1]
curr_wp = waypoints[i]
distance = self._calculate_distance(
prev_wp.latitude_deg, prev_wp.longitude_deg,
curr_wp.latitude_deg, curr_wp.longitude_deg
)
if distance > self.max_waypoint_distance:
raise ValueError(f"Waypoint {i} too far from previous waypoint: {distance}m")
def _check_altitude_consistency(self, waypoints: List[MissionItem]):
"""检查高度一致性"""
altitudes = [wp.relative_altitude_m for wp in waypoints]
min_alt = min(altitudes)
max_alt = max(altitudes)
if max_alt - min_alt > 500: # 高度差超过500米
logger.warning("Large altitude variation in waypoints")
def _check_speed_consistency(self, waypoints: List[MissionItem]):
"""检查速度一致性"""
speeds = [wp.speed_m_s for wp in waypoints]
for speed in speeds:
if speed < 0.1 or speed > 50:
raise ValueError(f"Invalid speed: {speed}m/s")
4.2 _convert_items_to_raw() 转换器
MAVLink 命令映射
# 完整的 MissionRaw 转换器实现
class MissionRawConverter:
def __init__(self):
# MAVLink 常量定义
self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT = 6
self.MAV_CMD_NAV_WAYPOINT = 16
self.MAV_CMD_NAV_TAKEOFF = 22
self.MAV_CMD_NAV_LAND = 21
self.MAV_CMD_NAV_RETURN_TO_LAUNCH = 20
# 坐标精度
self.COORDINATE_PRECISION = 1e7
def convert_items_to_raw(self, items: List[MissionItem]) -> List[RawMissionItem]:
"""将 MissionItem 列表转换为 RawMissionItem 列表"""
raw_items = []
seq_counter = 0
for i, item in enumerate(items):
try:
# 处理特殊命令
if item.vehicle_action == VehicleAction.TAKEOFF:
raw_items.append(self._create_takeoff_item(item, seq_counter))
seq_counter += 1
elif item.vehicle_action == VehicleAction.LAND:
raw_items.append(self._create_land_item(item, seq_counter))
seq_counter += 1
continue # LAND 后不再添加 NAV_WAYPOINT
elif item.vehicle_action == VehicleAction.RETURN_TO_LAUNCH:
raw_items.append(self._create_rtl_item(item, seq_counter))
seq_counter += 1
continue
# 创建普通航点
raw_items.append(self._create_waypoint_item(item, seq_counter))
seq_counter += 1
except Exception as e:
logger.error(f"Failed to convert waypoint {i}: {e}")
raise ValueError(f"Waypoint conversion failed: {e}")
return raw_items
def _create_waypoint_item(self, item: MissionItem, seq: int) -> RawMissionItem:
"""创建普通航点"""
return RawMissionItem(
seq=seq,
frame=self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
command=self.MAV_CMD_NAV_WAYPOINT,
current=1 if seq == 0 else 0,
autocontinue=1 if item.is_fly_through else 0,
param1=item.loiter_time_s,
param2=item.acceptance_radius_m,
param3=0.0, # 未使用
param4=item.yaw_deg,
x=int(item.latitude_deg * self.COORDINATE_PRECISION),
y=int(item.longitude_deg * self.COORDINATE_PRECISION),
z=item.relative_altitude_m,
mission_type=0
)
def _create_takeoff_item(self, item: MissionItem, seq: int) -> RawMissionItem:
"""创建起飞命令"""
return RawMissionItem(
seq=seq,
frame=self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
command=self.MAV_CMD_NAV_TAKEOFF,
current=1 if seq == 0 else 0,
autocontinue=1,
param1=0.0, # 最小俯仰角(多旋翼不使用)
param2=0.0,
param3=0.0,
param4=item.yaw_deg,
x=int(item.latitude_deg * self.COORDINATE_PRECISION),
y=int(item.longitude_deg * self.COORDINATE_PRECISION),
z=item.relative_altitude_m, # 起飞高度
mission_type=0
)
def _create_land_item(self, item: MissionItem, seq: int) -> RawMissionItem:
"""创建降落命令"""
return RawMissionItem(
seq=seq,
frame=self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
command=self.MAV_CMD_NAV_LAND,
current=1 if seq == 0 else 0,
autocontinue=0, # 降落不自动继续
param1=0.0,
param2=0.0,
param3=0.0,
param4=item.yaw_deg,
x=int(item.latitude_deg * self.COORDINATE_PRECISION),
y=int(item.longitude_deg * self.COORDINATE_PRECISION),
z=0.0, # 降落时高度为0
mission_type=0
)
def _create_rtl_item(self, item: MissionItem, seq: int) -> RawMissionItem:
"""创建返航命令"""
return RawMissionItem(
seq=seq,
frame=self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
command=self.MAV_CMD_NAV_RETURN_TO_LAUNCH,
current=1 if seq == 0 else 0,
autocontinue=0,
param1=0.0,
param2=0.0,
param3=0.0,
param4=0.0,
x=0, # 返航不需要坐标
y=0,
z=0,
mission_type=0
)
坐标系转换
# 坐标系转换实现
class CoordinateConverter:
def __init__(self):
self.precision = 1e7 # 经纬度精度
def deg_to_int7(self, degrees: float) -> int:
"""将度数转换为 int32 格式(度*1e7)"""
return int(round(degrees * self.precision))
def int7_to_deg(self, int_value: int) -> float:
"""将 int32 格式转换为度数"""
return int_value / self.precision
def validate_coordinates(self, lat: float, lon: float) -> bool:
"""验证坐标有效性"""
return (-90 <= lat <= 90) and (-180 <= lon <= 180)
def calculate_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""计算两点间距离(米)"""
from math import radians, cos, sin, asin, sqrt
# 转换为弧度
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
# 使用 Haversine 公式
dlat = lat2 - lat1
dlon = lon2 - lon1
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
c = 2 * asin(sqrt(a))
# 地球半径(米)
r = 6371000
return c * r
4.3 upload_mission() 上传流程
任务清除机制
# 完整的任务上传实现
class MissionUploader:
def __init__(self, drone):
self.drone = drone
self.converter = MissionRawConverter()
self.clear_timeout = 5.0 # 清除超时时间
async def upload_mission(self, items: List[MissionItem]) -> str:
"""上传任务到飞行控制器"""
try:
# 检查连接状态
if not await self.drone.is_connected():
raise ValueError("Drone not connected")
# 清除旧任务
await self._clear_existing_mission()
# 转换航点
raw_items = self.converter.convert_items_to_raw(items)
# 上传新任务
await self.drone.mission_raw.upload_mission(raw_items)
logger.info(f"Mission uploaded successfully: {len(raw_items)} waypoints")
return "Mission uploaded successfully"
except Exception as e:
logger.error(f"Mission upload failed: {e}")
raise ValueError(f"Mission upload failed: {e}")
async def _clear_existing_mission(self):
"""清除现有任务"""
try:
# 清除任务
await self.drone.mission_raw.clear_mission()
# 等待清除完成
await asyncio.sleep(0.5)
# 验证清除结果
mission_items = await self.drone.mission_raw.download_mission()
if len(mission_items) > 0:
logger.warning("Mission clear may not be complete")
# 再次尝试清除
await self.drone.mission_raw.clear_mission()
await asyncio.sleep(0.5)
logger.info("Existing mission cleared")
except Exception as e:
logger.error(f"Failed to clear existing mission: {e}")
raise ValueError(f"Mission clear failed: {e}")
参考文档
在 QGC 和 大模型 FastAPI 后端实现的端到端无人机语音指令框架
1. 概述
1.1 版本信息
基础版本:QGroundControl v5.0.6 Stable 开发目标:为QGC添加语音交互能力,实现通过自然语言控制无人机
1.2 二次开发说明
本实现在不修改QGC核心功能的前提下,通过以下方式扩展语音交互能力:
新增源文件:
AudioRecorderController.h/cc:独立的音频录制控制器BackendSettings.h/cc:后端配置管理Backend.SettingsGroup.json:配置项定义BottomFlyViewToolBar.qml:添加语音按钮和交互逻辑
修改现有文件:
QGroundControlQmlGlobal.cc:注册新的QML类型CMakeLists.txt:添加新源文件到构建系统MainWindow.qml:添加全局状态管理
保持兼容性:
- 所有新功能作为可选模块,不影响现有功能
- 使用QGC现有的设置管理框架
- 遵循QGC的代码风格和架构设计
- 新增组件与原有组件解耦,便于独立维护
2. 系统架构
2.1 整体架构图
graph TB
subgraph QGC["QGroundControl 客户端"]
User["用户操作<br/>(按住/松开按钮)"]
subgraph UI["QML 界面层"]
ToolBar["BottomFlyViewToolBar.qml"]
end
subgraph Controller["C++ 控制器层"]
Audio["AudioRecorderController<br/>• startRecording()<br/>• stopRecording()<br/>• 音频格式配置<br/>• WAV文件生成"]
end
subgraph QtLayer["Qt Multimedia 层"]
QtAudio["QAudioSource / QAudioFormat<br/>• 音频设备管理<br/>• PCM数据采集"]
end
User -.-> ToolBar
ToolBar -->|调用C++接口| Audio
Audio -->|使用Qt框架| QtAudio
end
subgraph Backend["后端服务器 (FastAPI)"]
APIRouter["/api/v1/voice<br/>(FastAPI 路由)"]
VoiceCommand["POST /api/v1/voice/command<br/>voice_command()"]
Whisper["Whisper 模型<br/>(small, CPU, 单例)"]
Agent["DroneAgent<br/>(LLM 解释命令)"]
Fleet["FleetManager / MavSDKWrapper<br/>(实际控制无人机)"]
LLM["外部 / 本地部署 LLM 服务<br/>"]
APIRouter --> VoiceCommand
VoiceCommand --> Whisper
Whisper --> Agent
Agent --> LLM
Agent --> Fleet
end
Dialog["结果展示<br/>对话框"]
QtAudio -->|"HTTP POST<br/>(multipart/form-data)<br/>字段: audio(WAV)"| APIRouter
Fleet -->|"执行结果字符串<br/>(含ANSI颜色)"| Agent
Agent -->|"JSON { result: '执行结果...' }"| VoiceCommand
VoiceCommand --> Dialog
style QGC fill:#e1f5ff,stroke:#01579b,stroke-width:2px
style Backend fill:#fff3e0,stroke:#e65100,stroke-width:2px
style User fill:#f3e5f5,stroke:#4a148c
style ToolBar fill:#e8f5e9,stroke:#1b5e20
style Audio fill:#fff9c4,stroke:#f57f17
style QtAudio fill:#fce4ec,stroke:#880e4f
style Dialog fill:#f1f8e9,stroke:#33691e
style APIRouter fill:#ffe0b2,stroke:#ef6c00
style VoiceCommand fill:#ffe082,stroke:#ff8f00
style Whisper fill:#fff9c4,stroke:#fbc02d
style Agent fill:#e1bee7,stroke:#6a1b9a
style Fleet fill:#c8e6c9,stroke:#2e7d32
style LLM fill:#bbdefb,stroke:#1565c02.2 数据流向:时序图
完整交互流程
sequenceDiagram
autonumber
actor User as 用户
participant UI as QML界面<br/>BottomFlyViewToolBar
participant Controller as C++控制器<br/>AudioRecorderController
participant Timer as 定时器<br/>QTimer(50ms)
participant QtAudio as Qt Multimedia<br/>QAudioSource
participant Network as 网络层<br/>XMLHttpRequest
participant APIRouter as FastAPI路由<br/>/api/v1/voice
participant VoiceAPI as 语音接口<br/>voice_command()
participant Whisper as Whisper模型<br/>small@CPU(单例)
participant Agent as DroneAgent<br/>LLM 命令解释器
participant LLM as 外部 / 本地部署 LLM 服务<br/>
participant Fleet as 机队管理器<br/>FleetManager/MavSDK
%% 阶段1: 开始录音
rect rgb(227, 242, 253)
Note over User,QtAudio: 阶段1: 开始录音
User->>+UI: 按下"发送语音"按钮
UI->>+Controller: startRecording()
Controller->>Controller: 清空旧数据<br/>_rawAudioData.clear()
Controller->>QtAudio: 创建QAudioSource
activate QtAudio
QtAudio-->>Controller: 返回音频设备
Controller->>QtAudio: start() 启动录音
QtAudio-->>Controller: 返回QIODevice
Controller->>Timer: start() 启动定时器
activate Timer
Controller-->>UI: emit isRecordingChanged(true)
UI-->>User: 显示"录音中..."
Controller-->>UI: 录音已启动
end
%% 阶段2: 周期性读取音频数据
rect rgb(243, 229, 245)
Note over Timer,Controller: 阶段2: 周期性读取音频数据
loop 每50ms循环
Timer->>Controller: timeout() 触发
Controller->>QtAudio: bytesAvailable() 检查
QtAudio-->>Controller: 可用字节数
Controller->>QtAudio: read() 读取PCM数据
QtAudio-->>Controller: PCM音频数据
Controller->>Controller: _rawAudioData.append()<br/>追加到缓冲区
Note right of Controller: 内存缓冲区持续增长
end
end
%% 阶段3: 停止录音
rect rgb(255, 243, 224)
Note over User,Controller: 阶段3: 停止录音
User->>UI: 松开按钮
UI->>Controller: stopRecording()
Controller->>Timer: stop() 停止定时器
deactivate Timer
Controller->>QtAudio: 读取剩余数据
QtAudio-->>Controller: 最后的PCM数据
Controller->>QtAudio: stop() 停止录音
deactivate QtAudio
Controller->>Controller: _createWavHeader()<br/>生成WAV头部(44字节)
Controller->>Controller: _audioData = header + _rawAudioData<br/>拼接完整WAV文件
Note right of Controller: 完整WAV文件已准备就绪
Controller-->>UI: emit recordingFinished()
Controller-->>UI: emit isRecordingChanged(false)
UI-->>User: 显示确认对话框
deactivate UI
end
%% 阶段4: 用户确认
rect rgb(232, 245, 233)
Note over User,UI: 阶段4: 用户确认
User->>+UI: 点击"是"确认发送
end
%% 阶段5: 准备网络请求
rect rgb(255, 249, 196)
Note over UI,Network: 阶段5: 准备网络请求
UI->>Controller: 获取audioData
Controller-->>UI: 返回QByteArray(WAV文件)
UI->>UI: 转换QByteArray → Uint8Array
UI->>+Network: 构建multipart/form-data
Network->>Network: 生成boundary标识
Network->>Network: 构建HTTP头部
Network->>Network: 组装ArrayBuffer
Note right of Network: 完整HTTP请求已构建完成
UI->>Controller: clearAudioData() 立即清理
Note right of Controller: 内存清理(关键优化点)
end
%% 阶段6: 发送HTTP请求
rect rgb(252, 228, 236)
Note over Network,APIRouter: 阶段6: 发送HTTP请求
Network->>+APIRouter: POST /api/v1/voice/command<br/>Content-Type: multipart/form-data<br/>Body: audio=WAV
UI-->>User: 显示"等待响应..."
deactivate UI
end
%% 阶段7: 后端处理(语音 → 文本 → 命令 → 控制)
rect rgb(224, 242, 241)
Note over APIRouter,Fleet: 阶段7: 后端处理
APIRouter->>+VoiceAPI: 路由到 voice_command()<br/>依赖注入 DroneAgent
VoiceAPI->>VoiceAPI: 校验文件名/扩展名/内容长度
VoiceAPI->>VoiceAPI: 写入临时文件(temp.wav)
VoiceAPI->>+Whisper: get_whisper_model()<br/>单例加载 small 模型
Whisper-->>VoiceAPI: model 实例
VoiceAPI->>Whisper: transcribe(temp.wav)
Whisper-->>VoiceAPI: transcribed_text(自然语言命令)
VoiceAPI->>VoiceAPI: 删除临时文件
VoiceAPI->>+Agent: await process(transcribed_text)
Agent->>Agent: 构造控制提示词<br/>检查机队状态
Agent->>+Fleet: get_fleet_status()<br/>仅使用已连接无人机
Fleet-->>Agent: 当前无人机状态
Agent->>+LLM: chat.completions.create()<br/>生成控制代码
LLM-->>Agent: Python 控制代码
Agent->>Agent: 在受限作用域内执行代码<br/>调用 FleetManager/MavSDK
Agent-->>VoiceAPI: 执行结果字符串<br/>(含ANSI颜色/Success等)
VoiceAPI-->>-APIRouter: AgentResponse(result=...)
APIRouter-->>-Network: JSON响应<br/>{result: "..."}
end
%% 阶段8: 处理响应
rect rgb(225, 245, 254)
Note over Network,User: 阶段8: 处理响应
Network-->>+UI: onreadystatechange<br/>xhr.responseText
UI->>UI: parseResponseText()<br/>提取result字段
UI->>UI: ansiToHtml()<br/>转换ANSI颜色→HTML
UI->>Controller: clearAudioData() 防御性清理
deactivate Controller
Note right of Controller: 确保内存已释放
UI-->>User: 显示结果对话框<br/>可选择复制
deactivate UI
User->>User: 查看执行结果
end3. 核心组件
3.1 AudioRecorderController(C++音频控制器)
类设计
class AudioRecorderController : public QObject
{
Q_OBJECT
QML_ELEMENT
// QML可访问属性
Q_PROPERTY(bool isRecording READ isRecording NOTIFY isRecordingChanged)
Q_PROPERTY(QByteArray audioData READ audioData NOTIFY audioDataChanged)
Q_PROPERTY(int audioDataSize READ audioDataSize NOTIFY audioDataChanged)
public:
explicit AudioRecorderController(QObject *parent = nullptr);
~AudioRecorderController();
// 公共方法 - QML可调用
Q_INVOKABLE void startRecording();
Q_INVOKABLE void stopRecording();
Q_INVOKABLE void clearAudioData();
signals:
void isRecordingChanged(bool isRecording);
void audioDataChanged();
void recordingFinished();
void errorOccurred(const QString &errorString);
private:
QAudioSource* _audioSource; // 音频输入设备
QIODevice* _audioIODevice; // I/O设备接口
QTimer* _readTimer; // 定时读取音频数据
QAudioFormat _audioFormat; // 音频格式配置
QByteArray _rawAudioData; // 原始PCM数据
QByteArray _audioData; // 完整WAV数据(含头部)
bool _isRecording; // 录制状态
};
3.2 音频格式配置
AudioRecorderController::AudioRecorderController(QObject *parent)
: QObject(parent)
, _audioSource(nullptr)
, _audioIODevice(nullptr)
, _readTimer(new QTimer(this))
, _isRecording(false)
{
// 配置音频格式: 16位PCM, 44100Hz采样率, 单声道
_audioFormat.setSampleRate(44100);
_audioFormat.setChannelCount(1);
_audioFormat.setSampleFormat(QAudioFormat::SampleFormat::Int16);
// 设置定时器周期性读取音频数据(每50ms)
_readTimer->setInterval(50);
_readTimer->setSingleShot(false);
connect(_readTimer, &QTimer::timeout, this,
&AudioRecorderController::_onAudioDataReady);
}
3.3 开始录制流程
void AudioRecorderController::startRecording()
{
// 1. 状态检查
if (_isRecording) {
qCWarning(AudioRecorderControllerLog) << "Already recording";
return;
}
// 2. 清空旧数据
_rawAudioData.clear();
_audioData.clear();
// 3. 创建音频源
if (_audioSource) {
_audioSource->deleteLater();
}
_audioSource = new QAudioSource(_audioFormat, this);
// 4. 启动音频输入
_audioIODevice = _audioSource->start();
if (!_audioIODevice) {
emit errorOccurred("Failed to start audio input device");
return;
}
// 5. 启动定时器
_readTimer->start();
// 6. 更新状态
_isRecording = true;
emit isRecordingChanged(_isRecording);
}
3.4 周期性数据读取
void AudioRecorderController::_onAudioDataReady()
{
if (!_audioIODevice || !_isRecording) {
return;
}
// 检查可用字节数
qint64 bytesAvailable = _audioIODevice->bytesAvailable();
if (bytesAvailable > 0) {
// 读取音频数据并追加到缓冲区
QByteArray data = _audioIODevice->read(bytesAvailable);
if (!data.isEmpty()) {
_rawAudioData.append(data);
qCDebug(AudioRecorderControllerLog)
<< "Read audio data:" << data.size()
<< "bytes, total:" << _rawAudioData.size();
}
}
}
3.5 停止录制并生成WAV文件
void AudioRecorderController::stopRecording()
{
if (!_isRecording) {
return;
}
// 1. 停止定时器
_readTimer->stop();
// 2. 读取剩余数据
if (_audioIODevice && _audioIODevice->bytesAvailable() > 0) {
QByteArray remainingData = _audioIODevice->readAll();
if (!remainingData.isEmpty()) {
_rawAudioData.append(remainingData);
}
}
// 3. 停止音频源
if (_audioSource) {
_audioSource->stop();
}
_audioIODevice = nullptr;
// 4. 生成WAV文件头部
if (!_rawAudioData.isEmpty()) {
QByteArray wavHeader = _createWavHeader(_rawAudioData.size(),
_audioFormat);
_audioData = wavHeader + _rawAudioData;
// WAV文件结构验证
qCDebug(AudioRecorderControllerLog)
<< "WAV total size:" << _audioData.size();
}
// 5. 更新状态并发出信号
_isRecording = false;
emit isRecordingChanged(_isRecording);
emit recordingFinished();
emit audioDataChanged();
}
3.6 WAV文件头部生成
QByteArray AudioRecorderController::_createWavHeader(
qint32 dataSize,
const QAudioFormat &format) const
{
QByteArray header;
QDataStream stream(&header, QIODevice::WriteOnly);
stream.setByteOrder(QDataStream::LittleEndian);
// 计算格式参数
qint16 numChannels = format.channelCount(); // 1 (单声道)
qint32 sampleRate = format.sampleRate(); // 44100
qint16 bytesPerSample = format.bytesPerSample(); // 2 (16位)
qint16 bitsPerSample = bytesPerSample * 8; // 16
qint32 byteRate = sampleRate * numChannels * bytesPerSample;
qint16 blockAlign = numChannels * bytesPerSample;
// RIFF头部 (12字节)
stream.writeRawData("RIFF", 4);
qint32 fileSize = 36 + dataSize; // 总大小 - 8
stream << fileSize;
stream.writeRawData("WAVE", 4);
// fmt块 (24字节)
stream.writeRawData("fmt ", 4);
qint32 fmtChunkSize = 16; // PCM格式块大小
stream << fmtChunkSize;
qint16 audioFormat = 1; // PCM = 1
stream << audioFormat;
stream << numChannels;
stream << sampleRate;
stream << byteRate;
stream << blockAlign;
stream << bitsPerSample;
// data块头 (8字节)
stream.writeRawData("data", 4);
stream << dataSize;
// 验证头部大小 (应为44字节)
if (header.size() != 44) {
qCWarning(AudioRecorderControllerLog)
<< "WAV header size incorrect:" << header.size();
}
return header;
}
WAV文件格式详解:
偏移 大小 字段 值
─────────────────────────────────────
0 4 ChunkID "RIFF"
4 4 ChunkSize 文件大小-8
8 4 Format "WAVE"
12 4 Subchunk1ID "fmt "
16 4 Subchunk1Size 16 (PCM)
20 2 AudioFormat 1 (PCM)
22 2 NumChannels 1 (单声道)
24 4 SampleRate 44100
28 4 ByteRate 88200 (=44100*1*2)
32 2 BlockAlign 2 (=1*2)
34 2 BitsPerSample 16
36 4 Subchunk2ID "data"
40 4 Subchunk2Size PCM数据大小
44 N Data 实际音频数据
音频质量与性能平衡
| 参数 | 值 | 理由 |
|---|---|---|
| 采样率 | 44100 Hz | 标准CD音质,适合所有语音识别系统 |
| 位深度 | 16 bit | 足够的动态范围,比8bit清晰,比24bit节省空间 |
| 声道数 | 1 (单声道) | 语音识别不需要立体声,减少50%数据量 |
| 读取间隔 | 50 ms | 平衡响应性和CPU占用 |
数据量计算:
每秒数据量 = 44100 Hz × 2 bytes × 1 channel = 88,200 bytes/s ≈ 86 KB/s
10秒录音 ≈ 860 KB
30秒录音 ≈ 2.5 MB
音频参数配置
如需修改音频参数,编辑 AudioRecorderController 构造函数:
AudioRecorderController::AudioRecorderController(QObject *parent)
{
// 可自定义的参数
_audioFormat.setSampleRate(44100); // 采样率:8000, 16000, 44100, 48000
_audioFormat.setChannelCount(1); // 声道数:1=单声道, 2=立体声
_audioFormat.setSampleFormat( // 采样格式
QAudioFormat::SampleFormat::Int16 // Int16, Int32, Float
);
// 读取间隔(毫秒)
_readTimer->setInterval(50); // 10-100ms范围推荐
}
常见配置组合:
| 场景 | 采样率 | 位深度 | 声道 | 数据率 |
|---|---|---|---|---|
| 语音识别(推荐) | 44100Hz | 16bit | 单声道 | 86KB/s |
| 低质量/节省带宽 | 16000Hz | 16bit | 单声道 | 31KB/s |
| 高质量/专业录音 | 48000Hz | 24bit | 立体声 | 281KB/s |
| 电话音质 | 8000Hz | 16bit | 单声道 | 16KB/s |
3.8 QML界面层
主要组件结构
Item {
id: _root
// ========== 属性定义 ==========
property bool isBackendAlive: false
property bool isWaitingForBackendResponse: false
property bool isRecording: audioRecorderController.isRecording
// 后端API URLs
property string backendBaseUrl:
QGroundControl.settingsManager.backendSettings.backendBaseUrl.value
property string voiceApiPath:
QGroundControl.settingsManager.backendSettings.voiceApiPath.value
property string voiceApiUrl: _buildCompleteUrl(voiceApiPath)
// ========== 音频录制控制器 ==========
AudioRecorderController {
id: audioRecorderController
onRecordingFinished: {
// 录音完成,显示确认对话框
var dialog = voiceConfirmDialogComponent.createObject(mainWindow)
dialog.open()
}
onErrorOccurred: function(errorString) {
// 处理录音错误
console.error("Audio recording error:", errorString)
}
}
// ========== 录音按钮 ==========
QGCButton {
id: microphoneButton
text: isRecording ? qsTr("录音中...") : qsTr("发送语音")
enabled: !isWaitingForBackendResponse
onPressed: {
_root.startRecording()
}
onReleased: {
_root.stopRecording()
}
}
// ========== 确认对话框 ==========
Component {
id: voiceConfirmDialogComponent
QGCPopupDialog {
title: qsTr("发送语音指令")
buttons: Dialog.Yes | Dialog.No
onAccepted: {
var audioData = audioRecorderController.audioData
if (audioData && audioData.length > 0) {
_root.sendVoiceCommandFromMemory(audioData)
}
}
onRejected: {
audioRecorderController.clearAudioData()
}
}
}
// ========== 响应显示对话框 ==========
Component {
id: responseDialogComponent
QGCPopupDialog {
property string responseText: ""
property string plainText: ""
property string coloredHtml: ""
// 可滚动的文本显示区域
ScrollView {
TextEdit {
text: responseDialog.coloredHtml
textFormat: TextEdit.RichText
readOnly: true
selectByMouse: true
}
}
// 复制按钮
QGCButton {
text: qsTr("复制")
onClicked: {
clipboard.text = responseDialog.plainText
}
}
}
}
}
启动录制
function startRecording() {
try {
audioRecorderController.startRecording()
console.log("Started recording to memory")
} catch (e) {
console.error("Failed to start recording:", e)
}
}
停止录制
function stopRecording() {
try {
audioRecorderController.stopRecording()
console.log("Stopped recording")
} catch (e) {
console.error("Failed to stop recording:", e)
}
}
发送语音命令
function sendVoiceCommandFromMemory(audioData) {
if (!audioData || audioData.length === 0) {
console.error("No audio data to send")
audioRecorderController.clearAudioData()
return
}
// 设置等待状态
mainWindow.isWaitingForBackendResponse = true
try {
// ========== 数据转换 ==========
// 将QByteArray转换为Uint8Array
var uint8Array = new Uint8Array(audioData.length)
for (var i = 0; i < audioData.length; i++) {
uint8Array[i] = audioData.charCodeAt(i) & 0xFF
}
// ========== 构建multipart请求 ==========
var boundary = "----WebKitFormBoundary" + new Date().getTime()
var fileName = "recording_" + new Date().getTime() + ".wav"
// 辅助函数:字符串转字节数组
function stringToBytes(str) {
var bytes = new Uint8Array(str.length)
for (var i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i) & 0xFF
}
return bytes
}
// 构建multipart各部分
var boundaryStr = "--" + boundary + "\r\n"
var dispositionStr = 'Content-Disposition: form-data; ' +
'name="audio"; filename="' + fileName + '"\r\n'
var contentTypeStr = "Content-Type: audio/wav\r\n\r\n"
var crlfStr = "\r\n"
var closingStr = "--" + boundary + "--\r\n"
var boundaryBytes = stringToBytes(boundaryStr)
var dispositionBytes = stringToBytes(dispositionStr)
var contentTypeBytes = stringToBytes(contentTypeStr)
var crlfBytes = stringToBytes(crlfStr)
var closingBytes = stringToBytes(closingStr)
// ========== 组装完整请求体 ==========
var totalSize = boundaryBytes.length +
dispositionBytes.length +
contentTypeBytes.length +
uint8Array.length +
crlfBytes.length +
closingBytes.length
var multipartBuffer = new ArrayBuffer(totalSize)
var multipartView = new Uint8Array(multipartBuffer)
var offset = 0
// 按顺序复制各部分
multipartView.set(boundaryBytes, offset)
offset += boundaryBytes.length
multipartView.set(dispositionBytes, offset)
offset += dispositionBytes.length
multipartView.set(contentTypeBytes, offset)
offset += contentTypeBytes.length
multipartView.set(uint8Array, offset)
offset += uint8Array.length
multipartView.set(crlfBytes, offset)
offset += crlfBytes.length
multipartView.set(closingBytes, offset)
// ========== 发送HTTP请求 ==========
var xhr = new XMLHttpRequest()
xhr.open("POST", voiceApiUrl, true)
xhr.setRequestHeader("Content-Type",
"multipart/form-data; boundary=" + boundary)
xhr.setRequestHeader("Accept", "application/json")
// 立即清理音频数据
audioRecorderController.clearAudioData()
console.log("Sending audio, size:", totalSize)
// 发送请求
xhr.send(multipartBuffer)
// ========== 处理响应 ==========
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
var status = xhr.status
var body = xhr.responseText || ""
// 确保清理内存
audioRecorderController.clearAudioData()
mainWindow.isWaitingForBackendResponse = false
if (status >= 200 && status < 300) {
// 成功响应
var parsedBody = parseResponseText(body)
var displayText = parsedBody.length ?
parsedBody : qsTr("命令执行成功,但未返回内容")
var parsed = ansiToHtml(displayText)
var dialog = responseDialogComponent.createObject(
mainWindow, {
responseTitle: qsTr("命令执行结果"),
plainText: parsed.plain,
coloredHtml: parsed.colored
}
)
dialog.open()
} else {
// 错误响应
var errorText = qsTr("命令执行失败\n状态码: %1\n响应: %2")
.arg(status).arg(parseResponseText(body))
// 显示错误对话框...
}
}
}
xhr.onerror = function() {
audioRecorderController.clearAudioData()
mainWindow.isWaitingForBackendResponse = false
// 显示网络错误...
}
} catch (e) {
audioRecorderController.clearAudioData()
mainWindow.isWaitingForBackendResponse = false
console.error("Error sending voice command:", e)
}
}
音频质量与性能考虑
在发送语音命令时,音频数据的质量和性能参数直接影响传输效率和识别准确度。本实现采用以下参数配置:
音频参数配置:
| 参数 | 值 | 理由 |
|---|---|---|
| 采样率 | 44100 Hz | 标准CD音质,适合所有语音识别系统 |
| 位深度 | 16 bit | 足够的动态范围,比8bit清晰,比24bit节省空间 |
| 声道数 | 1 (单声道) | 语音识别不需要立体声,减少50%数据量 |
| 读取间隔 | 50 ms | 平衡响应性和CPU占用 |
数据量计算:
每秒数据量 = 44100 Hz × 2 bytes × 1 channel = 88,200 bytes/s ≈ 86 KB/s
10秒录音 ≈ 860 KB
30秒录音 ≈ 2.5 MB
为什么选择这些参数:
44100 Hz 采样率:这是标准CD音质,能够完整保留人声频率范围(20 Hz - 20 kHz),确保语音识别系统能够准确识别语音内容。虽然16000 Hz也能满足基本需求,但44100 Hz提供了更好的音质冗余,适应不同环境下的录音质量变化。
16 bit 位深度:提供了65536个量化级别,足够捕捉人声的动态范围。8 bit(256级)会导致明显的量化噪声,而24 bit虽然更精确,但会增加50%的数据量,对语音识别来说收益有限。
单声道:语音识别主要关注频率内容和时间序列,不需要立体声的空间信息。使用单声道可以将数据量减半,显著降低网络传输负担和内存占用。
50 ms 读取间隔:在录音过程中,每50毫秒读取一次音频数据,既能及时响应录音状态变化,又不会过度占用CPU资源。过短的间隔(如10 ms)会增加CPU开销,过长的间隔(如100 ms)会导致内存缓冲区过大。
常见配置组合对比:
| 场景 | 采样率 | 位深度 | 声道 | 数据率 | 适用性 |
|---|---|---|---|---|---|
| 语音识别(推荐) | 44100Hz | 16bit | 单声道 | 86KB/s | ✅ 最佳平衡 |
| 低质量/节省带宽 | 16000Hz | 16bit | 单声道 | 31KB/s | ⚠️ 音质可能不足 |
| 高质量/专业录音 | 48000Hz | 24bit | 立体声 | 281KB/s | ❌ 过度配置 |
| 电话音质 | 8000Hz | 16bit | 单声道 | 16KB/s | ❌ 音质较差 |
性能影响:
- 网络传输:10秒录音约860 KB,即使在较慢的网络环境下也能快速上传
- 内存占用:单次录音的内存占用可控,不会导致内存溢出
- CPU占用:50 ms读取间隔保证了流畅的录音体验,不会造成明显的CPU负担
如需修改音频参数,可在 AudioRecorderController 构造函数中调整:
AudioRecorderController::AudioRecorderController(QObject *parent)
{
// 可自定义的参数
_audioFormat.setSampleRate(44100); // 采样率:8000, 16000, 44100, 48000
_audioFormat.setChannelCount(1); // 声道数:1=单声道, 2=立体声
_audioFormat.setSampleFormat( // 采样格式
QAudioFormat::SampleFormat::Int16 // Int16, Int32, Float
);
// 读取间隔(毫秒)
_readTimer->setInterval(50); // 10-100ms范围推荐
}
multipart/form-data
与上面的 sendVoiceCommandFromMemory 函数对应,音频数据通过 multipart/form-data 编码发送到后端。
什么是 multipart/form-data
multipart/form-data 是 HTTP 协议中定义的一种内容编码类型(Content-Type),用于在单个 HTTP 请求中传输多个不同类型的数据块。
核心特点:
- 多部分传输:可在一个请求中同时发送文本字段和二进制文件
- 边界分隔:使用 boundary(边界标识符)分隔不同的数据部分
- 独立描述:每个部分都有自己的 Content-Disposition 和 Content-Type
- 二进制安全:能够正确传输任意二进制数据,不会损坏文件内容
为什么使用 multipart/form-data
文件上传的标准方式
传统的表单编码方式 application/x-www-form-urlencoded 存在局限:
- 只能传输文本数据
- 会对二进制数据进行 URL 编码,导致文件体积膨胀 33%
- 无法高效传输大文件
而 multipart/form-data 专为文件上传设计:
- 原样传输二进制数据,无额外开销
- 可同时上传多个文件
- 支持混合文本字段和文件
在本项目中的应用场景
QGC 需要将录制的 WAV 音频文件发送到后端服务器进行语音识别:
sequenceDiagram
participant Client as 客户端(QGC)
participant Server as 服务端(后端API)
Note over Client: 录制音频<br/>(内存中的WAV文件)
Client->>+Server: POST multipart/form-data
Note right of Client: 字段名: "audio"<br/>文件名: "recording.wav"<br/>内容类型: audio/wav<br/>二进制数据: [WAV bytes]
Note over Server: 语音识别 + 意图理解<br/>+ 命令执行
Server-->>-Client: JSON响应
Note left of Server: { "result": "执行结果" }选择 multipart/form-data 的原因:
- WAV 是二进制格式,必须保持原始字节不变
- 服务端可通过标准的文件上传处理库解析
- 符合 Web 标准,兼容性好
- 便于调试(可用 curl、Postman 等工具测试)
与其他格式的对比
| 编码格式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
application/x-www-form-urlencoded | 简单文本表单 | 简单,历史悠久 | 不支持文件,二进制数据效率低 |
application/json | API 数据交换 | 结构清晰,易解析 | 二进制需 Base64 编码(+33%体积) |
multipart/form-data | 文件上传 | 二进制高效,标准化 | 格式复杂,需手动构建 |
application/octet-stream | 纯二进制流 | 最简单 | 无元数据,无法传递文件名等信息 |
格式结构说明
基本组成:
请求头:
Content-Type: multipart/form-data; boundary=<边界标识符>
请求体:
--<边界标识符> ← 开始边界
Content-Disposition: form-data; ... ← 字段描述
Content-Type: ... ← 内容类型
← 空行(重要!)
[数据内容] ← 实际数据
--<边界标识符>-- ← 结束边界(多了两个横杠)
关键概念:
- Boundary(边界):唯一标识符,不能出现在数据内容中
- CRLF(\r\n):每行必须以
\r\n结尾(HTTP 标准) - Content-Disposition:描述字段名(name)和文件名(filename)
- Content-Type:指定数据的 MIME 类型
完整请求示例
POST /api/v1/voice/command HTTP/1.1
Host: 127.0.0.1:8000
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1234567890
Content-Length: 44132
Accept: application/json
------WebKitFormBoundary1234567890
Content-Disposition: form-data; name="audio"; filename="recording_1234567890.wav"
Content-Type: audio/wav
RIFF....WAVE....data[二进制音频数据]
------WebKitFormBoundary1234567890--
构建步骤
// 1. 生成唯一boundary
var boundary = "----WebKitFormBoundary" + new Date().getTime()
// 2. 构建各个部分(所有部分都是Uint8Array)
var parts = {
boundary: "--" + boundary + "\r\n",
disposition: 'Content-Disposition: form-data; name="audio"; filename="file.wav"\r\n',
contentType: "Content-Type: audio/wav\r\n\r\n",
audioData: [WAV文件的二进制数据],
crlf: "\r\n",
closing: "--" + boundary + "--\r\n"
}
// 3. 计算总大小
var totalSize = parts.boundary.length +
parts.disposition.length +
parts.contentType.length +
parts.audioData.length +
parts.crlf.length +
parts.closing.length
// 4. 分配ArrayBuffer
var buffer = new ArrayBuffer(totalSize)
var view = new Uint8Array(buffer)
// 5. 按顺序复制
var offset = 0
view.set(parts.boundary, offset); offset += parts.boundary.length
view.set(parts.disposition, offset); offset += parts.disposition.length
view.set(parts.contentType, offset); offset += parts.contentType.length
view.set(parts.audioData, offset); offset += parts.audioData.length
view.set(parts.crlf, offset); offset += parts.crlf.length
view.set(parts.closing, offset)
// 6. 发送
xhr.send(buffer)
4. 端到端语音链路
4.1 后端整体角色与接口约束
- 后端框架:基于一个现代的 Python Web 框架构建,对外提供 REST 风格 API。
- 版本管理:所有接口统一挂载在一个版本前缀之下,例如
/api/v1,便于未来扩展与兼容。 - 语音相关接口:
- 语音转命令接口:
POST /api/v1/voice/command - 语音转文字接口(测试语音):
POST /api/v1/voice/transcribe
- 语音转命令接口:
- 与 QGC 对接方式:
- QGC 前端通过
multipart/form-data上传字段名为audio的音频文件。 - 后端返回 JSON 结构,主体字段为
result或text等,用于承载执行结果或转写文字。
- QGC 前端通过
4.2 后端分层结构
API 接口层:
- 暴露
/voice/command和/voice/transcribe等 HTTP 接口。 - 负责请求解析、基础校验、依赖注入以及异常到 HTTP 错误码的转换。
- 暴露
语音处理层:
- 接收上传音频,完成格式和内容的基础检查(例如:文件是否为空、扩展名是否在允许列表中)。
- 将音频内容写入临时介质或缓冲区,以便后续语音识别模型使用。
- 调用本地语音识别模型(如 Whisper small@CPU,单例加载),得到转写文本。
智能体与无人机控制层:
- 提供一个“无人机智能体”对象,对外暴露
process(command_text, task_type)等高层方法。 - 内部持有一个“机队管理器”对象,用于实际下发飞行指令(如起飞、降落、绕圈、飞往航点等)。
- 通过外部大模型(LLM)完成自然语言到控制代码的转换,并在受控环境中执行。
- 提供一个“无人机智能体”对象,对外暴露
配置与异常处理层:
- 定义一组专门的异常类型(例如:音频为空、音频格式不支持、语音模型不可用、转写结果为空、智能体未初始化等),并由统一异常处理器转换为结构化 HTTP 响应。
4.3 语音转命令接口:逻辑流程
语音转命令接口的逻辑可以概括为以下步骤:
接收上传音频
- 使用一个通用的文件上传参数(例如
audio_file),通过multipart/form-data方式接收。 - 若文件名缺失或内容为空,抛出如
EmptyAudioError之类的业务异常。
- 使用一个通用的文件上传参数(例如
文件格式与内容校验
- 允许扩展名集合如:
{".wav", ".mp3", ".flac", ".m4a", ".ogg", ".webm", ".mpeg", ".mp4"}。 - 若扩展名不在允许列表中,抛出如
UnsupportedAudioFormatError异常,并在错误信息中提示支持的格式。 - QGC 端推荐统一上传标准 WAV(PCM, 44100Hz, 16bit, 单声道),以简化链路和调试。
- 允许扩展名集合如:
写入临时介质并调用语音模型
- 将上传的二进制内容写入一个临时路径或缓冲区,供语音识别模型使用。
- 通过类似
get_speech_model()的单例函数获取语音识别模型实例:- 首次调用时完成模型加载,并捕获缺少依赖、加载失败等情况。
- 后续请求复用同一个模型实例,避免重复加载导致的性能问题。
- 调用模型的
transcribe(temp_audio)方法获得transcribed_text。 - 无论成功与否,都在合适时机删除临时音频文件或释放缓冲区,避免磁盘/内存泄漏。
- 若
transcribed_text为空,抛出如AudioTranscribeError的语义化异常。
调用智能体进行命令解释与执行
- 通过依赖注入或全局管理函数获取当前的“无人机智能体”实例(例如
get_drone_agent())。 - 若智能体尚未正确初始化(例如机队管理器不可用等),直接返回指引用户检查环境的配置提示,而不是继续执行。
- 调用智能体的异步方法,例如:
agent.process(transcribed_text, task_type="control")
- 在
task_type="control"场景下,智能体内部的大致逻辑为:- 读取当前机队状态,只允许对“已连接无人机”进行操作。
- 若无可用无人机,直接返回例如“未连接任何无人机”的错误文案,而不是尝试自行连接。
- 构造上游约束清晰的提示词,将用户自然语言命令、可用方法列表和安全限制一同输入 LLM。
- 从 LLM 返回中提取 Python 控制代码片段,在受限制的命名空间内执行,仅暴露受控的机队管理对象和必要的工具方法。
- 汇总“生成代码文本 + 实际执行结果”,拼接成一个富文本字符串,作为最终返回值。
- 通过依赖注入或全局管理函数获取当前的“无人机智能体”实例(例如
统一结果封装与返回
- 将智能体返回的字符串包装到统一的响应模型中,例如:
{ "result": "<带或不带 ANSI 颜色的执行结果文本>" }
- 发生异常时,将内部异常转换为结构化 JSON 错误响应,包含至少:
- 机器可读的错误类型(如
"error_type": "empty_audio") - 面向用户的错误提示(如
"message": "音频文件为空,请重新录制后再试")
- 机器可读的错误类型(如
- 将智能体返回的字符串包装到统一的响应模型中,例如:
4.4 语音转文字接口(测试接口)
除语音转命令外,后端还提供一个纯“语音转文字”能力,主要用于:
- 调试语音识别链路(确认录音质量与模型表现)。
- 为其他上层应用提供字幕/转写服务。
该接口的典型行为:
- 与
/voice/command相同方式接收multipart/form-data的音频文件。 - 复用相同的语音识别模型,将音频转写为文本。
- 返回结构化响应,例如:
text: 转写得到的文本内容。language: 语言标识(如"zh")。duration: 音频时长的字符串表示。processing_time: 服务端处理耗时的字符串表示。
4.5 端到端时序总结(后端视角)
结合前端章节中的整体时序图,从后端视角可以将关键步骤概括为:
- 接收请求:收到
POST /api/v1/voice/command请求,Content-Type 为multipart/form-data,字段名为audio。 - 基础校验:检查文件名、扩展名、内容长度,过滤掉明显无效请求。
- 语音转写:将音频内容交给本地语音识别模型,得到自然语言
transcribed_text。 - 智能体处理:调用无人机智能体的
process()方法,由 LLM 生成受约束的控制代码并执行,严格遵守“只操作已连接无人机、不自行连接”的规则。 - 结果封装:将执行结果封装成统一 JSON 响应返回前端,可包含 ANSI 颜色信息以便前端渲染。
- 资源清理:确保临时音频文件和中间缓冲在成功或异常场景下均被正确清理,避免长期资源泄漏。
5. API 接口规范
5.1 语音命令接口
请求规范
POST /api/v1/voice/command
Content-Type: multipart/form-data; boundary=<boundary>
Accept: application/json
请求体(multipart格式):
------WebKitFormBoundary1234567890
Content-Disposition: form-data; name="audio"; filename="recording_1234567890.wav"
Content-Type: audio/wav
[WAV文件二进制数据]
------WebKitFormBoundary1234567890--
WAV文件格式要求:
- 格式:PCM
- 采样率:44100 Hz
- 位深度:16 bit
- 声道数:1 (单声道)
- 文件格式:标准WAV (RIFF header)
响应规范
成功响应(200 OK):
{
"result": "命令执行成功的详细信息"
}
result字段可以包含:
- 纯文本
- 带ANSI颜色代码的文本
- 多行文本(\n换行)
ANSI颜色代码示例:
\033[32m成功\033[0m: 起飞命令已执行
\033[33m警告\033[0m: 电池电量较低
\033[31m错误\033[0m: GPS信号弱
6. 附录:文件位置参考
C++文件
src/QmlControls/AudioRecorderController.h - 音频控制器头文件
src/QmlControls/AudioRecorderController.cc - 音频控制器实现
src/Settings/BackendSettings.h - 后端设置头文件
src/Settings/BackendSettings.cc - 后端设置实现
src/QmlControls/QGroundControlQmlGlobal.cc - QML类型注册
JSON配置文件
src/Settings/Backend.SettingsGroup.json - 后端设置配置
QML文件
src/QmlControls/BottomFlyViewToolBar.qml - 底部工具栏(含语音交互)
src/UI/MainWindow.qml - 主窗口状态管理
构建配置
src/QmlControls/CMakeLists.txt - 添加AudioRecorderController
参考文档
QGroundControl Docker 进阶构建指南
版本约束: 本文档基于 QGroundControl 5.0.6 版本编写
1. Android 构建
# 在项目根目录执行
./deploy/docker/run-docker-android.sh
依赖版本
| 组件 | 版本 |
|---|---|
| 基础镜像 | Ubuntu 22.04 |
| Java | OpenJDK 17 |
| Qt | 6.6.3 |
| Android SDK | 34 |
| Build Tools | 34.0.0 |
| NDK | 25.1.8937393 (25B) |
| Platform | android-28 (Android 9) |
| 构建工具 | CMake + Ninja |
| 时区 | Asia/Shanghai |
Qt 版本详情:
为什么 Android 使用 Qt 6.6.3?
在官方文档中,对QGC v5.0.6 的 Qt 版本要求明确指定为6.8.3,但因为NDK和Herelink兼容性问题,只能选择6.6.3进行编译。
- NDK 兼容性 - Qt 6.6.3 与 Android NDK 25.1.8937393 有最佳兼容性
- Herelink 支持 - 该版本对 Herelink 设备有完整的支持
- 构建工具链 - 与 CMake 3.24+ 和 Android SDK 34 配合良好
安装的 Qt 模块:
- qtcharts, qtpositioning, qtspeech, qt5compat
- qtmultimedia, qtserialport, qtimageformats
- qtshadertools, qtconnectivity, qtquick3d
- qtsensors, qtlocation
1.1 支持版本
架构支持: 构建同时支持两种架构:
armeabi-v7a(32位 ARM)arm64-v8a(64位 ARM)
Android 版本支持:
| 项目 | 版本 | 说明 |
|---|---|---|
| 最低版本 (minSdk) | API 28 (Android 9.0) | 设备必须运行 Android 9.0 或更高版本 |
| 目标版本 (targetSdk) | API 35 (Android 15) | 针对 Android 15 优化 |
| 编译版本 (compileSdk) | API 34 (Android 14) | 使用 Android 14 SDK 编译 |
- ✅ Android 9.0 (Pie, API 28)
- ✅ Android 10 (Q, API 29)
- ✅ Android 11 (R, API 30)
- ✅ Android 12 (S, API 31)
- ✅ Android 12L (Sv2, API 32)
- ✅ Android 13 (T, API 33)
- ✅ Android 14 (U, API 34)
- ✅ Android 15 (V, API 35)
1.2 构建说明
构建输出:
build/shadow_build_dir/android-build/build/outputs/apk/release/android-build-release.apk
安装APK到设备:
如果使用wifi连接adb设备
#在usb连接时
adb tcpip 5555
#之后使用
adb connect {IP}:5555
# 使用adb安装APK到连接的Android设备
adb install build/shadow_build_dir/android-build/build/outputs/apk/release/android-build-release-signed.apk
# 如果设备上已存在旧版本,使用以下命令强制覆盖安装
adb install -r build/shadow_build_dir/android-build/build/outputs/apk/release/android-build-release-signed.apk
# 查看连接的设备
adb devices
# 卸载旧版本(如果需要)
adb uninstall org.mavlink.qgroundcontrol
1.3 APK 签名
为什么需要签名?
系统要求 - Android 系统强制要求所有 APK 必须签名才能安装,未签名的 APK 无法运行
应用身份 - 签名是应用的唯一标识,用于:
- 验证应用来源和开发者身份
- 防止应用被篡改或伪造
- 建立应用之间的信任关系
应用更新 - 只有使用相同密钥签名的新版本才能覆盖安装旧版本
- 更换密钥后,用户必须先卸载旧版本(会丢失数据)
- 无法在应用市场(如 Google Play)更新应用
安全保障 - 签名机制确保 APK 在分发过程中未被修改
生成密钥库:
使用以下脚本自动生成 Android 发布密钥库:
#!/usr/bin/env bash
# Script to create Android release keystore
# Make sure to install JDK first: sudo apt install openjdk-17-jdk-headless -y
set -e
echo "============================================"
echo "Android Release Keystore Generator"
echo "============================================"
echo ""
# Configuration - Edit these values
KEYSTORE_NAME="android_release_new.keystore"
KEY_ALIAS="qgc_release"
KEY_PASSWORD="{yourpasswd}" # Change this!
STORE_PASSWORD="{yourpasswd}" # Change this!
VALIDITY_DAYS=10000 # About 27 years
# Distinguished Name (DN) information
DN_CN="QGroundControl" # Common Name
DN_OU="Development" # Organizational Unit
DN_O="QGroundControl" # Organization
DN_L="City" # Locality/City
DN_S="State" # State
DN_C="US" # Country Code (2 letters)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KEYSTORE_PATH="$SCRIPT_DIR/$KEYSTORE_NAME"
echo "Keystore will be created at: $KEYSTORE_PATH"
echo "Key alias: $KEY_ALIAS"
echo ""
# Check if keytool is available
if ! command -v keytool &> /dev/null; then
echo "ERROR: keytool not found!"
echo "Please install JDK first:"
echo " sudo apt install openjdk-17-jdk-headless -y"
exit 1
fi
# Check if keystore already exists
if [ -f "$KEYSTORE_PATH" ]; then
read -p "Keystore already exists. Overwrite? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Cancelled."
exit 0
fi
rm -f "$KEYSTORE_PATH"
fi
# Generate keystore
echo "Generating keystore..."
keytool -genkey -v \
-keystore "$KEYSTORE_PATH" \
-alias "$KEY_ALIAS" \
-keyalg RSA \
-keysize 2048 \
-validity $VALIDITY_DAYS \
-storepass "$STORE_PASSWORD" \
-keypass "$KEY_PASSWORD" \
-dname "CN=$DN_CN, OU=$DN_OU, O=$DN_O, L=$DN_L, S=$DN_S, C=$DN_C"
echo ""
echo "============================================"
echo "Keystore created successfully!"
echo "============================================"
echo ""
echo "Add these lines to your deploy/docker/run-docker-android.sh:"
echo ""
echo "QT_ANDROID_KEYSTORE_PATH=\"/project/source/deploy/android/$KEYSTORE_NAME\""
echo "QT_ANDROID_KEYSTORE_ALIAS=\"$KEY_ALIAS\""
echo "QT_ANDROID_KEYSTORE_STORE_PASS=\"$STORE_PASSWORD\""
echo "QT_ANDROID_KEYSTORE_KEY_PASS=\"$KEY_PASSWORD\""
echo ""
echo "⚠️ IMPORTANT: Keep these passwords safe!"
echo "⚠️ Backup your keystore file - you cannot recover it if lost!"
echo ""
# Verify the keystore
echo "Verifying keystore..."
keytool -list -v -keystore "$KEYSTORE_PATH" -storepass "$STORE_PASSWORD" | head -20
使用步骤:
安装 JDK(如果未安装):
sudo apt install openjdk-17-jdk-headless -y修改脚本配置:
- 编辑
KEY_PASSWORD和STORE_PASSWORD为您的密码 - 根据需要修改 DN 信息(组织名称、城市等)
- 编辑
运行脚本:
chmod +x create_keystore.sh ./create_keystore.sh配置构建脚本: 将生成的配置信息添加到
run-docker-android.sh中
密钥配置:
密钥文件:deploy/android/android_release.keystore
配置位置:deploy/docker/run-docker-android.sh
QT_ANDROID_KEYSTORE_PATH="/project/source/deploy/android/android_release.keystore"
QT_ANDROID_KEYSTORE_ALIAS="qgc_release"
QT_ANDROID_KEYSTORE_STORE_PASS="{yourpasswd}"
QT_ANDROID_KEYSTORE_KEY_PASS="{yourpasswd}"
密钥管理建议:
- ⚠️ 不要丢失密钥文件和密码
- ⚠️ 更换密钥将导致应用无法覆盖升级
- ⚠️ 生产环境应使用独立的发布密钥
- ⚠️ 定期备份密钥库文件
1.4 故障排查
清理构建缓存:
sudo rm -rf build/shadow_build_dir
查看构建日志:
docker ps # 找到容器 ID
docker logs -f <container_id>
重新构建 Docker 镜像:
docker rmi qgc-android-docker
./deploy/docker/run-docker-android.sh
2. Ubuntu 构建
2.1 依赖版本
| 组件 | 版本 |
|---|---|
| 基础镜像 | Ubuntu 22.04 |
| Qt | 6.8.3 |
| 构建工具 | CMake + Ninja |
| 时区 | Asia/Shanghai |
安装的 Qt 模块:
- qtcharts, qtlocation, qtpositioning, qtspeech, qt5compat
- qtmultimedia, qtserialport, qtimageformats
- qtshadertools, qtconnectivity, qtquick3d, qtsensors
2.2 快速开始
./deploy/docker/run-docker-ubuntu.sh
构建输出:
- 路径:
build/AppDir/usr/bin/ - 可执行文件:
QGroundControl
运行:
./build/AppDir/usr/bin/QGroundControl
验证编译结果
在每次构建完成后,请务必验证以下信息:
1. 检查版本号
构建完成后,在QGroundControl中查看版本信息:
- Android版本: 设置 → 关于 → 版本信息
- Ubuntu版本: 帮助 → 关于QGroundControl
预期版本信息:
- 主版本:5.0.6
- 构建日期:应与您的构建时间一致
- Git提交:应与您使用的源码版本一致
2. 检查构建时间戳
# Android APK
ls -la build/shadow_build_dir/android-build/build/outputs/apk/release/android-build-release-signed.apk
# Ubuntu可执行文件
ls -la build/AppDir/usr/bin/QGroundControl
3. 功能验证清单
- 应用正常启动
- 连接设备功能正常
- 航点规划功能可用
- 设置界面正常显示
- 版本信息正确显示
如果版本号或构建时间不正确,说明更新内容未实际编译,请重新执行构建流程。
3. 为什么使用 Docker?
- 环境一致性 - 所有依赖预装在镜像中
- 简化配置 - 无需手动安装 Qt、SDK、NDK
- 隔离构建 - 不污染主机环境
- 可重复性 - 任何机器都能获得相同的构建结果
3.1 网络优化
使用 --network=host 继承主机 DNS 配置,提高下载速度。
3.2 构建参数
CMake 配置参数:
CMAKE_BUILD_TYPE=Release- 发布版本QT_ANDROID_BUILD_ALL_ABIS=OFF- 仅构建指定架构QT_ANDROID_ABIS="armeabi-v7a;arm64-v8a"- 32位和64位ARMQT_ANDROID_SIGN_APK=ON- 启用APK签名ANDROID_PLATFORM=android-28- 最低支持 Android 9
4. 修改版构建脚本
4.1 Android 构建脚本
以下是修改版的 Android 构建脚本 run-docker-android.sh:
#!/usr/bin/env bash
# Exit immediately if a command exits with a non-zero status
set -e
# ============================================================
# Android APK Signing Configuration
# ============================================================
QT_ANDROID_KEYSTORE_PATH="/project/source/deploy/android/android_release.keystore"
QT_ANDROID_KEYSTORE_ALIAS="qgc_release"
QT_ANDROID_KEYSTORE_STORE_PASS="{yourpasswd}"
QT_ANDROID_KEYSTORE_KEY_PASS="{yourpasswd}"
# ============================================================
# Define variables for better maintainability
DOCKERFILE_PATH="./deploy/docker/Dockerfile-build-android"
IMAGE_NAME="qgc-android-docker"
SOURCE_DIR="$(pwd)"
BUILD_DIR="${SOURCE_DIR}/build"
# Default values
QGC_ENABLE_HERELINK=OFF
QT_ANDROID_SIGN_APK=ON
REBUILD_IMAGE=false
FULL_CLEAN=false
# Interactive mode for cleanup selection
echo "============================================"
echo "QGroundControl Android 构建选项"
echo "============================================"
echo ""
echo "请选择构建模式:"
echo "1) 增量编译 (最快,保留所有缓存) - 日常开发推荐"
echo "2) 完全清理 (删除构建目录和Docker镜像) - 30-60分钟"
echo "3) 重新构建Docker镜像"
echo "4) 退出"
echo ""
while true; do
read -p "请输入选择 (1-4): " choice
case $choice in
1)
echo "选择: 增量编译"
break
;;
2)
echo "选择: 完全清理"
FULL_CLEAN=true
REBUILD_IMAGE=true
break
;;
3)
echo "选择: 重新构建Docker镜像"
REBUILD_IMAGE=true
break
;;
4)
echo "退出脚本"
exit 0
;;
*)
echo "无效选择,请输入 1-4"
;;
esac
done
echo ""
# Ask about Herelink support
echo "============================================"
echo "Herelink 支持配置"
echo "============================================"
echo ""
while true; do
read -p "是否启用 Herelink 支持? (y/n): " herelink_choice
case $herelink_choice in
[Yy]*)
QGC_ENABLE_HERELINK=ON
echo "已启用 Herelink 支持"
break
;;
[Nn]*)
QGC_ENABLE_HERELINK=OFF
echo "未启用 Herelink 支持"
break
;;
*)
echo "请输入 y 或 n"
;;
esac
done
echo ""
# Handle full clean: delete everything
if [ "$FULL_CLEAN" = true ]; then
echo "============================================"
echo "执行完全清理..."
echo "============================================"
# Delete build directory
if [ -d "${BUILD_DIR}" ]; then
echo "清理构建目录: ${BUILD_DIR}"
sudo rm -rf "${BUILD_DIR}" 2>/dev/null || true
echo "✓ 构建目录已清理"
fi
# Delete Docker image
if docker image inspect "${IMAGE_NAME}" > /dev/null 2>&1; then
echo "删除Docker镜像: ${IMAGE_NAME}"
docker rmi "${IMAGE_NAME}" 2>/dev/null || true
echo "✓ Docker镜像已删除"
fi
echo "完全清理完成!"
echo ""
fi
# Create build directory if it doesn't exist
mkdir -p "${BUILD_DIR}"
# Check if Docker image exists, build only if needed
if ! docker image inspect "${IMAGE_NAME}" > /dev/null 2>&1 || [ "$REBUILD_IMAGE" = true ]; then
if [ "$REBUILD_IMAGE" = true ]; then
echo "============================================"
echo "重新构建Docker镜像..."
echo "============================================"
else
echo "============================================"
echo "构建Docker镜像..."
echo "============================================"
fi
docker build --file "${DOCKERFILE_PATH}" \
--build-arg QGC_ENABLE_HERELINK=$QGC_ENABLE_HERELINK \
--network=host \
-t "${IMAGE_NAME}" "${SOURCE_DIR}"
echo "✓ Docker镜像构建完成"
echo ""
fi
# Run the Docker container with adjusted mount points and DNS configuration
echo "============================================"
echo "启动Docker容器进行构建..."
echo "============================================"
docker run --rm \
--network=host \
-v "${SOURCE_DIR}:/project/source" \
-v "${BUILD_DIR}:/workspace/build" \
-e QT_ANDROID_SIGN_APK=$QT_ANDROID_SIGN_APK \
-e QT_ANDROID_KEYSTORE_PATH=$QT_ANDROID_KEYSTORE_PATH \
-e QT_ANDROID_KEYSTORE_ALIAS=$QT_ANDROID_KEYSTORE_ALIAS \
-e QT_ANDROID_KEYSTORE_STORE_PASS=$QT_ANDROID_KEYSTORE_STORE_PASS \
-e QT_ANDROID_KEYSTORE_KEY_PASS=$QT_ANDROID_KEYSTORE_KEY_PASS \
"${IMAGE_NAME}"
echo "============================================"
echo "修复文件权限..."
echo "============================================"
# Fix permissions so you can modify build directory without sudo next time
sudo chown -R $(id -u):$(id -g) "${BUILD_DIR}" 2>/dev/null || true
echo "✓ 构建完成!"
echo "构建输出位置: ${BUILD_DIR}"
echo ""
echo "============================================"
echo "版本验证提示"
echo "============================================"
echo "请验证以下信息确保更新内容已实际编译:"
echo "1. 检查APK版本号:安装后查看 设置→关于→版本信息"
echo "2. 检查构建时间:ls -la ${BUILD_DIR}/shadow_build_dir/android-build/build/outputs/apk/release/android-build-release-signed.apk"
echo "3. 确认版本号显示为 5.0.6 且构建时间正确"
echo "============================================"
对应的 Dockerfile:
以下是 Android 构建使用的 Dockerfile-build-android:
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
COPY tools/setup/install-dependencies-debian.sh /tmp/qt/
RUN chmod +x /tmp/qt/*.sh && /tmp/qt/install-dependencies-debian.sh
# Configure DNS for better network connectivity in Docker container
# Note: Docker will override this, but we'll test with available tools
# Set environment variables for Android SDK, NDK, and paths
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV ANDROID_NDK_ROOT=$ANDROID_SDK_ROOT/ndk/25.1.8937393
ENV ANDROID_HOME=$ANDROID_SDK_ROOT
ENV ANDROID_BUILD_TOOLS=$ANDROID_SDK_ROOT/build-tools/34.0.0
# Set apt-get to non-interactive and configure time zone
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
# Configure time zone and install dependencies
# Use official Ubuntu sources only
RUN echo "=== Verifying DNS configuration ===" && \
cat /etc/resolv.conf && \
echo "=== Updating package lists ===" && \
apt-get update && \
apt-get install -y tzdata && \
ln -fs /usr/share/zoneinfo/$TZ /etc/localtime && \
dpkg-reconfigure --frontend noninteractive tzdata && \
apt-get install -y \
apt-utils \
build-essential \
libpulse-dev \
libxcb-glx0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-render0 \
libxcb-shape0 \
libxcb-shm0 \
libxcb-sync1 \
libxcb-util1 \
libxcb-xfixes0 \
libxcb-xinerama0 \
libxcb1 \
libxkbcommon-dev \
libxkbcommon-x11-0 \
libxcb-xkb-dev \
python3 \
python3-pip \
wget \
unzip \
git \
openjdk-17-jdk \
curl \
locales \
ninja-build \
software-properties-common \
lsb-release
# Install newer CMake version
RUN wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc 2>/dev/null | gpg --dearmor - | tee /etc/apt/trusted.gpg.d/kitware.gpg >/dev/null && \
apt-add-repository "deb https://apt.kitware.com/ubuntu/ $(lsb_release -cs) main" && \
apt-get update && \
apt-get install -y cmake
# Set JAVA_HOME and update PATH
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
# Install Android SDK and NDK
RUN mkdir -p $ANDROID_SDK_ROOT/cmdline-tools/latest && \
wget https://dl.google.com/android/repository/commandlinetools-linux-12266719_latest.zip -O /opt/cmdline-tools.zip && \
unzip /opt/cmdline-tools.zip -d $ANDROID_SDK_ROOT/cmdline-tools && \
mv $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools/* $ANDROID_SDK_ROOT/cmdline-tools/latest/ && \
rm -rf $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools && \
rm /opt/cmdline-tools.zip && \
yes | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_SDK_ROOT --licenses && \
$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_SDK_ROOT "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;25.1.8937393"
# Build arguments for Qt version selection
ARG QGC_ENABLE_HERELINK=OFF
ARG QT_VERSION_6_6_3="6.6.3"
# Qt setup and environment variables
# Use Qt 6.6.3 as default (known to work)
ENV QT_VERSION="6.6.3"
ENV QT_PATH="/opt/Qt"
ENV QT_HOST="linux"
ENV QT_HOST_ARCH="gcc_64"
ENV QT_HOST_ARCH_DIR="linux_gcc_64"
ENV QT_TARGET="android"
ENV QT_TARGET_ARCH_ARMV7="android_armv7"
ENV QT_TARGET_ARCH_ARM64="android_arm64_v8a"
ENV QT_MODULES="qtcharts qtpositioning qtspeech qt5compat qtmultimedia qtserialport qtimageformats qtshadertools qtconnectivity qtquick3d qtsensors qtlocation"
# Install aqtinstall
RUN python3 -m pip install setuptools wheel py7zr aqtinstall && \
mkdir -p $QT_PATH
# Install Qt desktop version with retry logic (split into separate RUN for better caching)
RUN export QT_VERSION="$QT_VERSION_6_6_3" && \
echo "=== Installing Qt Desktop $QT_VERSION ===" && \
for i in 1 2 3; do \
echo "Attempt $i of 3..." && \
aqt install-qt $QT_HOST desktop $QT_VERSION $QT_HOST_ARCH -O $QT_PATH -m $QT_MODULES && break || \
if [ $i -eq 3 ]; then \
echo "ERROR: Failed to install Qt desktop version after 3 attempts" && exit 1; \
else \
echo "Retrying in 5 seconds..." && sleep 5; \
fi \
done
# Install Qt Android ARMv7 version with retry logic
RUN export QT_VERSION="$QT_VERSION_6_6_3" && \
echo "=== Installing Qt Android ARMv7 $QT_VERSION ===" && \
for i in 1 2 3; do \
echo "Attempt $i of 3..." && \
aqt install-qt $QT_HOST $QT_TARGET $QT_VERSION $QT_TARGET_ARCH_ARMV7 -O $QT_PATH -m $QT_MODULES --autodesktop && break || \
if [ $i -eq 3 ]; then \
echo "ERROR: Failed to install Qt Android ARMv7 version after 3 attempts" && exit 1; \
else \
echo "Retrying in 5 seconds..." && sleep 5; \
fi \
done
# Install Qt Android ARM64 version with retry logic
RUN export QT_VERSION="$QT_VERSION_6_6_3" && \
echo "=== Installing Qt Android ARM64 $QT_VERSION ===" && \
for i in 1 2 3; do \
echo "Attempt $i of 3..." && \
aqt install-qt $QT_HOST $QT_TARGET $QT_VERSION $QT_TARGET_ARCH_ARM64 -O $QT_PATH -m $QT_MODULES --autodesktop && break || \
if [ $i -eq 3 ]; then \
echo "ERROR: Failed to install Qt Android ARM64 version after 3 attempts" && exit 1; \
else \
echo "Retrying in 5 seconds..." && sleep 5; \
fi \
done && \
echo "=== Qt installation completed successfully ==="
# Set Qt-related environment variables for ARMv7 and ARM64 architectures
# Using Qt 6.6.3
RUN export QT_VERSION="$QT_VERSION_6_6_3" && \
echo "export QT_ROOT_DIR_ARMV7=$QT_PATH/$QT_VERSION/$QT_TARGET_ARCH_ARMV7" >> /etc/environment && \
echo "export QT_ROOT_DIR_ARM64=$QT_PATH/$QT_VERSION/$QT_TARGET_ARCH_ARM64" >> /etc/environment && \
echo "export QT_HOST_PATH=$QT_PATH/$QT_VERSION/$QT_HOST_ARCH" >> /etc/environment
# Set default values (will be overridden by the RUN command above)
ENV QT_ROOT_DIR_ARMV7=$QT_PATH/6.6.3/$QT_TARGET_ARCH_ARMV7
ENV QT_ROOT_DIR_ARM64=$QT_PATH/6.6.3/$QT_TARGET_ARCH_ARM64
ENV QT_HOST_PATH=$QT_PATH/6.6.3/$QT_HOST_ARCH
ENV QT_PLUGIN_PATH_ARMV7=$QT_ROOT_DIR_ARMV7/plugins
ENV QT_PLUGIN_PATH_ARM64=$QT_ROOT_DIR_ARM64/plugins
ENV QML2_IMPORT_PATH_ARMV7=$QT_ROOT_DIR_ARMV7/qml
ENV QML2_IMPORT_PATH_ARM64=$QT_ROOT_DIR_ARM64/qml
ENV PKG_CONFIG_PATH_ARMV7=$QT_ROOT_DIR_ARMV7/lib/pkgconfig:$PKG_CONFIG_PATH
ENV PKG_CONFIG_PATH_ARM64=$QT_ROOT_DIR_ARM64/lib/pkgconfig:$PKG_CONFIG_PATH
ENV LD_LIBRARY_PATH_ARMV7=$QT_ROOT_DIR_ARMV7/lib:$LD_LIBRARY_PATH
ENV LD_LIBRARY_PATH_ARM64=$QT_ROOT_DIR_ARM64/lib:$LD_LIBRARY_PATH
# Consolidate PATH settings
ENV PATH=$JAVA_HOME/bin:/usr/lib/ccache:$QT_HOST_PATH/bin:$QT_ROOT_DIR_ARMV7/bin:$QT_ROOT_DIR_ARM64/bin:$PATH:$ANDROID_SDK_ROOT/tools:$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_BUILD_TOOLS:$ANDROID_NDK_ROOT
RUN locale-gen en_US.UTF-8 && update-locale LANG=en_US.UTF-8
RUN git config --global --add safe.directory /project/source
# Set working directory
WORKDIR /project/build
# Build the project
CMD echo "=== Build Environment Verification ===" && \
echo "QGC_ENABLE_HERELINK: $QGC_ENABLE_HERELINK" && \
echo "Android SDK: $ANDROID_SDK_ROOT" && \
echo "Android NDK: $ANDROID_NDK_ROOT" && \
echo "Qt Host Path: $QT_HOST_PATH" && \
ls -la $ANDROID_SDK_ROOT || (echo "ERROR: Android SDK not found" && exit 1) && \
ls -la $ANDROID_NDK_ROOT || (echo "ERROR: Android NDK not found" && exit 1) && \
ls -la $QT_HOST_PATH || (echo "ERROR: Qt host installation not found" && exit 1) && \
echo "=== Creating build directory ===" && \
mkdir -p /workspace/build/shadow_build_dir && \
cd /workspace/build/shadow_build_dir && \
echo "=== Running CMake configuration ===" && \
qt-cmake -S /project/source -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DQT_HOST_PATH=$QT_HOST_PATH \
-DQT_ANDROID_BUILD_ALL_ABIS=OFF \
-DQT_ANDROID_ABIS="armeabi-v7a;arm64-v8a" \
-DQT_DEBUG_FIND_PACKAGE=ON \
-DANDROID_PLATFORM=android-28 \
-DANDROID_BUILD_TOOLS=$ANDROID_SDK_ROOT/build-tools/34.0.0 \
-DANDROID_SDK_ROOT=$ANDROID_SDK_ROOT \
-DQT_ANDROID_SIGN_APK=${QT_ANDROID_SIGN_APK:-ON} \
-DQGC_ENABLE_HERELINK=$QGC_ENABLE_HERELINK \
-DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_ROOT/build/cmake/android.toolchain.cmake || (echo "ERROR: CMake configuration failed" && exit 1) && \
echo "=== Starting build process ===" && \
cmake --build . --target all --config Release || (echo "ERROR: Build failed" && exit 1) && \
echo "=== Build completed successfully ==="
脚本特点:
- 交互式构建模式选择(增量编译/完全清理/重新构建镜像)
- 自动配置 APK 签名参数
- Herelink 支持可选配置
- 智能缓存管理
- 自动权限修复
4.2 Ubuntu 构建脚本
以下是修改版的 Ubuntu 构建脚本 run-docker-ubuntu.sh:
#!/usr/bin/env bash
# Exit immediately if a command exits with a non-zero status
set -e
# Define variables for better maintainability
DOCKERFILE_PATH="./deploy/docker/Dockerfile-build-ubuntu"
IMAGE_NAME="qgc-ubuntu-docker"
SOURCE_DIR="$(pwd)"
BUILD_DIR="${SOURCE_DIR}/build"
CCACHE_DIR="${SOURCE_DIR}/.ccache"
CMAKE_CACHE_DIR="${SOURCE_DIR}/.cmake-cache"
# Default values
REBUILD_IMAGE=false
CLEAN_BUILD=false
FULL_CLEAN=false
# Interactive mode for cleanup selection
echo "============================================"
echo "QGroundControl Ubuntu 构建选项"
echo "============================================"
echo ""
echo "请选择构建模式:"
echo "1) 增量编译 (最快,保留所有缓存) - 日常开发推荐"
echo "2) 完全清理 (删除构建、缓存、Docker镜像) - 30-60分钟"
echo "3) 重新构建Docker镜像"
echo "4) 退出"
echo ""
while true; do
read -p "请输入选择 (1-4): " choice
case $choice in
1)
echo "选择: 增量编译"
break
;;
2)
echo "选择: 完全清理"
FULL_CLEAN=true
CLEAN_BUILD=true
REBUILD_IMAGE=true
break
;;
3)
echo "选择: 重新构建Docker镜像"
REBUILD_IMAGE=true
break
;;
4)
echo "退出脚本"
exit 0
;;
*)
echo "无效选择,请输入 1-4"
;;
esac
done
echo ""
# Handle full clean: delete everything
if [ "$FULL_CLEAN" = true ]; then
echo "============================================"
echo "执行完全清理..."
echo "============================================"
# Delete build directory
if [ -d "${BUILD_DIR}" ]; then
echo "清理构建目录: ${BUILD_DIR}"
sudo rm -rf "${BUILD_DIR}" 2>/dev/null || true
echo "✓ 构建目录已清理"
fi
# Delete ccache
if [ -d "${CCACHE_DIR}" ]; then
echo "清理ccache缓存: ${CCACHE_DIR}"
sudo rm -rf "${CCACHE_DIR}" 2>/dev/null || true
echo "✓ ccache缓存已清理"
fi
# Delete cmake cache
if [ -d "${CMAKE_CACHE_DIR}" ]; then
echo "清理cmake缓存: ${CMAKE_CACHE_DIR}"
sudo rm -rf "${CMAKE_CACHE_DIR}" 2>/dev/null || true
echo "✓ cmake缓存已清理"
fi
# Delete Docker image
if docker image inspect "${IMAGE_NAME}" > /dev/null 2>&1; then
echo "删除Docker镜像: ${IMAGE_NAME}"
docker rmi "${IMAGE_NAME}" 2>/dev/null || true
echo "✓ Docker镜像已删除"
fi
echo "完全清理完成!"
echo ""
fi
# Create cache directories if they don't exist
mkdir -p "${CCACHE_DIR}"
mkdir -p "${CMAKE_CACHE_DIR}"
mkdir -p "${BUILD_DIR}"
# Check if Docker image exists, build only if needed
if ! docker image inspect "${IMAGE_NAME}" > /dev/null 2>&1 || [ "$REBUILD_IMAGE" = true ]; then
if [ "$REBUILD_IMAGE" = true ]; then
echo "============================================"
echo "重新构建Docker镜像..."
echo "============================================"
else
echo "============================================"
echo "构建Docker镜像..."
echo "============================================"
fi
docker build --file "${DOCKERFILE_PATH}" -t "${IMAGE_NAME}" "${SOURCE_DIR}"
echo "✓ Docker镜像构建完成"
echo ""
fi
# Clean build artifacts only if requested or for QML changes
if [ "$CLEAN_BUILD" = true ] && [ "$FULL_CLEAN" = false ]; then
echo "============================================"
echo "清理构建目录..."
echo "============================================"
sudo rm -rf "${BUILD_DIR}"/* 2>/dev/null || true
echo "✓ 构建目录已清理"
echo ""
elif [ -d "${BUILD_DIR}" ] && [ "$FULL_CLEAN" = false ]; then
# Only clean QML-related artifacts for incremental builds
echo "清理QML相关文件..."
sudo rm -rf "${BUILD_DIR}/qml/" \
"${BUILD_DIR}"/*.qrc \
"${BUILD_DIR}"/*_autogen/ \
"${BUILD_DIR}/qgroundcontrol.qrc" 2>/dev/null || true
echo "✓ QML相关文件已清理"
fi
# Stop any running QGroundControl instances before building
QGC_PROCESSES=$(pgrep -f "QGroundControl|qgroundcontrol" || true)
if [ -n "${QGC_PROCESSES}" ]; then
echo "============================================"
echo "停止运行中的QGroundControl进程..."
echo "============================================"
# Try graceful shutdown first (SIGTERM)
echo "发送优雅关闭信号..."
pkill -TERM -f "QGroundControl|qgroundcontrol" 2>/dev/null || true
# Wait up to 5 seconds for graceful shutdown
echo "等待进程优雅关闭..."
for i in {1..5}; do
if ! pgrep -f "QGroundControl|qgroundcontrol" > /dev/null 2>&1; then
echo "✓ 进程已优雅关闭"
break
fi
echo "等待中... ($i/5)"
sleep 1
done
# Force kill if still running
if pgrep -f "QGroundControl|qgroundcontrol" > /dev/null 2>&1; then
echo "强制终止进程..."
pkill -KILL -f "QGroundControl|qgroundcontrol" 2>/dev/null || true
sleep 1
echo "✓ 进程已强制终止"
fi
echo ""
fi
# Run the Docker container with necessary permissions and volume mounts
echo "============================================"
echo "启动Docker容器进行构建..."
echo "============================================"
docker run \
--rm \
--cap-add SYS_ADMIN \
--device /dev/fuse \
--security-opt apparmor:unconfined \
-v "${SOURCE_DIR}:/project/source" \
-v "${BUILD_DIR}:/project/build" \
-v "${CCACHE_DIR}:/ccache" \
-v "${CMAKE_CACHE_DIR}:/cmake-cache" \
-e CCACHE_DIR=/ccache \
"${IMAGE_NAME}"
echo "============================================"
echo "修复文件权限..."
echo "============================================"
# Fix permissions so you can modify build directory without sudo next time
sudo chown -R $(id -u):$(id -g) "${BUILD_DIR}" "${CCACHE_DIR}" "${CMAKE_CACHE_DIR}" 2>/dev/null || true
echo "✓ 构建完成!"
echo "构建输出位置: ${BUILD_DIR}"
echo ""
echo "============================================"
echo "版本验证提示"
echo "============================================"
echo "请验证以下信息确保更新内容已实际编译:"
echo "1. 检查版本号:运行 ./${BUILD_DIR}/AppDir/usr/bin/QGroundControl 后查看 帮助→关于QGroundControl"
echo "2. 检查构建时间:ls -la ${BUILD_DIR}/AppDir/usr/bin/QGroundControl"
echo "3. 确认版本号显示为 5.0.6 且构建时间正确"
echo "============================================"
对应的 Dockerfile:
以下是 Ubuntu 构建使用的 Dockerfile-build-ubuntu:
FROM ubuntu:22.04
ARG QT_VERSION=6.8.3
ARG QT_MODULES="qtcharts qtlocation qtpositioning qtspeech qt5compat qtmultimedia qtserialport qtimageformats qtshadertools qtconnectivity qtquick3d qtsensors"
ENV DEBIAN_FRONTEND noninteractive
ENV DISPLAY :99
ENV QT_PATH /opt/Qt
ENV QT_DESKTOP $QT_PATH/${QT_VERSION}/gcc_64
ENV PATH /usr/lib/ccache:$QT_DESKTOP/bin:$PATH
COPY tools/setup/install-dependencies-debian.sh /tmp/qt/
RUN /tmp/qt/install-dependencies-debian.sh
COPY tools/setup/install-qt-debian.sh /tmp/qt/
RUN /tmp/qt/install-qt-debian.sh
RUN locale-gen en_US.UTF-8 && dpkg-reconfigure locales
RUN git config --global --add safe.directory /project/source
WORKDIR /project/build
CMD cmake -S /project/source -B . -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache ; \
cmake --build . --target all --config Release ; \
cmake --install . --config Release
脚本特点:
- 交互式构建模式选择
- 智能缓存管理(ccache + cmake cache)
- 自动进程管理(优雅关闭运行中的QGC)
- QML文件增量清理
- 完整的权限修复
4.3 脚本使用说明
Android 脚本使用:
# 给脚本执行权限
chmod +x run-docker-android.sh
# 运行脚本
./run-docker-android.sh
Ubuntu 脚本使用:
# 给脚本执行权限
chmod +x run-docker-ubuntu.sh
# 运行脚本
./run-docker-ubuntu.sh
5. 参考文档
QGroundControl 基于官方文档的容器化构建
本文档基于 QGroundControl v5.0.6 Stable 的二次开发,使用最简单稳定的容器构建方法,也是官方强烈推荐的构建方法。
环境要求
- Linux(已在 Ubuntu 上验证)
- Git、Docker、bash
获取源码
- 使用递归方式获取 v5.0.6 Stable,并自动拉取所有子模块(不要直接下载仓库 ZIP 或发布源码包,容易缺少子模块)。
git clone --recursive --branch v5.0.6 https://github.com/mavlink/qgroundcontrol.git
- 拉取完成后,仓库目录大小通常应在 400MB 以上;若明显偏小,请重新以递归方式克隆。
构建(Docker)
- 在仓库根目录执行:
./deploy/docker/run-docker-ubuntu.sh
- 首次编译耗时较长(取决于机器配置)。出现如下日志表示编译完成并生成可运行产物:
Making AppRun file executable: /project/build/AppDir/AppRun
运行与版本校验
- 可直接运行生成的
build/AppDir/AppRun:
./build/AppDir/AppRun
- 应用打开后,点击左上角图标,在下拉框底部可见版本号为
v5.0.6 64bit,以此确认构建版本正确。
常用工具与别名(可选)
- 为避免私有仓库配置繁琐,可使用 GitHub Desktop(Linux AppImage 版本)。
- 建议在
~/.bashrc中添加快捷别名:
# 编辑配置文件
nano ~/.bashrc
# 为 AppImage 增加执行权限
chmod +x ~/GitHubDesktop-linux-x86_64-3.4.13-linux1.AppImage
# 添加别名(可直接在编辑器中追加到 ~/.bashrc)
alias qgcdev='~/qgroundcontrol/build/AppDir/AppRun'
alias github='~/GitHubDesktop-linux-x86_64-3.4.13-linux1.AppImage'
# 使配置生效
source ~/.bashrc
# 快速启动
qgcdev # 打开二次开发的 QGC v5.0.6
github # 打开 GitHub Desktop for Linux
开发工作流
- 每次新增功能或修复请创建独立分支;不要直接推送到
main。 - 修改代码后,重复执行
./deploy/docker/run-docker-ubuntu.sh进行增量构建与验证。
常见问题
- 子模块缺失:确保使用
--recursive克隆;若仓库体积明显小于 400MB,请重新克隆。 - 构建失败:优先对照官方容器构建指南,确认本地 Docker 环境与网络条件正常。
参考文档
- 官方容器构建指南(中文):QGC Dev Guide / Getting Started / Container