新增能运行的仿真脚本
This commit is contained in:
@@ -0,0 +1,842 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PX4 SITL Offboard 视觉跟踪集成测试 (MAVSDK-Python)
|
||||||
|
连接: udpin://0.0.0.0:14540
|
||||||
|
流程:
|
||||||
|
解锁 → 起飞 → 切换 Offboard(速度设定点)
|
||||||
|
→ 视觉跟踪闭环 20 Hz(追踪器 → 控制律 → offboard 网关)
|
||||||
|
→ 目标消失 → Coast 预测 → 判丢零速刹车 → 退出 Offboard → RTL → 落地 → 上锁
|
||||||
|
适用于 PX4 1.17
|
||||||
|
|
||||||
|
YOLO 检测未接入:VirtualTarget 用无人机真实位置/航向做针孔反投影生成
|
||||||
|
检测框与激光测距(几何一致闭环),无人机会实际飞向该虚拟目标。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from matplotlib import font_manager
|
||||||
|
from matplotlib.ticker import MaxNLocator
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_chinese_font() -> str | None:
|
||||||
|
"""为 matplotlib 配置可用的中文字体,避免中文标题/图例乱码。
|
||||||
|
|
||||||
|
优先从字体文件注册,再按 family 名称匹配(Linux Noto/WQY、
|
||||||
|
Windows 雅黑/黑体、macOS 苹方等)。返回实际字体名;失败返回 None。
|
||||||
|
"""
|
||||||
|
# 1) 直接按路径注册(TTC/TTF)
|
||||||
|
path_candidates = [
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||||
|
"C:/Windows/Fonts/msyh.ttc",
|
||||||
|
"C:/Windows/Fonts/msyhbd.ttc",
|
||||||
|
"C:/Windows/Fonts/simhei.ttf",
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||||
|
]
|
||||||
|
chosen = None
|
||||||
|
for path in path_candidates:
|
||||||
|
if not Path(path).is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
font_manager.fontManager.addfont(path)
|
||||||
|
prop = font_manager.FontProperties(fname=path)
|
||||||
|
chosen = prop.get_name()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2) 按已注册 family 名称匹配
|
||||||
|
if chosen is None:
|
||||||
|
name_candidates = [
|
||||||
|
"Noto Sans CJK SC",
|
||||||
|
"Noto Sans CJK JP",
|
||||||
|
"Noto Serif CJK SC",
|
||||||
|
"Noto Serif CJK JP",
|
||||||
|
"Droid Sans Fallback",
|
||||||
|
"WenQuanYi Micro Hei",
|
||||||
|
"WenQuanYi Zen Hei",
|
||||||
|
"Source Han Sans SC",
|
||||||
|
"Source Han Sans CN",
|
||||||
|
"Microsoft YaHei",
|
||||||
|
"SimHei",
|
||||||
|
"PingFang SC",
|
||||||
|
"Heiti SC",
|
||||||
|
"Arial Unicode MS",
|
||||||
|
]
|
||||||
|
available = {f.name for f in font_manager.fontManager.ttflist}
|
||||||
|
for name in name_candidates:
|
||||||
|
if name in available:
|
||||||
|
chosen = name
|
||||||
|
break
|
||||||
|
|
||||||
|
if chosen:
|
||||||
|
matplotlib.rcParams["font.sans-serif"] = [chosen, "DejaVu Sans"]
|
||||||
|
matplotlib.rcParams["font.family"] = "sans-serif"
|
||||||
|
# 解决坐标轴负号显示为方块的问题
|
||||||
|
matplotlib.rcParams["axes.unicode_minus"] = False
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
_CHINESE_FONT = _setup_chinese_font()
|
||||||
|
|
||||||
|
_UTILS_DIR = Path(__file__).resolve().parent / "utils"
|
||||||
|
if str(_UTILS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_UTILS_DIR))
|
||||||
|
|
||||||
|
from mavsdk import System
|
||||||
|
from mavsdk.action import ActionError
|
||||||
|
|
||||||
|
from utils.target_tracker import Detection, FrameShape, TargetTracker, TrackerConfig
|
||||||
|
from utils.demo_track_control import track_result_to_state
|
||||||
|
from utils.visual_track_control.config import load_from_json
|
||||||
|
from utils.visual_track_control.mavsdk_gateway import MavsdkCommandGateway
|
||||||
|
from utils.visual_track_control.tracking_control_law import TrackingControlLaw
|
||||||
|
from utils.visual_track_control.types import ControlMode, ControlOutput, ControlSnapshot
|
||||||
|
|
||||||
|
logger = logging.getLogger("test")
|
||||||
|
|
||||||
|
# ==================== 配置 ====================
|
||||||
|
CONNECTION = "udpin://0.0.0.0:14540" # 推荐写法,也可用 "udp://127.0.0.1:14540"
|
||||||
|
TAKEOFF_ALTITUDE = 5.0 # 起飞高度 (m)
|
||||||
|
|
||||||
|
TRACK_CONFIG_PATH = _UTILS_DIR / "config" / "track_control.json"
|
||||||
|
TRACK_MAX_LOST_FRAMES = 60 # 追踪器 Coast 窗口(20 Hz 下 = 3 s)
|
||||||
|
TRACK_NORMAL_SECS = 20.0 # 虚拟目标保持可见时长(之后进入丢失场景)
|
||||||
|
TRACK_BRAKE_SECS = 5.0 # 判丢后零速刹车时长
|
||||||
|
TRACK_TIMEOUT_SECS = 120.0 # 跟踪段整体安全超时
|
||||||
|
STATUS_EVERY_TICKS = 10 # 每 0.5 s 打印一行状态
|
||||||
|
PLOT_TRAJECTORY = False # 跟踪结束后绘制无人机与虚拟目标轨迹
|
||||||
|
TRAJECTORY_SAVE_PATH = Path(__file__).resolve().parent / "trajectory_plot.png"
|
||||||
|
|
||||||
|
# 虚拟目标:相对悬停锚点、按锚点机头朝向放置的机体系偏移
|
||||||
|
# (前方 AHEAD / 右方 RIGHT,横偏用于激发航向通道)。
|
||||||
|
# 默认与悬停点同高:地面目标场景下垂直通道会持续下降趋近目标高度,
|
||||||
|
# SITL 中有触发 PX4 着陆检测的风险;TARGET_DOWN_M 取正值(目标更低,
|
||||||
|
# 如 +5.0 = 地面)即可切换为地面目标场景。
|
||||||
|
TARGET_AHEAD_M = 30.0
|
||||||
|
TARGET_RIGHT_M = 15.0
|
||||||
|
TARGET_DOWN_M = 0.0
|
||||||
|
MOCK_BOX_W = 120.0 # 模拟检测框尺寸 (px)
|
||||||
|
MOCK_BOX_H = 160.0
|
||||||
|
MOCK_CONFIDENCE = 0.9
|
||||||
|
MOCK_TARGET_SPEED_MPS = 1.0 # 圆弧切向速度 (m/s)
|
||||||
|
TARGET_ARC_RADIUS_M = 12.0 # 圆弧半径 (m)
|
||||||
|
# 旋转方向:+1 = NED 水平面逆时针(从北向东转),-1 = 顺时针
|
||||||
|
TARGET_ARC_CCW = 1
|
||||||
|
|
||||||
|
|
||||||
|
def now_ms() -> int:
|
||||||
|
return int(time.monotonic() * 1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
def plot_trajectories(
|
||||||
|
drone_traj: list,
|
||||||
|
target_traj: list,
|
||||||
|
save_path=None,
|
||||||
|
) -> None:
|
||||||
|
"""绘制无人机与虚拟目标的 NED 轨迹。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
drone_traj / target_traj: 列表元素为 (t_s, north_m, east_m, down_m)
|
||||||
|
save_path: 若给定则保存图片,否则仅弹窗显示
|
||||||
|
"""
|
||||||
|
if not drone_traj and not target_traj:
|
||||||
|
print("! 无轨迹数据,跳过绘图")
|
||||||
|
return
|
||||||
|
|
||||||
|
if _CHINESE_FONT is None:
|
||||||
|
print("! 未找到中文字体,图中中文可能显示为方框。"
|
||||||
|
"可安装 fonts-noto-cjk 或文泉驿,或改用英文标签。")
|
||||||
|
else:
|
||||||
|
# 再次确保本进程内 rc 生效(部分后端会重置)
|
||||||
|
matplotlib.rcParams["font.sans-serif"] = [_CHINESE_FONT, "DejaVu Sans"]
|
||||||
|
matplotlib.rcParams["axes.unicode_minus"] = False
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
||||||
|
|
||||||
|
# ---- 左图:水平面 N-E 轨迹(俯视图)----
|
||||||
|
ax = axes[0]
|
||||||
|
if drone_traj:
|
||||||
|
dn = [p[1] for p in drone_traj]
|
||||||
|
de = [p[2] for p in drone_traj]
|
||||||
|
ax.plot(de, dn, "b-", linewidth=1.5, label="无人机")
|
||||||
|
ax.scatter(de[0], dn[0], c="blue", s=60, marker="o", zorder=5, label="无人机起点")
|
||||||
|
ax.scatter(de[-1], dn[-1], c="blue", s=80, marker="x", zorder=5, label="无人机终点")
|
||||||
|
if target_traj:
|
||||||
|
tn = [p[1] for p in target_traj]
|
||||||
|
te = [p[2] for p in target_traj]
|
||||||
|
ax.plot(te, tn, "r--", linewidth=1.5, label="虚拟目标")
|
||||||
|
ax.scatter(te[0], tn[0], c="red", s=60, marker="o", zorder=5, label="目标起点")
|
||||||
|
ax.scatter(te[-1], tn[-1], c="red", s=80, marker="x", zorder=5, label="目标终点")
|
||||||
|
ax.set_xlabel("East (m)")
|
||||||
|
ax.set_ylabel("North (m)")
|
||||||
|
ax.set_title("水平轨迹 (NED 俯视)")
|
||||||
|
ax.set_aspect("equal", adjustable="datalim")
|
||||||
|
ax.grid(True, linestyle="--", alpha=0.6)
|
||||||
|
ax.legend(loc="best", fontsize=8)
|
||||||
|
ax.xaxis.set_major_locator(MaxNLocator(nbins=6))
|
||||||
|
ax.yaxis.set_major_locator(MaxNLocator(nbins=6))
|
||||||
|
|
||||||
|
# ---- 右图:高度随时间(Down 取负号显示为高度)----
|
||||||
|
ax2 = axes[1]
|
||||||
|
if drone_traj:
|
||||||
|
dt = [p[0] for p in drone_traj]
|
||||||
|
d_alt = [-p[3] for p in drone_traj] # NED down → 高度
|
||||||
|
ax2.plot(dt, d_alt, "b-", linewidth=1.5, label="无人机高度")
|
||||||
|
if target_traj:
|
||||||
|
tt = [p[0] for p in target_traj]
|
||||||
|
t_alt = [-p[3] for p in target_traj]
|
||||||
|
ax2.plot(tt, t_alt, "r--", linewidth=1.5, label="目标高度")
|
||||||
|
ax2.set_xlabel("时间 (s)")
|
||||||
|
ax2.set_ylabel("高度 (m)")
|
||||||
|
ax2.set_title("高度-时间曲线")
|
||||||
|
ax2.grid(True, linestyle="--", alpha=0.6)
|
||||||
|
ax2.legend(loc="best", fontsize=8)
|
||||||
|
|
||||||
|
fig.suptitle("视觉跟踪段轨迹", fontsize=12)
|
||||||
|
fig.tight_layout()
|
||||||
|
|
||||||
|
if save_path is not None:
|
||||||
|
fig.savefig(str(save_path), dpi=150, bbox_inches="tight")
|
||||||
|
print(f"-- 轨迹图已保存: {save_path}")
|
||||||
|
try:
|
||||||
|
plt.show()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"! 无法弹窗显示图像(无图形界面?): {e}")
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualTarget:
|
||||||
|
"""几何一致虚拟目标 —— YOLO 检测与激光雷达的替身。
|
||||||
|
|
||||||
|
目标初始位置:
|
||||||
|
以起飞后的悬停锚点为参考,
|
||||||
|
按起飞时机头方向放置:
|
||||||
|
前方 TARGET_AHEAD_M
|
||||||
|
右方 TARGET_RIGHT_M
|
||||||
|
下方 TARGET_DOWN_M
|
||||||
|
|
||||||
|
目标运动(水平圆弧):
|
||||||
|
跟踪开始时刻 t=0,目标以切向速度 MOCK_TARGET_SPEED_MPS、
|
||||||
|
半径 TARGET_ARC_RADIUS_M 在 NED 水平面内做匀速圆周运动。
|
||||||
|
|
||||||
|
圆心:初始位置沿“起飞时右方”偏移半径 R(目标从圆心左侧/右侧
|
||||||
|
起转,初始切向近似沿机头前方),高度保持不变。
|
||||||
|
|
||||||
|
角速度 ω = v / R,方向由 TARGET_ARC_CCW 控制(+1 逆时针)。
|
||||||
|
|
||||||
|
每次 observe() 调用时,根据经过的时间实时更新目标的
|
||||||
|
NED 世界坐标,然后再进行针孔投影。
|
||||||
|
|
||||||
|
相机假定与机体固连、无视差、忽略 roll/pitch;
|
||||||
|
仅使用 yaw 将世界 NED 坐标转换到机体系。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cfg,
|
||||||
|
anchor_ned: tuple[float, float, float],
|
||||||
|
anchor_yaw_rad: float,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. 根据起飞后的锚点 + 起飞时航向,计算目标初始位置
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
cos_y = math.cos(anchor_yaw_rad)
|
||||||
|
sin_y = math.sin(anchor_yaw_rad)
|
||||||
|
|
||||||
|
# 机体系:
|
||||||
|
# 前方 = (cos(yaw), sin(yaw))
|
||||||
|
# 右方 = (-sin(yaw), cos(yaw))
|
||||||
|
|
||||||
|
self._start_n = (
|
||||||
|
anchor_ned[0]
|
||||||
|
+ TARGET_AHEAD_M * cos_y
|
||||||
|
- TARGET_RIGHT_M * sin_y
|
||||||
|
)
|
||||||
|
|
||||||
|
self._start_e = (
|
||||||
|
anchor_ned[1]
|
||||||
|
+ TARGET_AHEAD_M * sin_y
|
||||||
|
+ TARGET_RIGHT_M * cos_y
|
||||||
|
)
|
||||||
|
|
||||||
|
self._start_d = (
|
||||||
|
anchor_ned[2]
|
||||||
|
+ TARGET_DOWN_M
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. 当前目标位置
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
self._pos_n = self._start_n
|
||||||
|
self._pos_e = self._start_e
|
||||||
|
self._pos_d = self._start_d
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. 圆弧参数
|
||||||
|
#
|
||||||
|
# 圆心取在初始点沿“起飞时右方”偏移 R 处,使 t=0 时目标
|
||||||
|
# 位于圆心左侧(相对机头),初始切向大致沿机头前方:
|
||||||
|
#
|
||||||
|
# 右方单位向量: (-sin(yaw), cos(yaw))
|
||||||
|
# 圆心 = start + R * 右方
|
||||||
|
# 初始相位:从圆心指向 start 的向量 = -R * 右方
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
self._radius_m = max(0.1, float(TARGET_ARC_RADIUS_M))
|
||||||
|
self._omega = (
|
||||||
|
float(MOCK_TARGET_SPEED_MPS) / self._radius_m
|
||||||
|
) * (1.0 if TARGET_ARC_CCW >= 0 else -1.0)
|
||||||
|
|
||||||
|
right_n = -sin_y
|
||||||
|
right_e = cos_y
|
||||||
|
|
||||||
|
self._center_n = self._start_n + self._radius_m * right_n
|
||||||
|
self._center_e = self._start_e + self._radius_m * right_e
|
||||||
|
|
||||||
|
# 初始角度:圆心 → 起点 在 NE 平面的方位角
|
||||||
|
# atan2(E, N) 与常见航向定义一致
|
||||||
|
self._theta0 = math.atan2(
|
||||||
|
self._start_e - self._center_e,
|
||||||
|
self._start_n - self._center_n,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._move_yaw_rad = anchor_yaw_rad # 仅作参考/日志
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. 记录目标运动开始时间
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
self._start_time = time.monotonic()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. 相机参数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
cam = cfg.camera
|
||||||
|
|
||||||
|
self._img_w = float(cam.image_width)
|
||||||
|
self._img_h = float(cam.image_height)
|
||||||
|
|
||||||
|
# 针孔模型焦距(像素):
|
||||||
|
#
|
||||||
|
# f = W / (2 * tan(FOV_h / 2))
|
||||||
|
#
|
||||||
|
self._f_px = self._img_w / (
|
||||||
|
2.0 * math.tan(
|
||||||
|
math.radians(cam.fov_h_deg) / 2.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. 目标可见状态
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
self._visible = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def visible(self) -> bool:
|
||||||
|
"""返回目标当前是否可见。"""
|
||||||
|
return self._visible
|
||||||
|
|
||||||
|
def set_visible(self, visible: bool) -> None:
|
||||||
|
"""设置目标是否可见。"""
|
||||||
|
self._visible = visible
|
||||||
|
|
||||||
|
def update_position(self) -> None:
|
||||||
|
"""根据经过的时间更新目标世界坐标(水平匀速圆弧)。
|
||||||
|
|
||||||
|
θ(t) = θ0 + ω * t
|
||||||
|
N = center_N + R * cos(θ)
|
||||||
|
E = center_E + R * sin(θ)
|
||||||
|
D 保持初始高度不变。
|
||||||
|
|
||||||
|
切向速度 v = |ω| * R = MOCK_TARGET_SPEED_MPS。
|
||||||
|
"""
|
||||||
|
|
||||||
|
elapsed_s = time.monotonic() - self._start_time
|
||||||
|
elapsed_s = max(0.0, elapsed_s)
|
||||||
|
|
||||||
|
theta = self._theta0 + self._omega * elapsed_s
|
||||||
|
|
||||||
|
self._pos_n = self._center_n + self._radius_m * math.cos(theta)
|
||||||
|
self._pos_e = self._center_e + self._radius_m * math.sin(theta)
|
||||||
|
self._pos_d = self._start_d
|
||||||
|
|
||||||
|
def observe(
|
||||||
|
self,
|
||||||
|
ned_n: float,
|
||||||
|
ned_e: float,
|
||||||
|
ned_d: float,
|
||||||
|
yaw_rad: float,
|
||||||
|
):
|
||||||
|
"""返回:
|
||||||
|
|
||||||
|
(detections, radar_distance_m, associated)
|
||||||
|
|
||||||
|
不可见时:
|
||||||
|
|
||||||
|
([], 0.0, False)
|
||||||
|
|
||||||
|
每次调用都会先更新虚拟目标位置,
|
||||||
|
然后根据无人机当前位置和当前 yaw
|
||||||
|
计算目标在相机画面中的投影。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. 始终更新目标真实世界坐标(即使“不可见”也继续运动,
|
||||||
|
# 便于轨迹记录;仅检测输出被屏蔽)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
self.update_position()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. 目标不可见 → 不返回检测框
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
if not self._visible:
|
||||||
|
return [], 0.0, False
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. 目标相对于无人机的 NED 坐标
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
rel_n = self._pos_n - ned_n
|
||||||
|
rel_e = self._pos_e - ned_e
|
||||||
|
rel_d = self._pos_d - ned_d
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. 世界坐标 NED → 当前无人机机体系
|
||||||
|
#
|
||||||
|
# 这里只考虑 yaw,不考虑 roll / pitch。
|
||||||
|
#
|
||||||
|
# fwd :目标在机头前方的距离
|
||||||
|
# right:目标在机体右侧的距离
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
cos_y = math.cos(yaw_rad)
|
||||||
|
sin_y = math.sin(yaw_rad)
|
||||||
|
|
||||||
|
fwd = (
|
||||||
|
rel_n * cos_y
|
||||||
|
+ rel_e * sin_y
|
||||||
|
)
|
||||||
|
|
||||||
|
right = (
|
||||||
|
-rel_n * sin_y
|
||||||
|
+ rel_e * cos_y
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. 目标在侧面或后方
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
if fwd <= 0.5:
|
||||||
|
return [], 0.0, False
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. 针孔相机投影
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
u = (
|
||||||
|
self._img_w * 0.5
|
||||||
|
+ self._f_px * (right / fwd)
|
||||||
|
)
|
||||||
|
|
||||||
|
v = (
|
||||||
|
self._img_h * 0.5
|
||||||
|
+ self._f_px * (rel_d / fwd)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 7. 目标投影超出画面
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
if not (
|
||||||
|
0.0 <= u < self._img_w
|
||||||
|
and
|
||||||
|
0.0 <= v < self._img_h
|
||||||
|
):
|
||||||
|
return [], 0.0, False
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 8. 构造模拟 YOLO 检测框
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
box = Detection(
|
||||||
|
u - MOCK_BOX_W / 2.0,
|
||||||
|
v - MOCK_BOX_H / 2.0,
|
||||||
|
u + MOCK_BOX_W / 2.0,
|
||||||
|
v + MOCK_BOX_H / 2.0,
|
||||||
|
MOCK_CONFIDENCE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 9. 计算模拟激光雷达距离
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
slant = math.sqrt(
|
||||||
|
fwd * fwd
|
||||||
|
+ right * right
|
||||||
|
+ rel_d * rel_d
|
||||||
|
)
|
||||||
|
|
||||||
|
return [box], slant, True
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryHub:
|
||||||
|
"""后台订阅姿态/位置遥测,控制回路每拍读取最新快照(§7.1 同拍取样)。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.yaw_rad = 0.0
|
||||||
|
self.roll_rad = 0.0
|
||||||
|
self.pitch_rad = 0.0
|
||||||
|
self.attitude_time_ms = 0
|
||||||
|
self.north_m = 0.0
|
||||||
|
self.east_m = 0.0
|
||||||
|
self.down_m = 0.0
|
||||||
|
self._tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
def start(self, drone) -> None:
|
||||||
|
self._tasks.append(asyncio.create_task(self._attitude_loop(drone)))
|
||||||
|
self._tasks.append(asyncio.create_task(self._position_loop(drone)))
|
||||||
|
|
||||||
|
async def _attitude_loop(self, drone) -> None:
|
||||||
|
async for euler in drone.telemetry.attitude_euler():
|
||||||
|
self.yaw_rad = math.radians(euler.yaw_deg)
|
||||||
|
self.roll_rad = math.radians(euler.roll_deg)
|
||||||
|
self.pitch_rad = math.radians(euler.pitch_deg)
|
||||||
|
self.attitude_time_ms = now_ms()
|
||||||
|
|
||||||
|
async def _position_loop(self, drone) -> None:
|
||||||
|
async for pos_vel in drone.telemetry.position_velocity_ned():
|
||||||
|
pos = pos_vel.position
|
||||||
|
self.north_m = pos.north_m
|
||||||
|
self.east_m = pos.east_m
|
||||||
|
self.down_m = pos.down_m
|
||||||
|
|
||||||
|
async def wait_attitude(self, timeout_s: float = 10.0) -> bool:
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
while self.attitude_time_ms == 0 and time.monotonic() < deadline:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
return self.attitude_time_ms > 0
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
for task in self._tasks:
|
||||||
|
task.cancel()
|
||||||
|
for task in self._tasks:
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._tasks.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch(sender, out: ControlOutput, tick_ms: int) -> None:
|
||||||
|
"""按模式分发指令。
|
||||||
|
|
||||||
|
DEGRADED_HOLD 在胶水层 demo 中不发送,但真实 offboard 会话中断流
|
||||||
|
(约 0.5~1 s)会触发 PX4 offboard failsafe,故此处改发零速设定点
|
||||||
|
(等效刹停悬停)保住设定点流。
|
||||||
|
"""
|
||||||
|
if out.mode is ControlMode.VELOCITY_HEADING and out.valid:
|
||||||
|
await sender.send_velocity_heading(out, tick_ms)
|
||||||
|
else:
|
||||||
|
await sender.send_brake_hover(tick_ms)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tracking(drone, anchor_ned: tuple[float, float, float],
|
||||||
|
anchor_yaw_deg: float) -> bool:
|
||||||
|
"""视觉跟踪闭环段:20 Hz 控制回路,返回是否正常走完场景。"""
|
||||||
|
cfg = load_from_json(str(TRACK_CONFIG_PATH))
|
||||||
|
period_s = 1.0 / cfg.control.frequency_hz
|
||||||
|
|
||||||
|
hub = TelemetryHub()
|
||||||
|
hub.start(drone)
|
||||||
|
if not await hub.wait_attitude():
|
||||||
|
logger.error("姿态遥测未就绪,放弃跟踪段")
|
||||||
|
await hub.stop()
|
||||||
|
return False
|
||||||
|
|
||||||
|
tracker = TargetTracker(TrackerConfig(max_lost_frames=TRACK_MAX_LOST_FRAMES))
|
||||||
|
law = TrackingControlLaw(cfg)
|
||||||
|
gateway = MavsdkCommandGateway(drone)
|
||||||
|
target = VirtualTarget(cfg, anchor_ned, math.radians(anchor_yaw_deg))
|
||||||
|
frame_shape = FrameShape(height=cfg.camera.image_height,
|
||||||
|
width=cfg.camera.image_width)
|
||||||
|
|
||||||
|
if not await gateway.start():
|
||||||
|
logger.error("offboard 启动失败,放弃跟踪段")
|
||||||
|
await hub.stop()
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"-- 进入视觉跟踪:锚点航向 {anchor_yaw_deg:.1f}°,虚拟目标按机头朝向放置于 "
|
||||||
|
f"前 {TARGET_AHEAD_M:.0f} m / 右 {TARGET_RIGHT_M:.0f} m / 下 {TARGET_DOWN_M:.0f} m "
|
||||||
|
f"(世界 NED: N={target._pos_n:.1f}, E={target._pos_e:.1f}, D={target._pos_d:.1f})")
|
||||||
|
print(f" 目标运动: 圆弧 R={TARGET_ARC_RADIUS_M:.1f} m, v={MOCK_TARGET_SPEED_MPS:.1f} m/s, "
|
||||||
|
f"{'逆时针' if TARGET_ARC_CCW >= 0 else '顺时针'} "
|
||||||
|
f"(圆心 N={target._center_n:.1f}, E={target._center_e:.1f})")
|
||||||
|
print(f" 场景: 正常跟踪 {TRACK_NORMAL_SECS:.0f}s → 目标消失 → "
|
||||||
|
f"Coast {TRACK_MAX_LOST_FRAMES / cfg.control.frequency_hz:.0f}s → "
|
||||||
|
f"判丢刹车 {TRACK_BRAKE_SECS:.0f}s")
|
||||||
|
|
||||||
|
started_ms = now_ms()
|
||||||
|
target_gone_ms = started_ms + TRACK_NORMAL_SECS * 1000.0
|
||||||
|
lost_at_ms = None
|
||||||
|
next_deadline = time.monotonic() + period_s
|
||||||
|
tick = 0
|
||||||
|
|
||||||
|
# 轨迹记录:(t_s, north_m, east_m, down_m)
|
||||||
|
drone_traj = []
|
||||||
|
target_traj = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
now = now_ms()
|
||||||
|
if now - started_ms >= TRACK_TIMEOUT_SECS * 1000.0:
|
||||||
|
logger.warning("跟踪段超时(%.0f s),提前收尾", TRACK_TIMEOUT_SECS)
|
||||||
|
break
|
||||||
|
|
||||||
|
if target.visible and now >= target_gone_ms:
|
||||||
|
target.set_visible(False)
|
||||||
|
print("-- 模拟目标消失,追踪器进入 Coast 预测")
|
||||||
|
|
||||||
|
detections, radar_m, associated = target.observe(
|
||||||
|
hub.north_m, hub.east_m, hub.down_m, hub.yaw_rad)
|
||||||
|
|
||||||
|
# 记录本拍位置(目标位置在 observe 内已 update)
|
||||||
|
t_s = (now - started_ms) / 1000.0
|
||||||
|
drone_traj.append((t_s, hub.north_m, hub.east_m, hub.down_m))
|
||||||
|
target_traj.append((t_s, target._pos_n, target._pos_e, target._pos_d))
|
||||||
|
|
||||||
|
result = tracker.update(detections, frame_shape)
|
||||||
|
snap = ControlSnapshot(
|
||||||
|
track=track_result_to_state(result),
|
||||||
|
track_time_ms=now, # 每拍取样即打戳(视觉始终新鲜)
|
||||||
|
radar_distance_m=radar_m,
|
||||||
|
associated_to_target=associated,
|
||||||
|
radar_time_ms=now if associated else 0,
|
||||||
|
attitude_present=True,
|
||||||
|
attitude_time_ms=hub.attitude_time_ms,
|
||||||
|
yaw_rad=hub.yaw_rad,
|
||||||
|
roll_rad=hub.roll_rad,
|
||||||
|
pitch_rad=hub.pitch_rad,
|
||||||
|
)
|
||||||
|
out = law.update(snap, now)
|
||||||
|
await dispatch(gateway, out, now)
|
||||||
|
|
||||||
|
if tick % STATUS_EVERY_TICKS == 0:
|
||||||
|
print(f"[跟踪 t={t_s:5.1f}s] "
|
||||||
|
f"检测={len(detections)} "
|
||||||
|
f"追踪[trk={result.tracked} pred={result.is_predicted} "
|
||||||
|
f"lost={result.lost_frames}] 雷达={radar_m:5.1f}m | "
|
||||||
|
f"[{out.mode.name}] vx_body={out.vx_body_mps:+.2f} "
|
||||||
|
f"vz={out.vz_mps:+.2f} yaw_rate={out.yaw_rate_dps:+.2f}°/s "
|
||||||
|
f"coast={out.coast_active}")
|
||||||
|
tick += 1
|
||||||
|
|
||||||
|
if not result.tracked:
|
||||||
|
if lost_at_ms is None:
|
||||||
|
lost_at_ms = now
|
||||||
|
print("-- 判丢,执行零速刹车")
|
||||||
|
elif now - lost_at_ms >= TRACK_BRAKE_SECS * 1000.0:
|
||||||
|
print("-- 判丢刹车完成,退出跟踪段")
|
||||||
|
break
|
||||||
|
|
||||||
|
next_deadline += period_s
|
||||||
|
sleep_s = next_deadline - time.monotonic()
|
||||||
|
if sleep_s > 0:
|
||||||
|
await asyncio.sleep(sleep_s)
|
||||||
|
else:
|
||||||
|
next_deadline = time.monotonic() + period_s # 节拍落后,重锚
|
||||||
|
finally:
|
||||||
|
await gateway.stop()
|
||||||
|
await hub.stop()
|
||||||
|
|
||||||
|
# 跟踪段结束后绘制轨迹
|
||||||
|
if PLOT_TRAJECTORY:
|
||||||
|
print(f"-- 绘制轨迹:无人机 {len(drone_traj)} 点,目标 {len(target_traj)} 点")
|
||||||
|
plot_trajectories(drone_traj, target_traj, TRAJECTORY_SAVE_PATH)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def print_flight_mode(drone):
|
||||||
|
"""打印当前飞行模式"""
|
||||||
|
async for flight_mode in drone.telemetry.flight_mode():
|
||||||
|
print(f"[模式] 当前飞行模式: {flight_mode}")
|
||||||
|
return flight_mode
|
||||||
|
|
||||||
|
async def wait_until_ready(drone):
|
||||||
|
"""等待连接 + 位置估计就绪(各阶段限时 30 s)。"""
|
||||||
|
print("等待无人机连接...")
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(30.0):
|
||||||
|
async for state in drone.core.connection_state():
|
||||||
|
if state.is_connected:
|
||||||
|
print("-- 已连接")
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
raise RuntimeError("30 s 内未建立 MAVSDK 连接(检查 SITL 是否在运行、"
|
||||||
|
"14540 端口是否被占用)") from None
|
||||||
|
|
||||||
|
print("等待全球位置估计就绪...")
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(30.0):
|
||||||
|
async for health in drone.telemetry.health():
|
||||||
|
if health.is_global_position_ok and health.is_home_position_ok:
|
||||||
|
print("-- 位置估计 OK")
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
raise RuntimeError("30 s 内位置估计未就绪") from None
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
drone = System()
|
||||||
|
await drone.connect(system_address=CONNECTION)
|
||||||
|
|
||||||
|
# 1. 等待就绪
|
||||||
|
try:
|
||||||
|
await wait_until_ready(drone)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"! {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1.5 显式设定遥测流速率(不依赖默认配置;失败则沿用默认并告警)
|
||||||
|
for stream, rate in (("position", 50.0),
|
||||||
|
("position_velocity_ned", 30.0),
|
||||||
|
("attitude_euler", 50.0),
|
||||||
|
("in_air", 10.0)):
|
||||||
|
try:
|
||||||
|
await getattr(drone.telemetry, f"set_rate_{stream}")(rate)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"! 设置 {stream} 速率失败(沿用默认值): {e}")
|
||||||
|
print("-- 遥测速率已设定")
|
||||||
|
|
||||||
|
# 记录初始模式(可选,用于最后恢复)
|
||||||
|
initial_mode = await print_flight_mode(drone)
|
||||||
|
|
||||||
|
# 2. 解锁
|
||||||
|
print("-- 解锁")
|
||||||
|
try:
|
||||||
|
await drone.action.arm()
|
||||||
|
except ActionError as e:
|
||||||
|
print(f"解锁失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. 起飞
|
||||||
|
print(f"-- 起飞到 {TAKEOFF_ALTITUDE} m")
|
||||||
|
await drone.action.set_takeoff_altitude(TAKEOFF_ALTITUDE)
|
||||||
|
await drone.action.takeoff()
|
||||||
|
|
||||||
|
# 等待起飞完成。注意:MAVSDK 遥测是带缓冲队列的推送流,循环体内
|
||||||
|
# 不能 sleep —— 否则消费速率(如 2 Hz)远低于推送速率(50 Hz),
|
||||||
|
# 队列积压导致读到的是几十秒前的旧高度(问题一的根因)。全速排水
|
||||||
|
# 使每条消息都是最新值,另加整体超时兜底。
|
||||||
|
print("-- 等待起飞完成...")
|
||||||
|
wait_start = time.monotonic()
|
||||||
|
next_log = wait_start + 2.0
|
||||||
|
reached = False
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(30.0):
|
||||||
|
async for position in drone.telemetry.position():
|
||||||
|
if position.relative_altitude_m >= TAKEOFF_ALTITUDE * 0.90:
|
||||||
|
wait_s = time.monotonic() - wait_start
|
||||||
|
print(f"-- 已到达起飞高度: {position.relative_altitude_m:.2f} m "
|
||||||
|
f"(等待 {wait_s:.1f} s)")
|
||||||
|
reached = True
|
||||||
|
break
|
||||||
|
now_t = time.monotonic()
|
||||||
|
if now_t >= next_log:
|
||||||
|
print(f" 起飞爬升中: {position.relative_altitude_m:.2f} m")
|
||||||
|
next_log = now_t + 2.0
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
if not reached:
|
||||||
|
print("! 30 s 内未达到起飞高度,放弃本次测试(执行降落兜底)")
|
||||||
|
try:
|
||||||
|
await drone.action.land()
|
||||||
|
except ActionError as e:
|
||||||
|
print(f"! 降落指令失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# 4. 记录悬停锚点与锚点航向(虚拟目标按机头朝向放置)
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(10.0):
|
||||||
|
async for pos_vel in drone.telemetry.position_velocity_ned():
|
||||||
|
anchor_ned = (pos_vel.position.north_m,
|
||||||
|
pos_vel.position.east_m,
|
||||||
|
pos_vel.position.down_m)
|
||||||
|
break
|
||||||
|
anchor_yaw_deg = 0.0
|
||||||
|
async with asyncio.timeout(10.0):
|
||||||
|
async for euler in drone.telemetry.attitude_euler():
|
||||||
|
anchor_yaw_deg = euler.yaw_deg
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
print("! 10 s 内未取得悬停锚点/航向,放弃本次测试(执行降落兜底)")
|
||||||
|
try:
|
||||||
|
await drone.action.land()
|
||||||
|
except ActionError as e:
|
||||||
|
print(f"! 降落指令失败: {e}")
|
||||||
|
return
|
||||||
|
print(f" 悬停锚点 NED: N={anchor_ned[0]:.2f}, "
|
||||||
|
f"E={anchor_ned[1]:.2f}, D={anchor_ned[2]:.2f},"
|
||||||
|
f"机头 {anchor_yaw_deg:.1f}°")
|
||||||
|
|
||||||
|
# 5. 视觉跟踪闭环(offboard 速度设定点,20 Hz)
|
||||||
|
await run_tracking(drone, anchor_ned, anchor_yaw_deg)
|
||||||
|
|
||||||
|
# 6. 退出 offboard 后回到 Hold,稍候确认模式
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await print_flight_mode(drone)
|
||||||
|
|
||||||
|
# 7. 返航 (RTL)
|
||||||
|
print("-- 执行 RTL")
|
||||||
|
await drone.action.return_to_launch()
|
||||||
|
|
||||||
|
# 8. 等待落地(同样全速排水,避免 in_air 队列积压读到旧状态)
|
||||||
|
print("-- 等待落地...")
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(240.0):
|
||||||
|
async for is_in_air in drone.telemetry.in_air():
|
||||||
|
if not is_in_air:
|
||||||
|
print("-- 已落地")
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
print("! 240 s 内未检测到落地")
|
||||||
|
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# 9. 上锁
|
||||||
|
print("-- 上锁")
|
||||||
|
try:
|
||||||
|
await drone.action.disarm()
|
||||||
|
print("-- 上锁成功")
|
||||||
|
except ActionError as e:
|
||||||
|
print(f"上锁失败(可能已自动上锁): {e}")
|
||||||
|
|
||||||
|
print(f"-- 初始模式是: {initial_mode}")
|
||||||
|
print("\n===== 测试流程全部完成 =====")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO,
|
||||||
|
format="%(asctime)s.%(msecs)03d %(levelname)s %(message)s",
|
||||||
|
datefmt="%H:%M:%S")
|
||||||
|
asyncio.run(run())
|
||||||
Reference in New Issue
Block a user