AirSim/Cosys-AirSim 游戏开发(一)XBox 手柄 Windows + python 连接与读取

这个系列用来记录在开发 AirSim 应用过程中遇到的一些问题和解决方案,由于 AirSim 已经停止维护了,因此我实际的开发平台是 Cosys-AirSim,但这个 fork 在编译和部署的时候有不少坑,后续我会找机会补上。

第一篇博客实际上不需要你编译和部署 AirSim 和 Cosys-AirSim,主要是验证一下 Xbox 游戏手柄是否可用以及基本的通讯功能是否正常。

这篇博客涉及到的代码我都放在 GitHub 仓库中,欢迎大家 Issue Bug:


1. 硬件准备

我这里使用的是 Xbox 无线控制器,但连接方式使用的是 USB 连接,因为主机没有蓝牙收发器还需要额外买一个蓝牙增强模块。

正确连接后手柄的 XBox 指示灯会常亮,如果这个灯一闪一闪的则说明没有正确连接,在Windows平台上通常会自动弹出驱动安装确认,将驱动装上即可。


2. GUI 工具测试手柄

在写代码之前建议先用一些免费工具来测试手柄各个按键是否正常,虽然网上有很多免费工具,但我自己用的惯的还是 Microsoft Stroe 里面的一个工具 Controller Tester,可以直接在商店里面搜到:

安装完成后直接打开软件可以看到下面的画面,将手柄上的按钮全部按下,每检测到一个有效触发就会将其标绿,两个遥感和后面 LTRT 本质上是线性轴:


3. pygame 测试代码

我这里使用的是 conda 管理python包,在运行代码之前需要安装以下依赖:

bash 复制代码
(airsim) $ pip install pygame

然后执行下面的代码,代码中的 read_gamepad_buttons() 函数参数通常是上面软件显示的 Controller X 后面跟着的数字,即设备索引号。

【Note】:下面的代码只能在本地运行,即便你通过 ssh 过来运行也会显示没有监测到手柄,原因好像是 windows 平台下 USB 需要映射成 IP 端口,后面我整明白了会补充到这里。

python 复制代码
import pygame, os, time

def read_gamepad_buttons(joy_device_index:int=0):
    """
    读取游戏手柄上所有按键值。
    
    :return: 按键状态字典
    """
    pygame.init()
    pygame.joystick.init()
    
    if pygame.joystick.get_count() == 0:
        print("未检测到任何游戏手柄, pygame.joystick.get_count()=0")
        return None
    
    joystick = pygame.joystick.Joystick(joy_device_index)
    joystick.init()
    
    button_states = {}
    
    try:
        while True:
            time.sleep(0.1)
            pygame.event.pump()
            button_states = []
            balls_states = []
            axes_states = []
            hat_states = []
            for i in range(joystick.get_numbuttons()):
                button_states.append(joystick.get_button(i))
            for i in range(joystick.get_numballs()):
                balls_states.append(joystick.get_ball(i))
            for i in range(joystick.get_numaxes()):
                axes_states.append(joystick.get_axis(i))
            for i in range(joystick.get_numhats()):
                hat_states.append(joystick.get_hat(i))
            print('-' * 50)
            print(f'Button {len(button_states)}: {button_states}')
            print(f'Balls  {len(balls_states)}: {balls_states}')
            print(f'Axes   {len(axes_states)}: {axes_states}')
            print(f'Hat    {len(hat_states)}: {hat_states}')
    except KeyboardInterrupt:
        print("游戏手柄读取终止。")
    finally:
        pygame.quit()
    
    return button_states

if __name__ == '__main__':
    read_gamepad_buttons(0)

有下面的输出就说明手柄被正确连接:

bash 复制代码
--------------------------------------------------
Button 16: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Balls  0: []
Axes   6: [0.0, 0.0, 0.0, 0.0, -1.0, -1.0]
Hat    1: [(0, 0)]
--------------------------------------------------

根据自己业务需要去映射各个按键的功能。

【Note】:在 Windows 和 Linux 下部分按键的定义和索引是不同的,通常情况下代码是不通用的。如 LT 按键 RT 在 Windows 下被定义为 Axes ,但在 Linux 下是 Button


4. 类封装

为了方便自己和大家使用,我将上面的代码进行了封装,由于我个人通常需要在异步场景下获取手柄状态,因此这里的封装分为两种形式:异步 & 同步。

