基于 Qt Quick 3D 的面向实时渲染多机点云渲染实现

本文档针对 QGroundControl (stable v5.0.6) 的三维点云与多机航迹渲染子系统,提供详尽的技术架构与工程实现规范。系统基于 C++20 与 Qt 6.6.3 构建,全面利用 Qt Quick 3D 进行三维可视化。

1. 点云与轨迹系统总体设计架构

数据流管线设计基于 HTTP 异步拉取机制。用户在交互环境中可进行平移,三维轨道旋转,以及查看右上角的三维坐标指示器。

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
graph TD
    subgraph "Server (点云服务器)"
        A1[LiDAR Data] -->|Subscribe| B1(rustros Engine)
        B1 -->|Expose| C1[HTTP API Server]
    end

    subgraph "Network (数据拉取)"
        C1 -->|异步轮询| B(GET /api/merge)
        C1 -->|异步轮询| C(GET /api/traj)
        B --> D{Zstd 解压引擎}
    end

    subgraph "C++ (数据处理)"
        D -->|Byte Unshuffle| E[SoA 重组]
        E --> F[坐标映射与异常过滤]
        F --> G[高度染色与航迹相位]
        C --> G
    end

    subgraph "Qt3D (GPU 渲染层)"
        G -->|内存映射| H[QQuick3DGeometry]
        H --> I[Lines / DefaultMaterial.NoLighting]
    end

SoA 布局通过将三维信号解耦为一维信号,配合 Zstd 算法实现了点云体积的高倍率缩减。基于实际测试,该方案的性能指标如下:

  • 压缩效能:最高可实现 3 倍缩减,平均压缩比维持在 $230\%$。
  • 数据负载:在简单场景下,单帧多机点云数据量保持在 70KB 以下。
  • 增量优化:若在服务器中间件执行静态全局点云过滤,多机增量单帧点云数据可小于 10KB。

三维可视化最终渲染效果:

2. 数据流管线与压缩优化

2.1 异步拉取与数据流解析

在数据流管线设计上,数据由部署在中心点云服务器上的 rustros 中间件进行统一汇聚与预处理。地面站全面统一为基于 HTTP 的异步拉取机制,拉取管道包含:

  • 点云拉取:GET /api/pointcloud/raw_merge
  • 多机轨迹:GET /api/trajectory

为保证实时渲染性能,点云流二进制包头设定 12 字节私有头部,包含 pointCount (uint32_t) 与 serverTimestamp (uint64_t)。C++ 处理层通过多级中间缓冲(包括 QByteArray 原始数据,Zstd 解压缓冲,std::vector 临时浮点数组等)逐步解析。系统通过 setVertexData 方法将顶点数据映射至 GPU 的 VBO 中。

当前限制:当前协议层缺少 Magic Number,版本号及 CRC 校验。ZSTD_decompress 的返回大小目前未严格校验是否等于期望的 pointCount 尺寸,在工业级部署中需补充相关鲁棒性逻辑。

三维坐标系演示

2.2 SoA 布局与 Zstd 压缩原理

点云坐标从 AoS ([XYZ][XYZ]…) 切换为 SoA ([X…][Y…][Z…]),再配合 Zstd 算法。

2.2.1 字节重排(Byte Shuffle)的关键作用

源码中实现的 _unshuffle 并非简单的维度拆分。数据在发送端按字节级别进行了重排:原本属于同一个 float 的 4 个字节被分散存储。

  1. 发送端重排:将所有点的第 1 字节(最高位)排在一起,接着是所有点的第 2 字节,以此类推。
  2. 接收端恢复_unshuffle 负责先将字节流还原为连续的 float 数值,再拆分出 $X$, $Y$, $Z$ 数组。

这种做法之所以能大幅提升压缩比,是因为对于相近的点,其 float 的高位字节(符号位与指数位)几乎完全一致。将这些重复的字节集中排列,能使 Zstd 的 LZ77 字典匹配效率达到极致。

2.2.2 布局规整化与算法协同

  1. SoA 布局:将空间上的“数值连续性”转化为了内存中的“字节连续性”。
  2. 字节冗余:相近坐标的高位字节出现大规模完全重复。
  3. Zstd 协同Zstd (Zstandard) 作为现代高效通用压缩算法,其核心阶段能完美利用 SoA 优势。

实时点云:

2.3 实时流避用差分编码的原因

在分布式系统与网络通信中,若数据以分包方式发送(如通过 UDP,TCP 或 MAVLink),直接使用跨包差分编码会产生严重隐患:

  1. 网络丢包导致的错误扩散:标准差分编码强依赖前一状态 ($X_i = X_{i-1} + \Delta_i$)。若中间丢失一包,接收方缺失基准,后续解算的绝对坐标将发生严重偏差,直至系统重新同步。
  2. 浮点数精度累积漂移:对 Float32Float64 类型进行连续加减法差分会产生截断误差。随差分次数累积,微小误差叠加会导致坐标产生肉眼可见的漂移。

工程建议:实时点云流应优先采用 SoA 布局配合 Zstd 压缩;完整离线地图包(无需考虑丢包且可对坐标预排序)则可使用 原始点云 (SoA 布局) $\rightarrow$ 差分编码 (预处理) $\rightarrow$ Zstd 压缩 流程。该组合下通常可达到 10 倍至 15 倍(约 $90\%$ ~ $93\%$ 体积缩减)的极限压缩率。

2.4 精度漂移与错误扩散的工程解决方案

注意:本节提到的机制属于服务端协议优化建议,当前客户端源码仍以全量 SoA 传输为主,旨在面向实时渲染优化。

在工程实践中,解决浮点数差分编码带来的精度累计漂移以及丢包错误扩散,通常有以下三种主流的高效解决方案。

2.4.1 整型量化与定点化

核心原理:浮点数连续加减会产生微小截断误差,但整数的加减法在数学上是百分之百精确的。将浮点数转换成整型后,累积漂移可直接归零。

具体做法

  1. 编码端:根据精度要求乘以放大系数(如 1000 表示毫米级),四舍五入转成整型(int32_t 或 int64_t)。
  2. 差分与传输:在整型空间内做差分编码,传输整数。
  3. 解码端:解出绝对整数后,除以相同系数还原为浮点数。
# 编码端:Float -> Int -> Delta
SCALE = 1000.0
x0_int = int(round(x0_float * SCALE))
x1_int = int(round(x1_float * SCALE))
delta_int = x1_int - x0_int

# 解码端:Delta -> Int -> Float
x1_int_decoded = x0_int_decoded + delta_int
x1_float_decoded = x1_int_decoded / SCALE

2.4.2 基于全局锚点的局部坐标系

核心原理:把点之间的“串联差分”改为相对于同一个固定基准的“并联差分”。无论传递多少点,误差仅发生一次。

具体做法

  1. 在每个数据包或 SoA 块头部定义一个全局锚点,用高精度 Float64 存储其绝对坐标。
  2. 包内所有点云数据仅存储相对于该锚点的偏移量,使用 Float32 甚至更小位宽。

效果:点间解耦。丢包仅影响当前包,后续包因具备独立锚点可立即正确解析。

[数据包头: Anchor(Float64)] -> [点1 Offset(Float32)] -> [点2 Offset(Float32)] -> ...

2.4.3 关键帧机制

核心原理:借鉴视频编码逻辑,定期发送绝对坐标帧(不进行差分),强行对时校准。

具体做法:每隔固定时间(如 1 秒)或包数(如 50 包)强制发送一帧完整绝对坐标。

作用

  1. 斩断漂移:将累积误差限制在极短时间窗口内。
  2. 抗丢包:若网络故障导致解析混乱,接收端仅需等待下一个关键帧即可自动重新同步。

3. 几何映射与状态自愈机制

3.1 空间坐标投影

ROS 使用的 ENU 坐标系与 Qt Quick 3D 右手 Y-up 坐标系存在根本差异。三维模型映射必须执行几何轴转换:

$$x_{Qt} = x_{ROS}$$

$$y_{Qt} = z_{ROS}$$

$$z_{Qt} = -y_{ROS}$$

必须对 NaN,Inf 等坏值进行安全滤波,防止包围盒(Bounding Box)计算崩溃,导致 Frustum Culling 算法出现灾难性黑屏。

3.2 轨迹时钟回卷自愈、色彩算法与渲染性能控制

针对多机轨迹绘制,系统在接收端维护 1000 点缓存,渲染侧则支持每机最高 2000 点的几何显示。

// 伪代码:在 appendDronePoint 中处理轨迹数据与时钟回卷
void TrajectoryGeometry::appendDronePoint(uint64_t timestamp, const QVector3D& pos) {
    // 逻辑:检测到服务器重启或时间戳异常回滚(差值超过 5 秒)
    if (timestamp == 0 || (timestamp < m_lastTimestamp && (m_lastTimestamp - timestamp) > 5000)) {
        m_points.clear();
        qDebug() << "时钟回卷触发,轨迹引擎已执行缓存自愈";
    }
    m_lastTimestamp = timestamp;

    m_points.append(pos);
    // 自动清理旧点(维持局部缓存上限)
    if (m_points.size() > 2000) {
        m_points.removeFirst(); 
    }

    updateGeometry();
}

可视化层面应引入基于 HSV 空间与黄金分割比的角度累加算法,并针对大规模航迹进行渲染性能优化:

3.2.1 HSV 色彩空间与黄金分割比

