手势控制 AirSim 无人机

用摄像头 + MediaPipe 识别手部手势,把识别结果转换成指令,通过 ROS 话题交给 控制节点,由控制节点调用 AirSim 多旋翼 API 完成 起飞 / 降落 / 悬停 / 前进 / 上升 / 下降 / 紧急停止,并把载具位姿以 nav_msgs/Odometry(ENU)回传。

源码位于 src/air/gesture_control_drone, 完整运行细节(环境、参数、排查)见模块 README.md。


1. 它解决什么问题

用"抬手"指挥无人机,难点不在识别单个手势,而在让两半程序对同一套词、并且 把 AirSim 的多旋翼接口用对:

摄像头 -> MediaPipe -> 手势 token -> 指令 token -> AirSim 多旋翼 API
        └── 感知节点(发布)──┘└──── 控制节点(订阅 + 执行 + 回传)────┘
                                   │
                                   └─> /drone/odom (Odometry) /drone_state
  • 手势与指令的映射只在一处定义(scripts/gesture_map.py),两个节点 import 同一份常量;AirSim 的每个动作都有实现,缺一个就启动即报错;
  • 线上传输一律用 ASCII token(PALM_OPEN / takeoff …),中文只用于日志与 界面,避免编码与别名问题;
  • 识别结果经过去抖 + 边沿触发 + 冷却才发布:同一个手势一直举着只发一条指令;
  • 控制节点把耗时动作放到工作线程执行,订阅回调只入队;每次 RPC 调用都有 超时保护,仿真器卡住也不会把节点拖死;
  • 有安全限幅:限速、限高、限低;紧急停止默认悬停,不再空中锁桨。

2. 手势与指令

手势 手势 token 指令 token 动作
张开手掌 PALM_OPEN takeoff 解锁 → 起飞 → 爬升到 takeoff_altitude → 悬停(定高仅 *Async 路径生效,同步回退由仿真器默认高度决定,实测约 +1.54 m)
握拳 FIST land 降落
食指指向 POINT forward 机体系向前飞 speed × move_duration
胜利手势 VICTORY up 上升一档(受 max_altitude 限制)
拇指向下 THUMB_DOWN down 下降一档(受 min_altitude 限制)
拇指向上 THUMB_UP hover 原地悬停(稳定)
OK 手势 OK estop 紧急停止(默认悬停,可配 land/disarm)

另外还有两个不占用手指的指令,可直接用话题触发(便于联调/答辩演示): yaw_left / yaw_right(按 yaw_rate 偏航)与 home(返航到原点上方)。

3. 环境要求

位置 项目 版本
宿主 操作系统 Windows 10/11
宿主 模拟器 AirSim(Blocks,UE 4.27,RPC 41451)
客户机 操作系统 Ubuntu 20.04.6
客户机 ROS Noetic(ROS 1)
客户机 Python 3.8
客户机 依赖 python3-opencv python3-numpy python3-pil(apt)
mediapipe(0.10.x) airsim(pip,见 requirements.txt)

摄像头必须接在跑 ROS 的那台机器上;虚拟机里需要先把 USB 摄像头透传进来。 用的是经典版 AirSim 时客户端是 airsim(不是 cosysairsim)。

4. 运行

# 1) 编译
cd ~/uav_ws && catkin_make && source devel/setup.bash

# 2) 宿主机启动 AirSim 仿真器,并放行 41451 端口

# 3) 启动本模块(跨机部署时用 airsim_ip 指定宿主 IP)
roslaunch gesture_control_drone main.launch airsim_ip:=192.168.80.1

# 4) 只看识别效果、不动飞机
roslaunch gesture_control_drone main.launch start_drone_control:=false show_window:=true

# 5) 没有 ROS 也能验证手势与词表(单机演示,画一台"虚拟无人机")
rosrun gesture_control_drone gesture_demo.py

预期日志(起手"张开手掌"):

