Skip to content

Commit c8c3441

Browse files
Alex-Hacheclaude
andcommitted
feat: IP Webcam phone camera support (BACKLOG 6B.1)
- perception/camera.py: Camera(source) now accepts int (USB) or str (URL). normalize_camera_source() turns bare host:port into http://host:port/video. HTTP streams get CAP_PROP_BUFFERSIZE=1 to avoid frame lag. - config.py: CAMERA_SOURCE reads DECRAS_CAMERA env (digits -> int, else URL). - server.py + scripts/calibrate.py: pass CAMERA_SOURCE to Camera(). - .mcp.json: DECRAS_CAMERA=192.168.129.1:8080 for the MCP subprocess. - tests/test_perception.py: 5 new normalization tests. - Live-validated end-to-end: 1920x1080 BGR frame from phone. - BACKLOG + PROJECT_STATUS updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9a6d630 commit c8c3441

8 files changed

Lines changed: 88 additions & 21 deletions

File tree

‎.mcp.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"cwd": "/home/alexandre/IA/DecRAS",
77
"env": {
88
"SIMULATE": "false",
9-
"LEROBOT_FOLLOWER_PORT": "/dev/decras_follower"
9+
"LEROBOT_FOLLOWER_PORT": "/dev/decras_follower",
10+
"DECRAS_CAMERA": "192.168.129.1:8080"
1011
}
1112
}
1213
}

‎BACKLOG.md‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,9 @@
106106
107107
### 6B.1 Camera setup
108108

109-
- [ ] **Install IP Webcam on phone** — Android app, connect to same WiFi as robot PC. Note stream URL (`http://<phone-ip>:8080/video`).
110-
- *Done when*: Stream URL opens in browser with live video
111-
- *Time*: 10 min
109+
- [x] **Install IP Webcam on phone** — DONE (2026-04-19): stream at `http://192.168.129.1:8080/video`, verified returning 1920×1080 BGR frames.
112110

113-
- [ ] **Wire phone camera into perception pipeline** — Update `perception/camera.py` to accept an IP Webcam URL as input source (alongside USB webcam). Scene graph builder should work unchanged.
114-
- *Done when*: `observe()` MCP tool returns scene graph with detected objects via phone camera
115-
- *Time*: 30 min
116-
- *Tag*: `claude-code`
111+
- [x] **Wire phone camera into perception pipeline** — DONE (2026-04-19): `Camera(source)` accepts int (USB) or string URL; bare `host:port` normalized to `http://host:port/video`. `CAMERA_SOURCE` in `config.py` reads `DECRAS_CAMERA` env var (digits → int, else URL). `.mcp.json` sets `DECRAS_CAMERA=192.168.129.1:8080`. Live test: `Camera(CAMERA_SOURCE).capture()` returns a real frame from the phone; detector runs without error.
117112

118113
### 6B.2 LLM loop end-to-end
119114

‎PROJECT_STATUS.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ mcp_server/
3434
robot/kinematics.py — placo FK/IK engine (PyBullet DIRECT headless client)
3535
sim/base.py — Abstract SimEnvironment (designed for Isaac Sim swap)
3636
sim/pybullet_env.py — PyBullet backend
37-
perception/camera.py — Webcam capture (optional, degrades gracefully)
37+
perception/camera.py — Webcam capture (USB int device or IP Webcam URL via CAMERA_SOURCE / DECRAS_CAMERA env; degrades to simulated frames when SIMULATE=true)
3838
perception/detector.py — Color-based object detection
3939
perception/scene_graph.py — Scene graph builder
4040
episode.py — Episode recorder
@@ -97,7 +97,7 @@ datasets/
9797
- **placo FK/IK `.pos` suffix bug fixed (April 2026)** — `joints_to_cartesian()` and `cartesian_to_joints()` silently ignored input joint angles due to key name mismatch (`.pos` suffix from LeRobot). Fixed via `_normalize_joint_dict()`. FK/IK now produces correct results, validated with 10cm XY square on hardware. Remaining concern: IK still changes wrist_flex during EE-space moves → Z drop when moving in X.
9898
- **`move_cartesian_delta` rewritten (April 2026)** — was reading mid-motion `get_observation()` between sub-steps and using a fast inner interpolation (200ms) the servo couldn't track. Both bugs caused 30-50% reach. New implementation plans waypoints upfront from FK seed, chains IK seeds, uses convergence-based wait + active-hold. Reach now 80-95% on horizontal/down moves.
9999
- **+Z up moves limited to 25-50% reach** — Feetech servo torque insufficient to lift arm against gravity to IK target. Tracked in BACKLOG (Z-up gravity compensation task).
100-
- Camera/perception pipeline not tested on real hardware yet
100+
- Camera pipeline live-tested with IP Webcam on phone (April 2026): 1920×1080 frames from `http://192.168.129.1:8080/video`. Detector runs end-to-end. Real-scene detector tuning (HSV ranges for actual lab objects, not demo red cup / blue plate) not yet done.
101101
- Servo convergence under gravity load requires active hold loops (repeated send_joint_positions)
102102
- Only 2 ports: follower on `/dev/ttyACM0`, leader on `/dev/ttyACM1`
103103