使用 HSV 空间进行颜色分配。改变 H(色调)角度即可切换完全不同的纯色;固定 S(饱和度)与 V(明度)为极限值($100\%$),以确保轨迹在地图上保持极高清晰度。取黄金角度近似整数值 $137^\circ$ 作为相位累加步长,确保新生成的颜色总能自动插入视觉空隙。

3.2.2 GPU 渲染性能钳制

航迹折线数据量庞大。为保证在移动平台稳定保持 60 FPS 刷新率,三维管线材质应强制选用 DefaultMaterial.NoLighting(纯发射材质)。禁用法线计算,漫反射,高光与深度阴影,将渲染瓶颈限制在顶点坐标与颜色吞吐上。

3.3 视口自适应对齐与相机边界计算

为防止相机无限制后退导致点云因尺度过小失去可读性,系统实现基于 FOV 的 Clamp 钳制保护。

3.3.1 核心逻辑:安全极限距离

在三维空间中,相机与点云中心之间的实时距离 $d$ 由用户缩放决定。为确保点云在屏幕高度的占比不低于 $30\%$,需满足:

$$\frac{\text{boundingSize}}{2 \times d \times \tan\left(\frac{FOV}{2}\right)} \ge 0.3$$

解得最大安全距离限制:

$$d \le \frac{\text{boundingSize}}{0.6 \times \tan\left(\frac{FOV}{2}\right)}$$

3.3.2 拦截机制实现

引擎在每帧监听用户交互。当检测到缩放手势企图将相机移动至目标距离时,底层逻辑执行卡位拦截,确保点云始终占据视口核心区域。


参考文档

MavSDK和QGC航点规划兼容性问题的解决方案

1. 问题背景

1.1 QGC 5.0.6 稳定版航点管理问题

版本兼容性说明

本解决方案针对以下版本进行了测试和优化:

QGroundControl (QGC) 5.0.6 稳定版在航点管理方面存在一些已知问题,这些问题主要影响无人机任务的规划和执行:

主要问题表现

  1. 航点数量异常增长

    • 用户上传5个航点,QGC显示15-20个航点
    • 系统自动生成额外的航点,导致任务执行异常
    • 航点序列号不连续,影响任务逻辑
  2. 航点执行异常

    • 无人机在航点处"徘徊"不继续执行
    • 航点接受半径设置无效
    • 任务执行顺序混乱
  3. 界面显示问题

    • 航点列表显示不准确
    • 航点属性显示错误
    • 任务状态更新延迟

问题影响

  • 任务可靠性下降:航点数量异常导致任务执行不可预测
  • 操作效率降低:需要手动清理多余航点
  • 安全风险增加:航点执行异常可能导致飞行安全问题

1.2 MAVSDK-Python MissionItem 与 QGC 兼容性问题

MAVSDK-Python 的 MissionItem 数据结构与 QGC 的航点解析机制之间存在兼容性问题,主要体现在数据结构差异和协议层面问题两个方面。在数据结构方面,MAVSDK-Python 使用 latitude_deglongitude_deg 字段格式,而 QGC 期望 latlng 格式,同时 MAVSDK-Python 使用枚举类型(如 CameraActionVehicleAction),而 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 的航点解析机制存在一些已知问题:

解析流程

  1. 接收 MAVLink 消息

    # QGC 接收 MISSION_ITEM_INT 消息
    def parse_mission_item(message):
        seq = message.seq
        frame = message.frame
        command = message.command
        # ... 解析其他字段
    
  2. 数据验证和转换

    # QGC 的数据验证逻辑(存在问题)
    if command == MAV_CMD_NAV_WAYPOINT:
        # 错误:某些条件下会创建额外航点
        if param1 > 0:  # 问题条件
            create_additional_waypoint()
    
  3. 航点显示和存储

    • 将解析的航点添加到任务列表
    • 更新界面显示
    • 存储到本地数据库

已知问题

  1. 自动航点生成

    • QGC 5.0.6 在某些条件下会自动生成额外航点
    • 这些航点没有正确的序列号
    • 导致任务执行异常
  2. 参数解析错误

    • 某些参数被错误解释
    • 导致航点属性不正确
    • 影响任务执行逻辑
  3. 坐标系转换问题

    • 经纬度精度处理不当
    • 坐标系转换算法错误
    • 导致航点位置偏移

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      # 任务类型

关键参数说明

  1. 坐标系 (frame)

    • MAV_FRAME_GLOBAL_RELATIVE_ALT_INT = 6
    • 使用相对高度的全球坐标系
    • 经纬度精度为 1e7
  2. 命令类型 (command)

    • MAV_CMD_NAV_WAYPOINT = 16:普通航点
    • MAV_CMD_NAV_TAKEOFF = 22:起飞命令
    • MAV_CMD_NAV_LAND = 21:降落命令
  3. 参数含义

    • param1:盘旋时间(秒)
    • param2:接受半径(米)
    • param3:未使用
    • param4:偏航角(度)

兼容性要求

  1. 序列号连续性

    • 航点序列号必须连续
    • 从0开始递增
    • 不能有重复或跳跃
  2. 参数范围

    • 经纬度范围:-90 到 +90(纬度),-180 到 +180(经度)
    • 高度范围:0 到 1000 米
    • 速度范围:0.1 到 50 米/秒
  3. 命令兼容性

    • 使用标准的 MAVLink 命令
    • 避免使用非标准命令
    • 确保命令参数正确

3. 解决方案架构

3.1 双层数据转换架构

本解决方案采用双层数据转换架构,将高层 MissionItem 数据转换为底层 MissionRaw 格式,确保与 QGC 5.0.6 的完美兼容:

架构组件

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%

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 数据流程设计

完整数据流程

  1. 数据输入阶段

    # 用户代码数据输入示例(符合航点输出协议)
    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"
            }
        ]
    }
    
  2. 数据验证阶段

    # 字段验证
    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
    
  3. 数据转换阶段

    # 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)
    
  4. 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)
    
  5. 任务上传阶段

    # 上传到飞行控制器
    await drone.mission_raw.clear_mission()  # 清除旧任务
    await drone.mission_raw.upload_mission(raw_items)  # 上传新任务
    

3.3 兼容性保证机制

QGC 兼容性保证

  1. 使用 MissionRaw 接口

    • 直接使用 MISSION_ITEM_INT 消息格式
    • 避免 QGC 的自动解析和优化
    • 确保航点数据的一致性
  2. 正确的任务清除

    # 上传前清除旧任务
    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)
    
  3. 参数范围控制

    # 确保参数在 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() 转换器

# 完整的 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}")

参考文档

QGroundControl Docker 进阶构建指南

版本约束: 本文档基于 QGroundControl 5.0.6 版本编写

1. Android 构建

# 在项目根目录执行
./deploy/docker/run-docker-android.sh

依赖版本

组件版本
基础镜像Ubuntu 22.04
JavaOpenJDK 17
Qt6.6.3
Android SDK34
Build Tools34.0.0
NDK25.1.8937393 (25B)
Platformandroid-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进行编译。

  1. NDK 兼容性 - Qt 6.6.3 与 Android NDK 25.1.8937393 有最佳兼容性
  2. Herelink 支持 - 该版本对 Herelink 设备有完整的支持
  3. 构建工具链 - 与 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 签名

为什么需要签名?

  1. 系统要求 - Android 系统强制要求所有 APK 必须签名才能安装,未签名的 APK 无法运行

  2. 应用身份 - 签名是应用的唯一标识,用于:

    • 验证应用来源和开发者身份
    • 防止应用被篡改或伪造
    • 建立应用之间的信任关系
  3. 应用更新 - 只有使用相同密钥签名的新版本才能覆盖安装旧版本

    • 更换密钥后,用户必须先卸载旧版本(会丢失数据)
    • 无法在应用市场(如 Google Play)更新应用
  4. 安全保障 - 签名机制确保 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

使用步骤:

  1. 安装 JDK(如果未安装):

    sudo apt install openjdk-17-jdk-headless -y
    
  2. 修改脚本配置:

    • 编辑 KEY_PASSWORDSTORE_PASSWORD 为您的密码
    • 根据需要修改 DN 信息(组织名称、城市等)
  3. 运行脚本:

    chmod +x create_keystore.sh
    ./create_keystore.sh
    
  4. 配置构建脚本: 将生成的配置信息添加到 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
Qt6.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?

  1. 环境一致性 - 所有依赖预装在镜像中
  2. 简化配置 - 无需手动安装 Qt、SDK、NDK
  3. 隔离构建 - 不污染主机环境
  4. 可重复性 - 任何机器都能获得相同的构建结果

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位ARM
  • QT_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 和 大模型 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 整体架构图

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%

