人形机器人应用开发,实现了设备自动巡检的物件拾取,能够直接对接宇树科技的人形机器人,Python编码工程在没有硬件运行时可以自动模拟过程执行。

1 整体描述与架构

1.1 系统整体描述

HumanoidOps--人形机器人“视觉巡检”典型应用,Python编写的、分层解耦、可无硬件跑通、可一键切换到真机的人形机器人完整应用案例。

场景:人形机器人在展厅 / 车间中执行巡检任务--依次走到各巡检点,用头部相机识别目标物(红色料箱)并估算其相对位置,最后输出巡检报告。具备视觉引导抓取功能。

同一套业务代码,既能在 Mock 仿真后端(无需任何硬件)上跑通,也能切换到宇树G1/H1真机(基于官方 unitree_sdk2py)。

1.2 核心特性设计

特性

说明

分层架构

硬件抽象 / 感知 / 任务 / 编排 四层解耦,依赖倒置

双后端

mock(免硬件可跑)+ unitree(真机),任务层零改动

真实感知链路

OpenCV 颜色阈值检测 + 单目测距,非 mock 假数据

安全心跳

每个控制周期检查电量 / 姿态 / 温度,异常立即停机

可测试

端到端冒烟测试 + pytest 用例,CI 友好

可观测

结构化日志 + 巡检图像存档 + JSON 报告

1.3 IMA-Copilot助力

咨询提示词:详细介绍人形机器人及其PYTHON应用开发,给出一个完整的典型应用实现案例项目,能够适应WINDOWS下的PYTHON虚拟环境开发,并给出该案例项目的机器人运行轨迹示意图。

1.4 体系架构规划

依赖倒置:tasks 只依赖抽象接口 RobotBackend,因此更换机型或仿真/真机切换时,业务逻辑完全不动。

2 项目目录结构

humanoid_ops/

├── README.md

├── requirements.txt

├── config/

│   └── config.yaml               # 机器人 / 视觉 / 安全 / 导航 / 巡检点配置

├── humanoid_ops/

│   ├── __main__.py              # 命令行入口

│   ├── config.py                 # 配置模型与加载(含内置默认值)

│   ├── models.py                # 数据结构:RobotState / TargetEstimate / 报告…

│   ├── logger.py                 # 统一日志

│   ├── safety.py                 # 安全监控(SafetyAbort)

│   ├── orchestrator.py            # 任务编排 + 生命周期 + 安全心跳

│   ├── robot/

│   │   ├── base.py                  # RobotBackend 抽象接口

│   │   ├── mock_backend.py         # 仿真后端(运动学 + 成像)

│   │   └── unitree_backend.py        # 宇树真机后端(unitree_sdk2py)

│   ├── perception/

│   │   ├── camera.py               # 相机内参与取帧

│   │   ├── detector.py              # HSV 颜色阈值检测

│   │   ├── localizer.py              # 像素→机器人坐标(偏角/距离)

│   │   └── pipeline.py              # 取帧→检测→定位 + 标注存档

│   └── tasks/

│       ├── base.py              # 任务基类(导航/巡检复用)

│       ├── patrol.py             # 巡检任务

│       └── pick_place.py         # 视觉引导抓取任务

├── scripts/

│   ├── run_demo.sh              # 一键Mock演示[linux/macos]

│   ├── setup_windows.bat         # 一键构建:创建 .venv → 激活 → 升级 pip → 安装依赖。

│   ├── run_demo.bat              # 一键Mock演示[windows]

│   ├── run_real.bat               # 真机运行[windows]

│   └── run_real.sh               # 真机运行[linux/macos]

└── tests/

    └── test_smoke.py             # 端到端冒烟测试

3 快速运行测验

3.1 无硬件操控

cd humanoid_ops

# 依赖:numpy / opencv-python / PyYAML

pip install -r requirements.txt

# 场景一:巡检

python -m humanoid_ops --backend mock --scenario patrol

# 场景二:视觉引导抓取

python -m humanoid_ops --backend mock --scenario pick_place

# 或一键跑两个场景

bash scripts/run_demo.sh

Windows虚拟环境操控

Windows下Mock演示完全跑通,推荐用venv:

:: 1) 创建并激活虚拟环境

