精简仓库结构并增强任务链路:归档双目可选后端、新增LOS判据与自定义返航

归档 legacy/:stereo_yolo/stereo_ranger/target_tracker 三包 + 175 条测试 + demo/配置/权重/三份规格;运行链路不再依赖,默认 pytest 446 条,归档测试按需运行。

目标有效性第二判据改为 LOS 运动学一致性(pod_bridge/los_consistency.py),删除 require_vision;pod_locked() 叠加该判据,状态机随之后退重搜。

自定义返航(task_runner/return_home.py):保持转场高度回起飞点 → 恢复初始航向 → 垂直降落,不再爬 RTL_RETURN_ALT;未起飞/无起飞点/超时/异常仍退回 PX4 原生 RTL。

任务链安全兜底:主循环异常→紧急返航、stop 幂等、landing_stop 保留遥测、in_air 流复用+超时、解锁/起飞与吊舱识别仅执行一次、rtl_timeout 上限。

入口共享件拆到 utils/sitl_support.py;pod_sitl_test.py→l1_sitl_test.py;删除旧架构 test.py;相机脚本移入 tools/。

新增 .gitignore 并剔除历史误跟踪的 59 个 .pyc;requirements 按实际依赖重写;文档全面对齐,新增 legacy/README.md 与 tools/README.md。
This commit is contained in:
keita
2026-10-21 14:48:41 +08:00
parent 53fdd3b13f
commit df91b1110e
159 changed files with 6121 additions and 1617 deletions
+299
View File
@@ -0,0 +1,299 @@
"""app_test.py —— 地面站 Flask 端点测试(test_client + 真实 UDP 回环监听)。
覆盖:POST /api/inject 合法/非法(UDP 实收断言)、局部坐标注入换算与拒绝、
POST /api/command 白名单校验与 UDP 下发、POST /api/continuous 服务端持续注入启停、
GET /api/link 链路目标读写、GET /api/state 空日志目录兜底与 continuous 标志。
"""
import json
import math
import socket
import sys
import time
from pathlib import Path
import pytest
_ROOT = Path(__file__).resolve().parents[3] # utils/tests/ground_station → 仓库根
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from ground_station.app import create_app # noqa: E402
@pytest.fixture
def udp_listener():
"""真实 UDP 回环监听:返回 (port, recv_one)。"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("127.0.0.1", 0))
s.settimeout(1.5)
def recv_one():
data, _addr = s.recvfrom(2048)
return json.loads(data.decode("utf-8"))
yield s.getsockname()[1], recv_one
s.close()
@pytest.fixture
def client(tmp_path, udp_listener):
port, _recv = udp_listener
app = create_app(udp_port=port, log_dir=str(tmp_path),
inject_interval_s=0.05) # 测试用短周期
app.config["TESTING"] = True
return app.test_client()
# --- /api/inject ------------------------------------------------------------
def test_inject_ok_sends_udp(client, udp_listener):
_port, recv_one = udp_listener
r = client.post("/api/inject", json={"lat": 1.5, "lon": 2.5, "alt": 10})
assert r.get_json()["ok"] is True
assert recv_one() == {"lat": 1.5, "lon": 2.5, "alt": 10.0}
def test_inject_missing_lat_400(client):
r = client.post("/api/inject", json={"lon": 2.5})
assert r.status_code == 400
assert r.get_json()["ok"] is False
def test_inject_non_numeric_400(client):
r = client.post("/api/inject", json={"lat": "x", "lon": 2.5})
assert r.status_code == 400
# --- 局部坐标注入 -------------------------------------------------------------
_TEL_HEADER = ("t_s,state,pos_valid,fix_type,num_satellites,latitude_deg,"
"longitude_deg,relative_altitude_m,yaw_deg,target_injected,"
"distance_to_target_m,target_locked,off_target_x_deg,"
"off_target_y_deg,mode,yaw_rate_dps,vx_body_mps,vz_mps\n")
def _write_telemetry(log_dir, lat=47.0, lon=8.0, pos_valid="True"):
with open(Path(log_dir) / "task_telemetry.csv", "w", encoding="utf-8") as f:
f.write(_TEL_HEADER)
f.write(f"1.0,wait,{pos_valid},3,10,{lat},{lon},0,0,False,inf,False,"
"0,0,,0,0,0\n")
def test_inject_local_converts_from_vehicle_position(client, udp_listener, tmp_path):
_write_telemetry(tmp_path, lat=47.0, lon=8.0)
_port, recv_one = udp_listener
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15, "alt": 0})
j = r.get_json()
assert j["ok"] is True
exp_lat = 47.0 + 30 / 111320.0
exp_lon = 8.0 + 15 / (111320.0 * math.cos(math.radians(47.0)))
msg = recv_one()
assert msg["lat"] == pytest.approx(exp_lat, abs=1e-9)
assert msg["lon"] == pytest.approx(exp_lon, abs=1e-9)
assert j["local"]["north_m"] == 30 and j["local"]["east_m"] == 15
assert j["local"]["origin_lat"] == pytest.approx(47.0)
def test_inject_local_rejected_without_telemetry(client):
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15})
assert r.status_code == 400
assert "无法换算" in r.get_json()["error"]
def test_inject_local_rejected_when_pos_invalid(client, tmp_path):
_write_telemetry(tmp_path, pos_valid="False")
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15})
assert r.status_code == 400
def test_inject_local_bad_numbers_400(client, tmp_path):
_write_telemetry(tmp_path)
assert client.post("/api/inject",
json={"north_m": "x", "east_m": 15}).status_code == 400
def test_inject_local_home_origin_uses_session_start(client, udp_listener, tmp_path):
# 同一段:起始有效定位 47.0/8.0(≈起飞点),之后移动到 47.5/8.5
with open(tmp_path / "task_telemetry.csv", "w", encoding="utf-8") as f:
f.write(_TEL_HEADER)
f.write("1.0,wait,True,3,10,47.0,8.0,0,0,False,inf,False,0,0,,0,0,0\n")
f.write("60.0,goto,True,3,10,47.5,8.5,0,0,False,inf,False,0,0,,0,0,0\n")
_port, recv_one = udp_listener
j = client.post("/api/inject",
json={"north_m": 0, "east_m": 0, "origin": "home"}).get_json()
assert j["ok"] is True and j["local"]["origin"] == "home"
assert j["local"]["origin_lat"] == pytest.approx(47.0) # 段首而非最后行
assert j["local"]["origin_lon"] == pytest.approx(8.0)
recv_one()
j2 = client.post("/api/inject",
json={"north_m": 0, "east_m": 0}).get_json() # current
assert j2["local"]["origin"] == "current"
assert j2["local"]["origin_lat"] == pytest.approx(47.5) # 当前位置
recv_one()
def test_inject_local_home_origin_skips_invalid_rows(client, tmp_path):
with open(tmp_path / "task_telemetry.csv", "w", encoding="utf-8") as f:
f.write(_TEL_HEADER)
f.write("0.5,wait,False,0,0,0.0,0.0,0,0,False,inf,False,0,0,,0,0,0\n")
f.write("1.0,wait,True,3,10,47.0,8.0,0,0,False,inf,False,0,0,,0,0,0\n")
j = client.post("/api/inject",
json={"north_m": 30, "east_m": 15,
"origin": "home"}).get_json()
assert j["ok"] is True
assert j["local"]["origin_lat"] == pytest.approx(47.0) # 取首个有效定位
def test_inject_local_bad_origin_400(client, tmp_path):
_write_telemetry(tmp_path)
r = client.post("/api/inject",
json={"north_m": 1, "east_m": 1, "origin": "mars"})
assert r.status_code == 400
def test_inject_local_home_origin_rejected_without_telemetry(client):
r = client.post("/api/inject",
json={"north_m": 30, "east_m": 15, "origin": "home"})
assert r.status_code == 400
assert "起飞点" in r.get_json()["error"]
# --- /api/command -----------------------------------------------------------
def test_command_whitelist_ok_sends_udp(client, udp_listener):
_port, recv_one = udp_listener
r = client.post("/api/command", json={"cmd": "rtl"})
assert r.get_json() == {"ok": True, "sent": {"cmd": "rtl"}}
assert recv_one() == {"cmd": "rtl"}
def test_command_all_whitelist_entries_accepted(client, udp_listener):
_port, recv_one = udp_listener
for cmd in ("rtl", "emergency", "pod_detect_on", "pod_auto_lock",
"pod_stop_track", "pod_detect_off", "pod_center"):
r = client.post("/api/command", json={"cmd": cmd})
assert r.get_json()["ok"] is True
assert recv_one() == {"cmd": cmd}
def test_command_unknown_rejected_no_udp(client, udp_listener):
_port, recv_one = udp_listener
r = client.post("/api/command", json={"cmd": "reboot"})
assert r.status_code == 400
assert r.get_json()["ok"] is False
with pytest.raises(socket.timeout):
recv_one() # 未下发任何 UDP → 监听超时即证明
def test_command_non_string_rejected(client):
r = client.post("/api/command", json={"cmd": 1})
assert r.status_code == 400
# --- /api/continuous --------------------------------------------------------
def test_continuous_requires_prior_inject(client):
r = client.post("/api/continuous", json={"on": True})
assert r.status_code == 400
assert r.get_json()["ok"] is False
def test_continuous_non_bool_rejected(client):
r = client.post("/api/continuous", json={"on": "yes"})
assert r.status_code == 400
def test_continuous_sends_repeatedly_and_stops(client, udp_listener):
_port, recv_one = udp_listener
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0} # 手动单发
r = client.post("/api/continuous", json={"on": True})
assert r.get_json() == {"ok": True, "continuous": True}
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0} # 服务端自动重发
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0}
r = client.post("/api/continuous", json={"on": False})
assert r.get_json() == {"ok": True, "continuous": False}
time.sleep(0.2)
while True: # 排空在途包
try:
recv_one()
except socket.timeout:
break
with pytest.raises(socket.timeout):
recv_one() # 停止后不再发
def test_continuous_uses_latest_inject(client, udp_listener):
_port, recv_one = udp_listener
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
recv_one()
client.post("/api/inject", json={"lat": 3.0, "lon": 4.0}) # 中途改坐标
recv_one()
client.post("/api/continuous", json={"on": True})
assert recv_one() == {"lat": 3.0, "lon": 4.0, "alt": 0.0}
client.post("/api/continuous", json={"on": False})
# --- /api/link --------------------------------------------------------------
def test_link_get_default_and_options(client, udp_listener):
port, _recv = udp_listener
j = client.get("/api/link").get_json()
assert j["host"] == "127.0.0.1"
assert j["port"] == port
assert any(o["host"] == "127.0.0.1" for o in j["options"])
def test_link_set_bad_host_or_port_400(client):
assert client.post("/api/link", json={"host": " "}).status_code == 400
assert client.post("/api/link",
json={"host": "1.2.3.4", "port": "x"}).status_code == 400
assert client.post("/api/link",
json={"host": "1.2.3.4", "port": 70000}).status_code == 400
def test_link_switch_redirects_inject_and_command(client, udp_listener):
_port_a, recv_a = udp_listener
s_b = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s_b.bind(("127.0.0.1", 0))
s_b.settimeout(1.5)
port_b = s_b.getsockname()[1]
try:
r = client.post("/api/link", json={"host": "127.0.0.1", "port": port_b})
assert r.get_json() == {"ok": True, "host": "127.0.0.1", "port": port_b}
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
data, _addr = s_b.recvfrom(2048)
assert json.loads(data.decode("utf-8")) == {"lat": 1.0, "lon": 2.0,
"alt": 0.0}
client.post("/api/command", json={"cmd": "rtl"})
data, _addr = s_b.recvfrom(2048)
assert json.loads(data.decode("utf-8")) == {"cmd": "rtl"}
with pytest.raises(socket.timeout):
recv_a() # 旧目标不再收到任何包
finally:
s_b.close()
# --- /api/state -------------------------------------------------------------
def test_state_empty_log_dir(client):
j = client.get("/api/state").get_json()
assert j["state"] == "unknown"
assert j["telemetry"] is None
assert j["continuous"] is False
def test_video_disabled_by_default(client):
assert client.get("/api/state").get_json()["video"] == {
"enabled": False, "online": False}
assert client.get("/video_feed").status_code == 503
def test_state_reports_continuous(client):
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
client.post("/api/continuous", json={"on": True})
assert client.get("/api/state").get_json()["continuous"] is True
client.post("/api/continuous", json={"on": False})
assert client.get("/api/state").get_json()["continuous"] is False
@@ -0,0 +1,138 @@
"""video_source_test.py —— RTSP→MJPEG 视频源单元测试(注入假采集器,不依赖真流)。
覆盖:拉帧→JPEG 编码缓存、宽度限制(等比缩放)、断线重连(重新调用工厂)、
打开失败退避重试、online 判定、MJPEG 分块格式。
"""
import sys
import time
from pathlib import Path
import numpy as np
_ROOT = Path(__file__).resolve().parents[3] # utils/tests/ground_station → 仓库根
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from ground_station.video_source import RtspVideoSource # noqa: E402
class FakeCapture:
"""假采集器:可注入读失败次数;帧为纯色小图。"""
def __init__(self, fail_reads=0, height=60, width=120):
self.fail_reads = fail_reads
self._frame = np.full((height, width, 3), 128, dtype=np.uint8)
self.released = False
def isOpened(self):
return True
def read(self):
if self.fail_reads > 0:
self.fail_reads -= 1
return False, None
return True, self._frame
def release(self):
self.released = True
def _wait(pred, timeout_s=4.0, step=0.02):
t0 = time.monotonic()
while time.monotonic() - t0 < timeout_s:
if pred():
return True
time.sleep(step)
return False
def _decode(jpeg):
import cv2
return cv2.imdecode(np.frombuffer(jpeg, dtype=np.uint8), cv2.IMREAD_COLOR)
def test_frames_encoded_and_online():
src = RtspVideoSource("rtsp://fake", width=120, fps=50,
capture_factory=lambda: FakeCapture())
assert src.online is False # 尚无帧 → 离线
src.start()
try:
assert _wait(lambda: src.latest()[1] is not None)
seq, jpeg = src.latest()
assert seq >= 1
assert jpeg[:2] == b"\xff\xd8" # JPEG SOI 头
assert _decode(jpeg).shape[1] == 120 # 宽度不超限
assert src.online is True
finally:
src.stop()
def test_downscale_to_max_width():
src = RtspVideoSource("rtsp://fake", width=60, fps=50,
capture_factory=lambda: FakeCapture(height=60, width=120))
src.start()
try:
assert _wait(lambda: src.latest()[1] is not None)
img = _decode(src.latest()[1])
assert img.shape[1] == 60 and img.shape[0] == 30 # 等比缩到 60 宽
finally:
src.stop()
def test_reconnect_after_read_failure():
opened = []
def factory():
cap = FakeCapture(fail_reads=(10 ** 6 if not opened else 0))
opened.append(cap)
return cap
src = RtspVideoSource("rtsp://fake", width=120, fps=50,
capture_factory=factory)
src.start()
try:
assert _wait(lambda: src.latest()[1] is not None, timeout_s=5.0)
assert len(opened) >= 2 # 首采集器读失败 → 重新打开
assert opened[0].released is True
finally:
src.stop()
def test_open_failure_keeps_retrying():
calls = []
class ClosedCapture:
def isOpened(self):
return False
def release(self):
pass
def factory():
calls.append(1)
return ClosedCapture()
src = RtspVideoSource("rtsp://fake", capture_factory=factory)
src.start()
try:
assert _wait(lambda: len(calls) >= 2, timeout_s=4.0) # 1s 退避后重试
assert src.latest()[1] is None
finally:
src.stop()
def test_mjpeg_stream_chunks():
src = RtspVideoSource("rtsp://fake", width=120, fps=50,
capture_factory=lambda: FakeCapture())
src.start()
try:
assert _wait(lambda: src.latest()[1] is not None)
gen = src.mjpeg_stream()
chunk = next(gen)
assert chunk.startswith(b"--frame\r\nContent-Type: image/jpeg\r\n")
assert b"Content-Length: " in chunk
assert chunk.endswith(b"\r\n")
gen.close()
finally:
src.stop()
+184 -15
View File
@@ -6,6 +6,7 @@ start/stop 生命周期与停机刹车、run() 主循环异常护栏与故障恢
"""
import asyncio
import math
import time
from dataclasses import replace
@@ -29,6 +30,7 @@ class FakePx4:
self.offboard_start_ok = True
self.velocity_calls = []
self.brake_calls = []
self.yaw_calls = []
def own_state(self):
return replace(self._state)
@@ -54,6 +56,10 @@ class FakePx4:
self.brake_calls.append(time_boot_ms)
return True
async def send_yaw_rate(self, yaw_rate_dps):
self.yaw_calls.append(yaw_rate_dps)
return True
class FakePod:
def __init__(self):
@@ -159,6 +165,116 @@ def test_attitude_downlink_throttled():
assert len(pod.sent) == 2
def test_control_law_throttled_to_own_frequency():
"""生效律按自身频率推进:L2 配置 10Hz,外部 20Hz 调用时控制律只跑 10 次/秒。
链路维持(表3 姿态下发)不受此节流影响——否则吊舱解算会断流。
"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
cfg = BridgeConfig()
cfg.heading_servo.enabled = False # L2 地理引导(10Hz)
cfg.guidance.frequency_hz = 10.0
cfg.link.attitude_downlink_hz = 20.0
cfg.ground_inject.enabled = True
ground = GroundTarget(latitude_deg=0.01, longitude_deg=0.0, time_ms=1000)
b = PodBridge(cfg, px4, pod, now_ms_fn=lambda: 1000,
ground_target_fn=lambda: ground)
# 统计控制律实际推进次数(包装 update,不改变行为)
law_updates = {"n": 0}
real_update = b._guidance.update
def counting_update(inp, now):
law_updates["n"] += 1
return real_update(inp, now)
b._guidance.update = counting_update
ticks = 10 # 20Hz 调用 10 拍 = 500ms
async def scenario():
for i in range(ticks):
await b.step(1000 + i * 50)
asyncio.run(scenario())
# 10Hz 下 500ms 只应推进 5 拍控制律;其余 5 拍重发上一拍输出保心跳
assert law_updates["n"] == 5
assert len(px4.velocity_calls) == ticks # offboard 设定点每拍都在下发
assert len(pod.sent) == ticks # 表3 姿态下发不受控制律节流影响
def test_control_law_throttle_resets_on_law_switch():
"""切律后立即出第一拍(不被上一拍的节流压住)。"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
cfg = BridgeConfig()
cfg.guidance.frequency_hz = 10.0
b = _bridge(px4, pod, cfg)
async def scenario():
await b.step(1000)
b.set_heading_servo_enabled(True)
await b.step(1010) # 切律后 10ms 即应推进
asyncio.run(scenario())
assert b._last_law_ms == 1010
def test_stop_is_idempotent():
"""重复 stop() 只真正停机一次(真机在环实测:RTL 与收尾各调一次)。"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
b = _bridge(px4, pod)
async def scenario():
await b.start()
await b.stop()
await b.stop() # 第二次应为 no-op
asyncio.run(scenario())
assert pod.closed is True
assert px4.offboard_stopped is True
assert len(px4.brake_calls) == 1 # 刹车只发一次
def test_start_after_stop_reenables_stop():
"""停机后重新 start() 可再次生效(标志位复位)。"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
b = _bridge(px4, pod)
async def scenario():
await b.start()
await b.stop()
assert b._stopped is True
await b.start()
assert b._stopped is False
await b.stop()
asyncio.run(scenario())
assert len(px4.brake_calls) == 2 # 两次停机各发一次刹车
def test_landing_stop_keeps_telemetry_and_pod_open():
"""降落前只退 offboard:遥测与串口保留,供落地判定与高度曲线。"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
b = _bridge(px4, pod)
async def scenario():
await b.start()
await b.landing_stop()
assert b._landing_stopped is True
assert b._stopped is False # 未全停
assert px4.offboard_stopped is True
assert px4.telemetry_stopped is False # 遥测仍在
assert pod.closed is False # 串口仍在
await b.stop() # 收尾才全停
asyncio.run(scenario())
assert px4.telemetry_stopped is True
assert pod.closed is True
assert len(px4.brake_calls) == 2 # 两次停机各发一次刹车
# --- 吊舱帧入站缓存 --------------------------------------------------------
def test_target_frame_updates_latest_and_flips_to_velocity():
px4 = FakePx4(_valid_state(1000))
@@ -423,6 +539,12 @@ def _locked_off_target(x=0.0, y=0.0, frame_yaw=0.0, frame_pitch=0.0, laser=50.0)
frame_yaw_deg=frame_yaw, frame_pitch_deg=frame_pitch)
def _set_pose(px4, now_ms: int, yaw_deg: float):
"""更新假飞控姿态与时间戳(保证控制律的姿态超龄判定通过)。"""
px4._state = replace(px4._state, yaw_rad=math.radians(yaw_deg),
time_ms=now_ms, position_time_ms=now_ms)
def test_l1_off_target_beta_drives_yaw_rate():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
@@ -503,26 +625,73 @@ def test_l1_no_telemetry_degrades_and_keeps_heartbeat():
assert len(px4.brake_calls) == 1
def test_l1_require_vision_blocks_without_source():
# §2.3 第二判据:require_vision=True 但未接视觉源 → fail-safe 视为无目标 → 刹车
px4 = FakePx4(_valid_state(1000))
def test_l1_los_consistent_keeps_tracking():
"""机头右转多少、脱靶量就左移多少(目标在世界系静止)→ 判一致,正常跟踪。"""
clock = {"ms": 1_000_000}
px4 = FakePx4(_valid_state(clock["ms"]))
pod = FakePod()
pod.queue(_locked_off_target(x=10.0))
b = _bridge(px4, pod, _l1_config(require_vision=True))
out = asyncio.run(b.step(1000))
assert out.mode.name == "BRAKE_HOVER"
assert px4.velocity_calls == []
b = PodBridge(_l1_config(), px4, pod, now_ms_fn=lambda: clock["ms"])
async def scenario():
for i in range(80): # 4s,20°/s 右转、脱靶量 −20°/s
clock["ms"] += 50
yaw = 20.0 * (i + 1) * 0.05
beta = 10.0 - 20.0 * (i + 1) * 0.05
_set_pose(px4, clock["ms"], yaw)
pod.queue(_locked_off_target(x=beta))
out = await b.step(clock["ms"])
return out
def test_l1_require_vision_passes_with_source():
px4 = FakePx4(_valid_state(1000))
out = asyncio.run(scenario())
assert b._los_consistent is True
assert out.mode.name == "VELOCITY_HEADING"
assert b.pod_locked(clock["ms"]) is True
def test_l1_los_frozen_angle_blocks_tracking():
"""机头转了 60° 而脱靶量几乎不变 → 角度冻结/幽灵目标 → 判丢(刹车)。"""
clock = {"ms": 1_000_000}
px4 = FakePx4(_valid_state(clock["ms"]))
pod = FakePod()
pod.queue(_locked_off_target(x=10.0))
b = PodBridge(_l1_config(require_vision=True), px4, pod,
now_ms_fn=lambda: 0, vision_target_fn=lambda: True)
out = asyncio.run(b.step(1000))
b = PodBridge(_l1_config(), px4, pod, now_ms_fn=lambda: clock["ms"])
async def scenario():
out = None
for i in range(80): # 4s,右转 20°/s,脱靶量恒定 10°
clock["ms"] += 50
yaw = 20.0 * (i + 1) * 0.05
_set_pose(px4, clock["ms"], yaw)
pod.queue(_locked_off_target(x=10.0))
out = await b.step(clock["ms"])
return out
out = asyncio.run(scenario())
assert b._los_consistent is False
assert out.mode.name == "BRAKE_HOVER" # 控制律被喂 tracked=False
assert b.pod_locked(clock["ms"]) is False # 状态机侧也判为丢目标
def test_l1_los_disabled_falls_back_to_bit2():
"""关掉一致性判据 → 退回"只信 bit2"(角度冻结也不再判丢)。"""
clock = {"ms": 1_000_000}
px4 = FakePx4(_valid_state(clock["ms"]))
pod = FakePod()
b = PodBridge(_l1_config(los_consistency_enabled=False), px4, pod,
now_ms_fn=lambda: clock["ms"])
async def scenario():
out = None
for i in range(80):
clock["ms"] += 50
yaw = 20.0 * (i + 1) * 0.05
_set_pose(px4, clock["ms"], yaw)
pod.queue(_locked_off_target(x=10.0))
out = await b.step(clock["ms"])
return out
out = asyncio.run(scenario())
assert b._los_consistent is True
assert out.mode.name == "VELOCITY_HEADING"
assert out.yaw_rate_dps == pytest.approx(10.0, abs=1e-9)
# --- 地面坐标注入(A2)-------------------------------------------------------
+110 -3
View File
@@ -1,12 +1,14 @@
"""ground_link_test.py —— 地面坐标注入通道单元测试。
"""ground_link_test.py —— 地面站链路单元测试。
覆盖:parse_payload 解析(合法/缺字段/非法 JSON/非对象/类型错误)、
is_valid 新鲜度判定(未注入/新鲜/超龄边界)。
parse_message 双通道(坐标 + {"cmd"} 命令)、命令队列 drain、
is_valid 新鲜度判定(未注入/新鲜/超龄边界)、run() 真 UDP 回环。
"""
import pytest
from pod_bridge.ground_link import GroundInjectConfig, GroundLink, GroundTarget
from pod_bridge.ground_link import (GroundCommand, GroundInjectConfig,
GroundLink, GroundTarget)
# --- parse_payload ----------------------------------------------------------
@@ -92,3 +94,108 @@ def test_latest_returns_injected_target():
_inject(link, 1000)
assert link.latest().latitude_deg == pytest.approx(1.0)
assert link.latest().time_ms == 1000
# --- parse_message(坐标 + 命令双通道)---------------------------------------
def test_parse_message_command():
m = GroundLink.parse_message(b'{"cmd": "rtl"}')
assert isinstance(m, GroundCommand)
assert m.name == "rtl"
def test_parse_message_command_normalized():
m = GroundLink.parse_message(b'{"cmd": " RTL "}')
assert isinstance(m, GroundCommand)
assert m.name == "rtl"
def test_parse_message_cmd_takes_precedence_over_coords():
m = GroundLink.parse_message(b'{"cmd": "emergency", "lat": 1.0, "lon": 2.0}')
assert isinstance(m, GroundCommand)
assert m.name == "emergency"
def test_parse_message_coords_still_work():
m = GroundLink.parse_message(b'{"lat": 1.0, "lon": 2.0}')
assert isinstance(m, GroundTarget)
assert m.latitude_deg == pytest.approx(1.0)
def test_parse_message_empty_cmd_falls_back_to_coords():
m = GroundLink.parse_message(b'{"cmd": "", "lat": 1.0, "lon": 2.0}')
assert isinstance(m, GroundTarget)
def test_parse_message_non_string_cmd_falls_back_to_coords():
m = GroundLink.parse_message(b'{"cmd": 5, "lat": 1.0, "lon": 2.0}')
assert isinstance(m, GroundTarget)
def test_parse_message_invalid_returns_none():
assert GroundLink.parse_message(b"not json") is None
# --- drain_commands ---------------------------------------------------------
def test_drain_commands_empty():
assert _link().drain_commands() == []
def test_drain_commands_fifo_and_clear():
link = _link()
link._commands.append("rtl")
link._commands.append("pod_center")
assert link.drain_commands() == ["rtl", "pod_center"]
assert link.drain_commands() == [] # 取后清空
def test_commands_queue_drops_oldest_when_full():
link = _link()
for i in range(20):
link._commands.append(f"c{i}")
cmds = link.drain_commands()
assert len(cmds) == 16 # maxlen=16
assert cmds[0] == "c4" and cmds[-1] == "c19"
# --- run() 真 UDP 回环(坐标 + 命令)-----------------------------------------
def test_run_receives_coords_and_commands():
import asyncio
import socket as socket_mod
probe = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_DGRAM)
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
probe.close()
link = GroundLink(GroundInjectConfig(host="127.0.0.1", port=port),
now_ms_fn=lambda: 1234)
addr = ("127.0.0.1", port)
async def scenario():
stop = asyncio.Event()
task = asyncio.ensure_future(link.run(stop))
udp = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_DGRAM)
try:
for _ in range(200): # 重发防 bind 竞态
udp.sendto(b'{"lat": 1.5, "lon": 2.5}', addr)
await asyncio.sleep(0.01)
if link.latest().time_ms == 1234:
break
udp.sendto(b'{"cmd": "rtl"}', addr)
for _ in range(100):
if link._commands:
break
await asyncio.sleep(0.01)
finally:
udp.close()
stop.set()
task.cancel() # 收包循环阻塞在 sock_recvfrom,须 cancel 唤醒(同 SITL 入口)
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(scenario())
assert link.latest().latitude_deg == pytest.approx(1.5)
assert link.latest().time_ms == 1234
assert link.drain_commands() == ["rtl"]
@@ -0,0 +1,141 @@
"""los_consistency_test.py —— 吊舱角度运动学一致性判据的纯逻辑测试。
背景:本吊舱无电机、相机刚性固连机体,机头转 Δψ 时真实目标脱靶量必变 ≈−Δψ。
判据 `residual = Δβ + Δψ`:超限且激励足够 → 判不一致(角度冻结/幽灵目标)。
"""
import pytest
from pod_bridge.los_consistency import (LosConsistencyConfig,
LosConsistencyMonitor, wrap_deg_180)
def _mon(cfg=None):
return LosConsistencyMonitor(cfg or LosConsistencyConfig())
def _drive(mon, n, dt_ms, yaw0=0.0, beta0=0.0, yaw_rate=0.0, beta_rate=0.0,
t0=0):
"""按固定角速度喂 n 拍,返回最后一次状态。"""
status = None
for i in range(n):
t = t0 + (i + 1) * dt_ms
yaw = yaw0 + yaw_rate * (i + 1) * dt_ms / 1000.0
beta = beta0 + beta_rate * (i + 1) * dt_ms / 1000.0
status = mon.update(t, yaw, beta)
return status
# --- 条件开关 ---------------------------------------------------------------
def test_disabled_always_consistent():
mon = _mon(LosConsistencyConfig(enabled=False))
status = _drive(mon, 100, 50, yaw_rate=30.0, beta_rate=0.0)
assert status.consistent is True
assert status.excited is False
def test_no_history_is_not_excited():
mon = _mon()
status = mon.update(1000, 0.0, 10.0)
assert status.consistent is True
assert status.excited is False
def test_insufficient_span_is_not_excited():
"""窗口未积累到 3/4 之前不做判断(避免一开始就误判丢目标)。"""
mon = _mon(LosConsistencyConfig(window_ms=3000))
status = _drive(mon, 20, 50, yaw_rate=40.0, beta_rate=0.0) # 仅 1s
assert status.excited is False
assert status.consistent is True
# --- 一致:机头转、脱靶量反向跟随 -------------------------------------------
def test_consistent_rotation_is_ok():
"""右转 20°/s 且 β 以 −20°/s 变化 → 残差≈0。"""
mon = _mon()
status = _drive(mon, 80, 50, yaw_rate=20.0, beta_rate=-20.0) # 4s
assert status.excited is True
assert status.consistent is True
assert status.residual_deg == pytest.approx(0.0, abs=0.5)
def test_consistent_left_rotation_is_ok():
"""左转(负角速度)且 β 正向跟随 → 同样一致(符号对称)。"""
mon = _mon()
status = _drive(mon, 80, 50, yaw_rate=-20.0, beta_rate=20.0)
assert status.excited is True
assert status.consistent is True
def test_target_motion_within_residual_tolerance_is_ok():
"""目标自身在动(β 变化与 −Δψ 不完全相等)但残差在容差内 → 仍判一致。"""
mon = _mon(LosConsistencyConfig(max_residual_deg=8.0))
# 4s 内 Δψ=80°,β 只反向变 76° → 残差 4°(< 8°)
status = _drive(mon, 80, 50, yaw_rate=20.0, beta_rate=-19.0)
assert status.excited is True
assert status.consistent is True
# --- 不一致:角度冻结/幽灵目标 ---------------------------------------------
def test_frozen_beta_while_yawing_is_inconsistent():
"""机头转 80° 而脱靶量纹丝不动 → 角度冻结 → 判不一致。
比较窗口为 3s(默认),故 4s 数据里只比较最后 3s:Δψ = 20°/s × 3s = 60°。
"""
mon = _mon()
status = _drive(mon, 80, 50, yaw_rate=20.0, beta_rate=0.0)
assert status.excited is True
assert status.consistent is False
assert status.frozen is True
assert status.residual_deg == pytest.approx(60.0, abs=2.0) # 窗口内 Δψ
def test_small_yaw_change_does_not_judge_even_if_residual_big():
"""转得不够多(< 激励门槛)时不判不一致 —— 避免误杀。"""
mon = _mon(LosConsistencyConfig(min_yaw_change_deg=20.0))
# 窗口内只转 8°,虽然 β 没动(残差 8°)但未达激励门槛
status = _drive(mon, 80, 50, yaw_rate=2.0, beta_rate=0.0)
assert status.excited is False
assert status.consistent is True
# --- 历史管理 ---------------------------------------------------------------
def test_reset_clears_history():
mon = _mon()
_drive(mon, 80, 50, yaw_rate=20.0, beta_rate=0.0)
assert mon._samples # 有历史
mon.reset()
assert not mon._samples
status = mon.update(9999, 0.0, 0.0)
assert status.excited is False
def test_old_samples_dropped():
"""超出历史保留期的样本被丢弃,不会永久污染判定。"""
mon = _mon(LosConsistencyConfig(window_ms=1000, history_factor=2.0))
_drive(mon, 40, 50, yaw_rate=50.0, beta_rate=0.0) # 2s
# 再喂一拍"很久之后",旧样本应被清掉
status = mon.update(60_000, 0.0, 0.0)
assert status.excited is False
# --- 角度回绕 ---------------------------------------------------------------
@pytest.mark.parametrize("deg,expect", [
(0.0, 0.0), (179.0, 179.0), (181.0, -179.0),
(-181.0, 179.0), (359.0, -1.0),
])
def test_wrap_deg_180(deg, expect):
assert wrap_deg_180(deg) == pytest.approx(expect)
def test_wrap_around_north_is_consistent():
"""航向跨过 ±180 与 β 同步变化时,不应因回绕误判。"""
mon = _mon()
# 从 yaw=170 转到 190(即 -170),β 从 10 到 -10
for i in range(80):
t = (i + 1) * 50
yaw = 170.0 + 20.0 * (i + 1) * 0.05
beta = 10.0 - 20.0 * (i + 1) * 0.05
status = mon.update(t, wrap_deg_180(yaw), beta)
assert status.excited is True
assert status.consistent is True
+35 -1
View File
@@ -43,11 +43,13 @@ class FakeOffboard:
class FakeTelemetry:
"""各遥测流:先吐预设消息,再挂起(由 stop_telemetry 取消)。"""
def __init__(self, position=None, attitude=None, gps=None, velocity=None):
def __init__(self, position=None, attitude=None, gps=None, velocity=None,
status=None):
self._position = position or []
self._attitude = attitude or []
self._gps = gps or []
self._velocity = velocity or []
self._status = status or []
async def _stream(self, msgs):
for m in msgs:
@@ -66,6 +68,9 @@ class FakeTelemetry:
def velocity_ned(self):
return self._stream(self._velocity)
def status_text(self):
return self._stream(self._status)
class FakeDrone:
def __init__(self, telemetry=None):
@@ -197,6 +202,35 @@ def test_start_telemetry_populates_cache_then_stop():
asyncio.run(scenario())
# --- 飞控状态文本(STATUSTEXT)----------------------------------------------
class Sev(Enum):
INFO = 1
WARNING = 2
def test_update_fc_status_and_take_messages():
gw = Px4Gateway(FakeDrone())
gw.update_fc_status(SimpleNamespace(type=Sev.WARNING, text="Low battery"))
gw.update_fc_status(SimpleNamespace(type="INFO", text="Takeoff detected"))
assert gw.take_fc_messages() == [("WARNING", "Low battery"),
("INFO", "Takeoff detected")]
assert gw.take_fc_messages() == [] # 取后清空
def test_status_text_subscription_captures_messages():
telemetry = FakeTelemetry(status=[
SimpleNamespace(type=Sev.WARNING, text="EKF variance")])
gw = Px4Gateway(FakeDrone(telemetry))
async def scenario():
await gw.start_telemetry()
await asyncio.sleep(0.05)
assert gw.take_fc_messages() == [("WARNING", "EKF variance")]
await gw.stop_telemetry()
asyncio.run(scenario())
def test_default_own_state_is_invalid():
s = OwnState()
assert s.has_position is False and s.position_valid is False
-94
View File
@@ -1,94 +0,0 @@
"""StereoConfig 校验与 JSON 加载测试(规格 §9:配置外置、fail-fast)。"""
import json
from pathlib import Path
import pytest
from stereo_ranger import StereoConfig, load_from_json
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config" / "stereo_ranger.json"
def _config_dict() -> dict:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
def _write(tmp_path: Path, data: dict) -> str:
path = tmp_path / "stereo.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return str(path)
def test_defaults_are_valid_and_domains_are_consistent():
cfg = StereoConfig()
assert cfg.focal_baseline_px_m == pytest.approx(cfg.fx_px * cfg.baseline_m)
assert cfg.z_min_m == pytest.approx(cfg.focal_baseline_px_m / cfg.disparity_max_px)
assert cfg.z_max_m == pytest.approx(cfg.focal_baseline_px_m / cfg.disparity_min_px)
assert cfg.z_error_budget_max_m == pytest.approx((cfg.max_delta_z_m * cfg.focal_baseline_px_m) ** 0.5)
def test_load_from_json_matches_defaults():
assert load_from_json(str(CONFIG_PATH)) == StereoConfig()
def test_missing_field_rejected(tmp_path):
data = _config_dict()
data.pop("baseline_m")
with pytest.raises(ValueError, match="缺少配置字段"):
load_from_json(_write(tmp_path, data))
def test_unknown_field_rejected(tmp_path):
data = _config_dict()
data["radar_fallback"] = True
with pytest.raises(ValueError, match="未知配置字段"):
load_from_json(_write(tmp_path, data))
@pytest.mark.parametrize(
"field,value",
[
("fx_px", "960"),
("consistency_N_frames", 3.5),
("smooth_alpha", True),
],
)
def test_type_error_rejected(tmp_path, field, value):
data = _config_dict()
data[field] = value
with pytest.raises(ValueError, match="配置类型非法"):
load_from_json(_write(tmp_path, data))
@pytest.mark.parametrize(
"field,value",
[
("fx_px", 0.0),
("fy_px", -1.0),
("cx_px", -0.5),
("cy_px", -0.5),
("baseline_m", 0.0),
("tau_y_px", 0.0),
("tau_h_ratio", 0.0),
("disparity_min_px", 0.0),
("disparity_min_px", 60.0),
("max_delta_z_m", 0.0),
("consistency_N_frames", 0),
("consistency_M_frames", 2),
("consistency_jump_px", 0.0),
("smooth_alpha", 0.0),
("smooth_alpha", 1.5),
("jump_reset_m", 0.0),
("filter_reset_frames", 0),
("isolation_gate_px", 0.0),
],
)
def test_invalid_values_rejected(field, value):
with pytest.raises(ValueError, match="配置非法"):
StereoConfig(**{field: value})
def test_boundary_values_accepted():
cfg = StereoConfig(disparity_min_px=3.0, disparity_max_px=3.0 + 1e-9, smooth_alpha=1.0, consistency_M_frames=3)
assert cfg.z_max_m > cfg.z_min_m
@@ -1,80 +0,0 @@
"""一致性监控测试(规格 §6.7/§11.4:N 帧降级、M 帧判 invalid、正常帧清零、每目标隔离)。"""
import pytest
from stereo_ranger import ConsistencyMonitor
def _monitor() -> ConsistencyMonitor:
return ConsistencyMonitor(jump_px=3.0, degrade_frames=3, invalid_frames=5)
def test_first_frame_has_no_reference_jump():
status = _monitor().advance("t", 10.0)
assert status.jump_px is None
assert status.abnormal is False
assert status.streak == 0
def test_normal_jump_resets_streak():
monitor = _monitor()
monitor.advance("t", 10.0)
abnormal = monitor.advance("t", 16.0)
assert abnormal.abnormal and abnormal.streak == 1
normal = monitor.advance("t", 17.0)
assert not normal.abnormal and normal.streak == 0
def test_jump_at_threshold_is_normal():
monitor = _monitor()
monitor.advance("t", 10.0)
assert not monitor.advance("t", 13.0).abnormal # |ΔD| = 3.0 不超门限
def test_degrades_at_n_frames_and_invalidates_at_m_frames():
monitor = _monitor()
monitor.advance("t", 10.0)
disparities = [14.0, 18.0, 22.0, 26.0, 30.0] # 每帧跳变 4 px > 3 px
statuses = [monitor.advance("t", d) for d in disparities]
assert [s.streak for s in statuses] == [1, 2, 3, 4, 5]
assert [s.confidence_degraded for s in statuses] == [False, False, True, True, True]
assert [s.invalid for s in statuses] == [False, False, False, False, True]
def test_targets_are_isolated():
monitor = _monitor()
monitor.advance("a", 10.0)
monitor.advance("b", 20.0)
monitor.advance("a", 15.0) # a 跳变异常
assert monitor.streak("a") == 1
assert monitor.streak("b") == 0
def test_drop_clears_only_target():
monitor = _monitor()
monitor.advance("a", 10.0)
monitor.advance("a", 20.0)
monitor.drop("a")
assert monitor.streak("a") == 0
assert monitor.advance("a", 40.0).jump_px is None # 状态已清空,重新起始
def test_clear_resets_all():
monitor = _monitor()
monitor.advance("a", 10.0)
monitor.advance("a", 20.0)
monitor.clear()
assert monitor.streak("a") == 0
@pytest.mark.parametrize(
"kwargs",
[
{"jump_px": 0.0, "degrade_frames": 3, "invalid_frames": 5},
{"jump_px": 3.0, "degrade_frames": 0, "invalid_frames": 5},
{"jump_px": 3.0, "degrade_frames": 5, "invalid_frames": 3},
],
)
def test_invalid_ctor_args(kwargs):
with pytest.raises(ValueError):
ConsistencyMonitor(**kwargs)
@@ -1,94 +0,0 @@
"""时间滤波器测试(规格 §6.5/§11.4:低通收敛、跳变重置不拖尾、失效清理、每目标隔离)。"""
import pytest
from stereo_ranger import DistanceFilter
def _filter() -> DistanceFilter:
return DistanceFilter(alpha=0.3, jump_reset_m=5.0, reset_frames=5)
def test_first_value_initializes():
assert _filter().advance("t", 10.0) == pytest.approx(10.0)
def test_low_pass_step_response():
distance_filter = _filter()
distance_filter.advance("t", 10.0)
assert distance_filter.advance("t", 12.0) == pytest.approx(10.6)
def test_low_pass_converges_to_measurement():
distance_filter = _filter()
distance_filter.advance("t", 10.0)
value = 10.0
for _ in range(40):
value = distance_filter.advance("t", 12.0)
assert value == pytest.approx(12.0, abs=1e-3)
def test_alpha_one_is_identity():
distance_filter = DistanceFilter(alpha=1.0, jump_reset_m=5.0, reset_frames=5)
distance_filter.advance("t", 10.0)
assert distance_filter.advance("t", 12.0) == pytest.approx(12.0)
def test_jump_reset_without_tail():
distance_filter = _filter()
distance_filter.advance("t", 10.0)
assert distance_filter.advance("t", 20.0) == pytest.approx(20.0) # |Δ| > 5 → 直接重置
assert distance_filter.value("t") == pytest.approx(20.0)
assert distance_filter.advance("t", 20.2) == pytest.approx(20.06) # 之后恢复低通
def test_invalidate_keeps_state_before_threshold_and_clears_after():
distance_filter = _filter()
distance_filter.advance("t", 10.0)
for _ in range(4):
distance_filter.invalidate("t")
assert distance_filter.value("t") == pytest.approx(10.0)
assert distance_filter.invalid_streak("t") == 4
distance_filter.invalidate("t")
assert distance_filter.value("t") is None
assert distance_filter.advance("t", 30.0) == pytest.approx(30.0) # 重新初始化而非平滑
def test_valid_frame_resets_invalid_streak():
distance_filter = _filter()
distance_filter.advance("t", 10.0)
distance_filter.invalidate("t")
distance_filter.invalidate("t")
distance_filter.advance("t", 10.5)
assert distance_filter.invalid_streak("t") == 0
def test_invalidate_without_state_is_noop():
distance_filter = _filter()
distance_filter.invalidate("t")
assert distance_filter.value("t") is None
def test_targets_are_isolated():
distance_filter = _filter()
distance_filter.advance("a", 10.0)
distance_filter.advance("b", 100.0)
assert distance_filter.advance("a", 12.0) == pytest.approx(10.6)
assert distance_filter.value("b") == pytest.approx(100.0)
distance_filter.drop("a")
assert distance_filter.value("a") is None
assert distance_filter.value("b") == pytest.approx(100.0)
@pytest.mark.parametrize(
"kwargs",
[
{"alpha": 0.0, "jump_reset_m": 5.0, "reset_frames": 5},
{"alpha": 1.5, "jump_reset_m": 5.0, "reset_frames": 5},
{"alpha": 0.3, "jump_reset_m": 0.0, "reset_frames": 5},
{"alpha": 0.3, "jump_reset_m": 5.0, "reset_frames": 0},
],
)
def test_invalid_ctor_args(kwargs):
with pytest.raises(ValueError):
DistanceFilter(**kwargs)
-103
View File
@@ -1,103 +0,0 @@
"""测距计算与有效性判定测试(规格 §3.2/§6.4/§6.6/§7.1/§11.4)。"""
import math
import pytest
from stereo_ranger import (
Detection,
StereoConfig,
confidence_from_cost,
evaluate_validity,
forward_distance_m,
range_error_abs_m,
range_error_rel,
solve_range,
virtual_center_px,
)
CFG = StereoConfig()
def _boxes_for_point(x_m: float, y_m: float, z_m: float, width_px: float = 50.0, height_px: float = 40.0):
"""由左目系 (X, Y, Z) 反推左右目框(同宽,边缘视差 = 中心视差 = F·B/Z)。"""
d_px = CFG.focal_baseline_px_m / z_m
x_l = CFG.cx_px + CFG.fx_px * x_m / z_m
y_l = CFG.cy_px + CFG.fy_px * y_m / z_m
left = Detection(x_l - width_px / 2, y_l - height_px / 2, x_l + width_px / 2, y_l + height_px / 2)
right = Detection(
x_l - width_px / 2 - d_px, y_l - height_px / 2, x_l + width_px / 2 - d_px, y_l + height_px / 2
)
return left, right, d_px
def test_solve_range_recovers_ground_truth():
# §11.4:已知 Z 反推 D 构造框对,断言 Z = F·B/D 回收
left, right, d_px = _boxes_for_point(0.6, 0.3, 6.0)
solution = solve_range(left, right, d_px, CFG)
assert solution.z_m == pytest.approx(6.0)
assert solution.x_m == pytest.approx(0.6)
assert solution.y_m == pytest.approx(0.3)
assert solution.r_m == pytest.approx(math.sqrt(0.6**2 + 0.3**2 + 6.0**2))
def test_forward_distance_rejects_non_positive_disparity():
with pytest.raises(ValueError):
forward_distance_m(0.0, CFG)
with pytest.raises(ValueError):
forward_distance_m(-2.0, CFG)
def test_virtual_center_equals_midline_camera_projection():
# §6.6 恒等式:xL = cx + F(X_C + B/2)/Z,xR = cx + F(X_C − B/2)/Z ⇒ c_mid = cx + F·X_C/Z
z_m = 5.0
x_center_m = 0.57
x_l = CFG.cx_px + CFG.fx_px * (x_center_m + CFG.baseline_m / 2.0) / z_m
x_r = CFG.cx_px + CFG.fx_px * (x_center_m - CFG.baseline_m / 2.0) / z_m
left = Detection(x_l - 30.0, 480.0, x_l + 30.0, 520.0)
right = Detection(x_r - 30.0, 480.0, x_r + 30.0, 520.0)
center_mid = virtual_center_px(left, right)
assert center_mid[0] == pytest.approx(CFG.cx_px + CFG.fx_px * x_center_m / z_m)
assert center_mid[1] == pytest.approx(500.0)
def test_error_model_is_quadratic_in_z():
assert range_error_abs_m(10.0, CFG) == pytest.approx(10.0**2 / CFG.focal_baseline_px_m)
assert range_error_rel(10.0, CFG) == pytest.approx(10.0 / CFG.focal_baseline_px_m)
assert range_error_abs_m(20.0, CFG) == pytest.approx(4.0 * range_error_abs_m(10.0, CFG))
def test_validity_gate_1_out_of_domain():
decision = evaluate_validity(CFG.disparity_min_px - 0.1, 30.0, CFG)
assert not decision.in_domain and not decision.valid
def test_validity_gate_2_error_budget_blocks_far_field():
# 域内(D = 4.8 px → Z = 12 m)但 δZ = 2.5 m > 2 m,预算门拦截
d_px = 4.8
z_m = CFG.focal_baseline_px_m / d_px
decision = evaluate_validity(d_px, z_m, CFG)
assert decision.in_domain and not decision.budget_ok and not decision.valid
def test_validity_gate_2_boundary_sides():
z_limit = CFG.z_error_budget_max_m
assert evaluate_validity(CFG.focal_baseline_px_m / z_limit, z_limit * 0.999, CFG).budget_ok
assert not evaluate_validity(CFG.focal_baseline_px_m / z_limit, z_limit * 1.001, CFG).budget_ok
def test_validity_requires_finite_z():
assert not evaluate_validity(5.0, None, CFG).valid
assert not evaluate_validity(5.0, float("inf"), CFG).valid
def test_domain_boundaries_are_inclusive():
assert evaluate_validity(CFG.disparity_min_px, 19.2, CFG).in_domain
assert evaluate_validity(CFG.disparity_max_px, 1.0, CFG).in_domain
def test_confidence_from_cost_linear_clamped():
assert confidence_from_cost(0.0) == pytest.approx(1.0)
assert confidence_from_cost(0.4) == pytest.approx(0.6)
assert confidence_from_cost(1.0) == pytest.approx(0.0)
assert confidence_from_cost(1.5) == pytest.approx(0.0)
@@ -1,87 +0,0 @@
"""匹配器测试(规格 §6.2/§11.4:硬约束各一例、贪心排序、一一配对、cost ≥ 1 拒绝)。"""
import pytest
from stereo_ranger import Detection, GreedyMatcher, SamplePoint, StereoConfig, sample_point
CFG = StereoConfig()
MATCHER = GreedyMatcher()
def _point(cx: float, cy: float, *, w: float = 60.0, h: float = 40.0, class_id: int = 0) -> SamplePoint:
return sample_point(
Detection(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0, class_id=class_id)
)
def test_class_mismatch_rejected():
assert MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 500.0, class_id=1)], CFG) == []
def test_dy_beyond_tau_rejected():
assert MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 502.1)], CFG) == []
def test_dy_within_tau_accepted_with_cost():
pairs = MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 501.9)], CFG)
assert len(pairs) == 1
assert pairs[0].cost == pytest.approx(1.9 / CFG.tau_y_px)
def test_height_ratio_beyond_tau_rejected():
assert MATCHER.match([_point(1000.0, 500.0, h=100.0)], [_point(1000.0, 500.0, h=140.0)], CFG) == []
def test_height_ratio_near_tau_accepted():
pairs = MATCHER.match([_point(1000.0, 500.0, h=120.0)], [_point(1000.0, 500.0, h=159.0)], CFG)
assert len(pairs) == 1
def test_cost_at_one_rejected():
# dy = tau_y ⇒ cost = 1.0,严格 cost < 1 才接受(§6.2)
assert MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 502.0)], CFG) == []
def test_combined_cost_at_one_rejected():
# dy/tau = 0.5 与 Δh/(tau_h·h_max) = 0.5 相加恰为 1.0 → 拒绝
assert MATCHER.match([_point(1000.0, 500.0, h=112.0)], [_point(1000.0, 501.0, h=128.0)], CFG) == []
def test_greedy_pairs_cheapest_first_and_one_to_one():
left = [_point(1000.0, 500.0), _point(1000.0, 501.0)]
right = [_point(1000.0, 500.0), _point(1000.0, 501.0)]
pairs = MATCHER.match(left, right, CFG)
assert [(p.left_index, p.right_index) for p in pairs] == [(0, 0), (1, 1)]
assert pairs[0].cost == pytest.approx(0.0)
def test_one_left_claims_cheapest_right():
pairs = MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 500.0), _point(1000.0, 500.5)], CFG)
assert [(p.left_index, p.right_index) for p in pairs] == [(0, 0)]
def test_two_lefts_cannot_share_one_right():
left = [_point(1000.0, 500.0), _point(1000.0, 500.2)]
right = [_point(1000.0, 500.0)]
pairs = MATCHER.match(left, right, CFG)
assert len(pairs) == 1
assert pairs[0].left_index == 0
def test_unmatched_right_is_not_paired():
pairs = MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 500.0), _point(1400.0, 500.0)], CFG)
assert len(pairs) == 1
assert pairs[0].right_index == 0
def test_non_finite_points_excluded():
bad = sample_point(Detection(float("nan"), 500.0, 1060.0, 540.0))
good = _point(1000.0, 500.0)
assert MATCHER.match([bad], [good], CFG) == []
assert MATCHER.match([good], [bad], CFG) == []
def test_zero_height_box_rejected():
left = sample_point(Detection(970.0, 500.0, 1030.0, 500.0))
right = sample_point(Detection(970.0, 500.0, 1030.0, 500.0))
assert MATCHER.match([left], [right], CFG) == []
@@ -1,79 +0,0 @@
"""视差取点器测试(规格 §6.1/§11.4:同侧边缘、框宽差隔离、越窗回退、边界含等号)。"""
import pytest
from stereo_ranger import Detection, DisparitySource, StereoConfig, sample_point, select_disparity
CFG = StereoConfig()
def _box(x1: float, x2: float, *, y1: float = 500.0, y2: float = 540.0) -> Detection:
return Detection(x1, y1, x2, y2)
def _disparity(left: Detection, right: Detection):
return select_disparity(sample_point(left), sample_point(right), CFG)
def test_left_edge_used_as_primary_sampling():
d = _disparity(_box(1000.0, 1060.0), _box(990.4, 1050.4))
assert d.source is DisparitySource.LEFT_EDGE
assert d.d_px == pytest.approx(9.6)
assert d.in_domain and d.positive and not d.fallback_used
def test_box_width_difference_does_not_enter_edge_disparity():
# §11.4:构造两目不等宽框,断言同侧边缘视差相同(框宽差不进入视差)
left = _box(1000.0, 1100.0) # 宽 100
right_narrow = _box(994.0, 1024.0) # 宽 30,左边缘差同 6 px
right_wide = _box(994.0, 1094.0) # 宽 100
d_narrow = _disparity(left, right_narrow)
d_wide = _disparity(left, right_wide)
assert d_narrow.d_px == pytest.approx(6.0)
assert d_wide.d_px == pytest.approx(6.0)
def test_center_disparity_would_be_polluted_by_width_difference():
# 对照:中心视差 41 px = 边缘视差 6 px + 框宽差的一半 35 px,证明主取点选择边缘的理由
left = _box(1000.0, 1100.0) # 宽 100, center 1050
right_narrow = _box(994.0, 1024.0) # 宽 30, center 1009
assert (left.cx - right_narrow.cx) == pytest.approx(41.0)
def test_center_fallback_when_edge_out_of_window():
# 边缘视差 60 px 越窗(> 57.6),中心视差 40 px 在域内 → 回退中心并标记
left = _box(1000.0, 1060.0) # center 1030
right = _box(940.0, 1040.0) # center 990
d = _disparity(left, right)
assert d.fallback_used is True
assert d.source is DisparitySource.CENTER_FALLBACK
assert d.d_px == pytest.approx(40.0)
assert d.in_domain and d.positive
def test_both_out_of_window_keeps_edge_value_for_diagnostics():
left = _box(1000.0, 1200.0) # center 1100
right = _box(900.0, 1000.0) # center 950
d = _disparity(left, right)
assert d.d_px == pytest.approx(100.0) # 边缘值保留(诊断 + 违例告警)
assert not d.in_domain and not d.fallback_used and d.positive
def test_domain_boundaries_are_inclusive():
assert _disparity(_box(1000.0, 1060.0), _box(997.0, 1057.0)).in_domain # D = 3.0 = 下限
# 上限用二进制可精确表示的 57.5 构造:十进制字面量 57.6 无法精确表示,
# 框坐标相减会落到 57.6 常量之上,边界断言被浮点舍入干扰
exact_cfg = StereoConfig(disparity_max_px=57.5)
left, right = _box(1000.0, 1060.0), _box(942.5, 1002.5)
assert select_disparity(sample_point(left), sample_point(right), exact_cfg).in_domain # D = 57.5 = 上限
def test_negative_disparity_flagged_as_violation():
d = _disparity(_box(1000.0, 1060.0), _box(1010.0, 1070.0))
assert d.d_px == pytest.approx(-10.0)
assert not d.positive and not d.in_domain
def test_non_finite_box_marked_unusable():
assert sample_point(Detection(float("nan"), 0.0, 10.0, 10.0)).finite is False
assert sample_point(Detection(0.0, 0.0, 10.0, 10.0)).finite is True
@@ -1,292 +0,0 @@
"""全流程测试(规格 §5/§11.4:单目标、多目标、单目退化、非同帧拒收、越界、跳变)。
合成框对驱动,无需真实图像:几何是解析可断言的(§11.4)。
"""
import logging
import math
from typing import List, Optional, Tuple
import pytest
from stereo_ranger import (
Degradation,
Detection,
DetectionFrame,
Eye,
MatchPair,
StereoConfig,
StereoRanger,
)
CFG = StereoConfig()
LOGGER_NAME = "stereo_ranger"
def _boxes(
x_center_m: float = 0.0,
y_m: float = 0.0,
z_m: float = 6.0,
*,
w_m: float = 0.5,
h_m: float = 0.3,
class_id: int = 0,
track_id: Optional[int] = None,
confidence: float = 0.95,
) -> Tuple[Detection, Detection]:
"""模组中心系 (X_C, Y, Z) 目标 → 左右目检测框(两目同宽,边缘视差 = 中心视差)。"""
x_l = CFG.cx_px + CFG.fx_px * (x_center_m + CFG.baseline_m / 2.0) / z_m
x_r = CFG.cx_px + CFG.fx_px * (x_center_m - CFG.baseline_m / 2.0) / z_m
y = CFG.cy_px + CFG.fy_px * y_m / z_m
w_px = CFG.fx_px * w_m / z_m
h_px = CFG.fy_px * h_m / z_m
def _box(cx_px: float) -> Detection:
return Detection(
cx_px - w_px / 2.0,
y - h_px / 2.0,
cx_px + w_px / 2.0,
y + h_px / 2.0,
confidence=confidence,
class_id=class_id,
track_id=track_id,
)
return _box(x_l), _box(x_r)
def _pair_with_disparity(d_px: float, *, y_center: float = 500.0, class_id: int = 0) -> Tuple[Detection, Detection]:
"""构造视差恰为 d_px 的等宽框对(d 取 0.5 的整数倍时浮点精确)。"""
width, height = 60.0, 40.0
left = Detection(1000.0 - width / 2, y_center - height / 2, 1000.0 + width / 2, y_center + height / 2, class_id=class_id)
right = Detection(
1000.0 - d_px - width / 2,
y_center - height / 2,
1000.0 - d_px + width / 2,
y_center + height / 2,
class_id=class_id,
)
return left, right
def _frame(frame_id: int, detections: List[Detection]) -> DetectionFrame:
return DetectionFrame(frame_id=frame_id, detections=detections)
def test_single_target_complete_valid_record():
ranger = StereoRanger()
left, right = _boxes(x_center_m=0.0, z_m=6.0)
records = ranger.update(_frame(1, [left]), _frame(1, [right]), now_ms=1000)
assert len(records) == 1
record = records[0]
assert record.degradation is Degradation.COMPLETE
assert record.valid is True
assert record.forward_distance_m == pytest.approx(6.0)
assert record.slant_range_m == pytest.approx(math.hypot(CFG.baseline_m / 2.0, 6.0)) # R 含左目系 +B/2 横向偏移
assert record.disparity_px == pytest.approx(CFG.focal_baseline_px_m / 6.0)
assert record.center_mid_px == pytest.approx((CFG.cx_px, CFG.cy_px))
assert record.reference_box == left.bbox
assert record.reference_eye is Eye.LEFT
assert record.confidence == pytest.approx(1.0)
assert record.frame_id == 1 and record.timestamp_ms == 1000
def test_filter_smooths_across_valid_frames():
ranger = StereoRanger()
left_1, right_1 = _boxes(z_m=6.0)
left_2, right_2 = _boxes(z_m=6.5)
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
assert records[0].valid is True
assert records[0].forward_distance_m == pytest.approx(6.0 + 0.3 * (6.5 - 6.0))
# 参考斜距与已发布 Z 同基准:按 z_out/z_raw 从本帧原始斜距精确缩放(R ≥ Z)
raw_slant = math.hypot(CFG.baseline_m / 2.0, 6.5)
assert records[0].slant_range_m == pytest.approx(raw_slant * 6.15 / 6.5)
def test_jump_reset_skips_smoothing():
ranger = StereoRanger()
left_1, right_1 = _boxes(z_m=4.0)
left_2, right_2 = _boxes(z_m=10.0) # 跳变 6 m > jump_reset_m = 5 m
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
assert records[0].forward_distance_m == pytest.approx(10.0)
def test_far_field_out_of_domain_still_matched():
# 越界 = 有效性门(决策):配对仍成立,c_mid(无偏方位)与 D 照常发布,无 Z
ranger = StereoRanger()
left, right = _boxes(x_center_m=0.6, z_m=25.0) # D ≈ 2.3 px < 3 px
records = ranger.update(_frame(1, [left]), _frame(1, [right]))
record = records[0]
assert record.degradation is Degradation.COMPLETE
assert record.valid is False
assert record.forward_distance_m is None
assert record.disparity_px == pytest.approx(2.304)
assert record.center_mid_px == pytest.approx((CFG.cx_px + CFG.fx_px * 0.6 / 25.0, CFG.cy_px))
assert record.slant_range_m == pytest.approx(math.hypot(0.6 + CFG.baseline_m / 2.0, 25.0))
def test_error_budget_gate_blocks_far_but_in_domain():
ranger = StereoRanger()
left, right = _boxes(z_m=12.0) # D = 4.8 px 域内,δZ = 2.5 m > 2 m
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
assert record.disparity_px == pytest.approx(4.8)
assert record.valid is False
assert record.forward_distance_m is None
assert record.slant_range_m == pytest.approx(math.hypot(CFG.baseline_m / 2.0, 12.0))
def test_error_budget_gate_passes_just_inside():
ranger = StereoRanger()
left, right = _pair_with_disparity(5.8) # Z ≈ 9.93 m,δZ ≈ 1.71 m ≤ 2 m
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
assert record.valid is True
assert record.forward_distance_m == pytest.approx(CFG.focal_baseline_px_m / 5.8)
def test_negative_disparity_violation(caplog):
ranger = StereoRanger()
left = Detection(1000.0, 500.0, 1060.0, 540.0)
right = Detection(1010.0, 500.0, 1070.0, 540.0)
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
assert record.degradation is Degradation.COMPLETE
assert record.valid is False
assert record.forward_distance_m is None
assert record.slant_range_m is None
assert record.disparity_px == pytest.approx(-10.0)
assert any("disparity_violation" in message for message in caplog.messages)
def test_center_fallback_keeps_target_valid(caplog):
ranger = StereoRanger()
left = Detection(1000.0, 500.0, 1060.0, 540.0) # 宽 60, center 1030
right = Detection(940.0, 500.0, 1040.0, 540.0) # 宽 100, center 990
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
assert record.disparity_px == pytest.approx(40.0) # 边缘 60 px 越窗 → 中心 40 px
assert record.valid is True
assert record.forward_distance_m == pytest.approx(CFG.focal_baseline_px_m / 40.0)
assert any("center_fallback" in message for message in caplog.messages)
def test_single_eye_degradation_left_and_right():
ranger = StereoRanger()
left, right = _boxes(x_center_m=0.3, z_m=4.0)
left_only = ranger.update(_frame(1, [left]), _frame(1, []))
assert len(left_only) == 1
record = left_only[0]
assert record.degradation is Degradation.BEARING_ONLY
assert record.valid is False
assert record.center_mid_px == pytest.approx((left.cx, left.cy))
assert record.forward_distance_m is None and record.disparity_px is None
assert record.reference_eye is Eye.LEFT
assert record.confidence == pytest.approx(left.confidence)
right_only = ranger.update(_frame(2, []), _frame(2, [right]))
assert len(right_only) == 1
assert right_only[0].reference_eye is Eye.RIGHT
assert right_only[0].center_mid_px == pytest.approx((right.cx, right.cy))
def test_records_order_left_first_then_unmatched_right():
ranger = StereoRanger()
left, right_matched = _boxes(z_m=6.0, class_id=0)
_, right_extra = _boxes(z_m=6.0, class_id=0, y_m=0.0)
right_extra = Detection(400.0, 500.0, 460.0, 540.0) # 远处无对应左框
records = ranger.update(_frame(1, [left]), _frame(1, [right_matched, right_extra]))
assert len(records) == 2
assert records[0].degradation is Degradation.COMPLETE
assert records[0].valid is True
assert records[1].degradation is Degradation.BEARING_ONLY
assert records[1].reference_eye is Eye.RIGHT
def test_frame_id_mismatch_rejects_frame_and_throttles_warning(caplog):
ranger = StereoRanger()
left, right = _boxes()
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
assert ranger.update(_frame(1, [left]), _frame(2, [right])) == []
assert ranger.update(_frame(3, [left]), _frame(4, [right])) == []
warnings = [message for message in caplog.messages if "frame_mismatch" in message]
assert len(warnings) == 1 # 第 1 次打印,第 2 次被节流
assert "累计 1 次" in warnings[0]
def test_empty_input_returns_empty_without_warning(caplog):
ranger = StereoRanger()
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
assert ranger.update(_frame(1, []), _frame(1, [])) == []
assert caplog.messages == []
def test_non_finite_box_dropped_with_warning(caplog):
ranger = StereoRanger()
bad = Detection(float("nan"), 500.0, 1060.0, 540.0)
good_left, good_right = _boxes(z_m=6.0)
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
records = ranger.update(_frame(1, [bad, good_left]), _frame(1, [good_right]))
assert len(records) == 1
assert records[0].valid is True
assert any("invalid_box" in message for message in caplog.messages)
def test_consistency_degrades_then_invalidates(caplog):
ranger = StereoRanger()
disparities = [9.5, 13.5, 17.5, 21.5, 25.5, 29.5] # 每帧跳变 4 px > 3 px
records = []
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
for index, d_px in enumerate(disparities, start=1):
left, right = _pair_with_disparity(d_px)
records.append(ranger.update(_frame(index, [left]), _frame(index, [right]))[0])
assert [record.valid for record in records] == [True, True, True, True, True, False]
assert records[3].confidence == pytest.approx(0.5) # 第 3 帧起置信度降级(N = 3)
assert records[5].forward_distance_m is None
assert records[5].confidence == pytest.approx(0.0)
assert any("consistency_invalid" in message for message in caplog.messages)
def test_track_id_keeps_state_across_large_pixel_move():
ranger = StereoRanger()
left_1, right_1 = _boxes(x_center_m=0.6, z_m=4.0, track_id=7)
left_2, right_2 = _boxes(x_center_m=0.0, z_m=4.5, track_id=7) # c_mid 移动 144 px > 门限
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
assert records[0].track_id == 7
assert records[0].forward_distance_m == pytest.approx(4.0 + 0.3 * (4.5 - 4.0))
def test_without_track_id_large_move_starts_new_state():
ranger = StereoRanger()
left_1, right_1 = _boxes(x_center_m=0.6, z_m=4.0)
left_2, right_2 = _boxes(x_center_m=0.0, z_m=4.5) # 最近邻越门限 → 新目标状态
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
assert records[0].forward_distance_m == pytest.approx(4.5) # 未平滑
def test_custom_matcher_injection():
class _NoMatchMatcher:
def match(self, left, right, config) -> List[MatchPair]:
return []
ranger = StereoRanger(matcher=_NoMatchMatcher())
left, right = _boxes(z_m=6.0)
records = ranger.update(_frame(1, [left]), _frame(1, [right]))
assert len(records) == 2
assert all(record.degradation is Degradation.BEARING_ONLY for record in records)
def test_reset_clears_state():
ranger = StereoRanger()
left_1, right_1 = _boxes(z_m=6.0)
left_2, right_2 = _boxes(z_m=6.5)
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
ranger.reset()
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
assert records[0].forward_distance_m == pytest.approx(6.5) # 未平滑(状态已清空)
@@ -1,73 +0,0 @@
"""目标隔离测试(规格 §4:track_id 优先 + 框位置最近邻、帧末老化清理)。"""
from stereo_ranger import StereoConfig, TargetStore
CFG = StereoConfig() # isolation_gate_px = 50,filter_reset_frames = 5
def test_track_key_stable_across_position_move():
store = TargetStore(CFG)
key_1 = store.resolve(0, 7, (100.0, 100.0), 1)
key_2 = store.resolve(0, 7, (400.0, 100.0), 2) # 位置大幅移动仍是同一目标
assert key_1 == key_2
assert store.count == 1
def test_nearest_neighbor_keeps_identity_within_gate():
store = TargetStore(CFG)
key_1 = store.resolve(0, None, (100.0, 100.0), 1)
key_2 = store.resolve(0, None, (130.0, 140.0), 2) # 距离 50 ≤ 门限
assert key_1 == key_2
def test_beyond_gate_creates_new_key():
store = TargetStore(CFG)
key_1 = store.resolve(0, None, (100.0, 100.0), 1)
key_2 = store.resolve(0, None, (200.0, 100.0), 2) # 距离 100 > 门限
assert key_1 != key_2
assert store.count == 2
def test_class_id_separates_states():
store = TargetStore(CFG)
assert store.resolve(0, None, (100.0, 100.0), 1) != store.resolve(1, None, (100.0, 100.0), 1)
def test_two_boxes_same_frame_claim_distinct_states():
store = TargetStore(CFG)
key_1 = store.resolve(0, None, (100.0, 100.0), 1)
key_2 = store.resolve(0, None, (110.0, 100.0), 1) # 同帧第二框不得接管已占用状态
assert key_1 != key_2
assert store.count == 2
def test_state_evicted_after_reset_frames_unseen():
store = TargetStore(CFG)
key = store.resolve(0, None, (100.0, 100.0), 1)
store.distance_filter.advance(key, 5.0)
store.consistency.advance(key, 9.6)
for frame in range(2, 7): # 第 2~6 帧:连续 5 帧缺席
store.end_frame(frame)
assert store.count == 0
assert store.distance_filter.value(key) is None
assert store.consistency.streak(key) == 0
def test_present_but_invalid_keeps_position_state():
store = TargetStore(CFG)
key = store.resolve(0, None, (100.0, 100.0), 1)
store.distance_filter.advance(key, 5.0)
for frame in range(2, 8):
store.resolve(0, None, (100.0, 100.0), frame) # 每帧仍出现
store.distance_filter.invalidate(key)
assert store.count == 1 # 位置状态保留
assert store.distance_filter.value(key) is None # 滤波状态已按门限清空
def test_clear_resets_everything():
store = TargetStore(CFG)
key = store.resolve(0, 5, (100.0, 100.0), 1)
store.distance_filter.advance(key, 5.0)
store.clear()
assert store.count == 0
assert store.distance_filter.value(key) is None
@@ -1,45 +0,0 @@
"""告警节流测试(规格 §5:第 1 次 + 每满 100 次,带累计计数)。"""
import pytest
from stereo_ranger import WarnThrottle
def test_first_and_every_hundred_emit():
throttle = WarnThrottle() # 默认 every=100
assert throttle.should_emit("a") is True
for _ in range(98):
assert throttle.should_emit("a") is False
assert throttle.should_emit("a") is True # 第 100 次
for _ in range(99):
assert throttle.should_emit("a") is False
assert throttle.should_emit("a") is True # 第 200 次
assert throttle.count("a") == 200
def test_categories_are_independent():
throttle = WarnThrottle()
assert throttle.should_emit("a") is True
assert throttle.should_emit("b") is True
assert throttle.count("a") == 1
assert throttle.count("b") == 1
assert throttle.count("never_seen") == 0
def test_custom_interval():
throttle = WarnThrottle(every=3)
# 规格语义"第 1 次 + 计数每满 every" → 第 1/3/6 次打印
assert [throttle.should_emit("a") for _ in range(6)] == [True, False, True, False, False, True]
def test_clear_resets_counts():
throttle = WarnThrottle()
throttle.should_emit("a")
throttle.clear()
assert throttle.count("a") == 0
assert throttle.should_emit("a") is True
def test_invalid_interval():
with pytest.raises(ValueError):
WarnThrottle(every=0)
-96
View File
@@ -1,96 +0,0 @@
"""StereoYoloConfig 校验与 JSON 加载测试(需求说明 §4:配置外置、fail-fast)。"""
import json
from pathlib import Path
import pytest
from stereo_yolo import StereoYoloConfig, load_from_json
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config" / "stereo_yolo.json"
def _config_dict() -> dict:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
def _write(tmp_path: Path, data: dict) -> str:
path = tmp_path / "stereo_yolo.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return str(path)
def test_load_from_json_matches_defaults():
assert load_from_json(str(CONFIG_PATH)) == StereoYoloConfig()
def test_whitelist_normalized_to_tuple():
cfg = StereoYoloConfig(class_whitelist=[0, 2])
assert cfg.class_whitelist == (0, 2)
def test_missing_field_rejected(tmp_path):
data = _config_dict()
data.pop("imgsz")
with pytest.raises(ValueError, match="缺少配置字段"):
load_from_json(_write(tmp_path, data))
def test_unknown_field_rejected(tmp_path):
data = _config_dict()
data["letterbox_scale"] = 0.5
with pytest.raises(ValueError, match="未知配置字段"):
load_from_json(_write(tmp_path, data))
@pytest.mark.parametrize(
"field,value",
[
("model_path", 123),
("imgsz", "640"),
("imgsz", True),
("conf_threshold", "0.25"),
("iou_threshold", None),
("class_whitelist", 0),
("class_whitelist", ["0"]),
("device", 5),
("concurrency", 1),
],
)
def test_type_error_rejected(tmp_path, field, value):
data = _config_dict()
data[field] = value
with pytest.raises(ValueError, match="配置类型非法"):
load_from_json(_write(tmp_path, data))
@pytest.mark.parametrize(
"field,value",
[
("model_path", ""),
("imgsz", 0),
("imgsz", 600),
("conf_threshold", -0.1),
("conf_threshold", 1.5),
("iou_threshold", 0.0),
("class_whitelist", []),
("class_whitelist", [-1]),
("device", " "),
("concurrency", "batch"),
],
)
def test_invalid_values_rejected(field, value):
with pytest.raises(ValueError, match="配置非法"):
StereoYoloConfig(**{field: value})
def test_boundary_values_accepted():
cfg = StereoYoloConfig(
imgsz=32,
conf_threshold=0.0,
iou_threshold=1.0,
class_whitelist=(0,),
device="cpu",
concurrency="sequential",
)
assert cfg.imgsz == 32
-155
View File
@@ -1,155 +0,0 @@
"""StereoYoloFrontend 出口契约测试(需求说明 §2.2/§2.3/§2.5/§5)。
全部用注入的假检测器驱动:不需要模型权重、不需要图像素材、不需要硬件;
真并行用双人屏障断言(串行实现会屏障超时)。
"""
import logging
import threading
import numpy as np
import pytest
from stereo_ranger.types import Detection
from stereo_yolo import StereoYoloConfig, StereoYoloFrontend
class ScriptedDetector:
"""按调用顺序返回脚本项:list[Detection] 正常返回;Exception 抛出。"""
def __init__(self, script):
self._script = list(script)
self.calls = 0
def detect(self, image):
self.calls += 1
if not self._script:
raise AssertionError("假检测器脚本已耗尽(调用次数多于预期)")
item = self._script.pop(0)
if isinstance(item, Exception):
raise item
return list(item)
class BarrierDetector:
"""两次调用必须同时在途(屏障超时即证明串行或单线程执行)。"""
def __init__(self, barrier: threading.Barrier, detections):
self._barrier = barrier
self._detections = list(detections)
def detect(self, image):
self._barrier.wait(timeout=2.0)
return list(self._detections)
def _img(fill=0, shape=(8, 8, 3)) -> np.ndarray:
return np.full(shape, fill, dtype=np.uint8)
def _det(x1=1.0, y1=2.0, x2=11.0, y2=12.0, class_id=0, confidence=0.9, track_id=None) -> Detection:
return Detection(x1, y1, x2, y2, confidence=confidence, class_id=class_id, track_id=track_id)
def _frontend(script, **cfg_kwargs) -> StereoYoloFrontend:
kwargs = {"concurrency": "sequential"}
kwargs.update(cfg_kwargs)
return StereoYoloFrontend(StereoYoloConfig(**kwargs), detector=ScriptedDetector(script))
def test_frames_share_frame_id_and_keep_boxes():
left_box, right_box = _det(1.0, 2.0, 11.0, 12.0, confidence=0.77), _det(3.0, 4.0, 13.0, 14.0)
frontend = _frontend([[left_box], [right_box]])
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=7)
assert left_frame.frame_id == right_frame.frame_id == 7
assert left_frame.detections == (left_box,)
assert right_frame.detections == (right_box,)
assert left_frame.detections[0].confidence == pytest.approx(0.77)
assert left_frame.detections[0].track_id is None # 无追踪器时留空(§2.1)
assert frontend._detector.calls == 2
def test_whitelist_filters_by_class():
frontend = _frontend([[_det(class_id=3)], [_det(class_id=0)]], class_whitelist=(0,))
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=1)
assert left_frame.detections == ()
assert len(right_frame.detections) == 1 and right_frame.detections[0].class_id == 0
def test_no_whitelist_keeps_all_classes():
frontend = _frontend([[_det(class_id=3)], [_det(class_id=5)]])
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=1)
assert len(left_frame.detections) == 1 and len(right_frame.detections) == 1
def test_degenerate_boxes_dropped(caplog):
nan_box = _det(x1=float("nan"))
zero_width = _det(x1=5.0, x2=5.0)
inverted = _det(x1=20.0, x2=10.0)
good = _det()
frontend = _frontend([[nan_box, zero_width, inverted, good], []])
with caplog.at_level(logging.WARNING, logger="stereo_yolo"):
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=2)
assert left_frame.detections == (good,)
assert right_frame.detections == ()
assert "丢弃退化检测框" in caplog.text
assert "累计 1 次" in caplog.text
def test_boundary_touching_box_kept():
hairline = _det(x1=10.0, x2=10.0 + 1e-9)
frontend = _frontend([[hairline], []])
left_frame, _ = frontend.detect(_img(), _img(), frame_id=3)
assert left_frame.detections == (hairline,)
def test_one_eye_failure_isolated(caplog):
good = _det()
frontend = _frontend([[good], RuntimeError("boom")])
with caplog.at_level(logging.WARNING, logger="stereo_yolo"):
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=4)
assert left_frame.detections == (good,)
assert right_frame.detections == ()
assert left_frame.frame_id == right_frame.frame_id == 4
assert "右目推理失败" in caplog.text
def test_shape_mismatch_rejected(caplog):
frontend = _frontend([]) # 脚本为空:若检测器被调用会抛断言
with caplog.at_level(logging.WARNING, logger="stereo_yolo"):
left_frame, right_frame = frontend.detect(_img(shape=(8, 8, 3)), _img(shape=(8, 6, 3)), frame_id=5)
assert left_frame.detections == () and right_frame.detections == ()
assert left_frame.frame_id == right_frame.frame_id == 5
assert frontend._detector.calls == 0
assert "左右图尺寸不一致" in caplog.text
def test_parallel_eyes_run_concurrently():
barrier = threading.Barrier(2)
box = _det()
frontend = StereoYoloFrontend(
StereoYoloConfig(concurrency="parallel"),
detector=BarrierDetector(barrier, [box]),
)
try:
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=6)
finally:
frontend.close()
assert left_frame.detections == (box,)
assert right_frame.detections == (box,)
def test_close_is_idempotent():
frontend = StereoYoloFrontend(StereoYoloConfig(concurrency="parallel"), detector=ScriptedDetector([]))
frontend.close()
frontend.close()
-27
View File
@@ -1,27 +0,0 @@
"""split_sbs 裁切测试(需求说明 §2.3:sbs 单流形态)。"""
import numpy as np
import pytest
from stereo_yolo import split_sbs
def test_split_returns_left_and_right_halves():
image = np.zeros((4, 10, 3), dtype=np.uint8)
image[:, :5] = 1
image[:, 5:] = 2
left, right = split_sbs(image)
assert left.shape == (4, 5, 3) and right.shape == (4, 5, 3)
assert (left == 1).all() and (right == 2).all()
def test_odd_width_rejected():
with pytest.raises(ValueError, match="宽度须为偶数"):
split_sbs(np.zeros((4, 9, 3), dtype=np.uint8))
def test_non_image_rejected():
with pytest.raises(ValueError, match="宽度须为偶数"):
split_sbs(np.zeros((9,), dtype=np.uint8))
@@ -1,107 +0,0 @@
"""kalman_box_filter_test.py —— KalmanBoxFilter 单元测试。
移植自 reference-c++/target_tracker/tests/kalman_box_filter_test.cpp。
覆盖(视觉追踪技术路线 §4):初始化状态、静止目标收敛、匀速目标速度收敛
且预测超前一个 dt、未初始化 predict 返回 None。
"""
import math
import pytest
from target_tracker.kalman_box_filter import BoxCXCYWH, KalmanBoxFilter
def test_uninitialized_predict_returns_none():
kf = KalmanBoxFilter()
assert not kf.initialized
assert kf.predict() is None
# 未初始化时 update 无副作用
kf.update(BoxCXCYWH(100.0, 100.0, 20.0, 20.0))
assert not kf.initialized
assert kf.predict() is None
for v in kf.state:
assert v == 0.0
def test_initiate_sets_state_correctly():
kf = KalmanBoxFilter()
kf.initiate(BoxCXCYWH(320.0, 240.0, 40.0, 30.0))
assert kf.initialized
expected = [320.0, 240.0, 40.0, 30.0, 0.0, 0.0, 0.0, 0.0]
state = kf.state
for i, want in enumerate(expected):
assert state[i] == want, f"index={i}"
# 初始化后 predict 返回状态前 4 维(速度为零 → 位置不变,§4.4)
pred = kf.predict()
assert pred is not None
assert pred.cx == pytest.approx(320.0, abs=1e-9)
assert pred.cy == pytest.approx(240.0, abs=1e-9)
assert pred.w == pytest.approx(40.0, abs=1e-9)
assert pred.h == pytest.approx(30.0, abs=1e-9)
def test_static_target_converges_to_observation():
kf = KalmanBoxFilter()
z = BoxCXCYWH(100.0, 150.0, 40.0, 60.0)
kf.initiate(BoxCXCYWH(104.0, 156.0, 42.0, 62.0)) # 带初始偏差,验证收敛能力
for _ in range(50):
kf.predict()
kf.update(z)
state = kf.state
assert state[0] == pytest.approx(z.cx, abs=0.5) # cx
assert state[1] == pytest.approx(z.cy, abs=0.5) # cy
assert state[2] == pytest.approx(z.w, abs=0.5) # w
assert state[3] == pytest.approx(z.h, abs=0.5) # h
# 静止目标速度应收敛到约 0
assert state[4] == pytest.approx(0.0, abs=0.1)
assert state[5] == pytest.approx(0.0, abs=0.1)
def test_constant_velocity_converges_and_predict_leads_one_dt():
kf = KalmanBoxFilter()
vx = 3.0 # 像素/帧
vy = 2.0 # 像素/帧
# 目标从 (100, 100) 出发匀速运动,每帧观测一次(predict → update)
z = BoxCXCYWH(100.0, 100.0, 30.0, 30.0)
kf.initiate(z)
for _ in range(60):
kf.predict()
z.cx += vx
z.cy += vy
kf.update(z)
state = kf.state
# 速度收敛到真实速度(像素/帧)
assert state[4] == pytest.approx(vx, abs=0.3), "vx 未收敛"
assert state[5] == pytest.approx(vy, abs=0.3), "vy 未收敛"
# 位置贴合当前观测
assert state[0] == pytest.approx(z.cx, abs=1.5)
assert state[1] == pytest.approx(z.cy, abs=1.5)
# 再预测一帧:应超前当前观测一个 dt(≈ 速度 × 1 帧)
pred = kf.predict()
assert pred is not None
assert pred.cx == pytest.approx(z.cx + vx, abs=1.0), "预测未超前一个 dt"
assert pred.cy == pytest.approx(z.cy + vy, abs=1.0), "预测未超前一个 dt"
def test_small_target_noise_scaling_stable():
# 动态噪声不退化:小尺寸目标多帧观测仍稳定收敛(§4.2 噪声与尺寸成比例)
kf = KalmanBoxFilter()
z = BoxCXCYWH(50.0, 60.0, 4.0, 4.0) # 小目标(4×4 像素)
kf.initiate(z)
for _ in range(30):
kf.predict()
kf.update(z)
state = kf.state
assert state[0] == pytest.approx(z.cx, abs=0.5)
assert state[1] == pytest.approx(z.cy, abs=0.5)
assert math.isfinite(state[2])
assert math.isfinite(state[3])
@@ -1,364 +0,0 @@
"""target_tracker_test.py —— TargetTracker 单元测试。
移植自 reference-c++/target_tracker/tests/target_tracker_test.cpp。
逐条覆盖 docs/视觉追踪技术路线.md §9.4 行为一致性验收用例(硬验收标准):
1. 空检测帧序列:Coast 持续 max_lost_frames 帧,第 max_lost_frames+1 帧判丢
2. ID 严格递增不复用(含 Reset 后归零)
3. 锁定不被抢占;锁定目标死亡后切换到新目标
4. min_hits 回退:无已确认实测轨道时新轨道也可当选主目标
5. cost=1.0 恰好不构成匹配(严格小于)
6. 置信度优先:两轨道竞争同一检测,置信度高者获胜
另含 NaN/Inf 防御与 §2.3/§6.3/§9.5 行为测试。
"""
import math
import pytest
from target_tracker.target_tracker import (Detection, FrameShape, TargetTracker,
TrackerConfig)
def make_det(x1, y1, x2, y2, conf, cls=0):
return Detection(x1, y1, x2, y2, conf, cls)
# ============================================================================
# 验收用例 1(§9.4-1):空检测帧序列
# ============================================================================
def test_empty_frames_coast_then_lost():
tracker = TargetTracker() # 默认配置 max_lost_frames = 8
assert tracker.get_config().max_lost_frames == 8
# 第 1 帧:给出一个检测,建立轨道并锁定为主目标
r = tracker.update([make_det(100.0, 100.0, 140.0, 140.0, 0.9)])
assert r.tracked
assert r.primary_id is not None
assert not r.is_predicted # 当帧有实测
assert r.lost_frames == 0
assert r.n_active == 1
assert r.box is not None
assert r.raw is not None # 当帧关联到原始检测框
# 后续空检测帧:应持续 Coast 共 max_lost_frames 帧(is_predicted=True)
for i in range(1, 9):
r = tracker.update([])
assert r.tracked, f"第 {i} 帧 Coast 期间不应判丢"
assert r.is_predicted, f"第 {i} 帧应为纯预测输出"
assert r.lost_frames == i
assert r.primary_id is not None
assert r.box is not None # Coast 期间继续输出预测框
assert r.raw is None # Coast 时 raw 为空(§2.3)
assert r.confidence == pytest.approx(0.9) # 继承最近实测置信度(§2.3)
# 第 max_lost_frames + 1 帧:轨道死亡回收 → tracked=False
r = tracker.update([])
assert not r.tracked
assert r.primary_id is None
assert r.box is None
assert r.lost_frames == 0 # 丢失时 lost_frames=0(§2.3)
assert r.n_active == 0
# ============================================================================
# 验收用例 2(§9.4-2):ID 单调性
# ============================================================================
def test_id_strictly_increasing_no_reuse():
cfg = TrackerConfig()
cfg.max_lost_frames = 1 # 缩短 Coast 窗口,加速轨道死亡
tracker = TargetTracker(cfg)
# 目标 A 出现 → id 0
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.9)])
assert r.primary_id is not None
id_a = r.primary_id
assert id_a == 0
# 目标 A 消失至死亡(max_lost=1:第 2 帧 Coast、第 3 帧死亡回收)
tracker.update([])
r = tracker.update([])
assert not r.tracked
assert tracker.active_count() == 0
# 目标 B 出现(同一位置):ID 不复用,严格递增 → id 1
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.9)])
assert r.primary_id is not None
id_b = r.primary_id
assert id_b == 1
assert id_b > id_a
# 目标 C 同帧新建 → id 2(同帧多目标亦严格递增)
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.9),
make_det(400.0, 300.0, 430.0, 330.0, 0.8)])
assert tracker.active_count() == 2
max_id = max(tracker.active_tracks().keys())
assert max_id == 2
# Reset 后计数器归零(§6.2):新轨道重新从 id 0 开始
tracker.reset()
assert tracker.active_count() == 0
assert not tracker.last_result().tracked
r = tracker.update([make_det(50.0, 50.0, 70.0, 70.0, 0.9)])
assert r.primary_id is not None
assert r.primary_id == 0
# ============================================================================
# 验收用例 3(§9.4-3):锁定不被抢占
# ============================================================================
def test_lock_not_stolen_and_switch_after_death():
cfg = TrackerConfig()
cfg.max_lost_frames = 3
tracker = TargetTracker(cfg)
# 低置信度目标 A 建立并锁定
r = tracker.update([make_det(100.0, 100.0, 140.0, 140.0, 0.4)])
assert r.primary_id is not None
id_a = r.primary_id
# A 消失进入 Coast;期间出现更高置信度新目标 B(远离 A,不会被误关联)
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
assert r.primary_id is not None
assert r.primary_id == id_a, "Coast 期间锁定不应被高置信度目标抢占"
assert r.is_predicted
assert tracker.active_count() == 2 # A(Coast)与 B 并存
# 继续 Coast,锁定依旧延续
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
assert r.primary_id == id_a
assert r.is_predicted
# A 继续丢失直至死亡(max_lost=3:lost 达 4 帧时回收)
for _ in range(2):
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
# A 已死亡删除 → 锁定解除,重新选举切换到 B(此时 B 已确认且实测)
assert r.tracked
assert r.primary_id != id_a, "锁定目标死亡后应切换到新目标"
assert not r.is_predicted
# 后续 B 持续作为主目标
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
assert r.primary_id is not None
assert r.primary_id != id_a
# ============================================================================
# 验收用例 4(§9.4-4):min_hits 回退
# ============================================================================
def test_min_hits_fallback_allows_new_track():
cfg = TrackerConfig()
cfg.min_hits = 5 # 抬高确认门槛,保证测试期间轨道始终未确认
tracker = TargetTracker(cfg)
# 首帧新建轨道 hits=1 < min_hits:候选池 confirmed 为空 → 退回 measured
r = tracker.update([make_det(200.0, 150.0, 240.0, 190.0, 0.6)])
assert r.tracked, "无已确认轨道时新轨道应能当选主目标"
assert r.primary_id is not None
assert r.primary_id == 0
assert not r.is_predicted
# 再跟几帧(hits 增长但仍未达 min_hits=5),依旧当选
for _ in range(3):
r = tracker.update([make_det(200.0, 150.0, 240.0, 190.0, 0.6)])
assert r.tracked
assert r.primary_id == 0
# ============================================================================
# 验收用例 5(§9.4-5):cost 边界(cost = 1.0 恰好不匹配)
# ============================================================================
def test_cost_exactly_one_does_not_match():
cfg = TrackerConfig()
cfg.max_lost_frames = 1
cfg.max_association_dist = 200.0
cfg.dist_weight = 1.0 # 纯距离项,IoU 项权重为 0 且两框零交集
cfg.iou_weight = 0.0
tracker = TargetTracker(cfg)
# 建立轨道:中心 (100,100)
r = tracker.update([make_det(90.0, 90.0, 110.0, 110.0, 0.9)])
assert r.primary_id is not None
id_a = r.primary_id
# 下一帧检测中心 (100,300):距离 = 200 = Dmax → cost = 1.0,恰好不匹配
r = tracker.update([make_det(90.0, 290.0, 110.0, 310.0, 0.9)])
# 未匹配 → 原轨道 Coast、检测新建轨道;两个目标并存
assert tracker.active_count() == 2, "cost=1.0 不应匹配,应新建轨道"
# 原轨道(锁定)进入 Coast:lost_frames=1
assert r.tracked
assert r.is_predicted
assert r.primary_id == id_a
assert r.lost_frames == 1
# 对照:距离略小于阈值(199px → cost=0.995 < 1.0)应匹配成功
tracker2 = TargetTracker(cfg)
r = tracker2.update([make_det(90.0, 90.0, 110.0, 110.0, 0.9)])
id_b = r.primary_id
r = tracker2.update([make_det(90.0, 289.0, 110.0, 309.0, 0.9)])
assert tracker2.active_count() == 1, "cost<1.0 应匹配成功,不应新建轨道"
assert r.primary_id is not None
assert r.primary_id == id_b
assert not r.is_predicted # 实测命中
# ============================================================================
# 验收用例 6(§9.4-6):置信度优先
# ============================================================================
def test_higher_confidence_track_wins_detection():
tracker = TargetTracker()
# 同帧建立两条轨道:A 置信度 0.5、B 置信度 0.9,相距 80px(不互相重叠)
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.5),
make_det(180.0, 100.0, 200.0, 120.0, 0.9)])
assert tracker.active_count() == 2
# 找出两条轨道 ID(按置信度区分)
id_low = None
id_high = None
for track_id, track in tracker.active_tracks().items():
if track.confidence > 0.8:
id_high = track_id
else:
id_low = track_id
assert id_low is not None
assert id_high is not None
# 下一帧只给一个检测,放在两轨道预测中心之间(各距 40px,对称):
# 距离项对两者相同,贪婪按置信度降序 → 高置信度轨道 B 优先匹配
r = tracker.update([make_det(140.0, 100.0, 160.0, 120.0, 0.7)])
tracks = tracker.active_tracks()
assert len(tracks) == 2
# 获胜者:命中(lost=0、hits=2、置信度更新)
assert tracks[id_high].lost_count == 0, "高置信度轨道应获胜"
assert tracks[id_high].hits == 2
assert tracks[id_high].confidence == pytest.approx(0.7)
# 失败者:未匹配进入 Coast(lost=1、hits 不变)
assert tracks[id_low].lost_count == 1, "低置信度轨道应落选"
assert tracks[id_low].hits == 1
assert tracks[id_low].confidence == pytest.approx(0.5)
# ============================================================================
# NaN/Inf 防御(评审修复项)
# ============================================================================
def test_non_finite_detections_are_discarded():
tracker = TargetTracker()
nan = math.nan
inf = math.inf
# 第 1 帧:建立正常轨道
r = tracker.update([make_det(100.0, 100.0, 140.0, 140.0, 0.9)])
assert r.tracked
# 第 2 帧:混入 NaN 坐标 / Inf 坐标 / NaN 置信度检测与一个正常检测:
# 非有限检测全部被丢弃,仅正常检测参与关联,不新建轨道
r = tracker.update([make_det(nan, 100.0, 140.0, 140.0, 0.9),
make_det(100.0, -inf, 140.0, 140.0, 0.9),
make_det(100.0, 100.0, nan, 140.0, 0.9),
make_det(102.0, 102.0, 142.0, 142.0, nan),
make_det(101.0, 101.0, 141.0, 141.0, 0.9)])
assert tracker.active_count() == 1, "非有限检测不应新建轨道"
assert r.tracked
assert not r.is_predicted # 正常检测命中,实测输出
# 主目标输出未被 NaN 污染
assert math.isfinite(r.center[0])
assert math.isfinite(r.center[1])
assert r.box is not None
assert math.isfinite(r.box.x1) and math.isfinite(r.box.y1)
assert math.isfinite(r.box.x2) and math.isfinite(r.box.y2)
assert math.isfinite(r.confidence)
assert r.raw is not None # 实测帧 raw 有值
# 第 3 帧:仅含 NaN 检测 → 等价空帧,轨道 Coast 不崩溃
r = tracker.update([make_det(nan, nan, nan, nan, nan)])
assert r.tracked
assert r.is_predicted
assert r.lost_frames == 1
assert r.raw is None # Coast 时 raw 为空(§2.3)
assert math.isfinite(r.center[0])
# 持续 NaN 检测直至轨道死亡回收,全程不崩溃
for _ in range(10):
r = tracker.update([make_det(nan, 0.0, 10.0, 10.0, 0.9)])
assert not r.tracked
assert tracker.active_count() == 0
# ============================================================================
# 补充行为测试(§2.3 / §6.3 / §9.5)
# ============================================================================
def test_lost_result_fields_are_empty():
tracker = TargetTracker()
r = tracker.update([]) # 空帧序列起点:无任何轨道
assert not r.tracked
assert r.primary_id is None
assert r.box is None
assert r.raw is None
assert r.lost_frames == 0
assert r.n_active == 0
assert r.confidence == 0.0
# LastResult 返回同一快照(§9.5 跨线程读取入口)
cached = tracker.last_result()
assert not cached.tracked
def test_coasting_fallback_chooses_nearest_to_frame_center():
# Coast 兜底选举:无实测轨道时选离画面中心最近的 Coast 轨道(§6.3)。
# 锁定延续优先:需先让锁定轨道死亡解除锁定,才会进入重新选举分支。
cfg = TrackerConfig()
cfg.max_lost_frames = 2
tracker = TargetTracker(cfg)
# F1:角落目标(高置信度)先建立并被锁定
tracker.update([make_det(10.0, 10.0, 30.0, 30.0, 0.95)])
# F2:画面中心附近目标建立;角落目标丢失(1)
tracker.update([make_det(300.0, 220.0, 340.0, 260.0, 0.9)])
# F3:远处目标建立;角落丢失(2)、中心目标丢失(1)
tracker.update([make_det(600.0, 450.0, 620.0, 470.0, 0.85)])
# F4:全部丢失:角落丢失(3) > max_lost 死亡解除锁定;
# 其余两条 Coast 存活 → 重新选举选离画面中心最近者
r = tracker.update([], FrameShape(480, 640))
assert r.tracked
assert r.is_predicted
assert r.center[0] == pytest.approx(320.0, abs=25.0) # 靠近画面中心 (320,240)
assert r.center[1] == pytest.approx(240.0, abs=25.0)
assert r.n_active == 2 # 角落轨道已回收
def test_locked_primary_kept_while_measured():
# 锁定延续优先于置信度选举(§6.3 ①)
tracker = TargetTracker()
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.4)])
assert r.primary_id is not None
locked_id = r.primary_id
# 高置信度新目标出现并持续,锁定目标也持续实测 → primary_id 不变
for i in range(5):
r = tracker.update([make_det(100.0 + i, 100.0, 120.0 + i, 120.0, 0.4),
make_det(400.0, 300.0, 430.0, 330.0, 0.95)])
assert r.primary_id is not None
assert r.primary_id == locked_id
assert not r.is_predicted
def test_default_frame_center_is_320x240():
# frame_shape 缺省按 640×480 画面中心 (320,240) 计算(§8.2 / §2.2)
cfg = TrackerConfig()
cfg.max_lost_frames = 2
tracker = TargetTracker(cfg)
# F1:角落目标先建立并被锁定(高置信度)
tracker.update([make_det(0.0, 0.0, 20.0, 20.0, 0.95)])
# F2:左侧目标(中心 (280,240),距默认中心 40px)建立
tracker.update([make_det(270.0, 230.0, 290.0, 250.0, 0.9)])
# F3:右侧目标(中心 (500,240),距默认中心 180px)建立
tracker.update([make_det(490.0, 230.0, 510.0, 250.0, 0.85)])
# F4:全部丢失:锁定轨道死亡解除锁定,两条 Coast 轨道兜底选举
r = tracker.update([])
assert r.tracked
assert r.is_predicted
assert r.center[0] == pytest.approx(280.0, abs=30.0) # 左侧更近,当选
assert r.center[1] == pytest.approx(240.0, abs=30.0)
-159
View File
@@ -1,159 +0,0 @@
"""tracklet_test.py —— Tracklet 与几何工具单元测试。
移植自 reference-c++/target_tracker/tests/tracklet_test.cpp。
覆盖(视觉追踪技术路线 §3.2/§5.2/§6.1/§7):框互转、IoU 边界、
IsDead/IsCoasting 判定(严格大于)、Predict/Update/MarkMiss 计数语义。
"""
import pytest
from target_tracker.tracklet import (BoxXYXY, BoxCXCYWH, Tracklet, compute_iou,
to_center_size, to_xyxy)
# ---- 框表示互转(§7) ----
def test_round_trip_preserves_values():
box = BoxXYXY(10.0, 20.0, 50.0, 80.0)
c = to_center_size(box)
assert c.cx == 30.0 # (10+50)/2
assert c.cy == 50.0 # (20+80)/2
assert c.w == 40.0 # 50−10
assert c.h == 60.0 # 80−20
back = to_xyxy(c)
assert back.x1 == box.x1
assert back.y1 == box.y1
assert back.x2 == box.x2
assert back.y2 == box.y2
# ---- IoU 边界(§5.2) ----
def test_identical_boxes_return_one():
a = BoxXYXY(10.0, 10.0, 30.0, 30.0)
assert compute_iou(a, a) == pytest.approx(1.0, abs=1e-12) # 完全重合 = 1
def test_disjoint_boxes_return_zero():
a = BoxXYXY(0.0, 0.0, 10.0, 10.0)
b = BoxXYXY(20.0, 20.0, 30.0, 30.0)
assert compute_iou(a, b) == 0.0 # 零交集
def test_touching_edges_return_zero():
# 仅边/角相接:交集宽高为 0 → 交集面积 ≤ 0 返回 0(§5.2)
a = BoxXYXY(0.0, 0.0, 10.0, 10.0)
b = BoxXYXY(10.0, 0.0, 20.0, 10.0) # 右边缘相接
assert compute_iou(a, b) == 0.0
c = BoxXYXY(10.0, 10.0, 20.0, 20.0) # 角点相接
assert compute_iou(a, c) == 0.0
def test_empty_box_returns_zero():
normal = BoxXYXY(0.0, 0.0, 10.0, 10.0)
zero_area = BoxXYXY(5.0, 5.0, 5.0, 5.0) # 零面积
inverted = BoxXYXY(10.0, 10.0, 0.0, 0.0) # 反向(负宽高)
assert compute_iou(normal, zero_area) == 0.0
assert compute_iou(zero_area, normal) == 0.0
assert compute_iou(normal, inverted) == 0.0
assert compute_iou(zero_area, zero_area) == 0.0
def test_partial_overlap_is_correct():
# A = [0,0,10,10](面积 100),B = [5,5,15,15](面积 100),交集 25
a = BoxXYXY(0.0, 0.0, 10.0, 10.0)
b = BoxXYXY(5.0, 5.0, 15.0, 15.0)
assert compute_iou(a, b) == pytest.approx(25.0 / 175.0, abs=1e-12)
assert compute_iou(b, a) == pytest.approx(25.0 / 175.0, abs=1e-12) # 对称性
# ---- 构造与派生状态(§3.2 / §6.1) ----
def test_construction_initial_fields():
t = Tracklet(7, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 1, 8)
assert t.track_id == 7
assert t.lost_count == 0
assert t.max_lost == 8
assert t.confidence == 0.9
assert t.cls_id == 1
assert t.age == 1 # 创建时为 1(§3.2)
assert t.hits == 1 # 创建时为 1(§3.2)
assert t.last_center == (100.0, 100.0)
assert t.last_box is not None # 创建当帧即赋 last_box(§7 步骤 5)
assert t.last_box.x1 == 90.0
assert t.last_box.y1 == 90.0
assert t.last_box.x2 == 110.0
assert t.last_box.y2 == 110.0
assert not t.is_dead
assert not t.is_coasting
def test_is_dead_uses_strictly_greater():
# IsDead 使用严格大于:lost_count == max_lost 仍存活(§6.1 注记 2)
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
for _ in range(8):
t.mark_miss()
assert not t.is_dead, f"lost_count={t.lost_count} 未超过 max_lost 不应判死"
assert t.lost_count == 8
t.mark_miss() # lost_count = 9 > 8
assert t.is_dead
def test_is_coasting_when_lost_positive():
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
assert not t.is_coasting
t.mark_miss()
assert t.is_coasting # lost_count > 0 即 Coast(§3.2)
# ---- 生命周期方法计数语义(§7) ----
def test_predict_advances_age_and_updates_last_state():
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
age_before = t.age
t.predict() # 速度为零:位置不变,但 age+1、last_box 更新为预测框
assert t.age == age_before + 1
assert t.last_box is not None
assert t.last_center[0] == pytest.approx(100.0, abs=1e-9)
assert t.last_center[1] == pytest.approx(100.0, abs=1e-9)
# hits / lost_count / confidence 不受预测影响
assert t.hits == 1
assert t.lost_count == 0
assert t.confidence == 0.9
def test_update_resets_lost_and_increments_hits():
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
t.mark_miss()
t.mark_miss()
assert t.lost_count == 2
assert t.is_coasting
age_before = t.age
box = BoxXYXY(150.0, 160.0, 170.0, 180.0)
t.update(box, 0.75, 2)
assert t.lost_count == 0 # 匹配成功清零(§7)
assert t.hits == 2 # hits+1
assert t.age == age_before + 1 # age+1
assert t.confidence == 0.75
assert t.cls_id == 2
assert t.last_center == (160.0, 170.0) # ((150+170)/2, (160+180)/2)
assert t.last_box is not None
assert t.last_box.x1 == box.x1
assert t.last_raw_box is not None # 实测框同步记录
assert t.last_raw_box.x2 == box.x2
assert not t.is_coasting
def test_mark_miss_increments_lost_and_age():
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
age_before = t.age
t.mark_miss()
assert t.lost_count == 1
assert t.age == age_before + 1
# MarkMiss 不影响 hits / confidence
assert t.hits == 1
assert t.confidence == 0.9
+82 -1
View File
@@ -1,7 +1,7 @@
"""log_test.py —— 任务日志模块单元测试。
覆盖:open 建目录/写表头、事件 JSONL、遥测 CSV、telemetry_every_n 节流、
disabled 时零副作用、close 收尾。
disabled 时零副作用、close 收尾、简洁任务简报(task_brief.log)。
"""
import csv
@@ -121,3 +121,84 @@ def test_append_keeps_existing_rows(tmp_path):
assert len(rows) == 2
assert rows[0]["state"] == "wait"
assert rows[1]["state"] == "goto"
# --- 简洁任务简报(task_brief.log)-------------------------------------------
def test_brief_log_lines_and_summary(tmp_path):
logger = TaskLogger(_cfg(tmp_path))
logger.open()
logger.log_event(1000, "state", from_state="wait", to_state="wait",
reason="任务启动")
logger.log_event(2000, "state", from_state="wait", to_state="arm_takeoff",
reason="收到地面坐标")
logger.log_event(3000, "command", name="rtl")
logger.log_event(4000, "fc_status", severity="WARNING", text="Low battery\t")
logger.log_event(5000, "takeoff", altitude_m=5.0)
logger.log_event(6000, "rtl")
logger.log_telemetry(1000, TelemetryRecord(
state="wait", relative_altitude_m=0.0, distance_to_target_m=33.5))
logger.log_telemetry(1100, TelemetryRecord(
state="arm_takeoff", relative_altitude_m=5.2, distance_to_target_m=20.0))
logger.log_telemetry(1200, TelemetryRecord(
state="arm_takeoff", relative_altitude_m=5.0,
distance_to_target_m=float("inf")))
logger.close()
text = (tmp_path / "task_brief.log").read_text(encoding="utf-8")
assert "任务开始" in text
assert "[00:00.0] 任务启动(当前: 待命)" in text
assert "[00:01.0] 阶段: 待命 → 起飞(收到地面坐标)" in text
assert "[00:02.0] 地面站命令: rtl" in text
assert "[00:03.0] 飞控 WARNING: Low battery" in text # 尾随 tab 已 strip
assert "[00:04.0] 起飞至 5.0 m" in text
assert "[00:05.0] 开始返航" in text
assert "本次运行汇总" in text
assert "时长 0.2s" in text # 最后一行遥测的 t_s
assert "最高 5.2m" in text
assert "最近距目标 20.0m" in text # inf 不计入
assert "事件 6 条" in text
assert "状态链 待命→起飞" in text
def test_brief_required_no_file_when_disabled_or_closed(tmp_path):
logger = TaskLogger(_cfg(tmp_path, enabled=False))
logger.open()
logger.log_event(1000, "rtl")
logger.close()
assert not (tmp_path / "task_brief.log").exists()
# --- 遥测落盘节流(2026-10-15:主循环 20Hz 不得每行同步 flush)----------------
def test_telemetry_buffered_until_flush_interval(tmp_path):
"""telemetry_flush_ms 内只写不 flush;close 兜底落盘,一行不丢。"""
logger = TaskLogger(_cfg(tmp_path, telemetry_flush_ms=1000))
logger.open()
rec = TelemetryRecord(state="track")
logger.log_telemetry(1000, rec) # 首行:落盘一次并打戳
for i in range(1, 19): # 其后 19 行都在 1s 内 → 不再 flush
logger.log_telemetry(1000 + i * 50, rec)
logger.close()
with open(tmp_path / "task_telemetry.csv", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
assert len(rows) == 19
def test_telemetry_flush_zero_flushes_every_row(tmp_path):
"""telemetry_flush_ms=0 保留旧的「每行立即 flush」行为(兼容/调试)。"""
logger = TaskLogger(_cfg(tmp_path, telemetry_flush_ms=0))
logger.open()
logger.log_telemetry(1000, TelemetryRecord(state="goto"))
assert logger._last_tel_flush_ms == 1000
logger.close()
def test_events_still_flushed_immediately(tmp_path):
"""事件(阶段切换)仍即时落盘:地面站页面实时性不受遥测节流影响。"""
logger = TaskLogger(_cfg(tmp_path, telemetry_flush_ms=10_000))
logger.open()
logger.log_event(1000, "state", from_state="goto", to_state="search")
with open(tmp_path / "task_events.jsonl", encoding="utf-8") as f:
lines = [json.loads(x) for x in f if x.strip()]
assert len(lines) == 1
logger.close()
+30 -1
View File
@@ -1,7 +1,8 @@
"""pod_control_test.py —— 吊舱目标识别/锁定控制器单元测试。
覆盖:arm_detection 发 0x07(识别,目标类型)+0x0F(自动锁定)、
stop_tracking 发 0x0E(停止)+0x08(识别关)。
stop_tracking 发 0x0E(停止)+0x08(识别关)、地面站手动命令
stop_track(0x0E)/auto_lock(0x0F+0x10)/center(0x2B)。
"""
from pod_bridge.protocol import ControlCode
@@ -43,3 +44,31 @@ def test_stop_tracking_sends_stop_and_detect_off():
assert len(bridge.sent) == 2
assert bridge.sent[0].code == ControlCode.STOP_TRACK
assert bridge.sent[1].code == ControlCode.TARGET_DETECT_OFF
def test_stop_track_sends_only_stop():
"""地面站「停跟踪」= 只发 0x0E(0x0F 常驻自动锁定仍生效,对齐按键 u)。"""
bridge = FakeBridge()
pc = PodController(bridge)
pc.stop_track()
assert [c.code for c in bridge.sent] == [ControlCode.STOP_TRACK]
def test_auto_lock_sends_autolock_and_detect_to_track():
"""地面站「自动锁定」= 0x0F + 0x10(对齐按键 a)。"""
bridge = FakeBridge()
pc = PodController(bridge)
pc.auto_lock()
assert [c.code for c in bridge.sent] == [ControlCode.AUTO_LOCK_ON_FOUND,
ControlCode.DETECT_TO_TRACK]
def test_center_sends_center():
"""地面站「归中」= 0x2B(对齐按键 c)。"""
bridge = FakeBridge()
pc = PodController(bridge)
pc.center()
assert [c.code for c in bridge.sent] == [ControlCode.CENTER]
+142
View File
@@ -0,0 +1,142 @@
"""return_home_test.py —— 自定义返航(回起飞点 + 恢复初始航向)纯逻辑测试。
覆盖:转场速度/对准门控、到达判定、转场高度保持(含符号)、初始航向恢复、
角度回绕、以及"不超过转场高度"的语义。
"""
import math
import pytest
from task_runner.return_home import (HomingController, ReturnConfig,
wrap_deg_180)
HOME_LAT, HOME_LON = 47.397971, 8.546164
_DEG_LAT_M = 111320.0
def _offset(lat0, lon0, north_m, east_m):
return (lat0 + north_m / _DEG_LAT_M,
lon0 + east_m / (_DEG_LAT_M * math.cos(math.radians(lat0))))
def _ctrl(cfg=None, initial_yaw_deg=0.0, transit_alt_m=5.0):
return HomingController(cfg or ReturnConfig(), HOME_LAT, HOME_LON,
math.radians(initial_yaw_deg), transit_alt_m)
# --- 角度回绕 ---------------------------------------------------------------
@pytest.mark.parametrize("deg,expect", [
(0.0, 0.0), (179.0, 179.0), (181.0, -179.0),
(-181.0, 179.0), (360.0, 0.0), (-360.0, 0.0),
])
def test_wrap_deg_180(deg, expect):
assert wrap_deg_180(deg) == pytest.approx(expect)
# --- 转场:朝起飞点飞 -------------------------------------------------------
def test_far_away_aligned_commands_full_speed():
"""远在起飞点北侧、机头朝南(对准回家方向)→ 满速前进、几乎不转。"""
cfg = ReturnConfig(speed_mps=2.0)
c = _ctrl(cfg)
# 在起飞点正北 50m,机头朝南(yaw=180°)→ 应朝南飞(北向速度为负)
lat, lon = _offset(HOME_LAT, HOME_LON, 50.0, 0.0)
out = c.update(lat, lon, math.radians(180.0), 5.0)
assert out.arrived is False
assert out.distance_m == pytest.approx(50.0, rel=5e-2)
assert out.vx_mps == pytest.approx(2.0, rel=1e-2) # 对准 → 全速
assert abs(out.yaw_rate_dps) < 1e-6
def test_misaligned_gates_forward_speed():
"""机头与回家方向垂直(90°)→ 前向速度被 cos(err) 门控为 0,只转向。"""
c = _ctrl(ReturnConfig(speed_mps=2.0, yaw_rate_limit_dps=20.0))
lat, lon = _offset(HOME_LAT, HOME_LON, 0.0, 50.0) # 起飞点正东 50m
out = c.update(lat, lon, math.radians(0.0), 5.0) # 机头朝北
assert out.vx_mps == pytest.approx(0.0, abs=1e-3) # cos(90°)=0(含地理近似误差)
assert out.bearing_err_deg == pytest.approx(-90.0, abs=1e-2) # 回家方向在西
assert out.yaw_rate_dps < 0.0 # 往左转(目标在西侧)
def test_yaw_rate_limited():
c = _ctrl(ReturnConfig(yaw_kp=5.0, yaw_rate_limit_dps=20.0))
lat, lon = _offset(HOME_LAT, HOME_LON, 0.0, 50.0)
out = c.update(lat, lon, math.radians(0.0), 5.0)
assert out.yaw_rate_dps == pytest.approx(-20.0) # 被限幅(左转)
def test_arrival_stops_forward_motion():
"""进入到达半径 → arrived,且不再有前向速度。"""
c = _ctrl(ReturnConfig(speed_mps=2.0, arrival_radius_m=3.0))
lat, lon = _offset(HOME_LAT, HOME_LON, 1.0, 0.0) # 距起飞点 1m
out = c.update(lat, lon, math.radians(180.0), 5.0)
assert out.arrived is True
assert out.vx_mps == 0.0
def test_distance_measured_to_home():
c = _ctrl()
lat, lon = _offset(HOME_LAT, HOME_LON, 30.0, 40.0) # 3-4-5 三角形 → 50m
out = c.update(lat, lon, 0.0, 5.0)
assert out.distance_m == pytest.approx(50.0, rel=5e-2)
# --- 转场高度保持(NED 下正)------------------------------------------------
def test_alt_hold_climbs_when_below_transit():
c = _ctrl(transit_alt_m=5.0)
vz = c.alt_hold_vz(3.0) # 低于转场高度
assert vz < 0.0 # NED 下正 → 负 = 爬升
def test_alt_hold_descends_when_above_transit():
c = _ctrl(transit_alt_m=5.0)
vz = c.alt_hold_vz(7.0)
assert vz > 0.0 # 正 = 下降
def test_alt_hold_zero_at_target():
assert _ctrl(transit_alt_m=5.0).alt_hold_vz(5.0) == pytest.approx(0.0)
def test_alt_hold_rate_limited():
cfg = ReturnConfig(alt_kp=10.0, alt_limit_mps=0.5)
c = _ctrl(cfg, transit_alt_m=5.0)
assert c.alt_hold_vz(0.0) == pytest.approx(-0.5) # 限幅
assert c.alt_hold_vz(100.0) == pytest.approx(0.5)
# --- 恢复初始航向 -----------------------------------------------------------
def test_yaw_align_rate_and_direction():
c = _ctrl(ReturnConfig(align_kp=1.0, align_rate_limit_dps=15.0),
initial_yaw_deg=0.0)
err, rate = c.yaw_align(math.radians(10.0)) # 当前偏右 10°
assert err == pytest.approx(-10.0) # 需要往左转
assert rate == pytest.approx(-10.0) # 负 = 左转
def test_yaw_align_limited():
c = _ctrl(ReturnConfig(align_kp=10.0, align_rate_limit_dps=15.0),
initial_yaw_deg=0.0)
_, rate = c.yaw_align(math.radians(90.0))
assert rate == pytest.approx(-15.0)
def test_yaw_aligned_within_tolerance():
c = _ctrl(ReturnConfig(yaw_align_tolerance_deg=5.0), initial_yaw_deg=30.0)
assert c.yaw_aligned(math.radians(33.0)) is True # 差 3° < 5°
assert c.yaw_aligned(math.radians(40.0)) is False # 差 10°
def test_yaw_align_wraps_around_north():
"""初始航向 179°、当前 -179° → 误差 2°(不是 358°)。"""
c = _ctrl(initial_yaw_deg=179.0)
err, _ = c.yaw_align(math.radians(-179.0))
assert abs(err) == pytest.approx(2.0, abs=1e-6)
# --- 配置默认值 -------------------------------------------------------------
def test_default_config_is_non_climbing_and_enabled():
cfg = ReturnConfig()
assert cfg.enabled is True
assert cfg.alt_limit_mps > 0.0
assert cfg.speed_mps > 0.0
+672 -11
View File
@@ -25,10 +25,17 @@ class FakePx4:
self.telemetry_started = 0
self.offboard_started = False
self.brake_calls = []
self.yaw_calls = [] # SEARCH 扫视下发记录
self.velocity_calls = [] # send_velocity_heading 记录(返航转场用)
self.fc_msgs = []
def own_state(self):
return replace(self._state)
def take_fc_messages(self):
msgs, self.fc_msgs = self.fc_msgs, []
return msgs
async def start_telemetry(self):
self.telemetry_started += 1
@@ -43,19 +50,26 @@ class FakePx4:
pass
async def send_velocity_heading(self, out, t=None):
self.velocity_calls.append(out)
return True
async def send_brake_hover(self, t=None):
self.brake_calls.append(t)
return True
async def send_yaw_rate(self, yaw_rate_dps):
self.yaw_calls.append(yaw_rate_dps)
return True
class FakeBridge:
def __init__(self):
self.started = False
self.stopped = False
self.landing_stopped = False # landing_stop():只退 offboard、保留遥测
self.law = None # set_heading_servo_enabled 最后值
self.steps = 0
self.search_steps = [] # SEARCH 态收到的扫视角速度
self._locked = False
self._telemetry = None
@@ -66,6 +80,9 @@ class FakeBridge:
async def stop(self):
self.stopped = True
async def landing_stop(self):
self.landing_stopped = True
def set_heading_servo_enabled(self, enabled):
self.law = enabled
@@ -75,6 +92,9 @@ class FakeBridge:
async def step(self, now_ms):
self.steps += 1
async def search_step(self, now_ms, yaw_rate_dps):
self.search_steps.append(yaw_rate_dps)
@property
def latest_telemetry(self):
return self._telemetry
@@ -83,10 +103,16 @@ class FakeBridge:
class FakeGround:
def __init__(self, target=None):
self._target = target
self.commands = [] # 地面站命令队列(供 drain_commands 取走)
def latest(self):
return self._target or GroundTarget()
def drain_commands(self):
cmds = list(self.commands)
self.commands.clear()
return cmds
class FakeDrone:
def __init__(self):
@@ -96,6 +122,25 @@ class FakeDrone:
self.rtl = False
self.landed = False
self.disarmed = False
self.telemetry = FakeDrone._Telemetry()
class _Telemetry:
"""模仿 MAVSDK `in_air()`:**异步生成器**,finalize 时取消底层流。
真实实现在 finally 里 `in_air_stream.cancel()`;「取一帧就 break」只是
把生成器挂起,复用同一生成器即可避免反复订阅。
"""
def __init__(self, frames=(False,)):
self._frames = frames
self.cancelled = False
async def in_air(self):
try:
for value in self._frames:
yield value
finally:
self.cancelled = True
class _Action:
def __init__(self, d):
@@ -132,7 +177,8 @@ def _state(now_ms, lat=0.0, lon=0.0, rel_alt=0.0):
def _make_runner(px4=None, bridge=None, ground=None, drone=None,
sm_cfg=None, vision_fn=None, pod_control=None):
sm_cfg=None, pod_control=None,
land_instead_of_rtl=False, now_ms_fn=None):
cfg = BridgeConfig()
cfg.ground_inject.enabled = True
cfg.ground_inject.timeout_ms = 5000
@@ -143,9 +189,9 @@ def _make_runner(px4=None, bridge=None, ground=None, drone=None,
TaskStateMachine(sm_cfg or TaskStateConfig()),
ground or FakeGround(),
drone or FakeDrone(),
vision_fn=vision_fn,
now_ms_fn=lambda: 1000,
now_ms_fn=now_ms_fn or (lambda: 1000),
pod_control=pod_control,
land_instead_of_rtl=land_instead_of_rtl,
)
@@ -180,6 +226,15 @@ def test_collect_takeoff_reached():
assert r._collect_inputs(1000).takeoff_reached is True
def test_collect_takeoff_reached_tolerance():
"""容差内(目标 5.0m,实际 4.6m ≥ 5.0-0.5)判到达;容差外(4.4m)判未到达。"""
sm_cfg = TaskStateConfig(takeoff_altitude_m=5.0)
px4_low = FakePx4(_state(1000, rel_alt=4.6))
assert _make_runner(px4=px4_low, sm_cfg=sm_cfg)._collect_inputs(1000).takeoff_reached is True
px4_below = FakePx4(_state(1000, rel_alt=4.4))
assert _make_runner(px4=px4_below, sm_cfg=sm_cfg)._collect_inputs(1000).takeoff_reached is False
def test_collect_locked_from_bridge():
bridge = FakeBridge()
bridge._locked = True
@@ -188,14 +243,6 @@ def test_collect_locked_from_bridge():
assert r._collect_inputs(1000).target_locked is True
def test_collect_vision_second_judgement():
r = _make_runner(vision_fn=lambda: True)
assert r._collect_inputs(1000).vision_has_target is True
r2 = _make_runner(vision_fn=lambda: False)
assert r2._collect_inputs(1000).vision_has_target is False
# --- 居中累加 ---------------------------------------------------------------
def test_centered_accumulates_duration():
bridge = FakeBridge()
@@ -257,6 +304,15 @@ def test_on_enter_rtl_stops_bridge_and_returns():
assert drone.rtl is True
def test_land_instead_of_rtl_lands():
"""--land 模式:收尾调 land 而不是 return_to_launch(真机就地回收)。"""
drone = FakeDrone()
r = _make_runner(drone=drone, land_instead_of_rtl=True)
asyncio.run(r._on_enter(TaskState.RTL, 1000))
assert drone.landed is True
assert drone.rtl is False
# --- 持续动作 ---------------------------------------------------------------
def test_tick_goto_steps_bridge():
bridge = FakeBridge()
@@ -281,12 +337,24 @@ class FakePodControl:
def __init__(self):
self.armed = False
self.stopped = False
self.calls = []
def arm_detection(self):
self.armed = True
self.calls.append("arm_detection")
def stop_tracking(self):
self.stopped = True
self.calls.append("stop_tracking")
def stop_track(self):
self.calls.append("stop_track")
def auto_lock(self):
self.calls.append("auto_lock")
def center(self):
self.calls.append("center")
def test_on_enter_goto_arms_detection():
@@ -309,3 +377,596 @@ def test_no_pod_control_is_safe():
r = _make_runner(pod_control=None)
asyncio.run(r._on_enter(TaskState.GOTO, 1000))
asyncio.run(r._on_enter(TaskState.RTL, 1000))
# --- 地面站命令分派 ---------------------------------------------------------
def test_ground_command_rtl_sets_requested():
ground = FakeGround()
ground.commands.append("rtl")
r = _make_runner(ground=ground)
r._apply_ground_commands()
assert r._collect_inputs(1000).rtl_requested is True
def test_ground_command_emergency_sets_flag():
ground = FakeGround()
ground.commands.append("emergency")
r = _make_runner(ground=ground)
r._apply_ground_commands()
assert r._collect_inputs(1000).emergency is True
def test_ground_command_pod_dispatch():
pod_control = FakePodControl()
ground = FakeGround()
ground.commands.extend(["pod_detect_on", "pod_auto_lock", "pod_stop_track",
"pod_detect_off", "pod_center"])
r = _make_runner(ground=ground, pod_control=pod_control)
r._apply_ground_commands()
assert pod_control.calls == ["arm_detection", "auto_lock", "stop_track",
"stop_tracking", "center"]
def test_ground_command_pod_without_control_is_ignored():
"""SITL(无 pod_control)→ 吊舱命令记警告忽略,不抛异常。"""
ground = FakeGround()
ground.commands.append("pod_center")
r = _make_runner(ground=ground, pod_control=None)
r._apply_ground_commands() # 不抛异常即通过
def test_ground_command_unknown_is_ignored():
ground = FakeGround()
ground.commands.append("unknown_cmd")
r = _make_runner(ground=ground)
r._apply_ground_commands() # 不抛异常即通过
def test_run_drains_ground_commands():
"""run() 主循环每拍排空命令队列:RTL 命令 → 状态机转 RTL → 调 return_to_launch。"""
ground = FakeGround()
ground.commands.append("rtl")
drone = FakeDrone()
r = _make_runner(ground=ground, drone=drone)
async def scenario():
stop = asyncio.Event()
task = asyncio.ensure_future(r.run(stop))
try:
for _ in range(100):
if drone.rtl:
break
await asyncio.sleep(0.01)
finally:
stop.set()
await task
asyncio.run(scenario())
assert r.state is TaskState.RTL
assert drone.rtl is True
# --- 飞控状态文本落日志 -----------------------------------------------------
class FakeLogger:
def __init__(self):
self.events = []
def open(self):
pass
def close(self):
pass
def log_event(self, now_ms, event_type, **fields):
self.events.append((event_type, fields))
def log_telemetry(self, now_ms, rec):
pass
def test_fc_messages_logged_as_events():
px4 = FakePx4(_state(1000))
px4.fc_msgs = [("WARNING", "Low battery"), ("INFO", "Takeoff detected")]
logger = FakeLogger()
r = _make_runner(px4=px4)
r._logger = logger
r._log_fc_messages()
assert [e[0] for e in logger.events] == ["fc_status", "fc_status"]
assert logger.events[0][1] == {"severity": "WARNING", "text": "Low battery"}
assert px4.fc_msgs == [] # 已排空,不重复记录
# --- SEARCH 搜索扫视(2026-10-15 新增)--------------------------------------
def test_on_enter_search_disables_servo_and_resets_sweep():
bridge = FakeBridge()
r = _make_runner(bridge=bridge)
r._sweep_dir = 9 # 脏值,应被重置
asyncio.run(r._on_enter(TaskState.SEARCH, 1000))
assert bridge.law is False # 搜索不用控制律(不由 L1/L2 出指令)
assert r._sweep_dir == 0 # 每次进搜索从右向起扫
assert r._sweep_since_ms == 1000
def test_search_yaw_rate_sweeps_both_directions():
"""扫视按 sweep_reverse_s 左右交替,幅值为配置角速度。"""
sm_cfg = TaskStateConfig(sweep_rate_dps=15.0, sweep_reverse_s=4.0)
r = _make_runner(sm_cfg=sm_cfg)
assert r._search_yaw_rate(1000) == pytest.approx(15.0) # 先向右
assert r._search_yaw_rate(4999) == pytest.approx(15.0) # 未到换向点
assert r._search_yaw_rate(5000) == pytest.approx(-15.0) # 换向左
assert r._search_yaw_rate(9000) == pytest.approx(15.0) # 再换回右
def test_search_yaw_rate_zero_disables_sweep():
sm_cfg = TaskStateConfig(sweep_rate_dps=0.0, sweep_reverse_s=4.0)
r = _make_runner(sm_cfg=sm_cfg)
assert r._search_yaw_rate(1000) == 0.0
def test_search_yaw_rate_continuous_when_reverse_disabled():
"""sweep_reverse_s=0 → 单向持续扫视(不换向)。"""
sm_cfg = TaskStateConfig(sweep_rate_dps=10.0, sweep_reverse_s=0.0)
r = _make_runner(sm_cfg=sm_cfg)
assert r._search_yaw_rate(1000) == pytest.approx(10.0)
assert r._search_yaw_rate(600_000) == pytest.approx(10.0)
def test_tick_search_sends_sweep_step():
bridge = FakeBridge()
sm_cfg = TaskStateConfig(sweep_rate_dps=12.0, sweep_reverse_s=4.0)
r = _make_runner(bridge=bridge, sm_cfg=sm_cfg)
r._sm.force_state(TaskState.SEARCH, 1000)
asyncio.run(r._tick(1000))
assert bridge.search_steps == [pytest.approx(12.0)]
assert bridge.steps == 0 # 搜索不走控制律 step
def test_search_step_polls_pod_so_lock_can_be_fresh():
"""SEARCH 必须 poll 吊舱:否则遥测超龄,搜到目标也切不进 TRACK。"""
from pod_bridge.bridge import PodBridge
from pod_bridge.protocol import Telemetry
class FakePod:
def __init__(self):
self.polls = 0
def poll(self):
self.polls += 1
return [Telemetry(status1=0b100, off_target_x_deg=1.0,
off_target_y_deg=0.0)]
def send(self, data):
pass
def close(self):
pass
pod = FakePod()
px4 = FakePx4(_state(1000))
cfg = BridgeConfig()
cfg.link.require_laser_valid = False # 本吊舱无激光(与部署配置一致)
bridge = PodBridge(cfg, px4, pod, now_ms_fn=lambda: 1000,
ground_target_fn=lambda: None)
asyncio.run(bridge.search_step(1000, -15.0))
assert pod.polls == 1
assert bridge.pod_locked(1000) is True # 本拍锁定位已刷新
assert px4.yaw_calls == [pytest.approx(-15.0)] # 扫视角速度已下发
# --- 进入动作只执行一次(防重入导致的自激)----------------------------------
class _CountingPodControl:
def __init__(self):
self.arm_calls = 0
self.stop_calls = 0
def arm_detection(self):
self.arm_calls += 1
def stop_tracking(self):
self.stop_calls += 1
def stop_track(self):
pass
def auto_lock(self):
pass
def center(self):
pass
def test_pod_detection_armed_only_once_across_reentry():
"""丢目标回 SEARCH 再进 TRACK 时,不得重发 0x07/0x0F(会打断正在进行的跟踪)。"""
pod_control = _CountingPodControl()
r = _make_runner(pod_control=pod_control)
asyncio.run(r._on_enter(TaskState.GOTO, 1000))
asyncio.run(r._on_enter(TaskState.SEARCH, 2000))
asyncio.run(r._on_enter(TaskState.TRACK, 3000))
asyncio.run(r._on_enter(TaskState.SEARCH, 4000)) # 丢目标回搜索
asyncio.run(r._on_enter(TaskState.TRACK, 5000)) # 再次锁定
assert pod_control.arm_calls == 1
def test_rtl_stops_pod_tracking_even_after_reentry():
pod_control = _CountingPodControl()
r = _make_runner(pod_control=pod_control)
asyncio.run(r._on_enter(TaskState.GOTO, 1000))
asyncio.run(r._on_enter(TaskState.RTL, 2000))
assert pod_control.stop_calls == 1
# --- 主循环异常兜底:必须返航,不能静默停摆 ---------------------------------
def test_iteration_exception_triggers_rtl():
"""主循环单周期异常 → 必须转紧急返航,不能只停机静默停摆。
异常注入在**每拍都跑**的输入采集路径(own_state),与当前状态无关。
"""
px4 = FakePx4(_state(1000))
drone = FakeDrone()
r = _make_runner(px4=px4, drone=drone)
calls = {"n": 0}
def boom():
calls["n"] += 1
raise RuntimeError("模拟瞬态异常(遥测/串口抖动)")
px4.own_state = boom
async def scenario():
stop = asyncio.Event()
task = asyncio.ensure_future(r.run(stop))
for _ in range(300):
if drone.rtl:
break
await asyncio.sleep(0.005)
stop.set()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(scenario())
assert calls["n"] >= 1
assert drone.rtl is True # 异常兜底确实触发了返航
assert r.state is TaskState.RTL
def test_iteration_exception_records_event():
"""异常兜底要在任务日志留痕,便于事后定位。"""
px4 = FakePx4(_state(1000))
drone = FakeDrone()
r = _make_runner(px4=px4, drone=drone)
logger = FakeLogger()
r._logger = logger
def boom():
raise RuntimeError("串口拔线")
px4.own_state = boom
async def scenario():
stop = asyncio.Event()
task = asyncio.ensure_future(r.run(stop))
for _ in range(300):
if drone.rtl:
break
await asyncio.sleep(0.005)
stop.set()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(scenario())
types = [e[0] for e in logger.events]
assert "iteration_error" in types
assert "rtl" in types # 兜底返航事件也落盘
def test_safe_abort_is_noop_when_already_rtl():
"""已在 RTL 时兜底不重复触发返航(避免重复 RTL 指令)。"""
bridge = FakeBridge()
drone = FakeDrone()
r = _make_runner(bridge=bridge, drone=drone)
r._sm.force_state(TaskState.RTL, 0)
asyncio.run(r._safe_abort())
assert drone.rtl is False
assert bridge.stopped is False
# --- in_air 遥测流复用(降低服务端订阅churn)--------------------------------
def test_in_air_stream_reused_across_ticks():
"""in_air 流只订阅一次:每拍新建会在服务端反复订阅/退订同一条流。
注意必须在**同一个事件循环**内连续调用(与生产一致:主循环是单 loop);
跨 loop 复用 async generator 会被 asyncio 拒绝,实现里也就丢弃重建。
"""
drone = FakeDrone()
calls = {"n": 0}
class CountingTelemetry:
def in_air(self):
calls["n"] += 1
return FakeDrone._Telemetry().in_air()
drone.telemetry = CountingTelemetry()
r = _make_runner(drone=drone)
async def scenario():
for _ in range(5):
await r._check_landed()
asyncio.run(scenario())
assert calls["n"] == 1 # 只订阅一次
assert r.rtl_done is True # in_air=False → 判落地并 disarm
def test_in_air_stream_rebuilt_when_broken():
"""流建立/消费失败时丢弃缓存,下拍重建(不永久卡在坏流上)。"""
drone = FakeDrone()
calls = {"n": 0}
def broken():
calls["n"] += 1
raise RuntimeError("stream broken")
class FlakyTelemetry:
def in_air(self):
return broken()
drone.telemetry = FlakyTelemetry()
r = _make_runner(drone=drone)
async def scenario():
await r._check_landed()
assert calls["n"] == 1
assert r._in_air_stream is None # 已丢弃
await r._check_landed()
assert calls["n"] == 2 # 重建后再试
asyncio.run(scenario())
# --- RTL 落地等待上限(真机在环发现:判不出落地会无限等)--------------------
def test_rtl_timeout_ends_task_when_landing_unobservable():
"""in_air 始终不报落地 → 超过 rtl_timeout_s 后结束任务,不无限等待。"""
now = {"ms": 1_000_000}
drone = FakeDrone()
class AlwaysAirborne:
async def in_air(self):
while True:
yield True
drone.telemetry = AlwaysAirborne()
sm_cfg = TaskStateConfig(rtl_timeout_s=5.0)
r = _make_runner(drone=drone, sm_cfg=sm_cfg,
now_ms_fn=lambda: now["ms"])
r._rtl_entered_ms = now["ms"]
r._rtl_phase = "land"
async def scenario():
now["ms"] += 4_000 # 未到上限:不应结束
await r._tick_rtl(now["ms"])
assert r.rtl_done is False
now["ms"] += 2_000 # 超过上限:结束任务
await r._tick_rtl(now["ms"])
assert r.rtl_done is True
asyncio.run(scenario())
def test_rtl_timeout_not_triggered_after_landing():
"""已判落地后不再触发超时逻辑(rtl_done 保持)。"""
now = {"ms": 1_000_000}
drone = FakeDrone() # 默认 in_air 首帧 False
sm_cfg = TaskStateConfig(rtl_timeout_s=1.0)
r = _make_runner(drone=drone, sm_cfg=sm_cfg,
now_ms_fn=lambda: now["ms"])
r._rtl_entered_ms = now["ms"]
r._rtl_phase = "land"
async def scenario():
await r._tick_rtl(now["ms"])
assert r.rtl_done is True
now["ms"] += 60_000 # 大幅超过上限
await r._tick_rtl(now["ms"])
assert r.rtl_done is True
asyncio.run(scenario())
# --- 自定义返航:回起飞点 → 恢复初始航向 → 降落 -----------------------------
def _armed_runner(px4=None, drone=None, return_cfg=None):
"""走完 解锁→GOTO(记录起飞点 + 进入 offboard)的 runner。"""
px4 = px4 or FakePx4(_state(1000, lat=0.0, lon=0.0))
r = _make_runner(px4=px4, drone=drone or FakeDrone())
if return_cfg is not None:
r._return_cfg = return_cfg
asyncio.run(r._on_enter(TaskState.ARM_TAKEOFF, 1000))
asyncio.run(r._on_enter(TaskState.GOTO, 1000))
return r, px4
def test_custom_return_selected_after_takeoff():
"""已起飞 + 有起飞点 → 进入 RTL 走自定义返航,不调 PX4 RTL。"""
drone = FakeDrone()
r, _ = _armed_runner(drone=drone)
asyncio.run(r._on_enter(TaskState.RTL, 2000))
assert r._rtl_phase == "return"
assert drone.rtl is False # 不再交给 PX4 原生 RTL
assert r._bridge_started is True
def test_custom_return_records_home_and_initial_yaw():
r, px4 = _armed_runner()
assert r._home_lat == pytest.approx(0.0)
assert r._home_lon == pytest.approx(0.0)
assert r._initial_yaw_rad == pytest.approx(0.0)
def test_return_step_commands_velocity_toward_home():
px4 = FakePx4(_state(1000, lat=0.0, lon=0.0))
r, _ = _armed_runner(px4=px4)
asyncio.run(r._on_enter(TaskState.RTL, 2000))
# 飞到起飞点正北 ~111m,机头朝南(对准回家方向)
px4._state = _state(3000, lat=0.001, lon=0.0, rel_alt=5.0)
px4._state.yaw_rad = 3.141592653589793
px4.velocity_calls.clear()
asyncio.run(r._tick_rtl(3000))
assert r._rtl_phase == "return"
assert len(px4.velocity_calls) == 1
out = px4.velocity_calls[0]
assert out.vx_body_mps > 0.0 # 朝起飞点前进
assert out.vz_mps == pytest.approx(0.0) # 转场高度 5m = 当前高度
def test_return_arrival_switches_to_yaw_phase():
px4 = FakePx4(_state(1000, lat=0.0, lon=0.0))
r, _ = _armed_runner(px4=px4)
asyncio.run(r._on_enter(TaskState.RTL, 2000))
px4._state = _state(3000, lat=0.000005, lon=0.0, rel_alt=5.0) # ~0.5m
asyncio.run(r._tick_rtl(3000))
assert r._rtl_phase == "yaw"
def test_yaw_phase_restores_heading_then_lands():
drone = FakeDrone()
px4 = FakePx4(_state(1000, lat=0.0, lon=0.0))
r, _ = _armed_runner(px4=px4, drone=drone)
asyncio.run(r._on_enter(TaskState.RTL, 2000))
r._rtl_phase = "yaw"
r._yaw_started_ms = 2000
px4._state = _state(3000, lat=0.0, lon=0.0, rel_alt=5.0) # 航向已回 0
px4.velocity_calls.clear()
asyncio.run(r._tick_rtl(3000))
assert len(px4.velocity_calls) == 1 # 先发了一拍"原地保持"指令
assert r._rtl_phase == "land"
assert drone.landed is True # 航向对齐后降落
assert r._land_cmd_sent is True
def test_landing_keeps_telemetry_running():
"""降落阶段只退 offboard、保留遥测(否则日志冻结在降落前高度)。"""
drone = FakeDrone()
bridge = FakeBridge()
r, _ = _armed_runner(drone=drone)
r._bridge = bridge
asyncio.run(r._start_land())
assert bridge.landing_stopped is True
assert bridge.stopped is False # 尚未全停:遥测/串口仍在
assert drone.landed is True
def test_land_phase_completes_on_landed():
drone = FakeDrone() # in_air -> False
r, _ = _armed_runner(drone=drone)
asyncio.run(r._on_enter(TaskState.RTL, 2000))
r._rtl_phase = "land"
asyncio.run(r._tick_rtl(3000))
assert r.rtl_done is True
assert drone.disarmed is True
def test_return_timeout_falls_back_to_px4_rtl():
drone = FakeDrone()
r, px4 = _armed_runner(drone=drone)
asyncio.run(r._on_enter(TaskState.RTL, 2000))
px4._state = _state(3000, lat=0.001, lon=0.0, rel_alt=5.0)
# 超过转场超时
late = 2000 + int(r._return_cfg.timeout_s * 1000) + 1000
asyncio.run(r._tick_rtl(late))
assert r._rtl_phase == "fallback"
assert drone.rtl is True # 退回 PX4 原生 RTL
def test_custom_return_disabled_uses_px4_rtl():
from task_runner.return_home import ReturnConfig
drone = FakeDrone()
r, _ = _armed_runner(drone=drone,
return_cfg=ReturnConfig(enabled=False))
asyncio.run(r._on_enter(TaskState.RTL, 2000))
assert r._rtl_phase == "fallback"
assert drone.rtl is True
def test_rtl_before_takeoff_uses_px4_rtl():
"""未起飞(还在待命/地面)时不能自主返航 → 退回 PX4 RTL。"""
drone = FakeDrone()
r = _make_runner(drone=drone) # 未记录起飞点、未进 offboard
asyncio.run(r._on_enter(TaskState.RTL, 2000))
assert r._rtl_phase == "fallback"
assert drone.rtl is True
def test_safe_abort_prefers_px4_fallback():
"""异常兜底不再信任自身状态:强制走 PX4 RTL。"""
drone = FakeDrone()
r, _ = _armed_runner(drone=drone)
asyncio.run(r._safe_abort())
assert r._rtl_phase == "fallback"
assert drone.rtl is True
# --- 起飞点记录时机(SITL 实测:解锁时 gps_info 可能还没到)------------------
def test_home_recorded_when_position_becomes_valid_during_takeoff():
"""解锁瞬间定位无效 → 起飞阶段每拍补记,不丢起飞点。"""
px4 = FakePx4(_state(1000, lat=0.0, lon=0.0))
px4._state.position_valid = False
r = _make_runner(px4=px4)
r._sm.force_state(TaskState.ARM_TAKEOFF, 0)
r._collect_inputs(1000)
assert r._home_lat is None # 定位无效 → 暂不记录
px4._state = _state(1000, lat=0.0012, lon=0.0034)
r._collect_inputs(1100)
assert r._home_lat == pytest.approx(0.0012) # 补记成功
assert r._home_lon == pytest.approx(0.0034)
def test_home_not_recorded_once_airborne():
"""进入 GOTO(已在空中)后不再把当前位置当作起飞点。"""
px4 = FakePx4(_state(1000, lat=0.005, lon=0.0))
r = _make_runner(px4=px4)
r._sm.force_state(TaskState.GOTO, 0)
r._collect_inputs(1000)
assert r._home_lat is None
def test_home_recorded_only_once():
px4 = FakePx4(_state(1000, lat=0.0, lon=0.0))
r = _make_runner(px4=px4)
r._sm.force_state(TaskState.ARM_TAKEOFF, 0)
r._collect_inputs(1000)
first = (r._home_lat, r._home_lon, r._initial_yaw_rad)
px4._state = _state(1000, lat=0.009, lon=0.009) # 位置已变(飞机在爬升/移动)
r._collect_inputs(1100)
assert (r._home_lat, r._home_lon, r._initial_yaw_rad) == first
def test_record_home_returns_false_without_fix():
px4 = FakePx4(_state(1000))
px4._state.position_valid = False
r = _make_runner(px4=px4)
assert r._record_home() is False
assert r._home_lat is None
+136 -88
View File
@@ -1,8 +1,12 @@
"""state_machine_test.py —— 任务级状态机单元测试。
覆盖:完整状态链 WAIT→ARM_TAKEOFF→GOTO→TRACK→APPROACH→RTL、各状态超时、
GOTO→TRACK 的锁定/视觉守卫、TRACK 丢目标退回 GOTO、紧急/返航全局终止、
RTL 终态与 reset。
覆盖(2026-10-15 按「到参考点 → 视觉搜索 → 缓慢靠近」重构后的契约):
* 完整状态链 WAIT→ARM_TAKEOFF→GOTO→SEARCH→TRACK→(超时)RTL
* GOTO 只按「到参考点半径」退出(不看锁定);SEARCH 才看锁定/视觉守卫
* 搜索超时 → RTL;追踪(靠近)超时 → RTL
* TRACK 丢目标 → 回 SEARCH(不回 GOTO);坐标同时失效 → RTL
* TRACK **不**因「目标居中」退出(居中即应继续靠近)
* 紧急/返航全局终止、RTL 终态与 reset、force_state 兜底
"""
import pytest
@@ -19,6 +23,25 @@ def _sm(cfg=None) -> TaskStateMachine:
return TaskStateMachine(cfg or TaskStateConfig())
def _to_search(cfg=None, t0: int = 1000):
"""推进到 SEARCH 态:WAIT→ARM_TAKEOFF→GOTO→SEARCH。返回 (sm, now_ms)。"""
sm = _sm(cfg)
sm.update(_inp(target_injected=True), t0)
sm.update(_inp(target_injected=True, takeoff_reached=True), t0 + 1000)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0), t0 + 2000)
assert tr.to_state is TaskState.SEARCH
return sm, t0 + 2000
def _to_track(cfg=None, t0: int = 1000):
"""推进到 TRACK 态:WAIT→…→SEARCH→TRACK。返回 (sm, now_ms)。"""
sm, t = _to_search(cfg, t0)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), t + 1000)
assert tr.to_state is TaskState.TRACK
return sm, t + 1000
# --- 初始态与 reset ---------------------------------------------------------
def test_initial_state_is_wait():
assert _sm().state is TaskState.WAIT
@@ -32,6 +55,13 @@ def test_reset_returns_to_wait():
assert sm.state is TaskState.WAIT
def test_force_state_jumps_without_transition():
"""force_state 供异常兜底直接落状态(不产生 TaskTransition)。"""
sm = _sm()
sm.force_state(TaskState.RTL, 5000)
assert sm.state is TaskState.RTL
# --- WAIT -------------------------------------------------------------------
def test_wait_without_coords_stays():
tr = _sm().update(_inp(), 1000)
@@ -60,12 +90,11 @@ def test_arm_takeoff_timeout_goes_rtl():
cfg = TaskStateConfig(arm_takeoff_timeout_s=60.0)
sm = _sm(cfg)
sm.update(_inp(target_injected=True), 1000)
# 60s 未到高度 → 超时返航
tr = sm.update(_inp(target_injected=True), 1000 + 61_000)
assert tr.occurred and tr.to_state is TaskState.RTL
# --- GOTO -------------------------------------------------------------------
# --- GOTO:只按「到参考点半径」退出(不看锁定)-------------------------------
def test_goto_far_away_stays():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
@@ -76,45 +105,31 @@ def test_goto_far_away_stays():
assert sm.state is TaskState.GOTO
def test_goto_within_radius_and_locked_goes_track():
def test_goto_within_radius_goes_search_even_if_locked():
"""到参考点即转搜索——参考点是搜索起点,锁定与否不影响该转移。"""
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
assert tr.occurred and tr.to_state is TaskState.TRACK
assert tr.occurred and tr.to_state is TaskState.SEARCH
assert "参考点" in tr.reason
def test_goto_within_radius_but_unlocked_stays():
def test_goto_within_radius_unlocked_still_goes_search():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=False), 3000)
assert not tr.occurred
assert sm.state is TaskState.GOTO
def test_goto_requires_vision_when_enabled():
cfg = TaskStateConfig(require_vision=True)
sm = _sm(cfg)
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
# 有锁定但无视觉第二判据 → 不切换
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True, vision_has_target=False), 3000)
assert not tr.occurred
# 补上视觉 → 切换
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True, vision_has_target=True), 4000)
assert tr.occurred and tr.to_state is TaskState.TRACK
assert tr.occurred and tr.to_state is TaskState.SEARCH
def test_goto_loses_coords_goes_rtl():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
tr = sm.update(_inp(target_injected=False, distance_to_target_m=10.0), 3000)
tr = sm.update(_inp(target_injected=False, distance_to_target_m=100.0), 3000)
assert tr.occurred and tr.to_state is TaskState.RTL
@@ -123,87 +138,113 @@ def test_goto_timeout_goes_rtl():
sm = _sm(cfg)
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=100.0), 2000 + 121_000)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=100.0),
2000 + 121_000)
assert tr.occurred and tr.to_state is TaskState.RTL
# --- TRACK ------------------------------------------------------------------
def test_track_centered_stable_goes_approach():
sm = _sm(TaskStateConfig(center_hold_s=3.0))
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
assert sm.state is TaskState.TRACK
# 居中但未满时长 → 停留
tr = sm.update(_inp(target_injected=True, target_locked=True,
target_centered=True, centered_duration_s=1.0), 4000)
# --- SEARCH:搜索发现目标才进 TRACK -----------------------------------------
def test_search_sees_target_goes_track():
sm, t = _to_search()
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), t + 1000)
assert tr.occurred and tr.to_state is TaskState.TRACK
assert "发现目标" in tr.reason
def test_search_without_target_stays():
sm, t = _to_search()
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=False), t + 1000)
assert not tr.occurred
# 居中稳定满 3s → APPROACH
tr = sm.update(_inp(target_injected=True, target_locked=True,
target_centered=True, centered_duration_s=3.0), 5000)
assert tr.occurred and tr.to_state is TaskState.APPROACH
assert sm.state is TaskState.SEARCH
def test_track_lost_with_coords_goes_back_to_goto():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
tr = sm.update(_inp(target_injected=True, target_locked=False), 4000)
assert tr.occurred and tr.to_state is TaskState.GOTO
def test_track_lost_without_coords_goes_rtl():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
tr = sm.update(_inp(target_injected=False, target_locked=False), 4000)
def test_search_timeout_goes_rtl():
cfg = TaskStateConfig(search_timeout_s=20.0)
sm, t = _to_search(cfg)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0),
t + 21_000)
assert tr.occurred and tr.to_state is TaskState.RTL
assert "搜索超时" in tr.reason
def test_search_loses_coords_goes_rtl():
sm, t = _to_search()
tr = sm.update(_inp(target_injected=False), t + 1000)
assert tr.occurred and tr.to_state is TaskState.RTL
# --- TRACK:缓慢靠近,只受超时/返航/紧急控制 ---------------------------------
def test_track_centered_does_not_exit():
"""目标居中**不再**触发退出:居中正是应当继续缓慢靠近的时刻。"""
sm, t = _to_track()
tr = sm.update(_inp(target_injected=True, target_locked=True,
target_centered=True, centered_duration_s=99.0), t + 1000)
assert not tr.occurred
assert sm.state is TaskState.TRACK
def test_track_holds_while_locked():
sm, t = _to_track()
for dt in (1000, 2000, 3000):
tr = sm.update(_inp(target_injected=True, target_locked=True), t + dt)
assert not tr.occurred
assert sm.state is TaskState.TRACK
def test_track_timeout_goes_rtl():
cfg = TaskStateConfig(track_timeout_s=120.0)
sm = _sm(cfg)
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
tr = sm.update(_inp(target_injected=True, target_locked=True), 3000 + 121_000)
cfg = TaskStateConfig(track_timeout_s=20.0)
sm, t = _to_track(cfg)
tr = sm.update(_inp(target_injected=True, target_locked=True), t + 21_000)
assert tr.occurred and tr.to_state is TaskState.RTL
assert "超时" in tr.reason
def test_track_lost_with_coords_goes_back_to_search():
"""丢目标回 SEARCH 重新扫视(不回 GOTO:飞机本就在参考点附近)。"""
sm, t = _to_track()
tr = sm.update(_inp(target_injected=True, target_locked=False), t + 1000)
assert tr.occurred and tr.to_state is TaskState.SEARCH
assert "重新搜索" in tr.reason
def test_track_lost_without_coords_goes_rtl():
sm, t = _to_track()
tr = sm.update(_inp(target_injected=False, target_locked=False), t + 1000)
assert tr.occurred and tr.to_state is TaskState.RTL
# --- APPROACH ---------------------------------------------------------------
def test_reacquire_after_loss_returns_to_track():
"""丢目标→SEARCH→重新锁定→TRACK 全链路可循环。"""
sm, t = _to_track()
tr = sm.update(_inp(target_injected=True, target_locked=False), t + 1000)
assert tr.to_state is TaskState.SEARCH
tr = sm.update(_inp(target_injected=True, target_locked=True), t + 2000)
assert tr.to_state is TaskState.TRACK
# --- APPROACH(保留态:真机可选收尾)----------------------------------------
def test_approach_hover_done_goes_rtl():
cfg = TaskStateConfig(center_hold_s=0.0, approach_hover_s=10.0)
cfg = TaskStateConfig(approach_hover_s=10.0)
sm = _sm(cfg)
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
sm.update(_inp(target_injected=True, target_locked=True, target_centered=True,
centered_duration_s=0.0), 4000)
assert sm.state is TaskState.APPROACH
# 悬停未满 10s → 停留
tr = sm.update(_inp(target_injected=True, target_locked=True), 4000 + 5000)
sm.force_state(TaskState.APPROACH, 1000)
tr = sm.update(_inp(), 1000 + 5000)
assert not tr.occurred
# 满 10s → RTL
tr = sm.update(_inp(target_injected=True, target_locked=True), 4000 + 10_000)
tr = sm.update(_inp(), 1000 + 10_000)
assert tr.occurred and tr.to_state is TaskState.RTL
# --- 全局终止(紧急 / 返航)-------------------------------------------------
@pytest.mark.parametrize("state_kw", [
{}, # WAIT
{"target_injected": True}, # 会先转 ARM_TAKEOFF
])
def test_emergency_goes_rtl_from_any_state(state_kw):
sm = _sm()
sm.update(_inp(**state_kw), 1000)
tr = sm.update(_inp(emergency=True), 2000)
def test_emergency_from_search_goes_rtl():
sm, t = _to_search()
tr = sm.update(_inp(emergency=True), t + 1000)
assert tr.occurred and tr.to_state is TaskState.RTL
def test_emergency_from_track_goes_rtl():
sm, t = _to_track()
tr = sm.update(_inp(target_locked=True, emergency=True), t + 1000)
assert tr.occurred and tr.to_state is TaskState.RTL
@@ -218,7 +259,6 @@ def test_rtl_is_terminal():
sm = _sm()
sm.update(_inp(rtl_requested=True), 1000)
assert sm.state is TaskState.RTL
# 终态不再转移(连紧急/坐标也不动)
tr = sm.update(_inp(target_injected=True, emergency=True), 2000)
assert not tr.occurred
assert sm.state is TaskState.RTL
@@ -231,3 +271,11 @@ def test_transition_dataclass_defaults():
assert tr.to_state is TaskState.WAIT
assert tr.occurred is False
assert tr.reason == ""
# --- 配置默认值(搜索/靠近超时口径)------------------------------------------
def test_default_config_has_search_and_pursuit_timeouts():
cfg = TaskStateConfig()
assert cfg.search_timeout_s == pytest.approx(20.0)
assert cfg.track_timeout_s == pytest.approx(20.0)
assert cfg.sweep_rate_dps > 0.0