graph TB
    subgraph QGC["QGroundControl 客户端"]
        User["用户操作
(按住/松开按钮)"] subgraph UI["QML 界面层"] ToolBar["BottomFlyViewToolBar.qml"] end subgraph Controller["C++ 控制器层"] Audio["AudioRecorderController
• startRecording()
• stopRecording()
• 音频格式配置
• WAV文件生成"] end subgraph QtLayer["Qt Multimedia 层"] QtAudio["QAudioSource / QAudioFormat
• 音频设备管理
• PCM数据采集"] end User -.-> ToolBar ToolBar -->|调用C++接口| Audio Audio -->|使用Qt框架| QtAudio end subgraph Backend["后端服务器 (FastAPI)"] APIRouter["/api/v1/voice
(FastAPI 路由)"] VoiceCommand["POST /api/v1/voice/command
voice_command()"] Whisper["Whisper 模型
(small, CPU, 单例)"] Agent["DroneAgent
(LLM 解释命令)"] Fleet["FleetManager / MavSDKWrapper
(实际控制无人机)"] LLM["外部 / 本地部署 LLM 服务
"] APIRouter --> VoiceCommand VoiceCommand --> Whisper Whisper --> Agent Agent --> LLM Agent --> Fleet end Dialog["结果展示
对话框"] QtAudio -->|"HTTP POST
(multipart/form-data)
字段: audio(WAV)"| APIRouter Fleet -->|"执行结果字符串
(含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:#1565c0

2.2 数据流向:时序图

完整交互流程

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%

sequenceDiagram
    autonumber
    actor User as 用户
    participant UI as QML界面
BottomFlyViewToolBar participant Controller as C++控制器
AudioRecorderController participant Timer as 定时器
QTimer(50ms) participant QtAudio as Qt Multimedia
QAudioSource participant Network as 网络层
XMLHttpRequest participant APIRouter as FastAPI路由
/api/v1/voice participant VoiceAPI as 语音接口
voice_command() participant Whisper as Whisper模型
small@CPU(单例) participant Agent as DroneAgent
LLM 命令解释器 participant LLM as 外部 / 本地部署 LLM 服务
participant Fleet as 机队管理器
FleetManager/MavSDK %% 阶段1: 开始录音 rect rgb(227, 242, 253) Note over User,QtAudio: 阶段1: 开始录音 User->>+UI: 按下"发送语音"按钮 UI->>+Controller: startRecording() Controller->>Controller: 清空旧数据
_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()
追加到缓冲区 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()
生成WAV头部(44字节) Controller->>Controller: _audioData = header + _rawAudioData
拼接完整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
Content-Type: multipart/form-data
Body: audio=WAV UI-->>User: 显示"等待响应..." deactivate UI end %% 阶段7: 后端处理(语音 → 文本 → 命令 → 控制) rect rgb(224, 242, 241) Note over APIRouter,Fleet: 阶段7: 后端处理 APIRouter->>+VoiceAPI: 路由到 voice_command()
依赖注入 DroneAgent VoiceAPI->>VoiceAPI: 校验文件名/扩展名/内容长度 VoiceAPI->>VoiceAPI: 写入临时文件(temp.wav) VoiceAPI->>+Whisper: get_whisper_model()
单例加载 small 模型 Whisper-->>VoiceAPI: model 实例 VoiceAPI->>Whisper: transcribe(temp.wav) Whisper-->>VoiceAPI: transcribed_text(自然语言命令) VoiceAPI->>VoiceAPI: 删除临时文件 VoiceAPI->>+Agent: await process(transcribed_text) Agent->>Agent: 构造控制提示词
检查机队状态 Agent->>+Fleet: get_fleet_status()
仅使用已连接无人机 Fleet-->>Agent: 当前无人机状态 Agent->>+LLM: chat.completions.create()
生成控制代码 LLM-->>Agent: Python 控制代码 Agent->>Agent: 在受限作用域内执行代码
调用 FleetManager/MavSDK Agent-->>VoiceAPI: 执行结果字符串
(含ANSI颜色/Success等) VoiceAPI-->>-APIRouter: AgentResponse(result=...) APIRouter-->>-Network: JSON响应
{result: "..."} end %% 阶段8: 处理响应 rect rgb(225, 245, 254) Note over Network,User: 阶段8: 处理响应 Network-->>+UI: onreadystatechange
xhr.responseText UI->>UI: parseResponseText()
提取result字段 UI->>UI: ansiToHtml()
转换ANSI颜色→HTML UI->>Controller: clearAudioData() 防御性清理 deactivate Controller Note right of Controller: 确保内存已释放 UI-->>User: 显示结果对话框
可选择复制 deactivate UI User->>User: 查看执行结果 end

3. 核心组件

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范围推荐
}

常见配置组合

场景采样率位深度声道数据率
语音识别(推荐)44100Hz16bit单声道86KB/s
低质量/节省带宽16000Hz16bit单声道31KB/s
高质量/专业录音48000Hz24bit立体声281KB/s
电话音质8000Hz16bit单声道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

为什么选择这些参数

  1. 44100 Hz 采样率:这是标准CD音质,能够完整保留人声频率范围(20 Hz - 20 kHz),确保语音识别系统能够准确识别语音内容。虽然16000 Hz也能满足基本需求,但44100 Hz提供了更好的音质冗余,适应不同环境下的录音质量变化。

  2. 16 bit 位深度:提供了65536个量化级别,足够捕捉人声的动态范围。8 bit(256级)会导致明显的量化噪声,而24 bit虽然更精确,但会增加50%的数据量,对语音识别来说收益有限。

  3. 单声道:语音识别主要关注频率内容和时间序列,不需要立体声的空间信息。使用单声道可以将数据量减半,显著降低网络传输负担和内存占用。

  4. 50 ms 读取间隔:在录音过程中,每50毫秒读取一次音频数据,既能及时响应录音状态变化,又不会过度占用CPU资源。过短的间隔(如10 ms)会增加CPU开销,过长的间隔(如100 ms)会导致内存缓冲区过大。

常见配置组合对比

场景采样率位深度声道数据率适用性
语音识别(推荐)44100Hz16bit单声道86KB/s✅ 最佳平衡
低质量/节省带宽16000Hz16bit单声道31KB/s⚠️ 音质可能不足
高质量/专业录音48000Hz24bit立体声281KB/s❌ 过度配置
电话音质8000Hz16bit单声道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 音频文件发送到后端服务器进行语音识别:

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%

sequenceDiagram
    participant Client as 客户端(QGC)
    participant Server as 服务端(后端API)
    
    Note over Client: 录制音频
(内存中的WAV文件) Client->>+Server: POST multipart/form-data Note right of Client: 字段名: "audio"
文件名: "recording.wav"
内容类型: audio/wav
二进制数据: [WAV bytes] Note over Server: 语音识别 + 意图理解
+ 命令执行 Server-->>-Client: JSON响应 Note left of Server: { "result": "执行结果" }

选择 multipart/form-data 的原因

  • WAV 是二进制格式,必须保持原始字节不变
  • 服务端可通过标准的文件上传处理库解析
  • 符合 Web 标准,兼容性好
  • 便于调试(可用 curl、Postman 等工具测试)

与其他格式的对比

编码格式适用场景优点缺点
application/x-www-form-urlencoded简单文本表单简单,历史悠久不支持文件,二进制数据效率低
application/jsonAPI 数据交换结构清晰,易解析二进制需 Base64 编码(+33%体积)
multipart/form-data文件上传二进制高效,标准化格式复杂,需手动构建
application/octet-stream纯二进制流最简单无元数据,无法传递文件名等信息

格式结构说明

基本组成

请求头:
  Content-Type: multipart/form-data; boundary=<边界标识符>

请求体:
  --<边界标识符>                          ← 开始边界
  Content-Disposition: form-data; ...    ← 字段描述
  Content-Type: ...                      ← 内容类型
                                         ← 空行(重要!)
  [数据内容]                              ← 实际数据
  --<边界标识符>--                        ← 结束边界(多了两个横杠)

关键概念

  1. Boundary(边界):唯一标识符,不能出现在数据内容中
  2. CRLF(\r\n):每行必须以 \r\n 结尾(HTTP 标准)
  3. Content-Disposition:描述字段名(name)和文件名(filename)
  4. 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 结构,主体字段为 resulttext 等,用于承载执行结果或转写文字。

4.2 后端分层结构

  • API 接口层

    • 暴露 /voice/command/voice/transcribe 等 HTTP 接口。
    • 负责请求解析、基础校验、依赖注入以及异常到 HTTP 错误码的转换。
  • 语音处理层

    • 接收上传音频,完成格式和内容的基础检查(例如:文件是否为空、扩展名是否在允许列表中)。
    • 将音频内容写入临时介质或缓冲区,以便后续语音识别模型使用。
    • 调用本地语音识别模型(如 Whisper small@CPU,单例加载),得到转写文本。
  • 智能体与无人机控制层

    • 提供一个“无人机智能体”对象,对外暴露 process(command_text, task_type) 等高层方法。
    • 内部持有一个“机队管理器”对象,用于实际下发飞行指令(如起飞、降落、绕圈、飞往航点等)。
    • 通过外部大模型(LLM)完成自然语言到控制代码的转换,并在受控环境中执行。
  • 配置与异常处理层

    • 定义一组专门的异常类型(例如:音频为空、音频格式不支持、语音模型不可用、转写结果为空、智能体未初始化等),并由统一异常处理器转换为结构化 HTTP 响应。

4.3 语音转命令接口:逻辑流程

语音转命令接口的逻辑可以概括为以下步骤:

  1. 接收上传音频

    • 使用一个通用的文件上传参数(例如 audio_file),通过 multipart/form-data 方式接收。
    • 若文件名缺失或内容为空,抛出如 EmptyAudioError 之类的业务异常。
  2. 文件格式与内容校验

    • 允许扩展名集合如:{".wav", ".mp3", ".flac", ".m4a", ".ogg", ".webm", ".mpeg", ".mp4"}
    • 若扩展名不在允许列表中,抛出如 UnsupportedAudioFormatError 异常,并在错误信息中提示支持的格式。
    • QGC 端推荐统一上传标准 WAV(PCM, 44100Hz, 16bit, 单声道),以简化链路和调试。
  3. 写入临时介质并调用语音模型

    • 将上传的二进制内容写入一个临时路径或缓冲区,供语音识别模型使用。
    • 通过类似 get_speech_model() 的单例函数获取语音识别模型实例:
      • 首次调用时完成模型加载,并捕获缺少依赖、加载失败等情况。
      • 后续请求复用同一个模型实例,避免重复加载导致的性能问题。
    • 调用模型的 transcribe(temp_audio) 方法获得 transcribed_text
    • 无论成功与否,都在合适时机删除临时音频文件或释放缓冲区,避免磁盘/内存泄漏。
    • transcribed_text 为空,抛出如 AudioTranscribeError 的语义化异常。
  4. 调用智能体进行命令解释与执行

    • 通过依赖注入或全局管理函数获取当前的“无人机智能体”实例(例如 get_drone_agent())。
    • 若智能体尚未正确初始化(例如机队管理器不可用等),直接返回指引用户检查环境的配置提示,而不是继续执行。
    • 调用智能体的异步方法,例如:
      • agent.process(transcribed_text, task_type="control")
    • task_type="control" 场景下,智能体内部的大致逻辑为:
      1. 读取当前机队状态,只允许对“已连接无人机”进行操作。
      2. 若无可用无人机,直接返回例如“未连接任何无人机”的错误文案,而不是尝试自行连接。
      3. 构造上游约束清晰的提示词,将用户自然语言命令、可用方法列表和安全限制一同输入 LLM。
      4. 从 LLM 返回中提取 Python 控制代码片段,在受限制的命名空间内执行,仅暴露受控的机队管理对象和必要的工具方法。
      5. 汇总“生成代码文本 + 实际执行结果”,拼接成一个富文本字符串,作为最终返回值。
  5. 统一结果封装与返回

    • 将智能体返回的字符串包装到统一的响应模型中,例如:
      • { "result": "<带或不带 ANSI 颜色的执行结果文本>" }
    • 发生异常时,将内部异常转换为结构化 JSON 错误响应,包含至少:
      • 机器可读的错误类型(如 "error_type": "empty_audio"
      • 面向用户的错误提示(如 "message": "音频文件为空,请重新录制后再试"

4.4 语音转文字接口(测试接口)

除语音转命令外,后端还提供一个纯“语音转文字”能力,主要用于:

  • 调试语音识别链路(确认录音质量与模型表现)。
  • 为其他上层应用提供字幕/转写服务。

该接口的典型行为:

  1. /voice/command 相同方式接收 multipart/form-data 的音频文件。
  2. 复用相同的语音识别模型,将音频转写为文本。
  3. 返回结构化响应,例如:
    • text: 转写得到的文本内容。
    • language: 语言标识(如 "zh")。
    • duration: 音频时长的字符串表示。
    • processing_time: 服务端处理耗时的字符串表示。

4.5 端到端时序总结(后端视角)

结合前端章节中的整体时序图,从后端视角可以将关键步骤概括为:

  1. 接收请求:收到 POST /api/v1/voice/command 请求,Content-Type 为 multipart/form-data,字段名为 audio
  2. 基础校验:检查文件名、扩展名、内容长度,过滤掉明显无效请求。
  3. 语音转写:将音频内容交给本地语音识别模型,得到自然语言 transcribed_text
  4. 智能体处理:调用无人机智能体的 process() 方法,由 LLM 生成受约束的控制代码并执行,严格遵守“只操作已连接无人机、不自行连接”的规则。
  5. 结果封装:将执行结果封装成统一 JSON 响应返回前端,可包含 ANSI 颜色信息以便前端渲染。
  6. 资源清理:确保临时音频文件和中间缓冲在成功或异常场景下均被正确清理,避免长期资源泄漏。

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

参考文档

静态页面中的动态交互:CSS 空间换逻辑实践

1. 技术背景

在现代 Web 交互设计中,鼠标追踪效果(如卡片的动态倾斜、光影跟随等)通常被认为是 JavaScript 的专属领域。然而,随着 CSS 选择器逻辑(尤其是 :has() 伪类)的增强以及 Grid 布局的普及,开发者可以利用“空间换逻辑”的方案,在零脚本环境下实现高响应性的追踪效果。

去 JS 的核心动机之一是在静态网站中,以极低的代码成本显著提升页面的交互体验与视觉美感。这种方案能够在不引入复杂脚本逻辑的前提下,为静态内容注入动态生命力,使站点不仅加载迅速,且交互反馈更加细腻、丝滑。本方案的设计思路引用于 kennyotsuUIverse 分享的技术实践。

以下是该方案实现的最终视觉效果:

2. 核心原理:离散区域映射

由于 CSS 无法直接获取鼠标的实时坐标 $(x, y)$,该方案的核心在于将交互区域划分为 $N \times M$ 的感知网格。

2.1 空间分割

通过在容器内布满一组透明的元素作为“触发器”,将连续的鼠标移动路径切割成多个离散的触发区域。每个区域对应一个预定义的样式状态(如特定的旋转角度或位移)。

开启调试模式(显示网格边界)后的感应逻辑如下图所示:

2.2 逻辑分发

利用 CSS 的层级关系或状态感知能力(如 :has()),当鼠标进入某个特定触发区域时,驱动目标元素(卡片或背景)产生相应的视觉变换。

3. 实现细节

3.1 构造触发层

在 HTML 结构中,触发器 tracker 通常作为容器的直接子元素,并利用 z-index 置于展示内容之上。

<div class="container">
  <!-- 触发网格 -->
  <div class="tracker"></div>
  <div class="tracker"></div>
  <div class="tracker"></div>
  <!-- ... 更多网格 -->
  
  <div class="card">
    <div class="glow"></div>
  </div>
</div>

3.2 布局与感应

通过 display: grid 将触发器均匀铺满容器空间。

.container {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, 1fr);
  position: relative;
}

.tracker {
  position: absolute;
  width: 100%;
  height: 100%;
  z-index: 10;
}

3.3 状态联动

使用现代 CSS 的 :has() 伪类,可以非常简洁地捕获子元素状态并作用于父级或其他子元素。

/* 当左上角网格被悬停时,改变卡片的倾斜角度 */
.container:has(.tracker:nth-child(1):hover) .card {
  transform: rotateX(10deg) rotateY(-10deg);
}

/* 结合 transition 补间动画实现平滑效果 */
.card {
  transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
}

4. 关键特性分析

4.1 动画插值 (Interpolation)

尽管网格感应是离散的(例如 3x3 只有 9 个状态点),但通过在目标元素上应用 transitionlinear() 缓动函数,浏览器会在状态切换时自动进行属性插值。在视觉感官上,这会产生一种鼠标坐标被实时追踪的连贯错觉。

4.2 性能优势

  • 非主线程渲染:该方案完全不依赖 JavaScript 事件循环 (Event Loop),避免了在高频移动时的 mousemove 回调开销。
  • 合成层优化:结合 will-change: transform,所有的视觉变换均可在 GPU 合成层完成,确保 60+ FPS 的流畅度。

4.3 维护与扩展

对于更复杂的追踪场景(如 10x10 的精细追踪),建议使用 SCSS 或 CSS 变量进行样式生成,以减少重复代码:

@for $i from 1 through 100 {
  .container:has(.tracker:nth-child(#{$i}):hover) .card {
    /* 动态计算旋转值 */
  }
}

参考文档

自定义 NiceGUI 中 Leaflet 的 marker 样式和旋转

本文档详细描述了在NiceGUI框架中实现JavaScript Bridge架构的方法,该架构通过Python封装JavaScript代码,实现对Leaflet地图插件的样式定制和功能扩展。最终实现了替换NiceGUI中Leaflet标记样式,新增无人机和人的标记,并通过对象管理所有标记。

Leaflet 自定义图标示意图

技术栈

  • 前端: NiceGUI + Leaflet + JavaScript
  • 后端: Python
  • 通信: JavaScript Bridge
  • 样式: CSS + SVG图标

架构设计

整体架构图

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
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

文件关联关系

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
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

数据流图

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
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)

状态管理架构

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
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 --> MarkerFunctions

Python包装器实现 (marker.py)

类设计架构

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
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)

样式注入流程

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
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)

