Scanbot Stereo Camera

Task Details
← Task Board

Task Description

# 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

Job Queue (0)

No job queue entries for this task yet