归档 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。
139 lines
3.9 KiB
Python
139 lines
3.9 KiB
Python
"""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()
|