[gesture] 已启动:摄像头索引 0,640x480,10.0 Hz
[gesture] 手势 -> 指令:张开手掌->起飞,握拳->降落,食指指向->前进,胜利手势->上升,拇指向下->下降,拇指向上->悬停,OK 手势->紧急停止
[gesture] 识别到 张开手掌
[gesture] 发布指令 起飞(takeoff)
[drone] 已启动:订阅 /gesture_command,AirSim 192.168.80.1:41451,estop_action=hover,起飞高度 3.0m,限速 3.0 m/s,限高 30.0 m,指令冷却 1.5s
Connected! AirSim 192.168.80.1:41451
[drone] 执行成功:起飞(累计 1 次)

5. 话题接口

方向 话题 类型 说明
发布 /gesture_command std_msgs/String 指令 token,边沿触发
发布 /gesture_state std_msgs/String 当前识别到的手势 token(变化时才发)
订阅 /gesture_command std_msgs/String 控制节点订阅;词表外的字符串会被忽略并告警
发布 /drone/odom nav_msgs/Odometry 载具位姿与速度(ENU,frame_id=world),可加进 RViz
发布 /drone_state std_msgs/String 执行结果,如 takeoff OK / down FAIL 已到最低高度…

排查"为什么没触发":

rostopic echo /gesture_state     # 识别有没有出结果
rostopic echo /gesture_command   # 指令有没有发出来
rostopic echo /drone_state       # 执行成功/失败原因
rostopic echo /drone/odom        # 位姿(确认飞机真的动了)
rostopic pub -1 /gesture_command std_msgs/String "data: 'takeoff'"   # 绕过识别直接测执行

为什么直连 AirSim、而不是走 carlair_ros_bridge + /uav/cmd_vel? 本轮验证用的是经典版 AirSim(Blocks, UE 4.27),这套环境里没有 carlair_ros_bridge(它服务于 CarlaAir / Cosys-AirSim 那套栈)。直连时桥接层原本 提供的安全逻辑由本模块自己补齐:指令冷却、单次 RPC 超时(command_timeout, 超时判失败而非永久阻塞)、限速/限高/限低、退出时先悬停再交还控制权、 紧急停止三选一。若之后接入 CarlaAir 栈,只需把 drone_client.py 换成发布 /uav/cmd_vel——它是唯一与仿真器耦合的模块,感知/词表/闸门那一层不用动。

6. 参数

参数在 config/gesture_control.yaml,由 launch 载入各节点的私有命名空间。

参数 默认 说明
camera_index 0 摄像头索引,打不开时自动尝试 0~4
show_window false 是否弹出识别预览窗
publish_rate 10.0 识别循环频率 (Hz)
stable_frames 3 连续多少帧识别到同一手势才算数(去抖)
command_cooldown 1.5 同一条指令的最小间隔 (s)
command_topic /gesture_command 指令话题(两端共用)
airsim/ip 127.0.0.1 仿真器地址;跨机填宿主 IP
airsim/port 41451 RPC 端口
airsim/vehicle_name 空 留空 = 默认载具(Drone1)
estop_action hover 紧急停止:hover / land / disarm(空中锁桨会坠落)
takeoff_altitude 3.0 起飞后爬升高度 (m),不得超过 max_altitude
speed 1.0 前进/上升/下降速度 (m/s)
max_speed 3.0 速度上限 (m/s)
max_altitude 30.0 高度上限 (m)
min_altitude 0.5 高度下限 (m),到这里拒绝下降
move_duration 1.0 单次位移/偏航持续时间 (s)
yaw_rate 30.0 偏航角速度 (deg/s)
command_timeout 10.0 单次 RPC 超时 (s),超时视为失败
takeoff_timeout 60.0 起飞(含 moveToZAsync 定高)超时 (s);慢仿真器可调大
land_timeout 60.0 降落超时 (s);3 m 降落实测约 14~20 s,不能用 command_timeout
state_lock_timeout 0.1 读状态抢不到 RPC 锁时的等待上限 (s);降落时锁会被占 20 s,取值必须很短,否则 /drone/odom 会停更
state_rate 5.0 /drone/odom 发布频率 (Hz),0 = 关闭
connect_retries 3 连接 AirSim 的重试次数