扩展性设计

新标记类型扩展

扩展流程

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
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); }
}

动画效果扩展

动画系统架构

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
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

事件系统扩展

事件流架构

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
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: 处理业务逻辑

配置管理

配置层次结构

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
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"   # 旋转中心点

参考文档

基于 CPU 投影变换的多机点云可视化实现

本报告针对点云接收、解析及渲染模块的代码实现进行系统化分析。该子系统基于 Qt/C++ 与 Qt Quick Scene Graph 架构开发,用于实时接收、解压、重组多架无人机产生的三维点云数据、运行轨迹及里程计信息,并在前端进行三维向二维的投影渲染。

1. 报告概述

该代码库构成了无人机地面控制系统(GCS)中三维数据可视化的核心前端组件。系统涵盖了从网络底层异步数据流接收,基于 Zstd 的高性能数据解压与反交错变换,到基于 Qt Scene Graph 的自定义高性能渲染全链路流程。

2. 架构与模块设计

系统整体采用数据驱动与动静分离的架构设计。网络密集型与计算密集型的任务被封装于独立的线程中,而渲染交互则直接嵌入 Qt Quick 渲染管线。

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
graph TD
    subgraph "Server (服务端)"
        S[数据源节点]
    end

    subgraph "Data Ingestion (数据接入)"
        S -->|HTTP: 3000| PR[PointCloudReceiver]
    end

    subgraph "Frontend Pipeline (CPU 核心处理与投影管线)"
        PR -->|"Zstd 解压 / Unshuffle"| PR
        PR -->|驱动| RD[PointCloudRenderer]
        
        subgraph "Software Rendering (CPU 投影逻辑)"
            RD -->|三维透视投影变换| RD
            RD -->|填充 2D 顶点缓冲区| QN["QSGNode (2D)"]
        end
    end

    subgraph "GPU Layer"
        QN -->|Scene Graph| GPU[底层渲染与显示]
    end

