# Task: Build Scanbot Stereo Camera Module
**Priority:** HIGH
**Target file:** `Scanbot/src/vision/camera.py`
**Dependencies:** numpy, opencv-python
**Pattern reference:** `Scanbot/src/farmdefender/camera_capture.py` (lines 87-98 for OpenCV init)
## What to Build
A `StereoCamera` class that wraps OpenCV VideoCapture for the ELP USB3 stereo camera. The camera outputs 2560x720 side-by-side frames (left + right images stitched horizontally).
## Class Specification
```python
import cv2
import numpy as np
import logging
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
class StereoCamera:
"""Stereo camera capture for ELP USB3 dual global-shutter camera.
The camera outputs side-by-side frames (2560x720 default).
Left image is the left half, right image is the right half.
"""
def __init__(self, device_index: int = 0, width: int = 2560,
height: int = 720, fps: int = 30):
self.device_index = device_index
self.width = width # Full stereo frame width
self.height = height
self.target_fps = fps
self._cap: Optional[cv2.VideoCapture] = None
self._frame_count = 0
def open(self) -> bool:
"""Open the camera device and configure resolution/FPS.
Returns True if camera opened successfully.
"""
# Use cv2.VideoCapture with device index
# Set CAP_PROP_FRAME_WIDTH, CAP_PROP_FRAME_HEIGHT, CAP_PROP_FPS
# Verify actual resolution matches requested
# Log actual vs requested resolution
# Return True/False
def read(self) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
"""Capture a frame and split into left/right images.
Returns (left_frame, right_frame) or (None, None) on failure.
Each frame is width/2 x height.
"""
# cap.read() → ret, frame
# If not ret, return (None, None)
# Split: mid = frame.shape[1] // 2
# left = frame[:, :mid]
# right = frame[:, mid:]
# Increment frame_count
# Return (left, right)
def read_raw(self) -> Optional[np.ndarray]:
"""Capture raw side-by-side stereo frame.
Returns full 2560x720 frame or None on failure.
"""
def close(self):
"""Release camera resources."""
# cap.release()
def is_opened(self) -> bool:
"""Check if camera is currently open."""
@property
def frame_size(self) -> Tuple[int, int]:
"""Single image size (width/2, height)."""
return (self.width // 2, self.height)
@property
def frame_count(self) -> int:
"""Total frames captured since open."""
return self._frame_count
```
## Key Requirements
1. Follow the OpenCV init pattern from FarmDefender:
```python
self._cap = cv2.VideoCapture(self.device_index)
if not self._cap.isOpened():
logger.error(f"Failed to open camera {self.device_index}")
return False
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
self._cap.set(cv2.CAP_PROP_FPS, self.target_fps)
actual_w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
actual_h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
logger.info(f"Camera opened: requested {self.width}x{self.height}, got {actual_w}x{actual_h}")
```
2. Frame splitting must handle odd widths gracefully
3. Log warnings if actual resolution doesn't match requested
4. Thread-safe (will be called from main pipeline loop)
5. Include proper docstrings