py -3 -m venv .venv

.venv\Scripts\activate

:: 2) 安装依赖

python -m pip install --upgrade pip

pip install -r requirements.txt

:: 3) 运行(任选其一)

python -m humanoid_ops --backend mock --scenario patrol

python run.py --backend mock --scenario pick_place

或直接双击 / 执行脚本:

scripts\setup_windows.bat   :: 一键建环境 + 装依赖

scripts\run_demo.bat        :: 一键跑两个场景

依赖清单,均在 Windows 有 wheel,无需编译。

表5-9 人形机器人巡检的依赖清单列表

包

版本要求

Windows 说明

numpy

>=1.24

官方 wheel,直接装

opencv-python

>=4.6

官方 wheel(自带 OpenCV 运行时),直接装

PyYAML

>=6.0

官方 wheel,直接装

若所在网络访问 PyPI 慢,可加国内镜像:pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。

中文乱码/UnicodeEncodeError?

  1. Windows控制台默认代码页常为GBK/cp936。本项目已在logger.py中把标准输出重配置为UTF-8兜底;
  2. scripts\*.bat 也会先执行chcp 65001并设置PYTHONUTF8=1。

若在PowerShell中手动运行,可先执行chcp 65001。

注意:.sh脚本是给Linux/macOS用的,Windows用对应的 .bat。

3.2 真机运行(backend=unitree)

建议在Linux上做(CycloneDDS/unitree_sdk2py的原生支持,以Linux为主,Windows可用WSL2),Mock演示在Windows无限制。

3.3 运行及其结果

运行后:

  1. 控制台输出结构化日志(导航、检测、抓取、报告);
  2. output/patrol_report.json:任务报告;
  3. output/frames/WP*.png:各巡检点带检测框的图像。

典型输出

[1/3] 前往巡检点 WP1 (x=2.00, y=0.00)

巡检点 WP1:开始视觉巡检 (x=1.82, y=0.00, yaw=0.00)

  └ 发现 red_box:偏角 0.1°,估计距离 2.64 m

[2/3] 前往巡检点 WP2 (x=4.00, y=2.00)

  └ 发现 red_box:偏角 1.8°,估计距离 2.45 m

...

任务报告 | 成功=True | 用时=26.0s | 到达巡检点=3 | 发现目标=4

测试

python tests/test_smoke.py      # 直接跑

python -m pytest tests/ -q       # 用 pytest

4 关键编码实现

4.1 跨平台入口run.py

python run.py ... 等价于 python -m humanoid_ops ...,避免在Windows上因“工作目录/包路径问题”找不到模块。

"""跨平台一键入口(Windows / Linux / macOS 通用)。
用法:
    python run.py --backend mock --scenario patrol
    python run.py --backend mock --scenario pick_place
等价于 `python -m humanoid_ops ...`,但无需依赖包是否在 PATH 中,
在 Windows 上更直观。
"""
from __future__ import annotations
import os
import sys
# 保证无论从哪个工作目录启动都能找到 humanoid_ops 包
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from humanoid_ops.__main__ import main  # noqa: E402
if __name__ == "__main__":
    sys.exit(main())

4.2 应用tasks/patrol.py

"""巡检任务:依次到达各巡检点,做视觉巡检并汇总报告。"""
from __future__ import annotations
from typing import Callable
from ..models import InspectionRecord, MissionResult, Waypoint, now
from .base import BaseTask
class PatrolTask(BaseTask):
    @property
    def name(self) -> str:
        return "patrol"
    def __init__(self, robot, vision, cfg, log) -> None:
        super().__init__(robot, vision, cfg, log)
        self.waypoints = [Waypoint(**_normalize_wp(w)) for w in cfg.waypoints]
    def run(self, tick: Callable[[float], None]) -> MissionResult:
        self.log.info("巡检任务开始,共 %d 个巡检点", len(self.waypoints))
        for i, wp in enumerate(self.waypoints, 1):
            self.log.info("[%d/%d] 前往巡检点 %s (x=%.2f, y=%.2f)",
                          i, len(self.waypoints), wp.name, wp.x, wp.y)
            reached, elapsed = self.navigate_to(wp, tick)
            rec = InspectionRecord(waypoint=wp.name, reached=reached,
                                   elapsed_s=round(elapsed, 2), pose=self._pose())
            if reached and wp.inspect:
                rec.targets = self.inspect(wp, tick)
            elif not reached:
                rec.note = "未到达(超时)"
            self._records.append(rec)
            tick(0.0)
        success = all(r.reached for r in self._records)
        self.log.info("巡检任务结束,成功=%s,用时 %.1fs", success, now() - self._started)
        return MissionResult( mission=self.name, backend=self.robot.name,
            started_at=self._started, finished_at=now(),
            success=success, records=self._records, )