2.1 核心组件职责划分

  • PointCloudWorker
    • 核心职责:负责高频 UDP 点云数据报文的接收,幻数校验与数据拼接。
    • 线程上下文:独立工作线程 (_workerThread)。
    • 技术特征:异步非阻塞、零拷贝指针强转。
  • PointCloudReceiver
    • 核心职责:系统控制枢纽(单例),处理 REST API 通信,ZSTD 解压,SoA 到 AoS 数据转换,局域网节点检索。
    • 线程上下文:主线程(UI 线程)。
    • 技术特征:信号槽机制,状态机管理,QNetworkAccessManager。
  • PointCloudRenderer
    • 核心职责:承载三维点云、无人机历史轨迹与实时预测轨迹的绘制。
    • 线程上下文:Scene Graph 渲染线程(与主线程阻塞同步)。
    • 技术特征:继承 QQuickItem,重写 updatePaintNode,利用同步窗口将数据安全拷贝并构建场景树节点。

3. 核心技术实现解析

3.1 异步 HTTP 数据流接收与组包

PointCloudReceiver 利用 Qt 的 QNetworkAccessManager 实现了异步无阻塞的数据拉取逻辑:

  • 非阻塞拉取:通过建立定时轮询或长连接,系统周期性向服务端 3000 端口发送 HTTP GET 请求。网络响应数据包的接收回调发生在事件循环中,避免了界面卡顿。
  • 数据校验防线:在处理 HTTP 响应时,系统会检查响应状态码及数据包长度,若数据不完整或校验失败,将直接丢弃当前帧,防止脏数据注入渲染管线。

3.2 数据压缩与反交错重组 (SoA 至 AoS)

PointCloudReceiver::_handleUpdateResponse 中,由于网络传输的输入点云数据采用了空间结构体数组转单数组 (SoA) 并经过字节交错(Shuffling)处理,系统展现了高度集中的 CPU 变换逻辑:

Zstd (Zstandard) 是一种实时压缩算法,提供高压缩比的同时保持极快的解压速度,非常适合对吞吐量敏感的点云流。

  1. Zstd 解压缩:利用 ZSTD_decompress 一次性将压缩 payload 恢复为原始尺寸 PointCount * 3 * 4 字节。
  2. 反交错 (Unshuffle) 变换:原始数据在发送端按字节级别的转置 (Transposition) 存储。_unshuffle 通过双重循环将散落的字节重新归位到暂存的连续浮点数数组 tempFloats 中:
for (uint32_t i = 0; i < 4; ++i) {
    for (uint32_t j = 0; j < count; ++j) {
        dst[j * 4 + i] = src[i * count + j];
    }
}
  1. SoA 转换 AoS:将恢复的 SoA(分块连续的 $X$, $Y$, $Z$ 数组)重新转化为内存紧凑的 AoS 结构(Point3D 数组),追加至全局点云缓冲区 _accumulatedPoints
const float* xArray = tempFloats.data();
const float* yArray = tempFloats.data() + pointCount;
const float* zArray = tempFloats.data() + (pointCount * 2);

3.3 软件渲染:从三维空间到二维屏幕的数学流水线

PointCloudRendererupdatePaintNode 中手动模拟了标准的图形渲染管线,通过 CPU 执行完整的矩阵变换与透视投影。

3.3.1 为什么 3D 世界必须要用 4x4 矩阵?