7. 自测

# 离线回归(不需要 ROS / 摄像头 / 仿真器)
python3 tests/test_gesture_control_local.py

数据集工具(为神经网络分类器准备训练数据,同样不需要 ROS / 摄像头):

# 1) 用合成关键点验证整条流程(7 类 × N 个样本,临时文件跑完自动清理)
python3 src/air/gesture_control_drone/scripts/gesture_dataset.py selftest

# 2) 查看一个采集会话的统计(每类样本数、缺失类别、特征均值/标准差)
python3 src/air/gesture_control_drone/scripts/gesture_dataset.py stats data/session_01.npz

# 3) 合并多个会话(默认按固定种子打乱)
python3 src/air/gesture_control_drone/scripts/gesture_dataset.py merge data/all.npz \
    data/session_01.npz data/session_02.npz

selftest 的真实输出(log_to_png.py 渲染,非系统截屏):

数据集工具自测

其中第 [2] 步是弱标签复判:用 gesture_detection.classify_landmarks 对 7 只 合成手逐个复判,必须 7/7 与期望类别一致。原因是 extract_from_result() 给真实 数据打标签用的正是同一个函数——合成手一旦和它对不上,训练集的标签就是反的 (历史上就踩过:合成手的手指画成朝下,张开手掌被判成握拳)。

它负责三件事:把 MediaPipe 的 21 个关键点归一化成 42 维(减腕部 + 除手掌尺度, 与距离/分辨率无关)、把采集会话存成 .npz(features + labels + meta)、 以及按类分层划分训练/验证集(样本数 < 2 的类别整类留在训练集, 避免"某类在训练集里消失")。

覆盖:脚本语法、词表一致性(7 手势→7 动作、10 条指令都有实现)、手势分类 (含 OK / 拇指朝上朝下)、去抖/边沿/冷却、跨平台摄像头、AirSim 全部动作 (解锁→定高→悬停顺序、机体系速度、偏航、返航回退、急停三语义)、安全限幅 (限速/限高/限低)、RPC 超时保护、NED→ENU 状态转换、七种手势的端到端链路、 Odometry 回传、以及工程文件约定。

采集真实数据:

# 采 60 秒 -> /tmp/session_01.npz
# (示例写到系统临时目录,避免在仓库里留下未跟踪的 data/)
python3 src/air/gesture_control_drone/scripts/record_gesture_session.py \
    --out /tmp/session_01.npz --duration 60 --camera 0

# 看这一轮采得怎么样(每类数量 / 缺失类别 / 丢弃原因 / 实际采样率)
python3 src/air/gesture_control_drone/scripts/gesture_dataset.py stats /tmp/session_01.npz

一次真实录制(25 秒、Integrated Camera、--camera 1、--fps 10)的输出:

[4s] 处理 32 帧,已收录 9 个样本
[8s] 处理 64 帧,已收录 31 个样本
[16s] 处理 128 帧,已收录 79 个样本
[24s] 处理 192 帧,已收录 117 个样本
本轮采集:121 个样本
  PALM_OPEN      63
  FIST           55
  POINT           3  <- 偏少(建议 >= 30)
  VICTORY         0  <- 缺失
  ...
丢弃的帧:no_hand=75
结论:还有类别缺失,建议补采
实际采样率:7.7 Hz(目标 10 Hz,共 192 帧)

录制脚本实测

那次实际频率 7.7 Hz(目标 10 Hz)是 MediaPipe 单帧耗时导致的;脚本现在按 截止时刻排程并把实际频率报出来,不会再让名义值骗人。

采集规范(直接影响能不能训出模型):

  • 手离镜头 40~60 cm,手心正对镜头,手指朝上;
  • 每个手势保持 1~2 秒,之间手完全放下;
  • 手势要交替做,不要"一种做完再做下一种"。原因不是"按类分层会漏掉类", 而是 10 Hz 连拍的相邻帧几乎重复:一次性做完某一种,那段时间的样本彼此高度相似, 无论按时间划分还是随机划分,训练集与验证集都会互相泄漏,指标虚高。 单次采集只做一种手势的数据,建议按会话级留出(一次采集只进训练或只进验证)。