4.1 异步封装 XBoxControllerReaderAsync

python 复制代码
import pygame
import asyncio
from typing import List, Tuple


class XBoxControllerReaderAsync:
    def __init__(self, joy_device_index: int = 0, poll_interval: float = 0.05):
        self.joy_device_index = joy_device_index
        self.poll_interval = poll_interval

        self.button_states: List[int] = []
        self.ball_states: List[Tuple[int, int]] = []
        self.axis_states: List[float] = []
        self.hat_states: List[Tuple[int, int]] = []

        self._running = False
        self._task = None
        self._joystick = None

    async def start(self):
        pygame.init()
        pygame.joystick.init()

        if pygame.joystick.get_count() == 0:
            raise RuntimeError("未检测到任何游戏手柄,pygame.joystick.get_count()=0")

        self._joystick = pygame.joystick.Joystick(self.joy_device_index)
        self._joystick.init()

        self._running = True
        self._task = asyncio.create_task(self._poll_loop())

    async def stop(self):
        self._running = False
        if self._task:
            await self._task
        pygame.quit()

    async def _poll_loop(self):
        while self._running:
            pygame.event.pump()  # 处理事件队列
            self.button_states = [
                self._joystick.get_button(i) for i in range(self._joystick.get_numbuttons())
            ]
            self.ball_states = [
                self._joystick.get_ball(i) for i in range(self._joystick.get_numballs())
            ]
            self.axis_states = [
                self._joystick.get_axis(i) for i in range(self._joystick.get_numaxes())
            ]
            self.hat_states = [
                self._joystick.get_hat(i) for i in range(self._joystick.get_numhats())
            ]
            await asyncio.sleep(self.poll_interval)

    def get_button_states(self) -> List[int]:
        return self.button_states

    def get_axis_states(self) -> List[float]:
        return self.axis_states

    def get_ball_states(self) -> List[Tuple[int, int]]:
        return self.ball_states

    def get_hat_states(self) -> List[Tuple[int, int]]:
        return self.hat_states

运行下面的代码进行测试:

python 复制代码
async def main():
    reader = AsyncGamepadReader()
    await reader.start()

    try:
        for _ in range(100):
            print("Buttons:", reader.get_button_states())
            print("Axes:   ", reader.get_axis_states())
            print("Hats:   ", reader.get_hat_states())
            print("-" * 40)
            await asyncio.sleep(0.1)
    finally:
        await reader.stop()

asyncio.run(main())

4.2 同步封装 XBoxControllerReaderSync

python 复制代码
import pygame
import threading
import time
from typing import List, Tuple


class XBoxControllerReaderSync:
    def __init__(self, joy_device_index: int = 0, poll_interval: float = 0.05):
        self.joy_device_index = joy_device_index
        self.poll_interval = poll_interval

        self.button_states: List[int] = []
        self.ball_states: List[Tuple[int, int]] = []
        self.axis_states: List[float] = []
        self.hat_states: List[Tuple[int, int]] = []

        self._running = False
        self._thread = None
        self._joystick = None

    def start(self):
        self._running = True
        self._thread = threading.Thread(target=self._poll_loop, daemon=True)
        self._thread.start()

    def stop(self):
        self._running = False
        if self._thread:
            self._thread.join()
        pygame.quit()
        print("GamepadReader stopped and pygame quit.")

    def _poll_loop(self):
        print("Initializing pygame...")
        pygame.init()
        pygame.joystick.init()

        count = pygame.joystick.get_count()
        print(f"Detected {count} joystick(s)")

        if count == 0:
            raise RuntimeError("未检测到任何游戏手柄,pygame.joystick.get_count()=0")

        self._joystick = pygame.joystick.Joystick(self.joy_device_index)
        self._joystick.init()
        print(f"Joystick {self._joystick.get_name()} initialized.")
        
        while self._running:
            try:
                pygame.event.pump()
                self.button_states = [
                    self._joystick.get_button(i)
                    for i in range(self._joystick.get_numbuttons())
                ]
                self.ball_states = [
                    self._joystick.get_ball(i)
                    for i in range(self._joystick.get_numballs())
                ]
                self.axis_states = [
                    self._joystick.get_axis(i)
                    for i in range(self._joystick.get_numaxes())
                ]
                self.hat_states = [
                    self._joystick.get_hat(i)
                    for i in range(self._joystick.get_numhats())
                ]
            except pygame.error as e:
                print(f"Pygame error during polling: {e}")
            time.sleep(self.poll_interval)

    def get_button_states(self) -> List[int]:
        return self.button_states

    def get_axis_states(self) -> List[float]:
        return self.axis_states

    def get_ball_states(self) -> List[Tuple[int, int]]:
        return self.ball_states

    def get_hat_states(self) -> List[Tuple[int, int]]:
        return self.hat_states

