feat(edge): CSI camera source (picamera2) + CAMERA_BACKEND switch

Replace the PiCameraSource NotImplementedError stub with a working picamera2 CSI
source (SourcePort read()->(ok,frame), RGB888->BGR to match the pose adapters;
picamera2 lazy-imported as a system package, injected fake in tests). daemon.main()
gains CAMERA_BACKEND (csi|uvc) selecting PiCameraSource or UvcSource.
This commit is contained in:
유창욱 2026-07-12 22:36:05 +09:00
parent 2335faa568
commit 88ec0ba1d4
3 changed files with 67 additions and 6 deletions

View file

@ -1,10 +1,29 @@
"""CSI 카메라 소스 (실장비). 카메라당 별 프로세스로 picamera2 구동.""" """CSI 카메라 소스 (실장비 Pi5, picamera2). Implements SourcePort.
picamera2 is a SYSTEM package on Raspberry Pi OS (apt: python3-picamera2 + libcamera),
NOT a pip wheel — the venv sees it via --system-site-packages (see deploy/README).
COLOR NOTE: configured for "RGB888", which picamera2 delivers as a 3-channel array in
**BGR** byte order (documented picamera2 quirk). The downstream pose adapters
(MoveNet/Hailo) expect BGR (they do cv2.cvtColor(BGR2RGB)), same as cv2.VideoCapture,
so read() returns the array as-is. Validate the channel order on device during bringup
(a BGR<->RGB swap yields plausible-but-wrong keypoints).
"""
class PiCameraSource: class PiCameraSource:
def __init__(self, index: int = 0): def __init__(self, index: int = 0, size=(640, 480), picam2=None):
self.index = index self.index = index
raise NotImplementedError("실장비 전용: picamera2 배선 필요") if picam2 is None: # pragma: no cover (device-only: needs picamera2 + CSI camera)
from picamera2 import Picamera2 # system apt package (python3-picamera2)
picam2 = Picamera2(camera_num=index)
picam2.configure(picam2.create_video_configuration(
main={"size": tuple(size), "format": "RGB888"}))
picam2.start()
self.picam2 = picam2
def read(self): def read(self):
raise NotImplementedError frame = self.picam2.capture_array()
if frame is None:
return False, None
return True, frame

View file

@ -40,7 +40,6 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
from edge.alert import PostClient from edge.alert import PostClient
from edge.queue import OfflineQueue from edge.queue import OfflineQueue
from edge.runtime import PipelineRuntime from edge.runtime import PipelineRuntime
from edge.adapters.uvc_source import UvcSource
cfg = load_config(dict(os.environ)) cfg = load_config(dict(os.environ))
nonce = lambda: os.urandom(8).hex() nonce = lambda: os.urandom(8).hex()
@ -49,7 +48,14 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now) client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now) post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post) queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
source = UvcSource(int(os.environ.get("UVC_DEVICE", "0"))) # CAMERA_BACKEND selects the capture source: "csi" (Pi CSI cam via picamera2,
# uses CSI_DEVICE) or the default "uvc" (USB webcam via OpenCV, uses UVC_DEVICE).
if os.environ.get("CAMERA_BACKEND", "uvc").lower() == "csi":
from edge.adapters.picamera_source import PiCameraSource
source = PiCameraSource(int(os.environ.get("CSI_DEVICE", "0")))
else:
from edge.adapters.uvc_source import UvcSource
source = UvcSource(int(os.environ.get("UVC_DEVICE", "0")))
# POSE_BACKEND selects the accelerator path: "hailo" (Pi5 + Hailo-8, needs HAILO_HEF) # POSE_BACKEND selects the accelerator path: "hailo" (Pi5 + Hailo-8, needs HAILO_HEF)
# or the default CPU "movenet" (needs MOVENET_ONNX). # or the default CPU "movenet" (needs MOVENET_ONNX).
if os.environ.get("POSE_BACKEND", "movenet").lower() == "hailo": if os.environ.get("POSE_BACKEND", "movenet").lower() == "hailo":

View file

@ -0,0 +1,36 @@
"""TDD for PiCameraSource (CSI camera via picamera2) — no hardware/picamera2 needed,
by injecting a fake Picamera2 object. Locks the SourcePort contract: read() -> (ok, frame)."""
import numpy as np
from edge.adapters.picamera_source import PiCameraSource
class _FakePicam2:
def __init__(self, frames):
self.frames = list(frames)
self.i = 0
self.started = False
def capture_array(self):
frame = self.frames[self.i % len(self.frames)]
self.i += 1
return frame
def test_read_returns_ok_and_frame():
frame = np.full((480, 640, 3), 128, np.uint8)
src = PiCameraSource(picam2=_FakePicam2([frame]))
ok, f = src.read()
assert ok is True
assert f.shape == (480, 640, 3)
def test_read_none_frame_returns_false():
class _Empty:
def capture_array(self):
return None
src = PiCameraSource(picam2=_Empty())
ok, f = src.read()
assert ok is False
assert f is None