在传统的三维几何中,描述一个点只需要三个坐标 $(x, y, z)$。然而在计算机图形学中,如果单纯使用 $3 \times 3$ 矩阵,只能表达旋转和缩放等线性变换,无法通过单一的“矩阵乘以向量”操作来直接表达平移(即类似 $x' = x + \Delta x$ 的加法),更无法统一处理复杂的透视投影。

为了打破这一限制,必须引入齐次坐标(Homogeneous Coordinates),将维度从三维升级到四维:$(x, y, z, w)$。通过将坐标映射为 $(x, y, z, 1)$ 并乘以一个 $4 \times 4$ 矩阵,平移操作得以完美嵌入矩阵乘法中。

在软件工程与底层架构层面,图形学界之所以执着于“使用单一的矩阵乘以向量”来表达所有变换,源于对极致吞吐量硬件架构极简的追求。其核心的技术优势可归纳为以下两个维度:

3.3.2 矩阵级联降低计算复杂度

在实际的无人机地面控制软件(GCS)或 3D 场景中,一个点从传感器采集到最终屏幕显示,需经历一连串复杂的复合变换。以多无人机协同场景中红外相机捕获的火点坐标 $P$ 为例:

  1. 局部到机身:点 $P$ 需经历相机的旋转与平移,变换至无人机机身坐标系。
  2. 机身到世界:叠加无人机自身的姿态角(Pitch/Yaw/Roll)与 GPS 位置,变换至三维数字大地图的世界坐标系。
  3. 世界到相机:叠加用户操作地面站时的虚拟相机位姿,变换至观察者坐标系。
  4. 透视投影:执行近大远小的透视变换。

非统一变换的计算困境:若坚持使用 $3 \times 3$ 矩阵表达旋转 $R$,使用三维向量表达平移 $T$,计算最终位置的数学表达式将呈现为复杂的“乘加交替”流水线:

$$P_{最终} = R_{投影} \cdot (R_{观察} \cdot (R_{世界} \cdot (R_{安装} \cdot P + T_{安装}) + T_{世界}) + T_{观察}) + T_{投影}$$

若场景内存在上百万个点,CPU 或 GPU 必须为每一个点严格执行上述乘法与加法的嵌套循环,计算开销极其高昂。

矩阵级联的统一优势:引入齐次坐标后,所有旋转与平移被统一为 $4 \times 4$ 矩阵。得益于矩阵乘法的结合律($A \cdot (B \cdot C) = (A \cdot B) \cdot C$),系统可以在不涉及任何点数据的情况下,优先将所有中间变换矩阵融合成一个终极的 MVP 矩阵:

$$M_{MVP} = M_{投影} \times M_{观察} \times M_{世界} \times M_{安装}$$

此后,面对海量点云,计算机只需执行单次矩阵乘法:

$$\text{All Points}_{最终} = M_{MVP} \times \text{Point}$$

这种将无数中间步骤合并的技术被称为“矩阵级联(Matrix Concatenation)”,极大地降低了系统运算的时间复杂度。

3.3.3 透视投影与平移变换的数学统一

齐次坐标不仅解决了平移的线性化问题,更巧妙地完成了透视投影的非线性化。

在透视投影(近大远小)的最终阶段,需要将坐标除以物体的深度值($z$)。通过精心设计 $4 \times 4$ 投影矩阵的最后一行(第 3 行),矩阵乘法可自动将深度信息 $z$ 编码进原本用于平移的第四维分量 $w$ 中。

  • 初始状态:引入第四维使平移可用乘法表达,此时 $w = 1$。
  • 矩阵相乘:投影矩阵的特殊参数将深度信息记录至 $w$,此时 $w = clipW$。
  • 齐次除法:系统执行最终的齐次除法(各坐标分量除以 $w$),顺理成章地实现了近大远小的透视效果。

这种数学机制与底层硬件的高度契合,确立了 $4 \times 4$ 矩阵 host 在现代图形渲染管线中的统治地位。

3.3.4 MVP 矩阵

代码中所使用的 QMatrix4x4 mvp 矩阵,是图形学中最经典的复合变换矩阵。它是三个独立空间变换矩阵按特定顺序相乘的结果:

$$\text{MVP} = \text{Projection} \times \text{View} \times \text{Model}$$

它们的分工非常明确:

  • Model(模型矩阵):将无人机或点云从它们自己的局部空间(Local Space)摆放到地图的世界空间(World Space)中。
  • View(视图矩阵):根据“虚拟相机(观察者)”的位置和朝向,将世界空间中的所有点转换到相机视野空间(Camera Space)中。此时,相机位于原点 $(0,0,0)$,朝向固定。
  • Projection(投影矩阵):负责将相机空间中一个“前窄后宽”的四棱台视锥体(Frustum)空间,压扁并规范化。

在底层存储中,这个合体后的 $4 \times 4$ 矩阵被排列为 16 个参数。需要注意的是,QMatrix4x4::constData() 返回的是 OpenGL 风格的列主序 (Column-major) 数组,这意味着内存布局中连续的元素属于同一列而非同一行:

$$M = \begin{bmatrix} m_{00} & m_{01} & m_{02} & m_{03} \\ m_{10} & m_{11} & m_{12} & m_{13} \\ m_{20} & m_{21} & m_{22} & m_{23} \\ m_{30} & m_{31} & m_{32} & m_{33} \end{bmatrix}$$

根据矩阵乘法规则,这 16 个参数的逻辑行组合分别控制着不同的输出维度(在代码中通过特定的索引偏移如 m[4] 访问):

  • 逻辑第 0 行(控制 $X$):汇聚并输出最终屏幕的 $X$ 轴位置(左右)。
  • 逻辑第 1 行(控制 $Y$):汇聚并输出最终屏幕的 $Y$ 轴位置(上下)。
  • 逻辑第 2 行(控制 $Z$):用于计算 $Z$ 轴深度。
  • 逻辑第 3 行(控制 $W$):用于计算关键的 $w$ 分量,该分量是后续执行透视除法、实现深度缩放效果的核心依据。

3.3.5 详解代码中的数学流水线:从 3D 到 2D 屏幕

PointCloudRenderer::updatePaintNode 中,CPU 逐个对点云里的每一个点执行了以下标准的图形学管线计算:

A. 计算裁剪空间的 $w$ 分量(齐次坐标计算)

float clipW = p.x * m30 + p.y * m31 + p.z * m32 + m33;

数学原理:这是标准矩阵乘法的最后一行。在设计透视投影矩阵时,通过在 $m_{32}$ 位置填入特定系数,可将原先代表深度的三维空间坐标 $p.z$ 有效映射至 $clipW$ 变量中,从而完成深度信息的提取。

B. 视锥体裁剪(Clipping 防线)

if (clipW > 0.1f)

图形学目的:在 3D 渲染中,需剔除位于相机背面或距离镜头过近(近裁剪面之外)的无效点。代码通过判断 clipW > 0.1f,在 CPU 端实现视锥体裁剪逻辑,确保仅对视野内的点执行后续计算。

C. 透视除法(Perspective Division)

float invW = 1.0f / clipW;
float ndcX = (p.x * m00 + p.y * m01 + p.z * m02 + m03) * invW;
float ndcY = (p.x * m10 + p.y * m11 + p.z * m12 + m13) * invW;

技术原理:这是实现“近大远小”视觉效果的关键步骤。通过将矩阵前两行计算出的横向与纵向累加值除以代表深度的 $clipW$,可使远距离点的坐标向屏幕中心收缩。经过此步,坐标从四维齐次裁剪空间转换至二维归一化设备坐标系(NDC),所有可见点均被收敛在 $[-1.0, 1.0]$ 的标准区间内。

D. 视口变换(Viewport Transformation)

if (std::abs(ndcX) <= 1.05f && std::abs(ndcY) <= 1.05f) {
    v.x = (ndcX + 1.0f) * halfW;
    v.y = (1.0f - ndcY) * halfH;
}

物理映射:首先代码进行了一次边界过滤(允许超出边缘 $5\%$ 范围内的点参与构建)。接着利用视口变换公式,将 $[-1.0, 1.0]$ 的抽象数学比例,转换为真实的物理屏幕像素尺寸。 轴向翻转:由于屏幕坐标原点在左上角(向下为正),而数学 NDC 坐标原点在正中心(向上为正),因此使用 (1.0f - ndcY) 来翻转 $Y$ 轴,确保三维世界里的“天空”显示在屏幕上方。

4. 关键性能与潜在瓶颈分析

该方案本质上是将本该由 GPU 硬件级固化的几何流水线,完全搬到了 CPU 上用单线程软件进行模拟。其在表现出极高灵活性的同时,也带来了显著的性能限制。

4.1 方案优缺点评估

  • 优点:极端灵活与可控。所有数学运算均在 C++ 代码层发生,开发人员可极其方便地在 CPU 端加入自定义过滤逻辑(如单点边界过滤、实时高度热力图动态计算等)。此外,该实现不依赖复杂的图形 API 状态机,代码逻辑直观,适合作为算法原理解析的基准实现。
  • 缺点:放弃了现代 GPU 的并行计算红利。在标准的图形学管线中,CPU 应仅计算一次 $4 \times 4$ 的 MVP 矩阵并作为 Uniform 上传,而点云坐标应直接存入显存(VBO)。本方案采用 CPU 循环逐点计算矩阵乘法与除法,导致主线程在处理海量数据时过载。

4.2 核心性能瓶颈

  1. 渲染线程(Render Thread)负载过重导致 UI 同步阻塞

    • 分析:在 Qt Quick 架构中,updatePaintNode 运行在独立的渲染线程而非 GUI 主线程。然而,Scene Graph 的同步阶段 (Synchronize Phase) 会阻塞 GUI 线程,直到渲染树构建完成。系统在渲染线程中逐点执行密集的浮点投影运算,直接延长了同步时间。
    • 影响:当点云规模达到数十万级别时,渲染线程的投影循环成为瓶颈,导致 GUI 线程被长时间挂起,最终表现为界面交互响应迟钝和卡顿。
  2. 高频堆内存分配开销

    • 分析:在当前实现中,updatePaintNode 内部使用局部变量 std::vector tempBuffer(totalCapacity)。这意味着每一帧渲染都会在堆上进行一次大规模内存分配与释放 (malloc/free)。
    • 影响:虽然 std::vector 的内存分配速度很快,但每秒 60 次的高频操作仍会产生不必要的 CPU 开销与潜在的内存碎片,增加系统抖动风险。

4.3 渲染模式:GL_POINTS 的工程选择与性能优势

系统选择 GL_POINTS(代码中体现为 QSGGeometry::DrawPoints)作为底层渲染基元,是基于对拓扑结构精简与实时性要求的深度考量。

4.3.1 拓扑结构的极致精简与无缝投递

系统之所以选择该模式,其核心优势在于拓扑结构的彻底解耦。

  • 消除重建开销:传统三维网格渲染需要为顶点构建复杂的表面连接关系(如索引缓冲区)。而本系统在 CPU 端完成软件透视投影和高度热力图染色后,直接将降维得到的 2D 屏幕坐标 $(ScreenX, ScreenY)$ 以孤立点图元的形式投递至显卡。
  • 极简流水线:这一做法免去了任何几何表面重建或生成邻接关系的中间层开销,实现了从“CPU 数据变换”到“GPU 像素光栅化”的极简线性流水线。

4.3.2 硬件级点元光栅化与深度信息的丢失

即便投递至显卡端的是已经失去深度信息的 2D 屏幕坐标,底层显卡的光栅化硬件依然可以通过内置的 gl_PointSize 机制提供高效支持。

  • 高效光栅化:通过统一的 geometry->setLineWidth(_pointSize) 指令,显卡会在渲染时将每一个单独的二维顶点光栅化为指定像素尺寸的正方形颗粒。
  • 深度信息丢失的代价:由于传给显卡的顶点仅包含二维坐标 $(x, y)$ 和颜色,GPU 已完全失去了真实的 $Z$ 轴深度信息。这意味着无法利用显卡的 Z-Buffer 进行自动遮挡剔除。当前的遮挡关系完全取决于 CPU 遍历点云的顺序(后画的点覆盖先画的点),这是一种“伪 3D”渲染,而非真正的硬件 3D 管线。
  • 性能增益:这种做法彻底免去了在 C++ 端为了展示“有面积的点”而手工用两个三角形拼凑成方块多边形的巨大计算开销。这确保了系统在场景内累积显示的总点数达到 70 万个 时,渲染线程依然能高效完成几何组包。

4.3.3 工程判定总结

该架构逻辑实现了完美的审计闭环:CPU 承担了核心计算压力(负责 3D 到 2D 投影,视锥裁剪,深度信息处理与热力图染色);而 GPU 则扮演了纯粹的绘图工具角色,利用 GL_POINTS 的硬件特性将 2D 坐标高效放大为屏幕像素块。在当前点云可视化场景下,这是兼顾开发灵活性与极致渲染效率的平衡方案。

5. 系统安全性与健壮性审计

尽管系统在功能上实现了实时可视化,但在工业级部署中,仍需关注底层实现的安全性与健壮性。

5.1 网络安全性与边界校验

当前实现中,数据解析层直接利用 reinterpret_cast 将网络字节流强转为 PointCloudHeader 结构体。这种做法虽然极快,但存在以下风险:

  • 缺少边界校验:若收到恶意构造的 HTTP 响应,其 pointCount 字段被设为极大值(如 0xFFFFFFFF),系统在执行 tempFloats.resize(pointCount * 3) 时将触发 OOM(内存溢出)崩溃。
  • 对齐与字节序:代码未显式处理不同 CPU 架构间的字节序 (Endianness) 差异。虽然现代主流平台均为小端序,但在跨架构部署时可能引发解析错误。

5.2 数据完整性保护

系统目前仅通过数据包的总长度(totalBytes != pointCount * 3 * 4)来执行基础校验。

  • 风险:在不稳定网络环境下,虽然 HTTP 基于 TCP 保证了传输层可靠性,但由于缺乏应用层的 CRC32 校验或帧序号 (Sequence ID),系统无法识别数据块内部的局部污染或帧错位。
  • 改进建议:在协议头部引入 CRC 校验和,并在解压后对关键数据进行范围完整性检查。

6. 优化与重构建议

为全面提升本系统的工程上限,建议进行以下重构:

  1. GPU 硬件加速与 Shader 渲染

    • 策略:停止在 C++ 中执行投影循环。将 Point3D 的原始三维数据直接写入 QSGGeometry,通过自定义 QSGMaterial 引入顶点着色器(Vertex Shader),将 MVP 矩阵作为 Uniform 传入,把投影与裁剪全权交由 GPU 并行处理。
    • 预期收益:CPU 占用率降低 $80\%$ 以上,渲染吞吐量提升数个数量级。
  2. SIMD 矢量优化

    • 策略:针对无法移出 CPU 的 _unshuffle 数据重组阶段,使用英特尔高级矢量扩展指令集 (如 AVX2 / AVX-512) 进行字节转置与浮点数重排。
    • 预期收益_unshuffle 阶段的处理耗时可缩短至原先的 $25\%$ 左右。
  3. 内存复用与池化

    • 策略:将 tempFloats 升级为常驻类成员缓冲区,利用 reserve 预分配最大内存。
    • 预期收益:消除运行时堆内存分配引发的耗时抖动。
  4. 锁颗粒度优化

    • 策略:引入双缓冲区 (Double Buffering) 交换机制。接收回调线程向写缓冲区写入,Renderer 线程从读缓冲区读取。
    • 预期收益:彻底杜绝由于 UI 渲染锁死缓冲区导致的网络数据堆积与掉帧风险。

参考文档

基于 GPU Shader 投影的多机点云实时渲染实现

本文档基于 Qt 5.15 / Qt 6.x 的 Qt Quick SceneGraph 模块(非 Qt Quick 3D),通过自定义 QSGMaterial 与手写 GLSL Shader,在二维渲染管线中实现 GPU 端 MVP 矩阵投影变换,为无人机蜂群地面控制站提供三维点云与多机轨迹的实时可视化能力。

Qt 版本选型背景:Qt 5.15 中的 Qt Quick 3D 默认采用离屏渲染(Offscreen Rendering)架构,三维场景首先渲染至离屏纹理(Render Target/FBO),随后作为纹理与 Qt Quick 二维场景进行合成。这种两阶段渲染流程引入了额外的 Render Pass、纹理采样以及显存带宽开销,在大规模动态点云等高吞吐实时渲染场景下会增加 GPU 渲染负担。Qt 6 虽然默认仍采用 Offscreen 模式,但新增了 Underlay、Overlay 和 Inline 等 RenderMode,可在部分场景下直接渲染至窗口,从而避免默认的离屏纹理合成过程。

此外,Qt 5.15 的 QQuick3DGeometry 虽已支持自定义几何体构建,但其动态几何更新机制主要面向静态或低频更新模型设计。每次更新顶点数据通常需要重新构建几何缓冲区并同步至 Scene Graph,随后重新上传 GPU 顶点缓冲,不适合大规模动态点云的高频流式更新。在每帧需要刷新数十万至数百万顶点的实时点云渲染场景下,该机制会带来较高的 CPU–GPU 数据传输及 Scene Graph 同步开销。因此,本方案绕过 Qt Quick 3D 的高层三维框架,直接基于 Qt Quick SceneGraph 的底层渲染节点实现自定义 GPU Shader,仅保留点云渲染所需的最小图形管线,从而降低图形栈开销,实现高吞吐、高帧率的实时点云投影渲染。

1. 总体架构

系统以单一的 PointCloudRenderer 类为核心——该类直接继承 QQuickItem,将网络数据拉取、Zstd 解压、坐标变换、热力图染色、GPU Shader 渲染、轨迹管理及交互控制全部收敛于一体。

%%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 30, "padding": 5}}}%%
graph TD
    subgraph "Server(点云服务器)"
        S2["点云流数据"]
        S3["多机轨迹数据"]
    end

    subgraph "PointCloudRenderer(单一 QQuickItem)"
        F1["HTTP 轮询(500ms / 2Hz)"]
        F2["Zstd 解压与 Byte Unshuffle"]
        F3["SoA → AoS 重组"]
        F4["坐标映射与高度热力图染色"]

        subgraph "GPU Shader 渲染管线"
            VS["Vertex Shader:MVP 矩阵投影"]
            FS["Fragment Shader:颜色直通"]
        end

        T["多机轨迹处理(去重 / 锥体 / 自愈 / 跟随)"]
    end

    S2 --> F1
    S3 --> F1
    F1 --> F2 --> F3 --> F4 --> VS
    F3 --> T