运行下面的代码测试:

python 复制代码
if __name__ == "__main__":
    reader = GamepadReader()
    try:
        reader.start()
        for _ in range(100):
            print("Buttons:", reader.get_button_states())
            print("Axes:   ", reader.get_axis_states())
            print("Hats:   ", reader.get_hat_states())
            print("-" * 40)
            time.sleep(0.1)
    finally:
        reader.stop()

4.3 同时测试

在运行下面的测试代码前需要确保你的文件结构如下,其中 xbox_controller_async.py 存放异步封装类代码; xbox_controller_sync.py 存放同步封装类代码:

bash 复制代码
Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----          6/4/2025   1:38 PM                __pycache__
-a----          6/4/2025   1:18 PM           1656 test.py
-a----          6/4/2025   1:42 PM           2213 xbox_controller_async.py
-a----          6/4/2025   1:41 PM           2731 xbox_controller_sync.py

然后运行 test.py 的代码,这里会先测试异步后测试同步:

python 复制代码
import asyncio
from xbox_controller_async import XBoxControllerReaderAsync
from xbox_controller_sync import XBoxControllerReaderSync
import asyncio
import time

async def test_async_reader(duration=5):
    print("=== 异步读取开始 ===")
    reader = XBoxControllerReaderAsync()
    await reader.start()

    start = time.time()
    try:
        while time.time() - start < duration:
            print("Async - Buttons:", reader.get_button_states())
            print("Async - Axes:   ", reader.get_axis_states())
            print("Async - Hats:   ", reader.get_hat_states())
            print("-" * 40)
            await asyncio.sleep(0.1)
    finally:
        await reader.stop()
    print("=== 异步读取结束 ===\n")


def test_sync_reader(duration=5):
    print("=== 同步读取开始 ===")
    reader = XBoxControllerReaderSync()
    reader.start()

    start = time.time()
    try:
        while time.time() - start < duration:
            print("Sync - Buttons:", reader.get_button_states())
            print("Sync - Axes:   ", reader.get_axis_states())
            print("Sync - Hats:   ", reader.get_hat_states())
            print("-" * 40)
            time.sleep(0.1)
    finally:
        reader.stop()
    print("=== 同步读取结束 ===")


async def main():
    await test_async_reader(duration=5)
    await asyncio.sleep(1)
    test_sync_reader(duration=5)


if __name__ == "__main__":
    asyncio.run(main())

5. settings.json 文件解析类

在使用过程中我发现很多地方都需要获取 settings.json 这个文件中的信息,例如获取无人机相机相机信息、获取传感器类型等,但每次都自己写的话又非常麻烦,这里我提供两个文件用来解析该配置内容。

  • common_utils.py:通用工具脚本(当前只存储了一个 json IO 的函数);
  • airsim_aux.py:AirSim 辅助工具脚本(当前只存储了一个解析配置文件的类);

使用时需要将两个文件放在同级目录下:

common_utils.py

python 复制代码
import json
import re

def remove_json_comments(text):
    def replacer(match):
        s = match.group(0)
        if s.startswith('/') and not s.startswith('http'):
            return ''
        return s
    pattern = re.compile(
        r'"(?:\\.|[^"\\])*"'  # 匹配字符串(包括转义符)
        r'|(/\*[\s\S]*?\*/)'  # 匹配多行注释
        r'|(//[^\r\n]*)'      # 匹配单行注释
    )
    return re.sub(pattern, replacer, text)

def load_clean_json(file_path):
    from pathlib import Path
    path = Path(file_path).expanduser().resolve(strict=False)
    with open(path, 'r', encoding='utf-8') as f:
        raw_text = f.read()
    clean_text = remove_json_comments(raw_text)
    return json.loads(clean_text)

airsim_aux.py

python 复制代码
import common_utils
from termcolor import colored
from pathlib import Path

