feat: pybot 新增 @Camera 摄像头块——picamera2 惰性导入,后台守护线程维护有界帧队列(满丢最旧)
This commit is contained in:
1 parent
a8ffa73a6b
commit
fee1241e49
6 files changed
+283
-2
No files matched your search
+14
-1
@@ -12,6 +12,7 @@ PCA9685 PWM 控制器 + MPU6050 姿态传感器)。
|
|||||||
|
|
||||||
- **零第三方依赖**:纯标准库(fcntl / struct / threading / wave / argparse),
|
- **零第三方依赖**:纯标准库(fcntl / struct / threading / wave / argparse),
|
||||||
树莓派上 `git pull` 即部署,无构建步骤。
|
树莓派上 `git pull` 即部署,无构建步骤。
|
||||||
|
(picamera2 为可选依赖,仅 `@Camera` 摄像头块需要,树莓派 OS 预装,勿 pip 安装)
|
||||||
- **与仓颉版逐语义对齐**:FSM 解析器、占位符递归、Servo 校准算法、
|
- **与仓颉版逐语义对齐**:FSM 解析器、占位符递归、Servo 校准算法、
|
||||||
PCA9685 寄存器序列均与 bot4cj 一致(Mock 总线 journal 可逐字节 diff)。
|
PCA9685 寄存器序列均与 bot4cj 一致(Mock 总线 journal 可逐字节 diff)。
|
||||||
- **Python 插件替代 Lua**:`modules/<块名>.py` 定义
|
- **Python 插件替代 Lua**:`modules/<块名>.py` 定义
|
||||||
@@ -35,7 +36,7 @@ cat scripts/std.mt | ./_run.sh # 与 bot4cj 相同的管道用法
|
|||||||
|
|
||||||
```
|
```
|
||||||
pybot/ # 包:parser / robot / hardvars / plugin / main
|
pybot/ # 包:parser / robot / hardvars / plugin / main
|
||||||
pybot/hd/ # 硬件层:i2c / spi / servo / kalman / mpu6050 / speaker
|
pybot/hd/ # 硬件层:i2c / spi / servo / kalman / mpu6050 / speaker / camera
|
||||||
modules/ # 硬件驱动插件:PCA9685 / SPI / ADS1115 / ADS1256
|
modules/ # 硬件驱动插件:PCA9685 / SPI / ADS1115 / ADS1256
|
||||||
scripts -> ../bot4cj/scripts # .mt 脚本与仓颉版共享
|
scripts -> ../bot4cj/scripts # .mt 脚本与仓颉版共享
|
||||||
tests/ # pytest 单元测试(Mock 总线,无需硬件)
|
tests/ # pytest 单元测试(Mock 总线,无需硬件)
|
||||||
@@ -55,3 +56,15 @@ venv/bin/python -m pytest tests/ -v
|
|||||||
见 [bot4cj/README.md](../bot4cj/README.md) 与 `scripts/std.mt`。
|
见 [bot4cj/README.md](../bot4cj/README.md) 与 `scripts/std.mt`。
|
||||||
格式要点:`@块名` 开块、行 0 为表头、`;` 结行、`#` 注释、
|
格式要点:`@块名` 开块、行 0 为表头、`;` 结行、`#` 注释、
|
||||||
`-` 占位符(非首行取上一行同列值;首行数值列解析为 0)、`@` 收块。
|
`-` 占位符(非首行取上一行同列值;首行数值列解析为 0)、`@` 收块。
|
||||||
|
|
||||||
|
新增块(无仓颉版对应):
|
||||||
|
|
||||||
|
```
|
||||||
|
@Camera
|
||||||
|
name width height framerate capacity;
|
||||||
|
cam0 640 480 30 4;
|
||||||
|
@
|
||||||
|
```
|
||||||
|
|
||||||
|
`build` 启动后台抓帧线程,维护容量为 `capacity` 的有界帧队列(满丢最旧帧);
|
||||||
|
`play` 返回最新帧 `(时间戳, frame)`。
|
||||||
@@ -12,6 +12,7 @@ JsonValue 通信协议改为原生 dict/str(无跨语言边界后无需序列
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .hd import i2c as hd_i2c
|
from .hd import i2c as hd_i2c
|
||||||
|
from .hd.camera import Camera
|
||||||
from .hd.mpu6050 import MPU6050
|
from .hd.mpu6050 import MPU6050
|
||||||
from .hd.servo import Servo
|
from .hd.servo import Servo
|
||||||
from .hd.speaker import Speaker
|
from .hd.speaker import Speaker
|
||||||
@@ -186,3 +187,41 @@ class SpeakerVar(HardwareVar):
|
|||||||
volumn=volumn, max_f=max_f, min_f=min_f,
|
volumn=volumn, max_f=max_f, min_f=min_f,
|
||||||
dry_run=dry_run)
|
dry_run=dry_run)
|
||||||
return cls(name, speaker)
|
return cls(name, speaker)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Camera 摄像头变量(pybot 新增,无仓颉版对应) ----------------
|
||||||
|
|
||||||
|
class CameraVar(HardwareVar):
|
||||||
|
"""摄像头:build 启动后台抓帧线程维护有界队列;play() 返回最新帧"""
|
||||||
|
|
||||||
|
def __init__(self, name, camera):
|
||||||
|
super().__init__(name)
|
||||||
|
self.camera = camera
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
log.info("Camera HVar: %s is starting now", self.name)
|
||||||
|
self.camera.setup()
|
||||||
|
self.camera.start_daemon()
|
||||||
|
|
||||||
|
def play(self, params):
|
||||||
|
"""返回 (时间戳, frame) 或 None(队列空 / dry_run)"""
|
||||||
|
return self.camera.latest()
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
log.info("Camera HVar: %s is closing now", self.name)
|
||||||
|
self.camera.stop_daemon()
|
||||||
|
self.camera.wait_daemon()
|
||||||
|
self.camera.close()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_matrix(cls, matrix, i, M, dry_run=False, **kwargs):
|
||||||
|
name = matrix[i, matrix.index_of_col(0, "name")]
|
||||||
|
width = parse_mt(matrix, i, matrix.index_of_col(0, "width"), int)
|
||||||
|
height = parse_mt(matrix, i, matrix.index_of_col(0, "height"), int)
|
||||||
|
framerate = parse_mt(matrix, i, matrix.index_of_col(0, "framerate"))
|
||||||
|
capacity = parse_mt(matrix, i, matrix.index_of_col(0, "capacity"), int)
|
||||||
|
log.info("Create a Camera hard-Var: %s, %dx%d @ %.1ffps, capacity: %s",
|
||||||
|
name, width, height, framerate, capacity)
|
||||||
|
camera = Camera(width=width, height=height, framerate=framerate,
|
||||||
|
capacity=capacity, dry_run=dry_run)
|
||||||
|
return cls(name, camera)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""摄像头帧队列 —— pybot 新增(无仓颉版对应)
|
||||||
|
|
||||||
|
后台守护线程按固定帧率抓帧,维护一个有界帧队列
|
||||||
|
(deque(maxlen=capacity),满了丢最旧帧)——实时控制场景永远消费最新帧。
|
||||||
|
生命周期模式对齐 MPU6050 采样线程:
|
||||||
|
setup() → start_daemon() → latest()/snapshot() → stop_daemon()/wait_daemon() → close()
|
||||||
|
|
||||||
|
帧来源 picamera2(树莓派 OS 预装)是可选依赖:仅在 setup() 内惰性导入,
|
||||||
|
不使用 @Camera 的脚本 / 单测 / --dry-run 都不需要它,零依赖承诺不变。
|
||||||
|
测试可注入 grabber(callable() -> frame)完全脱离硬件。
|
||||||
|
|
||||||
|
dry_run 模式:setup/start 均为空操作(不碰硬件),latest() 恒为 None。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
log = logging.getLogger("pybot.camera")
|
||||||
|
|
||||||
|
|
||||||
|
class Camera:
|
||||||
|
"""picamera2 摄像头 + 后台有界帧队列
|
||||||
|
|
||||||
|
width/height: 帧尺寸;framerate: 抓帧帧率;capacity: 队列容量(满丢最旧);
|
||||||
|
grabber: 可注入的抓帧回调(测试用),None = 用 picamera2 抓帧。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, width=640, height=480, framerate=30.0, capacity=4,
|
||||||
|
grabber=None, dry_run=False):
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.framerate = framerate
|
||||||
|
self.capacity = capacity
|
||||||
|
self.dry_run = dry_run
|
||||||
|
self._grabber = grabber
|
||||||
|
self._picam = None
|
||||||
|
self._queue = deque(maxlen=capacity)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
# ---------------- 初始化 ----------------
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
"""打开摄像头(dry_run / 已初始化 / 注入 grabber 时不碰硬件,幂等)"""
|
||||||
|
if self.dry_run or self._grabber is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from picamera2 import Picamera2 # 可选依赖,惰性导入
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"@Camera 需要可选依赖 picamera2(树莓派 OS 预装,勿 pip 安装);"
|
||||||
|
"无硬件环境请用 --dry-run 或注入 grabber") from None
|
||||||
|
self._picam = Picamera2()
|
||||||
|
self._picam.configure(self._picam.create_video_configuration(
|
||||||
|
main={"size": (self.width, self.height)}))
|
||||||
|
self._picam.start()
|
||||||
|
self._grabber = self._picam.capture_array
|
||||||
|
time.sleep(0.1) # 等传感器稳定(对齐 MPU6050 setup)
|
||||||
|
log.debug("camera setup done (%dx%d @ %.1ffps)",
|
||||||
|
self.width, self.height, self.framerate)
|
||||||
|
|
||||||
|
# ---------------- 帧访问(线程安全) ----------------
|
||||||
|
|
||||||
|
def latest(self):
|
||||||
|
"""最新一帧 (monotonic 时间戳, frame);队列空返回 None"""
|
||||||
|
with self._lock:
|
||||||
|
return self._queue[-1] if self._queue else None
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
"""队列快照 list(旧→新),供消费方批量取帧"""
|
||||||
|
with self._lock:
|
||||||
|
return list(self._queue)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self):
|
||||||
|
"""当前队列内帧数"""
|
||||||
|
with self._lock:
|
||||||
|
return len(self._queue)
|
||||||
|
|
||||||
|
# ---------------- 抓帧线程 ----------------
|
||||||
|
|
||||||
|
def start_daemon(self):
|
||||||
|
if self.dry_run or self._thread is not None:
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
self._thread = threading.Thread(target=self._loop,
|
||||||
|
name="camera-grabber", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
log.debug("camera grabber started")
|
||||||
|
|
||||||
|
def _grab_once(self):
|
||||||
|
"""抓一帧入队(满则挤掉最旧帧);单独成方法便于无线程单测"""
|
||||||
|
frame = self._grabber()
|
||||||
|
with self._lock:
|
||||||
|
self._queue.append((time.monotonic(), frame))
|
||||||
|
|
||||||
|
def _loop(self):
|
||||||
|
interval = 1.0 / self.framerate
|
||||||
|
while not self._stop.wait(interval):
|
||||||
|
try:
|
||||||
|
self._grab_once()
|
||||||
|
except Exception:
|
||||||
|
# grabber 异常类型取决于帧源实现,守护线程不许死
|
||||||
|
log.warning("camera grab failed", exc_info=True)
|
||||||
|
|
||||||
|
def stop_daemon(self):
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def wait_daemon(self):
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join()
|
||||||
|
self._thread = None
|
||||||
|
log.debug("camera grabber stopped")
|
||||||
|
|
||||||
|
# ---------------- 释放 ----------------
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""关闭摄像头;幂等"""
|
||||||
|
if self._picam is not None:
|
||||||
|
self._picam.stop()
|
||||||
|
self._picam.close()
|
||||||
|
self._picam = None
|
||||||
|
log.debug("camera closed")
|
||||||
@@ -13,7 +13,8 @@ import atexit
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from . import plugin
|
from . import plugin
|
||||||
from .hardvars import (AliasVar, I2CVar, MPU6050Var, ServoVar, SpeakerVar)
|
from .hardvars import (AliasVar, CameraVar, I2CVar, MPU6050Var, ServoVar,
|
||||||
|
SpeakerVar)
|
||||||
from .parser import MatrixParser, MatrixSaver, get_mt, parse_mt
|
from .parser import MatrixParser, MatrixSaver, get_mt, parse_mt
|
||||||
|
|
||||||
log = logging.getLogger("pybot.robot")
|
log = logging.getLogger("pybot.robot")
|
||||||
@@ -25,6 +26,7 @@ _FACTORIES = {
|
|||||||
"Servo": ServoVar,
|
"Servo": ServoVar,
|
||||||
"Speaker": SpeakerVar,
|
"Speaker": SpeakerVar,
|
||||||
"MPU6050_BIND": MPU6050Var,
|
"MPU6050_BIND": MPU6050Var,
|
||||||
|
"Camera": CameraVar,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
# 运行时零第三方依赖(纯标准库:fcntl/struct/threading/wave/argparse)
|
# 运行时零第三方依赖(纯标准库:fcntl/struct/threading/wave/argparse)
|
||||||
|
# picamera2 为可选运行时依赖(仅 @Camera 需要,树莓派 OS 预装,勿 pip 安装)
|
||||||
# 以下仅开发测试用
|
# 以下仅开发测试用
|
||||||
pytest>=7.0
|
pytest>=7.0
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Camera 测试:有界帧队列(满丢最旧)、守护线程生命周期、dry_run、.mt 接入
|
||||||
|
|
||||||
|
全部经注入 grabber 运行,不碰 picamera2 / 真实摄像头。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from pybot.hd.camera import Camera
|
||||||
|
from pybot.robot import RobotStruct
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGrabber:
|
||||||
|
"""假帧源:产出递增序号帧"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.count = 0
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
self.count += 1
|
||||||
|
return self.count
|
||||||
|
|
||||||
|
|
||||||
|
CAMERA_MT = """
|
||||||
|
@Camera
|
||||||
|
name width height framerate capacity;
|
||||||
|
cam0 320 240 15 2;
|
||||||
|
@
|
||||||
|
|
||||||
|
@Action
|
||||||
|
cam0.frame;
|
||||||
|
0;
|
||||||
|
@
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_capacity_drops_oldest():
|
||||||
|
cam = Camera(capacity=3, grabber=FakeGrabber())
|
||||||
|
for _ in range(5):
|
||||||
|
cam._grab_once()
|
||||||
|
assert [f for _, f in cam.snapshot()] == [3, 4, 5] # 容量 3,丢最旧
|
||||||
|
assert cam.latest()[1] == 5
|
||||||
|
assert cam.size == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_empty():
|
||||||
|
assert Camera(grabber=FakeGrabber()).latest() is None
|
||||||
|
assert Camera(grabber=FakeGrabber()).snapshot() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_daemon_lifecycle():
|
||||||
|
cam = Camera(framerate=200.0, capacity=10, grabber=FakeGrabber())
|
||||||
|
cam.setup() # 注入 grabber 时不碰硬件
|
||||||
|
cam.start_daemon()
|
||||||
|
time.sleep(0.1)
|
||||||
|
cam.stop_daemon()
|
||||||
|
cam.wait_daemon()
|
||||||
|
assert cam.size > 0
|
||||||
|
n = cam.size
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert cam.size == n # 停止后队列不再增长
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_failure_keeps_daemon_alive(caplog):
|
||||||
|
def bad_grabber():
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
cam = Camera(framerate=200.0, capacity=2, grabber=bad_grabber)
|
||||||
|
cam.start_daemon()
|
||||||
|
time.sleep(0.05)
|
||||||
|
cam.stop_daemon()
|
||||||
|
cam.wait_daemon()
|
||||||
|
assert cam.size == 0
|
||||||
|
assert any("grab failed" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_no_hardware():
|
||||||
|
cam = Camera(dry_run=True)
|
||||||
|
cam.setup()
|
||||||
|
cam.start_daemon() # 空操作
|
||||||
|
cam.stop_daemon()
|
||||||
|
cam.wait_daemon()
|
||||||
|
cam.close()
|
||||||
|
assert cam.latest() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_var_from_matrix():
|
||||||
|
robot = RobotStruct(CAMERA_MT, dry_run=True)
|
||||||
|
cam = robot.M["cam0"].camera
|
||||||
|
assert (cam.width, cam.height) == (320, 240)
|
||||||
|
assert cam.framerate == 15.0
|
||||||
|
assert cam.capacity == 2
|
||||||
|
robot.deactive()
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_full_lifecycle_in_robot():
|
||||||
|
"""dry_run 下 build/run/destroy 全流程不碰硬件"""
|
||||||
|
robot = RobotStruct(CAMERA_MT, dry_run=True)
|
||||||
|
robot.active() # build:dry_run 空操作
|
||||||
|
assert robot.M["cam0"].play({}) is None
|
||||||
|
robot.run() # @Action 引用 cam0.frame 不报错
|
||||||
|
robot.deactive()
|
||||||
Reference in new issue
Block a user