1.1 核心数据结构

点云数据以紧凑结构体 Point3D 存储,包含三维坐标与 ABGR 颜色:

struct Point3D {
    float x, y, z;
    uint32_t color;   // ABGR 编码
};

多机轨迹使用 QMap<QString, QVector<Point3D>> 按无人机 ID 索引,每架无人机独立维护轨迹点队列。

1.2 Q_PROPERTY 接口

PointCloudRenderer 向 QML 层暴露了丰富的属性接口:

属性类型用途
yaw / pitchqreal视角旋转(弧度)
zoomqreal缩放级别
panX / panYqreal平移偏移(像素)
performanceModebool本地性能基准模式
followedDroneQString跟随的无人机 ID
fpsCountint实时帧率(2Hz 更新)
pointCountint当前点云总数

2. 数据流管线

2.1 数据包协议

点云二进制包使用 12 字节私有头部:

偏移长度类型字段
04uint32_tpointCount(点云数量)
48uint64_tserverTimestamp(服务器时间戳)
12NbytesZstd 压缩载荷

_onPointCloudReplyFinished 在解析时进行以下校验:

  1. 最小长度检查data.size() < 12 时直接丢弃。
  2. 时间戳去重:若 serverTimestamp <= _lastTimestamp,判定为重复或过时帧,跳过处理。
  3. Zstd 解压大小校验:解压后的实际尺寸必须严格等于 pointCount × 3 × sizeof(float),不匹配时丢弃并输出警告。

当前限制:协议头部缺少 Magic Number 与 CRC 校验和。pointCount 字段未做上限钳制——恶意构造的超大值可能导致 decompressedData.resize() 触发内存溢出。在工业级部署中,建议引入 pointCount 上限约束及包体完整性校验。

2.2 Zstd 解压与 Byte Unshuffle

_decompressAndProcess 方法实现完整的解压与数据重组管线,流程如下:

第一步:Zstd 解压缩

size_t expectedSize = expectedPointCount * 3 * sizeof(float);
QByteArray decompressedData;
decompressedData.resize(expectedSize);
size_t decompressedSize = ZSTD_decompress(
    decompressedData.data(), expectedSize,
    compressedData.constData(), compressedData.size()
);

利用 ZSTD_decompress 一次性恢复原始尺寸的 SoA 浮点数组。

第二步:字节 Unshuffle

发送端按字节级别对 float 数据进行了重排(Shuffle)——将每个 float 的第 $i$ 字节集中排在一起。接收端通过双重循环执行逆变换:

int count = expectedPointCount;
int numFloats = count * 3;
int elementSize = 4;
QByteArray soa(decompressedSize, 0);
const char* inPtr = decompressedData.constData();
char* outPtr = soa.data();
for (int i = 0; i < elementSize; i++) {
    for (int j = 0; j < numFloats; j++) {
        outPtr[j * elementSize + i] = inPtr[i * numFloats + j];
    }
}

字节 Shuffle 之所以能显著提升 Zstd 的压缩比,是因为相似高度点的 float 高位字节(符号位与指数位)几乎完全一致。将这些重复字节集中排列,使 Zstd 的 LZ77 字典匹配效率达到极致。

第三步:SoA → AoS 重组

Unshuffle 完成后,从连续内存中按偏移提取 $X$, $Y$, $Z$ 数组,逐个组包为 Point3D 结构体:

const float* xArr = reinterpret_cast<const float*>(soa.constData());
const float* yArr = reinterpret_cast<const float*>(soa.constData() + count * 4);
const float* zArr = reinterpret_cast<const float*>(soa.constData() + count * 8);

注意此处偏移使用 count * 4 而非 count * sizeof(float)——因为 SoA 布局下每一维度的数据块跨度为 count × 4 字节(每个 float 占 4 字节)。

3. 坐标转换与高度热力图

3.1 ROS ENU 到 Qt 坐标系映射

无人机传感器输出 ROS 标准 ENU(East-North-Up)坐标系,而 Qt Quick SceneGraph 以屏幕左上角为原点、$+Y$ 向下。系统在重组阶段执行如下映射:

float mappedX = x;       // ROS X(East)→ Qt X
float mappedY = -z;      // ROS Z(Up)→ 取反,使高处显示在屏幕上方
float mappedZ = y;       // ROS Y(North)→ 深度轴

此映射确保用户在旋转 Pitch(俯仰角)时,点云中物理位置更高的点直观地显示在屏幕更上方。由于最终渲染管线将 $Z$ 分量强制置零(见第 4.2 节),mappedZ 仅作为视角旋转时的中间参考值,不影响最终屏幕位置。

3.2 高度热力图

系统基于原始 $Z$ 坐标(高度)实现蓝→绿→红的双线性渐变热力图,使用固定高度区间 $[-2\mathrm{m}, 5\mathrm{m}]$:

float minZ = -2.0f;
float maxZ = 5.0f;
float normZ = (z - minZ) / (maxZ - minZ);
normZ = std::max(0.0f, std::min(1.0f, normZ));

uint8_t r, g, b;
if (normZ < 0.5f) {
    float t = normZ * 2.0f;
    r = 0;                            // 蓝端:R 通道关闭
    g = static_cast<uint8_t>(t * 255.0f);
    b = static_cast<uint8_t>((1.0f - t) * 255.0f);
} else {
    float t = (normZ - 0.5f) * 2.0f;
    r = static_cast<uint8_t>(t * 255.0f);  // 红端:B 通道关闭
    g = static_cast<uint8_t>((1.0f - t) * 255.0f);
    b = 0;
}

其中 $t$ 为高度归一化值在各自半区间的重映射系数。低处呈蓝色,中等高度过渡为绿色,$5\mathrm{m}$ 及以上饱和为纯红色。

4. GPU Shader 投影管线

这是本系统渲染架构的核心特征。系统不采用 CPU 端逐点软件投影,而是将 MVP 矩阵计算留在 CPU,将逐顶点变换推入 GPU——通过自定义 QSGMaterial 与手写 GLSL Shader 实现。