class AirSimSettingContantParaser:
    def __init__(self, file_path:str="~/Documents/AirSim/settings.json"):
        self.file_contant = {}
        self.file_path = Path(file_path).expanduser().resolve(strict=False)
        self.__load_settings_file__(file_path)
        self.camera_pose_keys = ["X", "Y", "Z", "Pitch", "Roll", "Yaw"]
        self.airsim_camera_type = {
            0: "Scene",
            1: "DepthPlanar",
            2: "DepthPerspective",
            3: "DepthVis",
            4: "DisparityNormalized",
            5: "Segmentation",
            6: "SurfaceNormals",
            7: "Infrared",
            8: "OpticalFlow",
            9: "OpticalFlowVis"
        }
        self.airsim_sensor_type = {
            0: "Camera",
            1: "Barometer",
            2: "Imu",
            3: "Gps",
            4: "Magnetometer",
            5: "Distance Sensor",
            6: "Lidar"
        }

    def __load_settings_file__(self, file_path:str):
        contant = common_utils.load_clean_json(file_path)
        if contant is None:
            return False
        self.file_contant = contant

    def is_sensor_registed(self, vehicles_name:str, sensor_name:str):
        if False == self.is_vehicle_registed(vehicles_name):
            return False
        vehicle_contant = self.file_contant["Vehicles"][vehicles_name]
        if "Sensors" not in vehicle_contant:
            return False
        if sensor_name not in vehicle_contant["Sensors"]:
            return False
        return True
    
    def get_sensor_type(self, vehicles_name:str, sensor_name:str):
        if False == self.is_sensor_registed(vehicles_name, sensor_name):
            return ""
        sensor_contant = self.file_contant["Vehicles"][vehicles_name]["Sensors"]
        if sensor_name not in sensor_contant:
            return ""
        if "SensorType" not in sensor_contant[sensor_name]:
            return ""
        return self.airsim_sensor_type.get(sensor_contant[sensor_name]["SensorType"])


    def is_vehicle_registed(self, vehicles_name:str):
        if "Vehicles" not in self.file_contant:
            return False
        if vehicles_name not in self.file_contant["Vehicles"]:
            return False
        return True

    def is_camera_registed(self, vehicles_name:str, camera_name:str):
        if False == self.is_vehicle_registed(vehicles_name=vehicles_name):
            return False
        vehicle_contant = self.file_contant["Vehicles"][vehicles_name]
        if "Cameras" not in vehicle_contant:
            return False
        if camera_name not in vehicle_contant["Cameras"]:
            return False
        return True

    def get_view_mode(self):
        if "ViewMode" not in self.file_contant:
            return ""
        return self.file_contant["ViewMode"]

    def get_clock_speed(self):
        if "ClockSpeed" not in self.file_contant:
            return -1
        return self.file_contant["ClockSpeed"]

    def is_vehicle_allow_api(self, drone_name:str):
        if False == self.is_vehicle_registed(drone_name):
            return False
        if "AllowAPIAlways" not in self.file_contant["Vehicles"][drone_name]:
            return False
        return self.file_contant["Vehicles"][drone_name]["AllowAPIAlways"]

    def get_vehicle_type(self, vehicle_name:str):
        if False == self.is_vehicle_registed(vehicle_name):
            return ""
        if "VehicleType" not in self.file_contant["Vehicles"][vehicle_name]:
            return ""
        return self.file_contant["Vehicles"][vehicle_name]["VehicleType"]

    def get_physics_engine_name(self):
        if "PhysicsEngineName" not in self.file_contant:
            return ""
        return self.file_contant["PhysicsEngineName"]

    def get_camera_pose(self, vehicles_name:str, camera_name:str):
        if False == self.is_camera_registed(vehicles_name=vehicles_name, camera_name=camera_name):
            return [] # 返回一个空list
        camera_contant = self.file_contant["Vehicles"][vehicles_name]["Cameras"][camera_name]
        if False == all(key in camera_contant for key in self.camera_pose_keys):
            return []
        return [camera_contant["X"], camera_contant["Y"], camera_contant["Z"], camera_contant["Pitch"], camera_contant["Roll"], camera_contant["Yaw"]]

    def get_camera_resulation(self, vehicles_name:str, camera_name:str):
        if False == self.is_camera_registed(vehicles_name=vehicles_name, camera_name=camera_name):
            return []
        cam_info = self.file_contant["Vehicles"][vehicles_name]["Cameras"][camera_name]    
        if "CaptureSettings" not in cam_info:
            return []
        if "Width" not in cam_info["CaptureSettings"][0] or "Height" not in cam_info["CaptureSettings"][0]:
            return []
        return [cam_info["CaptureSettings"][0]["Width"], cam_info["CaptureSettings"][0]["Height"]]

    def get_camera_fov(self, vehicles_name:str, camera_name:str):
        if False == self.is_camera_registed(vehicles_name=vehicles_name, camera_name=camera_name):
            return 0.0
        cam_settings = self.file_contant["Vehicles"][vehicles_name]["Cameras"][camera_name]
        if "CaptureSettings" not in cam_settings:
            return 0.0
        if "FOV_Degrees" not in cam_settings["CaptureSettings"][0]:
            return 0.0
        return cam_settings["CaptureSettings"][0]["FOV_Degrees"]

    def self_checking(self):
        print('=' * 50)
        print("Start to self checking...")
        if "Vehicles" not in self.file_contant:
            print(colored("No Vehicles item detected"))
            return
        vehicle_count = len(self.file_contant["Vehicles"])
        print(f"Total detected [{vehicle_count}] vehicles")
        for index, vehicles in enumerate(self.file_contant["Vehicles"]):
            vehicle_contant = self.file_contant["Vehicles"][vehicles]
            print(f"[{index+1}/{vehicle_count}] vehicle [{vehicles}] info:")
            if "Cameras" not in vehicle_contant:
                print(colored("\tThis vehicles is not contain camera.", "yellow"))
                continue
            camera_count = len(vehicle_contant["Cameras"])
            print(f'\t[{camera_count}] camera detected.')
            for cam in vehicle_contant["Cameras"]:
                if False == all(key in vehicle_contant["Cameras"][cam] for key in self.camera_pose_keys):
                    print(colored(f'\t\t[{cam}] pose information is not complate.'))
                else:
                    cam_info = vehicle_contant["Cameras"][cam]
                    print(f'\t\t[{cam}]:')
                    if "CaptureSettings" not in cam_info:
                        print(colored("\t\t\tNo CaptureSettings item detected.", 'yellow'))
                    else:
                        if "Width" not in cam_info["CaptureSettings"][0] or "Height" not in cam_info["CaptureSettings"][0]:
                            print(colored("\t\t\tNo Width or Height item detected.", 'yellow'))
                        else:
                            print(f'\t\t\tResulation: Width=[{cam_info["CaptureSettings"][0]["Width"]}], Height=[{cam_info["CaptureSettings"][0]["Height"]}]')
                    print(f'\t\t\tX=[{cam_info["X"]:.2f}], Y=[{cam_info["Y"]:.2f}], Z=[{cam_info["Z"]:.2f}]')
                    print(f'\t\t\tRoll=[{cam_info["Roll"]:.2f}], Pitch=[{cam_info["Pitch"]:.2f}], Yaw=[{cam_info["Yaw"]:.2f}]')
        print(colored("Self checking done.", 'green'))
        print('AirSim ImageType and SensorType dictory:')
        print("ImageType:")
        print('\t', self.airsim_camera_type)
        print("SensorType:")
        print('\t', self.airsim_sensor_type)
        print('=' * 50)
相关推荐
站大爷IP9 分钟前
Python文本序列的类型
python
千千寰宇25 分钟前
[Java/Python] Java 基于命令行调用 Python
python·java se-jdk/jvm
yvestine1 小时前
自然语言处理——文本表示
人工智能·python·算法·自然语言处理·文本表示
zzc9211 小时前
MATLAB仿真生成无线通信网络拓扑推理数据集
开发语言·网络·数据库·人工智能·python·深度学习·matlab
编程有点难2 小时前
Python训练打卡Day43
开发语言·python·深度学习
2301_805054562 小时前
Python训练营打卡Day48(2025.6.8)
pytorch·python·深度学习
LjQ20402 小时前
网络爬虫一课一得
开发语言·数据库·python·网络爬虫
哆啦A梦的口袋呀2 小时前
基于Python学习《Head First设计模式》第九章 迭代器和组合模式
python·学习·设计模式
sponge'2 小时前
opencv学习笔记2:卷积、均值滤波、中值滤波
笔记·python·opencv·学习
databook3 小时前
概率图模型:机器学习的结构化概率之道
python·机器学习·scikit-learn