def _normalize_wp(raw: dict) -> dict:
    """兼容 YAML 中省略 yaw/inspect 的写法。"""
    return { "name": raw.get("name", "WP"),
        "x": float(raw.get("x", 0.0)),
        "y": float(raw.get("y", 0.0)),
        "yaw": float(raw.get("yaw", 0.0)),
        "inspect": bool(raw.get("inspect", True)), }

4.3 应用tasks/pick_place.py

"""视觉引导抓取任务:发现目标 → 对准 → 靠近 → 抓取。"""
from __future__ import annotations
import math
from typing import Callable
from ..models import InspectionRecord, MissionResult, TargetEstimate, Waypoint, clamp, now
from .base import BaseTask
from .patrol import _normalize_wp
class PickPlaceTask(BaseTask):
    @property
    def name(self) -> str:
        return "pick_place"
    def __init__(self, robot, vision, cfg, log) -> None:
        super().__init__(robot, vision, cfg, log)
        self.waypoints = [Waypoint(**_normalize_wp(w)) for w in cfg.waypoints]
    def run(self, tick: Callable[[float], None]) -> MissionResult:
        self.log.info("视觉引导抓取任务开始"); wp = self.waypoints[0]
        reached, elapsed = self.navigate_to(wp, tick)
        rec = InspectionRecord(waypoint=wp.name, reached=reached,
                               elapsed_s=round(elapsed, 2), pose=self._pose())
        if not reached:
            rec.note = "未到达作业点"
        else:
            rec.targets = self.inspect(wp, tick)
            if rec.targets:
                target = min(rec.targets, key=lambda t: t.distance_m)
                ok = self.approach_and_grasp(target, tick)
                rec.note = "抓取成功" if ok else "抓取失败"
            else:
                rec.note = "未发现可抓取目标"
        self._records.append(rec); self.robot.stop()
        return MissionResult( mission=self.name, backend=self.robot.name,
            started_at=self._started, finished_at=now(),
            success=(rec.note == "抓取成功"), records=self._records, )
    def approach_and_grasp(self, target: TargetEstimate, tick,
                           grasp_dist: float = 1.0, timeout: float = 15.0) -> bool:
        """基于实时视觉闭环对准并前进到抓取距离,然后执行抓取动作。"""
        self.log.info("目标 %s:对准并靠近至 %.2f m", target.label, grasp_dist)
        self.robot.set_arm_pose("HOME"); t0 = now()
        while now() - t0 < timeout:
            targets, _, _ = self.vision.scan()
            if not targets:
                self.log.warning("目标丢失,停止靠近"); self.robot.stop()
                return False
            t = targets[0]
            if t.distance_m <= grasp_dist:
                self.robot.stop(); self.robot.set_arm_pose("GRAB")
                self.log.info("已到位(估计距离 %.2f m),执行抓取", t.distance_m)
                return True
            theta = -math.radians(t.bearing_deg)   # 目标在左为正,用于对准
            vyaw = clamp(1.5 * theta, -0.8, 0.8)
            vx = clamp(0.30 * max(0.0, math.cos(theta)), 0.0, 0.30)
            self.robot.move(vx, 0.0, vyaw, duration=self.cfg.nav.ctrl_period_s)
            tick(0.0)
        self.robot.stop(); self.log.warning("靠近目标超时")
        return False

4.4 控制robot/mock_backend.py