仓库自带的 data/session_host_121.npz(121 个样本、25 秒、只覆盖 3/7 类)是 采集链路的证据,不是可直接训练的数据集 —— stats 会如实报出 4 类缺失。

训练与评估(纯 numpy,不需要 PyTorch):

# 用仓库自带的真实样本训练(42 -> 64 -> 7 的两层 MLP,默认 epochs=200)
python3 src/air/gesture_control_drone/scripts/gesture_train.py

# 三种划分方式 + 多数类基线并排对比
python3 src/air/gesture_control_drone/scripts/gesture_train.py --compare

默认把模型写到包内的 models/(.gitignore 里有 !**/models/*.npz 例外), 不会在仓库根目录丢文件;要换位置用 --out。

真实样本(121 个、3 类)上的输出:

划分方式                训练acc      验证acc      样本数
time                1.000      0.972       36
stratified          1.000      0.972       36
random              1.000      0.972       36
多数类基线                   -      0.722

训练与评估

为什么还要报"多数类基线":验证集是 26 PALM + 9 FIST + 1 POINT,全猜 PALM 就已经 0.722。0.972 只有和它并排看才知道模型确实学到了东西(尤其 POINT 只有 3 个样本、 召回 0.00)。

为什么默认按时间划分:相邻帧几乎一样,随机划分会让训练集和验证集共享同一段 视频。这个现象是在另一份时序窗口数据上观察到的(按时间划分 0.167、随机划分 0.972,未随本仓库提交);本仓库这份逐帧样本因为手势交替出现,三种划分都是 0.972 —— 所以 --compare 会把三个数都打出来,让数据自己说话。

注意:gesture_dataset.py merge 默认会打乱顺序,合并后的数据集就不再有采集时间 信息,--split time 会退化成随机划分(训练脚本会打印提示)。

8. 常见问题

现象 原因与处理
无法打开摄像头(已尝试索引 [0,1,2,3,4]…) 摄像头没接在 ROS 机器上 / 没透传进虚拟机 / 被占用。ls /dev/video* 确认;可用 GESTURE_CAMERA_BACKEND=CAP_V4L2
连接 AirSim x.x.x.x:41451 失败 仿真器没启动、airsim_ip 填错、或 41451 被防火墙拦(Windows 需加入站规则)
缺少 airsim 客户端:pip3 install airsim 只装了 apt 依赖。经典 AirSim 用 airsim,Cosys-AirSim 才用 cosysairsim
手势识别到了但飞机不动 看 /gesture_command;没有 → 去抖参数或手势不在词表;有 → 看 /drone_state 与 [drone] 日志
一直举着手,指令只发一次 设计如此(边沿触发)。手放下再举起才再次触发
已到最大高度 / 已到最低高度 触发了 max_altitude / min_altitude 保护;改参数或改用降落
起飞后高度不对 检查 takeoff_altitude;AirSim 的 takeoffAsync 只到默认高度,本模块会再 moveToZAsync 定高
中文显示成方块 缺中文字体;Ubuntu 装 fonts-wqy-zenhei,Windows 自带 simhei
手指旋转超过 90° 后识别率下降 手指伸展判定基于"指尖在 PIP 关节上方"的启发式,掌心朝镜头、手指大致朝上时最稳

9. 运行证据

图片说明:本节所有图片都由 tools/log_to_png.py 从真实终端输出渲染而成,不是系统截屏;对应的原始文本见各小节代码块。

9.1 离线回归(本机真实输出)

离线回归 236 项全部通过

上图由 tools/log_to_png.py 从本机真实终端输出生成(不是拍照、不是外链), 复现命令:

python3 tests/test_gesture_control_local.py 2>&1 | tee /tmp/local_test.log
python3 tools/log_to_png.py /tmp/local_test.log \
    docs/img/air/gesture_control_drone/local_test.png \
    --title "python3 tests/test_gesture_control_local.py"

9.2 虚拟机内 ROS → AirSim 真机飞行(实测)

在 Ubuntu 20.04 + ROS Noetic 虚拟机里跑节点,AirSim Blocks 跑在 Windows 宿主上 (airsim_ip:=192.168.80.1),通过 rostopic 下发指令验证了完整控制链路:

节点:/drone_control_node   /gesture_ros_node   /rosout
话题:/drone/odom  /drone_state  /gesture_command  /gesture_state
/drone/odom frame_id = "world"

下发 takeoff 之后(/drone/odom 的 position.z,ENU):
  +3s   z = 0.852
  +6s   z = 0.855
  +18s  z = 0.855     ← 稳定悬停(相对地面爬升约 1.54 m)

下发 land 之后:
  +3s   z = -0.640
  +6s   z = -0.681    ← 回到地面并保持

VM 内 ROS -> AirSim 真机飞行

这一步证明 ROS 节点 → AirSim → 真实起降 是通的; 摄像头 → MediaPipe → 手势 这一段见下面的说明。

9.3 实跑才暴露的坑(已修)

  1. catkin_install_python 不能用来装"辅助模块" 它在 devel 空间给每个脚本生成的是启动器(exec(compile(...))), 被启动的文件能跑,但 from drone_client import AirSimDrone 会 ImportError —— 因为拿到的是启动器而不是真模块。 → 现在只把 4 个入口(main / gesture_ros_node / drone_control_node / gesture_demo)交给 catkin_install_python,辅助模块改用 install(FILES); 节点里再加一段 sys.path 引导,保证从源码目录也能 import。
  2. VMware 透传的摄像头默认格式读不到帧 /dev/video0 能 open,但 cap.read() 一直 select() timeout (默认 YUYV 720p 带宽不够)。→ camera_utils.open_capture() 现在默认设 MJPG(可用 GESTURE_CAMERA_FOURCC 覆盖)并降到 640×480, 同一台机器上立刻出画面(shape=(480, 640, 3))。

  3. 张开手掌在"拇指朝侧面"时识别不出来 thumb_extended() 用"拇指尖相对 IP 关节的 x 偏移 > 0.25×掌宽"判断;手掌正对 镜头时这个偏移实测只有 约 0.1×掌宽,于是"四指伸直"走不进 PALM_OPEN 分支, 整只手返回 NONE(真人面对镜头举手,系统毫无反应)。

改用拇指张开角(mcp2→tip4 与 mcp2→index_mcp5 的夹角)兜底。121 个真实 样本上的分布:

手势 张开角范围 中位
张开手掌 44.3° ~ 62.2° 54.1°
握拳 0.1° ~ 136.6° 8.8°

阈值取 40°,并且只在四指伸直时才走这个兜底分支 —— 握拳的四指是弯的, 那些高角度的握拳帧根本进不来(实测非手掌误判 0 帧,手掌 63/63 命中)。

张开手掌修复后的实机起降

9.4

摄像头(Integrated Camera,USB 透传进虚拟机)→ MediaPipe → /gesture_command → 控制节点 → AirSim,全程由真人做手势触发:

真实手势控制:6 次起飞 + 6 次降落

$ rostopic echo /gesture_state     -> "PALM_OPEN" / "FIST"(中间夹 "NONE")
$ rostopic echo /gesture_command   -> "takeoff" ×6 / "land" ×6
$ rostopic echo /drone/odom        -> position.z: -0.681(地面) → +1.229(空中) → -0.681(落地)

控制节点对 6 次起飞、6 次降落全部记录 执行成功(见上图时间戳)。

同步回退模式的结果语义(如实说明):客户端没有 *Async 时,同步 takeoff / land 是"发完即弃",节点只能确认命令已下发,拿不到 AirSim 的执行结果。 例如飞机已在空中再发 takeoff,AirSim 会返回 Cannot perform takeoff because vehicle is already moving,但节点侧仍按"已下发"记录。 有 *Async 时 join() 会把异常抛出来。

9.5 全部动作实机验证

forward / up / yaw_left / down 在修复"按服务端完整签名传参"之后都能真实驱动飞机:

全部动作实机验证

9.6 官方客户端(airsim 1.8.1)—— 真 *Async 路径实机验证

前面的 9.2~9.5 都是走同步回退分支(等价 msgpack-rpc 客户端)。这一节用官方 airsim 客户端, 走真实 takeoffAsync().join() / moveByVelocityBodyFrameAsync() 路径:

官方客户端 *Async 路径实机验证

协议:Connected!   Client Ver:1 (Min Req: 1), Server Ver:1 (Min Req: 1)

api_mode: async
初始:      alt=-0.68  Landed
takeoff -> True   5.39s   alt=2.63   Flying     <- 定高生效(moveToZAsync)
up      -> True   1.52s   alt=3.61   Flying
forward -> True   1.51s   y=0.65     Flying     <- 机体系前向位移
land    -> True  20.69s   alt=-0.68  落地

两个额外结论:

  • 定高只在 *Async 路径生效:这里 takeoff 到 2.63 m(takeoff_altitude=3.0), 而同步回退只到仿真器默认高度(约 +1.54 m)。

    2.63 m 比设定的 3.0 m 低 0.37 m,是 moveToZAsync 的减速/lookahead 造成的稳态误差 (AirSim 侧不做到绝对到位),不是控制逻辑的问题;要更贴近设定值可调大 moveToZAsync 的速度或在目标点附近补一次 moveToZAsync。

  • land 用了 20.69 s:证实必须给降落独立超时(land_timeout=60 s), 套用 command_timeout(10 s) 会误判失败。 同样因为它整段持锁,读状态必须用 state_lock_timeout(见 §6)而不是干等, 否则这 20 s 里 /drone/odom 不会更新。

安装方式(airsim 在 PyPI 只有 sdist,构建期要 numpy 和 msgpackrpc):

sudo apt install -y gcc python3-dev        # msgpack-python 0.5.6 需要编译
pip3 install --user numpy
pip3 install --user msgpack-rpc-python     # 会现场编出 msgpack-python 0.5.6
pip3 install --user --no-build-isolation airsim

另外实测发现:airsim 1.8.1 的 confirmConnection() 只打印 Connected! 不返回值(None), 早期代码用 if not confirmConnection() 会误判为失败;现已改为"只有明确 False 才算失败"。

10. 客户端兼容层与已知坑

10.1 先澄清一个容易误判的点

takeoffAsync / landAsync / hoverAsync / moveToZAsync / goHomeAsync 都是 Python 客户端的方法,内部只是 call_async('takeoff', ...) 之类的包装:

# microsoft/AirSim  PythonClient/airsim/client.py
def takeoffAsync(self, timeout_sec=20, vehicle_name=''):
    return self.client.call_async('takeoff', timeout_sec, vehicle_name)
def goHomeAsync(self, timeout_sec=3e+38, vehicle_name=''):
    return self.client.call_async('goHome', timeout_sec, vehicle_name)

也就是说 服务端从来只有 takeoff / land / hover / goHome(所有 AirSim 版本都一样), "异步"是客户端把请求发出去后不等结果。所以"服务端没有 takeoffAsync"不是老 build 的特征。

本模块的 api_mode 只解决另一个问题:某些客户端(例如自写的精简客户端、很老的 Python 客户端)不提供 *Async 方法,那时回退到同名同步方法:

客户端方法 服务端方法 缺 *Async 时的回退
takeoffAsync(timeout) takeoff takeoff(timeout, vehicle_name),阻塞
landAsync(timeout) land land(timeout, vehicle_name),阻塞
hoverAsync() hover hover(vehicle_name)
moveToZAsync(z, v) moveToZ moveToZ(z, velocity, timeout, yaw_mode, lookahead, adaptive, vehicle_name) —— 7 个参数
goHomeAsync(timeout) goHome goHome(timeout, vehicle_name),阻塞

10.2 已知坑(都已处理)

  1. 同步 takeoff / land 会阻塞到动作完成 3 m 高度按 ~0.22 m/s 下降约需 14 s,放在调用线程里会把 ROS 回调/定时器堵住。 → 用独立连接发命令、后台线程等待、调用方立即返回;状态靠轮询 /drone/odom。
  2. 降落比 command_timeout(10 s) 慢 从 3 m 降落约 14 s,套用 10 s 会误判超时。 → takeoff 与 land 各有独立上限 takeoff_timeout / land_timeout(默认 60 s)。
  3. msgpack-rpc 客户端不是线程安全的 控制节点的状态定时器(5 Hz 读状态)与指令工作线程共用同一个连接, msgpackrpc 的 Session 没有发送锁 —— 这是"响应错位/超时"的真正根因。 → 所有对主客户端的 RPC 由 _api_lock(threading.RLock)串行化。
  4. landed_state 的取值 官方 airsim.types.LandedState 是 Landed = 0、Flying = 1 (旧代码把 0/1 映射写反了,已修正);起降判定仍以高度为准,不依赖它。
  5. goHomeAsync 第一个位置参数是超时,不是速度 (旧代码误传 speed,等于给返航设了 1 s 超时,已改为 60 s)。

  6. 同步回退必须按服务端签名传参 —— 例如 moveByVelocityBodyFrame 要 (vx, vy, vz, duration, drivetrain, yaw_mode, vehicle_name) 共 7 个, rotateByYawRate 要 (yaw_rate, duration, vehicle_name) 3 个; 少传会报 Function ... was called with an invalid number of arguments。 drone_client 现在按完整签名调用,vehicle_name 用关键字传(对没有该形参的 旧客户端自动回退)。 实机验证:forward / up / down / yaw_left 都能让飞机真实移动。

  7. hover() 在移动中调用会让飞机掉高度(在等价 msgpack-rpc 客户端上观察到,未在官方客户端上复现)—— 实测:forward 之后调用 hover,飞机从 0.85 m 一路降到地面(-0.68 m);而静止时调用 hover 是安全的(6 s 高度不变)。 现在移动 / 偏航 / 急停之后一律改用零速度刹车 (moveByVelocityBodyFrame(0, 0, 0, 0.5)):实测 forward 位移约 0.9 m 后停住, 高度稳定在 0.78~0.85 m;停住后仍有 ±0.2 m 的位置保持抖动(在当前环境下观察到的位置保持抖动)。

10.3 本机实测(等价 msgpack-rpc 客户端 + 真机 Blocks)

这台机器上 pip install airsim 装不上,原因有两个(都实测过): 1. airsim 在 PyPI 只有 sdist,它的 setup.py 构建期要 import numpy,构建隔离环境里没有 numpy → ModuleNotFoundError: No module named 'numpy'; 2. 它依赖的 msgpack-rpc-python 要 msgpack-python==0.5.6(老 msgpack API),这个包在 Windows 没有预编译 wheel,需要 MSVC 才能编。

所以用了一个等价的 msgpack-rpc 客户端驱动 drone_client.py。它没有实现 *Async,因此走的是上面的同步回退分支(代码里已支持 pip install cosysairsim 作为 Windows 上的替代客户端):

AirSim API:客户端缺少 *Async —— 已自动切换到同步回退
Connected! AirSim 127.0.0.1:41451
execute('takeoff') -> True(0.02 s 立即返回)
    +3.0s 离地 +1.40 m            起飞 OK
execute('hover')  -> 稳定 1.40 → 1.43 m
execute('land')   -> True(0.02 s)
    +6.0s 离地 +0.12 m            降落 OK

11. 大模型使用声明

本文档及配套模块代码在编写过程中使用了 AI 大模型辅助(需求分析、问题排查、 代码与文档起草)。参数与话题接口以本模块的离线回归测试为准;真机 (AirSim + 摄像头)的动作序列以现场日志为准。作者对提交内容的正确性与 完整性负全部责任。