4.1 自定义材质与 Shader 体系

系统定义了三个紧密协作的类:

  • PointCloudMaterial(继承 QSGMaterial):持有 MVP 矩阵和点大小 Uniform,启用 Blending 标志以确保 UI 图层渲染于点云之上。
  • PointCloudShader(继承 QSGMaterialShader):包含完整的 GLSL 顶点着色器和片段着色器源码。
  • PointCloudNode(继承 QSGGeometryNode):封装 QSGGeometry 和材质,定义顶点属性布局(位置 3×float + 颜色 4×ubyte)。

顶点着色器承担核心投影职责:

attribute highp vec3 aPosition;
attribute lowp vec4 aColor;
uniform highp mat4 qt_Matrix;
uniform highp mat4 uMVP;
uniform highp float uPointSize;
varying lowp vec4 vColor;

void main() {
    vec4 pos = qt_Matrix * uMVP * vec4(aPosition, 1.0);
    gl_Position = vec4(pos.x, pos.y, 0.0, pos.w);
    gl_PointSize = uPointSize;
    vColor = aColor;
}

4.2 为何强制 $Z = 0$

Shader 中 gl_Position = vec4(pos.x, pos.y, 0.0, pos.w) 将深度分量强制归零。这一设计确保了所有点云渲染在屏幕空间的同一深度层上,与二维 UI 元素的 $Z$ 排序一致——避免点云因深度冲突遮挡控件或产生不可预期的绘制顺序。

在此前提下,PointCloudMaterialBlending 标志确保 Qt Quick SceneGraph 按节点树顺序(点云先于 UI)进行后向合成。

4.3 CPU 侧的 MVP 矩阵组装

updatePaintNode 中,CPU 仅负责矩阵级联和 Uniform 更新:

QMatrix4x4 mvp;
mvp.translate(width() / 2.0f, height() / 2.0f, 0.0f);  // 屏幕居中
mvp.translate(_panX, _panY, 0.0f);                       // 用户平移
mvp.scale(scale, scale, 1.0f);                            // 缩放
mvp.rotate(pitch_deg, 1.0f, 0.0f, 0.0f);                  // Pitch 旋转
mvp.rotate(yaw_deg, 0.0f, 1.0f, 0.0f);                    // Yaw 旋转
mvp.translate(-_targetX, -_targetY, -_targetZ);            // 平移至目标位置

ptsNode->material.mvpMatrix = mvp;
ptsNode->markDirty(QSGNode::DirtyMaterial);

该矩阵级联的数学含义为:

$$\text{Vertex}_{\text{screen}} = T_{\text{center}} \cdot T_{\text{pan}} \cdot S_{\text{scale}} \cdot R_{\text{pitch}} \cdot R_{\text{yaw}} \cdot T_{\text{-target}} \cdot \text{Vertex}_{\text{world}}$$

当用户旋转视角(改变 _yaw / _pitch)时,仅需更新 mvpMatrix Uniform 并调用 markDirty(DirtyMaterial)——几何数据缓冲区完全不变,避免了 $O(N)$ 的逐点重算开销。

4.4 顶点属性布局

PointCloudNode 定义的顶点格式为:

static QSGGeometry::Attribute attribs[] = {
    QSGGeometry::Attribute::create(0, 3, GL_FLOAT, true),        // aPosition
    QSGGeometry::Attribute::create(1, 4, GL_UNSIGNED_BYTE, false) // aColor
};

每个顶点占据 sizeof(PointVertex) = 16 字节(12 字节坐标 + 4 字节 RGBA)。由于投递给 GPU 的是三维世界坐标而非已投影的 2D 坐标,在视角旋转、缩放等交互中,几何数据始终不变,Shader 自动重新计算屏幕位置。

4.5 渲染基元:GL_POINTS

系统选择 GL_POINTS 作为点云渲染基元(geometry.setDrawingMode(GL_POINTS)),通过 gl_PointSize Uniform 控制点大小(固定为 2.0px)。这一策略免去了手工构建三角形方块的巨大开销,在场景累积显示数万点时仍能维持 60 FPS。

由于传给 GPU 的顶点仅包含三维世界坐标,GPU 完全通过 Shader 中的 MVP 矩阵获得深度感知。但最终 $Z$ 分量被强制归零(见 4.2 节),因此渲染结果本质上是「伪 3D」——遮挡关系取决于点提交顺序(后画的覆盖先画的),而非真实深度测试。

4.6 帧率统计与性能曲线

updatePaintNode 中内嵌了轻量级帧率计数器,每 500ms 通过 Qt::QueuedConnection 发射一次信号以避免高频 QML 属性绑定开销。系统在不同点云规模下的实测帧率如下:

点云规模与渲染帧率

图:点云规模与渲染帧率关系。

曲线呈现典型的两阶段衰减特征:

第一阶段:陡降区(7 万 → 15 万,$-$25%)。GPU 的并行计算单元数量是固定的。当点云数量翻倍时,每个着色器核心分摊的顶点数随之翻倍,Vertex Shader 执行时间线性增长。由于此时 GPU 尚未饱和,帧率对顶点数量高度敏感。

第二阶段:平缓衰减区(15 万 → 230 万,$-$67%)。GPU 计算单元满负荷后,瓶颈从 Shader 计算转移至显存带宽与固定功能单元(光栅化器、ROP)。GL_POINTS 固定 2px 的光栅化开销与顶点数呈亚线性关系,因此即使点数从 100 万增至 230 万($+130\%$),帧率仅从 30 FPS 降至 15 FPS($-50\%$)。

5. 多机轨迹与 3D 锥体方向指示器

系统通过 QMap<QString, QVector<Point3D>> 按无人机 ID 维护轨迹数据,单机上限 5000 点,连续点间距小于 5cm 时不追加以节约内存。轨迹线以 GL_LINES 绘制(线宽 4.0px),并在每架无人机当前位置以 24 个三角形面片构建实心 3D 锥体表示飞行方向——通过两帧位置差分归一化得到方向向量,叉积构建局部正交基,锥体尺寸随缩放级别自适应钳制以保持恒定屏幕像素尺寸。无人机颜色使用黄金角度 $137^\circ$ 作为 HSV 色相累加步长。轨迹时钟回卷自愈机制在检测到时间戳回滚超过 5 秒时自动清空缓存。此外,系统提供 performanceMode 属性用于生成本地 35,000 个模拟点以测试渲染吞吐量。

当用户选定一架无人机进行跟随时,系统通过 60Hz 定时器(_animTimer,16ms 间隔)驱动指数平滑动画:

float alpha = 0.1f;
_targetX += (targetPt.x - _targetX) * alpha;
_targetY += (targetPt.y - _targetY) * alpha;
_targetZ += (targetPt.z - _targetZ) * alpha;

指数平滑(Exponential Moving Average)以加权因子 $\alpha = 0.1$ 控制相机向目标位置的渐进靠近速度。此算法在无突变跳帧的前提下保证了跟随的流畅性——相机不是瞬间跳到目标位置,而是以指数衰减速率平滑逼近。

跟随模式下,手动平移操作被锁死:

void PointCloudRenderer::setPanX(qreal panX) {
    if (!_followedDrone.isEmpty()) return;  // 跟随中,拒绝手动平移
    // ...
}

取消跟随时,相机停在最后的目标位置,作为新的静态观察点。


6. 与同系列方案的对比

本方案与前两篇文档分别代表了 Qt 生态下点云渲染的三种技术路线,三者围绕「投影计算位置」构成了清晰的演进谱系。

  • 基于 CPU 投影变换的多机点云可视化实现:在 updatePaintNode 中逐点执行 MVP 矩阵乘法与透视除法,CPU 完成全部投影后将 2D 屏幕坐标投递给 GPU。灵活性最高——可在投影循环中插入任意自定义逐点逻辑——但 CPU 单线程计算成为大规模点云的瓶颈。

  • 本方案(GPU Shader 投影):MVP 矩阵在 CPU 侧组装为 Uniform,逐顶点矩阵乘法交由 GPU Vertex Shader 并行执行。几何缓冲区存储的是三维世界坐标,视角旋转无需重建顶点数据,仅更新一个 4×4 矩阵即可。在 Qt Quick SceneGraph 二维管线上获得了接近 GPU 原生的吞吐量,同时保留了管线中间过程的完整控制权。

  • 基于 Qt Quick 3D 的面向实时渲染多机点云渲染实现:完全交由 Qt Quick 3D 引擎管理矩阵运算与渲染状态。开发体验最简——无需手写 GLSL——但依赖 Qt 6.x 的 QQuick3DGeometry API 和空间场景图集成。

6.1 差异

维度CPU 方案本方案Qt3D 方案
Qt 模块SceneGraph (2D)SceneGraph (2D)Quick 3D
投影计算位置CPU 逐点GPU Vertex ShaderGPU 硬件管线
需手写 Shader是 (GLSL)
视角旋转开销O(N) 重算O(1) 更新 UniformO(1)
顶点存储2D 屏幕坐标3D 世界坐标3D 世界坐标
管线可控性极高
Qt 版本要求5.15+5.15+6.0+
20 万点帧率~10 FPS40 FPS60 FPS

本方案在工程上位于另外两者的中间地带:比 CPU 方案获得了数量级的性能提升(GPU 并行),比 Qt3D 方案保留了更低的版本门槛(Qt 5.15 即可运行)和更高的可定制性(GLSL 完全自主),是当前阶段兼顾性能与灵活性的最优工程折中。


参考文档