"""Mock 后端:无需任何硬件即可完整跑通整条业务链。
它同时模拟了三件事:
1) 运动学积分(速度指令 -> 位姿)与电量/温度随负载变化;
2) 一个 2D 世界中的目标物;
3) 前置相机成像——把世界目标投影为图像中的彩色色块,
   从而让真实的「检测 → 定位」感知链路被真正跑起来。
"""
from __future__ import annotations
import math, time
from typing import Dict, List
import numpy as np
from .base import RobotBackend
from ..models import RobotState, now, normalize_angle as _norm
class MockRobot(RobotBackend):
    name = "mock"
    def __init__( self, world: Dict | None = None,
        frame_size: tuple[int, int] = (480, 640),
        hfov_deg: float = 70.0, seed: int = 0, ) -> None:
        self.frame_size = frame_size            # (H, W)
        self.hfov_deg = hfov_deg
        self._targets: List[dict] = list((world or {}).get("targets", []))
        self._rng = np.random.default_rng(seed)
        self._state = RobotState(timestamp=now(), mode="DAMP",
                                 battery=96.0, temperature=36.0)
        self._state.joint_positions = [0.0] * 29
        self._arm_pose = "HOME"; self._connected = False
        self._last_t = now()
    # ---------------- 生命周期 ----------------
    def connect(self) -> None:
        self._connected = True; self._last_t = now()
        self._state.timestamp = self._last_t
        self._state.mode = "DAMP"
    def disconnect(self) -> None:
        self._connected = False
    # ---------------- 物理积分 ----------------
    def _integrate(self) -> RobotState:
        t = now(); dt = max(0.0, min(0.5, t - self._last_t))
        self._last_t = t; s = self._state
        if s.mode == "MOVE":
            cy, sy = math.cos(s.yaw), math.sin(s.yaw)
            s.x += (s.vx * cy - s.vy * sy) * dt
            s.y += (s.vx * sy + s.vy * cy) * dt
            s.yaw = _norm(s.yaw + s.vyaw * dt)
        load = abs(s.vx) + abs(s.vy) + abs(s.vyaw)
        s.battery = max(0.0, s.battery - (0.01 + 0.06 * load) * dt)
        target_temp = 36.0 + 16.0 * load
        s.temperature += (target_temp - s.temperature) * min(1.0, 0.6 * dt)
        s.imu_yaw = s.yaw
        # 站姿时给一点微小姿态扰动,模拟真实抖动
        if s.mode != "DAMP":
            s.imu_roll = float(self._rng.normal(0, 0.01))
            s.imu_pitch = float(self._rng.normal(0, 0.01))
        s.timestamp = t
        return s
    def get_state(self) -> RobotState:
        return self._integrate()
    # ---------------- 动作 ----------------
    def stand_up(self) -> None:
        self._integrate(); self._state.mode = "STAND"; time.sleep(0.3)
    def squat_down(self) -> None:
        self._integrate(); self._state.mode = "STAND"
        self._state.mode = "DAMP"; time.sleep(0.3)
    def damp(self) -> None:
        self._integrate(); self._state.mode = "DAMP"
    def move(self, vx: float, vy: float, vyaw: float, duration: float) -> None:
        self._integrate(); self._state.mode = "MOVE"
        self._state.vx, self._state.vy, self._state.vyaw = vx, vy, vyaw
        t_end = now() + max(0.0, duration)
        while now() < t_end:
            self._integrate(); time.sleep(0.02)
        self._integrate()
    def stop(self) -> None:
        self._integrate()
        self._state.vx = self._state.vy = self._state.vyaw = 0.0
        if self._state.mode == "MOVE":
            self._state.mode = "STAND"
    def set_arm_pose(self, pose: str) -> None:
        self._arm_pose = pose
        # 让手臂关节位随姿态变化,便于观察
        base = {"HOME": 0.0, "GRAB": 0.7, "WAVE": -0.6}.get(pose, 0.0)
        for i in range(len(self._state.joint_positions)):
            self._state.joint_positions[i] = base
        time.sleep(0.2)
    # ---------------- 相机成像 ----------------
    def capture_frame(self) -> np.ndarray:
        H, W = self.frame_size; s = self._state
        img = np.full((H, W, 3), 28, np.uint8)
        img[H // 2:, :] = 62                     # 地面
        img[H // 2 - 2:H // 2 + 2, :] = 90       # 地平线
        hfov = math.radians(self.hfov_deg)
        fx = (W / 2.0) / math.tan(hfov / 2.0)
        for tg in self._targets:
            dx = tg["x"] - s.x; dy = tg["y"] - s.y
            dist = math.hypot(dx, dy)
            if dist < 1e-3:
                continue
            brg = _norm(math.atan2(dy, dx) - s.yaw)   # >0 目标在左
            if abs(brg) > hfov / 2.0:
                continue                               # 视场外
            u = W / 2.0 - math.tan(brg) * fx
            size = fx * float(tg.get("size", 0.30)) / dist   # 表观像素边长
            if size < 4:
                continue
            half = int(max(3, size / 2))
            u0 = int(round(u + self._rng.normal(0, 1.2)))
            v0 = int(H * 0.62)
            x1, x2 = max(0, u0 - half), min(W, u0 + half)
            y1, y2 = max(0, v0 - half), min(H, v0 + half)
            if x2 > x1 and y2 > y1:
                img[y1:y2, x1:x2] = (40, 40, 220)   # BGR 红
        noise = self._rng.normal(0, 4, img.shape).astype(np.int16)
        return np.clip(img.astype(np.int16) + noise, 0, 255).astype(np.uint8)
    # ---------------- 仅测试用 ----------------
    def ground_truth_targets(self) -> List[dict]:
        return [dict(t) for t in self._targets]

4.5 控制robot/unitree_backend.py

"""宇树(Unitree)G1/H1 真机后端。
基于官方 Python SDK ``unitree_sdk2py`` 的高层运动接口实现,
与 Mock 后端实现同一套 ``RobotBackend`` 接口,因此任务层无需改动。
依赖安装(真机环境):
    pip install numpy opencv-python
    # 先装 CycloneDDS 0.10.x,再装 SDK(见仓库 README)
    git clone https://github.com/unitreerobotics/unitree_sdk2_python.git
    export CYCLONEDDS_HOME=~/cyclonedds/install
    pip install -e ./unitree_sdk2_python
运行前请确保主机与机器人处于同一网段,并用 ``ip link`` 查到对应网卡名,
以该网卡名作为 ``iface``(例如 enp3s0 / eth0)。
"""
from __future__ import annotations
import time
import numpy as np
from .base import RobotBackend
from ..models import RobotState, now
try:
    import cv2  # 可选,仅用于 USB 相机取帧
    _HAS_CV2 = True
except Exception:  # pragma: no cover
    _HAS_CV2 = False
class UnitreeRobot(RobotBackend):
    name = "unitree"
    def __init__(self, iface: str = "enp3s0", camera_index: int | None = None) -> None:
        self.iface = iface; self.camera_index = camera_index
        self._client = None           # LocoClient
        self._sub = None              # 状态订阅
        self._cam = None; self._st = RobotState(mode="DAMP")
        self._connected = False
    # ---------------- 生命周期 ----------------
    def connect(self) -> None:
        try:
            from unitree_sdk2py.core.channel import (
                ChannelFactoryInitialize, ChannelSubscriber, )
            from unitree_sdk2py.idl.unitree_go.msg.dds_ import SportModeState_
            from unitree_sdk2py.g1.loco.g1_loco_client import LocoClient
        except Exception as exc:  # pragma: no cover - 真机环境依赖
            raise RuntimeError( "未找到 unitree_sdk2py,请先在真机环境安装(见本文件顶部说明)。"
            ) from exc
        # 1) 初始化 DDS 通道(domain_id=0,绑定网卡)
        ChannelFactoryInitialize(0, self.iface)
        # 2) 订阅高层运动状态(位姿/速度/IMU/电量)
        self._sub = ChannelSubscriber("rt/sportmodestate", SportModeState_)
        self._sub.Init(self._on_state, 10)
        # 3) 创建高层运动客户端
        self._client = LocoClient(); self._client.SetTimeout(10.0)
        self._client.Init(); self._connected = True
    def disconnect(self) -> None:
        try:
            if self._client is not None:
                self._client.StopMove()
        except Exception:
            pass
        if self._cam is not None:
            self._cam.release(); self._cam = None
        self._connected = False
    # ---------------- 状态回调 ----------------
    def _on_state(self, msg) -> None:  # pragma: no cover - 需真机
        """把 SportModeState_ 映射为统一的 RobotState。"""
        try:
            pos = getattr(msg, "position", [0.0, 0.0, 0.0])
            vel = getattr(msg, "velocity", [0.0, 0.0, 0.0])
            self._st.x = float(pos[0]); self._st.y = float(pos[1])
            self._st.yaw = float(pos[2]) if len(pos) > 2 else 0.0
            self._st.vx = float(vel[0]); self._st.vy = float(vel[1])
            self._st.vyaw = float(vel[2]) if len(vel) > 2 else 0.0
            imu = getattr(msg, "imu_state", None)
            if imu is not None:
                rpy = getattr(imu, "rpy", None) or getattr(imu, "RPY", None)
                if rpy is not None:
                    self._st.imu_roll = float(rpy[0]); self._st.imu_pitch = float(rpy[1])
                    self._st.imu_yaw = float(rpy[2])
            bat = getattr(msg, "power_v", None)
            if bat is not None:
                self._st.battery = float(bat)
            self._st.timestamp = now()
        except Exception:
            pass
    def get_state(self) -> RobotState:
        return self._st
    # ---------------- 动作 ----------------
    def stand_up(self) -> None:
        # 官方推荐:先阻尼 -> 再起立
        self._client.Damp(); time.sleep(0.5)
        self._client.Squat2StandUp()      # 或 Lie2StandUp()(仰面、硬平地面)
        time.sleep(2.0); self._client.BalanceStand()
    def squat_down(self) -> None:
        # 官方高层接口的下蹲/坐低动作(方法名随固件版本略有差异,按需替换)
        self._client.LowStand(); time.sleep(1.5)
    def damp(self) -> None:
        self._client.Damp()
    def move(self, vx: float, vy: float, vyaw: float, duration: float) -> None:
        """持续下发速度指令。
        高层接口单条指令约 1s 过期(continous_move=False),因此需要在
        duration 内以约 20~50Hz 持续下发。
        """
        t_end = now() + max(0.0, duration)
        while now() < t_end:
            self._client.Move(vx, vy, vyaw, continous_move=False)
            time.sleep(0.05)
    def stop(self) -> None:
        self._client.StopMove()
    def set_arm_pose(self, pose: str) -> None:
        # 高层接口提供若干预设动作;可按需替换为自定义关节轨迹。
        if pose == "WAVE":
            self._client.WaveHand()
        elif pose == "GRAB":
            self._client.ShakeHand()      # 示例:抓取位可复用/自定义
        # HOME 等其它姿态交由低层关节控制实现,此处略。
    # ---------------- 相机 ----------------
    def capture_frame(self) -> np.ndarray:
        """取一帧头像。
        真机首选 SDK 前置相机接口(unitree_sdk2py.video),此处提供一条
        更通用的 USB 相机通路作为可用实现;若未配置相机则给出明确提示。
        """
        if self.camera_index is None:
            raise NotImplementedError(
                "未配置相机。真机请使用 SDK 前置相机接口,或在构造时传入 camera_index "
                "以使用 USB 相机。" )
        if not _HAS_CV2:
            raise RuntimeError("取帧需要 opencv-python,请先 pip install opencv-python")
        if self._cam is None:
            self._cam = cv2.VideoCapture(self.camera_index)
        ok, frame = self._cam.read()
        if not ok:
            raise RuntimeError("相机取帧失败")
        return frame

4.6 抽象robot/base.py

"""机器人后端抽象接口。
任务层与感知层只依赖本抽象类,因此更换机型 / 切换仿真与真机时,
业务代码无需改动(依赖倒置)。
"""
from __future__ import annotations
from abc import ABC, abstractmethod
import numpy as np
from ..models import RobotState
class RobotBackend(ABC):
    """人形机器人后端统一接口。
    坐标约定:世界坐标系 x 轴为机器人初始正前方、y 轴向左、yaw 为航向角(rad)。
    """
    name: str = "abstract"
    # ------------------------------------------------ 生命周期
    @abstractmethod
    def connect(self) -> None:
        """建立连接(真机:初始化 DDS/网卡;仿真:置位)。"""
    @abstractmethod
    def disconnect(self) -> None:
        """断开连接并释放资源。"""
    # ------------------------------------------------ 状态
    @abstractmethod
    def get_state(self) -> RobotState:
        """获取一帧机器人状态。"""
    # ------------------------------------------------ 动作
    @abstractmethod
    def stand_up(self) -> None:
        """从初始姿态起立。"""
    @abstractmethod
    def squat_down(self) -> None:
        """下蹲/坐低(便于收存)。"""
    @abstractmethod
    def damp(self) -> None:
        """进入阻尼保护模式(软停)。"""
    @abstractmethod
    def move(self, vx: float, vy: float, vyaw: float, duration: float) -> None:
        """按速度指令持续移动 duration 秒(阻塞)。
        vx: 前后(+前) m/s, vy: 左右(+左) m/s, vyaw: 偏航角速度(+逆时针) rad/s
        """
    @abstractmethod
    def stop(self) -> None:
        """停止移动。"""
    @abstractmethod
    def set_arm_pose(self, pose: str) -> None:
        """设置手臂/上身姿态,如 'HOME' / 'GRAB' / 'WAVE'。"""
    # ------------------------------------------------ 感知
    @abstractmethod
    def capture_frame(self) -> np.ndarray:
        """采集一帧头部相机图像(BGR,numpy 数组)。"""

5 切换真机运行

宇树G1/H1

安装 SDK(真机环境)

# 1) 先编译安装 CycloneDDS 0.10.x
git clone https://github.com/eclipse-cyclonedds/cyclonedds -b releases/0.10.x
cd cyclonedds && mkdir build install && cd build
cmake .. -DCMAKE_INSTALL_PREFIX=../install && cmake --build . --target install
# 2) 安装宇树官方Python SDK
cd ~ && git clone https://github.com/unitreerobotics/unitree_sdk2_python.git
cd unitree_sdk2_python
export CYCLONEDDS_HOME=~/cyclonedds/install
pip3 install -e .
查网卡名:ip link,找到与机器人同网段的网卡(如 enp3s0)。

运行:

python -m humanoid_ops --backend unitree --iface enp3s0 --scenario patrol
# 或
bash scripts/run_real.sh enp3s0

真机后端 robot/unitree_backend.py 中的关键调用(与官方示例一致):

ChannelFactoryInitialize(0, iface)                           # 初始化 DDS 并绑定网卡
sub = ChannelSubscriber("rt/sportmodestate", SportModeState_)  # 订阅状态
sub.Init(callback, 10)
client = LocoClient(); client.SetTimeout(10.0); client.Init()       # 高层运动客户端
client.Damp(); client.Squat2StandUp()                       # 阻尼 → 起立
client.Move(vx, vy, vyaw, continous_move=False)              # 速度控制(需持续下发)
client.StopMove()                                        # 停止

⚠️ 安全第一:真机首次运行请降低速度、清空周围2米区域、并确保有人可以随时拍急停。真机取帧默认走USB相机(camera_index),若使用机器人自带前置相机,请替换为 SDK 的 video 接口。

6 设计汇总与拓展

6.1 设计要点总结

  1. 分层解耦 + 依赖倒置:任务不关心是仿真还是真机。
  2. 安全是控制循环的一部分:不是启动时检查一次,而是每拍都查。
  3. 先对准再前进:比例控制航向,转向偏差大时几乎不前进,避免“边走边转”翻车。
  4. 感知闭环:抓取任务用实时视觉反复修正,而非开环走到固定点。
  5. 可观测:日志 + 图像 + JSON 报告,事后可复盘。

6.2 项目扩展考虑

  1. 换检测算法:perception/detector.py 的 ColorDetector 返回结构与深度学习检测器兼容,可直接替换为 YOLO / DETR 而不影响下游。
  2. 加新任务:继承 tasks/base.py::BaseTask,复用 navigate_to /inspect,在 tasks/__init__.py::create_task 注册即可。
  3. 加新机型:实现 RobotBackend 抽象接口,在robot/__init__.py::create_backend 注册。
  4. 接入强化学习策略:把 MockRobot 换成仿真环境,即可复用unitree_rl_gym 等框架训练并在真机部署。
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