@@ -172,7 +172,7 @@ All 8 motion primitives hardware-validated (March 2026):
172172
- **Test Zero**: replay segmenter v2 output on hardware — critical gate for Path B
173173

174174
**Phase 6B — Full Loop (NEXT)**:
175-
- Camera setup (IP Webcam on phone) + wire into perception pipeline
175+
- ~~Camera setup (IP Webcam on phone) + wire into perception pipeline~~ — DONE (phone MJPEG stream, `DECRAS_CAMERA` env var, live-tested)
176176
- LLM observe-think-act loop end-to-end on real hardware
177177
- Analyze LLM failure modes
178178
- First RAG experiment: inject demo sequence as few-shot example

‎mcp_server/config.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@
2121
# Camera-to-robot affine transform (populate during calibration)
2222
CAMERA_TO_ROBOT_MATRIX = None # 3x3 affine, set by calibration script
2323

24+
# Camera source: int for USB device index, str for IP Webcam URL.
25+
# Override via env var DECRAS_CAMERA, e.g. DECRAS_CAMERA=192.168.129.1:8080
26+
# — bare host:port is normalized to http://host:port/video by Camera().
27+
# — integers are parsed as USB device indices.
28+
_camera_env = os.environ.get("DECRAS_CAMERA", "").strip()
29+
if _camera_env == "":
30+
CAMERA_SOURCE: int | str = 0
31+
elif _camera_env.isdigit():
32+
CAMERA_SOURCE = int(_camera_env)
33+
else:
34+
CAMERA_SOURCE = _camera_env
35+
2436
# Robot workspace limits (meters, in robot frame)
2537
WORKSPACE = {
2638
"x_min": 0.0,

‎mcp_server/perception/camera.py‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
"""Webcam capture and frame buffering."""
1+
"""Webcam capture and frame buffering.
2+
3+
``Camera`` accepts either:
4+
- int — OpenCV USB device index (``Camera(0)``)
5+
- str — an IP Webcam MJPEG stream URL (``Camera("http://192.168.129.1:8080/video")``)
6+
Bare ``host:port`` is accepted and normalized to ``http://host:port/video``.
7+
"""
28

39
import logging
410
import numpy as np
@@ -13,9 +19,31 @@
1319
logger.warning("opencv-python not installed — camera will use simulated frames")
1420

1521

22+
def normalize_camera_source(source: int | str) -> int | str:
23+
"""Pass ints through; coerce string URLs into a form OpenCV can open.
24+
25+
Rules for strings:
26+
- If it already has a scheme (``http://``/``https://``/``rtsp://``), leave it.
27+
- Otherwise prepend ``http://``.
28+
- If no path is provided (just ``host:port``), append ``/video`` (IP Webcam default MJPEG endpoint).
29+
"""
30+
if isinstance(source, int):
31+
return source
32+
s = str(source).strip()
33+
if not s:
34+
raise ValueError("Camera source string is empty")
35+
lower = s.lower()
36+
if not (lower.startswith("http://") or lower.startswith("https://") or lower.startswith("rtsp://")):
37+
s = "http://" + s
38+
scheme, _, rest = s.partition("://")
39+
if "/" not in rest:
40+
s = f"{scheme}://{rest}/video"
41+
return s
42+
43+
1644
class Camera:
17-
def __init__(self, device_id: int = 0):
18-
self._device_id = device_id
45+
def __init__(self, source: int | str = 0):
46+
self._source = normalize_camera_source(source)
1947
self._cap = None
2048
self._last_frame: np.ndarray | None = None
2149

@@ -25,10 +53,13 @@ def __init__(self, device_id: int = 0):
2553
logger.info("Camera running in SIMULATE mode")
2654

2755
def _open(self):
28-
self._cap = cv2.VideoCapture(self._device_id)
56+
self._cap = cv2.VideoCapture(self._source)
2957
if not self._cap.isOpened():
30-
raise RuntimeError(f"Cannot open camera device {self._device_id}")
31-
logger.info("Camera opened on device %d", self._device_id)
58+
raise RuntimeError(f"Cannot open camera source {self._source!r}")
59+
if isinstance(self._source, str):
60+
# Minimize latency on HTTP streams — we always want the newest frame, not a backlog.
61+
self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
62+
logger.info("Camera opened on source %r", self._source)
3263

3364
def capture(self) -> np.ndarray:
3465
"""Capture a single frame. Returns BGR numpy array."""

‎mcp_server/server.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ def _get_camera():
6363
return None
6464
try:
6565
from mcp_server.perception.camera import Camera
66-
camera = Camera()
67-
logger.info("Camera opened")
66+
from mcp_server.config import CAMERA_SOURCE
67+
camera = Camera(CAMERA_SOURCE)
68+
logger.info("Camera opened (source=%r)", CAMERA_SOURCE)
6869
except Exception as e:
6970
logger.warning("Camera not available: %s", e)
7071
return camera

‎scripts/calibrate.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,10 @@ def collect_points_interactive() -> tuple[np.ndarray, np.ndarray]:
4444
4545
Returns (pixel_points, robot_points) as Nx2 and Nx2 arrays.
4646
"""
47+
from mcp_server.config import CAMERA_SOURCE
4748
from mcp_server.perception.camera import Camera
4849

49-
camera = Camera()
50+
camera = Camera(CAMERA_SOURCE)
5051
pixel_points = []
5152
robot_points = []
5253
click_pos = [None]

‎tests/test_perception.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,36 @@
11
"""Tests for the perception pipeline (camera, detector, scene graph)."""
22

3-
from mcp_server.perception.camera import Camera
3+
import pytest
4+
5+
from mcp_server.perception.camera import Camera, normalize_camera_source
46
from mcp_server.perception.detector import detect_objects
57
from mcp_server.perception.scene_graph import build_scene_graph
68

79

10+
def test_normalize_camera_source_passes_int():
11+
assert normalize_camera_source(0) == 0
12+
assert normalize_camera_source(2) == 2
13+
14+
15+
def test_normalize_camera_source_bare_host_port():
16+
assert normalize_camera_source("192.168.129.1:8080") == "http://192.168.129.1:8080/video"
17+
18+
19+
def test_normalize_camera_source_preserves_scheme():
20+
assert normalize_camera_source("http://cam/video") == "http://cam/video"
21+
assert normalize_camera_source("https://cam:443/stream") == "https://cam:443/stream"
22+
assert normalize_camera_source("rtsp://cam/live") == "rtsp://cam/live"
23+
24+
25+
def test_normalize_camera_source_appends_video_path():
26+
assert normalize_camera_source("http://cam:8080") == "http://cam:8080/video"
27+
28+
29+
def test_normalize_camera_source_rejects_empty():
30+
with pytest.raises(ValueError):
31+
normalize_camera_source("")
32+
33+
834
def test_camera_capture():
935
camera = Camera()
1036
frame = camera.capture()

0 commit comments

Comments
 (0)