From 88ec0ba1d430ac48226c471bd0d694637d412e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sun, 12 Jul 2026 22:36:05 +0900 Subject: [PATCH] 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. --- edge/adapters/picamera_source.py | 27 ++++++++++++++++++++---- edge/daemon.py | 10 +++++++-- tests/test_picamera_source.py | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/test_picamera_source.py diff --git a/edge/adapters/picamera_source.py b/edge/adapters/picamera_source.py index 2fa7c8e..c9ae8c5 100644 --- a/edge/adapters/picamera_source.py +++ b/edge/adapters/picamera_source.py @@ -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: - def __init__(self, index: int = 0): + def __init__(self, index: int = 0, size=(640, 480), picam2=None): 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): - raise NotImplementedError + frame = self.picam2.capture_array() + if frame is None: + return False, None + return True, frame diff --git a/edge/daemon.py b/edge/daemon.py index 226b027..256c176 100644 --- a/edge/daemon.py +++ b/edge/daemon.py @@ -40,7 +40,6 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de from edge.alert import PostClient from edge.queue import OfflineQueue from edge.runtime import PipelineRuntime - from edge.adapters.uvc_source import UvcSource cfg = load_config(dict(os.environ)) 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) post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now) 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) # or the default CPU "movenet" (needs MOVENET_ONNX). if os.environ.get("POSE_BACKEND", "movenet").lower() == "hailo": diff --git a/tests/test_picamera_source.py b/tests/test_picamera_source.py new file mode 100644 index 0000000..24cad99 --- /dev/null +++ b/tests/test_picamera_source.py @@ -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