From 25a68c24a984109a6fbc13fe92cac36f87004e2f Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 16 Oct 2025 16:54:05 +0200 Subject: [PATCH 01/86] feat: add new Camera factory --- src/arduino/app_peripherals/camera/README.md | 211 +++++++++++ .../app_peripherals/camera/__init__.py | 3 + .../app_peripherals/camera/base_camera.py | 196 ++++++++++ src/arduino/app_peripherals/camera/camera.py | 113 ++++++ src/arduino/app_peripherals/camera/errors.py | 22 ++ .../app_peripherals/camera/examples/README.md | 57 +++ .../camera/examples/camera_examples.py | 282 +++++++++++++++ .../app_peripherals/camera/examples/hls.py | 18 + .../app_peripherals/camera/examples/rtsp.py | 30 ++ .../camera/examples/websocket_camera_proxy.py | 247 +++++++++++++ .../examples/websocket_client_streamer.py | 301 ++++++++++++++++ .../app_peripherals/camera/ip_camera.py | 176 +++++++++ .../app_peripherals/camera/v4l_camera.py | 150 ++++++++ .../camera/websocket_camera.py | 338 ++++++++++++++++++ 14 files changed, 2144 insertions(+) create mode 100644 src/arduino/app_peripherals/camera/README.md create mode 100644 src/arduino/app_peripherals/camera/__init__.py create mode 100644 src/arduino/app_peripherals/camera/base_camera.py create mode 100644 src/arduino/app_peripherals/camera/camera.py create mode 100644 src/arduino/app_peripherals/camera/errors.py create mode 100644 src/arduino/app_peripherals/camera/examples/README.md create mode 100644 src/arduino/app_peripherals/camera/examples/camera_examples.py create mode 100644 src/arduino/app_peripherals/camera/examples/hls.py create mode 100644 src/arduino/app_peripherals/camera/examples/rtsp.py create mode 100644 src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py create mode 100644 src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py create mode 100644 src/arduino/app_peripherals/camera/ip_camera.py create mode 100644 src/arduino/app_peripherals/camera/v4l_camera.py create mode 100644 src/arduino/app_peripherals/camera/websocket_camera.py diff --git a/src/arduino/app_peripherals/camera/README.md b/src/arduino/app_peripherals/camera/README.md new file mode 100644 index 00000000..30064dee --- /dev/null +++ b/src/arduino/app_peripherals/camera/README.md @@ -0,0 +1,211 @@ +# Camera + +The `Camera` peripheral provides a unified abstraction for capturing images from different camera types and protocols. + +## Features + +- **Universal Interface**: Single API for V4L/USB, IP cameras, and WebSocket cameras +- **Automatic Detection**: Automatically selects appropriate camera implementation based on source +- **Multiple Protocols**: Supports V4L, RTSP, HTTP/MJPEG, and WebSocket streams +- **Flexible Configuration**: Resolution, FPS, compression, and protocol-specific settings +- **Thread-Safe**: Safe concurrent access with proper locking +- **Context Manager**: Automatic resource management with `with` statements + +## Quick Start + +### Basic Usage + +```python +from arduino.app_peripherals.camera import Camera + +# USB/V4L camera (index 0) +camera = Camera(0, resolution=(640, 480), fps=15) + +with camera: + frame = camera.capture() # Returns PIL Image + if frame: + frame.save("captured.png") +``` + +### Different Camera Types + +```python +# V4L/USB cameras +usb_camera = Camera(0) # Camera index +usb_camera = Camera("1") # Index as string +usb_camera = Camera("/dev/video0") # Device path + +# IP cameras +ip_camera = Camera("rtsp://192.168.1.100/stream") +ip_camera = Camera("http://camera.local/mjpeg", + username="admin", password="secret") + +# WebSocket cameras +- `"ws://localhost:8080"` - WebSocket server URL (extracts host and port) +- `"localhost:9090"` - WebSocket server host:port format +``` + +## API Reference + +### Camera Class + +The main `Camera` class acts as a factory that creates the appropriate camera implementation: + +```python +camera = Camera(source, **options) +``` + +**Parameters:** +- `source`: Camera source identifier + - `int`: V4L camera index (0, 1, 2...) + - `str`: Camera index, device path, or URL +- `resolution`: Tuple `(width, height)` or `None` for default +- `fps`: Target frames per second (default: 10) +- `compression`: Enable PNG compression (default: False) +- `letterbox`: Make images square with padding (default: False) + +**Methods:** +- `start()`: Initialize and start camera +- `stop()`: Stop camera and release resources +- `capture()`: Capture frame as PIL Image +- `capture_bytes()`: Capture frame as bytes +- `is_started()`: Check if camera is running +- `get_camera_info()`: Get camera properties + +### Context Manager + +```python +with Camera(source, **options) as camera: + frame = camera.capture() + # Camera automatically stopped when exiting +``` + +## Camera Types + +### V4L/USB Cameras + +For local USB cameras and V4L-compatible devices: + +```python +camera = Camera(0, resolution=(1280, 720), fps=30) +``` + +**Features:** +- Device enumeration via `/dev/v4l/by-id/` +- Resolution validation +- Backend information + +### IP Cameras + +For network cameras supporting RTSP or HTTP streams: + +```python +camera = Camera("rtsp://admin:pass@192.168.1.100/stream", + timeout=10, fps=5) +``` + +**Features:** +- RTSP, HTTP, HTTPS protocols +- Authentication support +- Connection testing +- Automatic reconnection + +### WebSocket Cameras + +For hosting a WebSocket server that receives frames from clients (single client only): + +```python +# Host:port format +camera = Camera("localhost:8080", frame_format="base64", max_queue_size=10) + +# URL format +camera = Camera("ws://0.0.0.0:9090", frame_format="json") +``` + +**Features:** +- Hosts WebSocket server (not client) +- **Single client limitation**: Only one client can connect at a time +- Additional clients are rejected with error message +- Receives frames from connected client +- Base64, binary, and JSON frame formats +- Frame buffering and queue management +- Bidirectional communication with connected client + +**Client Connection:** +Only one client can connect at a time. Additional clients receive an error: +```javascript +// JavaScript client example +const ws = new WebSocket('ws://localhost:8080'); +ws.onmessage = function(event) { + const data = JSON.parse(event.data); + if (data.error) { + console.log('Connection rejected:', data.message); + } +}; +ws.send(base64EncodedImageData); +``` + +## Advanced Usage + +### Custom Configuration + +```python +camera = Camera( + source="rtsp://camera.local/stream", + resolution=(1920, 1080), + fps=15, + compression=True, # PNG compression + letterbox=True, # Square images + username="admin", # IP camera auth + password="secret", + timeout=5, # Connection timeout + max_queue_size=20 # WebSocket buffer +) +``` + +### Error Handling + +```python +from arduino.app_peripherals.camera.camera import CameraError + +try: + with Camera("invalid://source") as camera: + frame = camera.capture() +except CameraError as e: + print(f"Camera error: {e}") +``` + +### Factory Pattern + +```python +from arduino.app_peripherals.camera.camera import CameraFactory + +# Create camera directly via factory +camera = CameraFactory.create_camera( + source="ws://localhost:8080/stream", + frame_format="json" +) +``` + +## Dependencies + +### Core Dependencies +- `opencv-python` (cv2) - Image processing and V4L/IP camera support +- `Pillow` (PIL) - Image format handling +- `requests` - HTTP camera connectivity testing + +### Optional Dependencies +- `websockets` - WebSocket server support (install with `pip install websockets`) + +## Examples + +See the `examples/` directory for comprehensive usage examples: +- Basic camera operations +- Different camera types +- Advanced configuration +- Error handling +- Context managers + +## Migration from Legacy Camera + +The new Camera abstraction is backward compatible with the existing Camera implementation. Existing code using the old API will continue to work, but new code should use the improved abstraction for better flexibility and features. diff --git a/src/arduino/app_peripherals/camera/__init__.py b/src/arduino/app_peripherals/camera/__init__.py new file mode 100644 index 00000000..ef561db1 --- /dev/null +++ b/src/arduino/app_peripherals/camera/__init__.py @@ -0,0 +1,3 @@ +from .camera import Camera + +__all__ = ["Camera"] \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py new file mode 100644 index 00000000..d26dc222 --- /dev/null +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -0,0 +1,196 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import threading +import time +import io +from abc import ABC, abstractmethod +from typing import Optional, Tuple +from PIL import Image +import cv2 +import numpy as np + +from arduino.app_utils import Logger + +from .errors import CameraOpenError + +logger = Logger("Camera") + + +class BaseCamera(ABC): + """ + Abstract base class for camera implementations. + + This class defines the common interface that all camera implementations must follow, + providing a unified API regardless of the underlying camera protocol or type. + """ + + def __init__(self, resolution: Optional[Tuple[int, int]] = None, fps: int = 10, + compression: bool = False, letterbox: bool = False, **kwargs): + """ + Initialize the camera base. + + Args: + resolution: Resolution as (width, height). None uses default resolution. + fps: Frames per second for the camera. + compression: Whether to compress captured images to PNG format. + letterbox: Whether to apply letterboxing to make images square. + **kwargs: Additional camera-specific parameters. + """ + self.resolution = resolution + self.fps = fps + self.compression = compression + self.letterbox = letterbox + self.config = kwargs + self._is_started = False + self._cap_lock = threading.Lock() + self._last_capture_time = time.monotonic() + self.desired_interval = 1.0 / fps if fps > 0 else 0 + + def start(self) -> None: + """Start the camera capture.""" + with self._cap_lock: + if self._is_started: + return + + try: + self._open_camera() + self._is_started = True + self._last_capture_time = time.monotonic() + logger.info(f"Successfully started {self.__class__.__name__}") + except Exception as e: + raise CameraOpenError(f"Failed to start camera: {e}") + + def stop(self) -> None: + """Stop the camera and release resources.""" + with self._cap_lock: + if not self._is_started: + return + + try: + self._close_camera() + self._is_started = False + logger.info(f"Stopped {self.__class__.__name__}") + except Exception as e: + logger.warning(f"Error stopping camera: {e}") + + def capture(self) -> Optional[Image.Image]: + """ + Capture a frame from the camera, respecting the configured FPS. + + Returns: + PIL Image or None if no frame is available. + """ + frame = self._extract_frame() + if frame is None: + return None + + try: + if self.compression: + # Convert to PNG bytes first, then to PIL Image + success, encoded = cv2.imencode('.png', frame) + if success: + return Image.open(io.BytesIO(encoded.tobytes())) + else: + return None + else: + # Convert BGR to RGB for PIL + if len(frame.shape) == 3 and frame.shape[2] == 3: + rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + else: + rgb_frame = frame + return Image.fromarray(rgb_frame) + except Exception as e: + logger.exception(f"Error converting frame to PIL Image: {e}") + return None + + def capture_bytes(self) -> Optional[bytes]: + """ + Capture a frame and return as bytes. + + Returns: + Frame as bytes or None if no frame is available. + """ + frame = self._extract_frame() + if frame is None: + return None + + if self.compression: + success, encoded = cv2.imencode('.png', frame) + return encoded.tobytes() if success else None + else: + return frame.tobytes() + + def _extract_frame(self) -> Optional[np.ndarray]: + """Extract a frame with FPS throttling and post-processing.""" + # FPS throttling + if self.desired_interval > 0: + current_time = time.monotonic() + elapsed = current_time - self._last_capture_time + if elapsed < self.desired_interval: + time.sleep(self.desired_interval - elapsed) + + with self._cap_lock: + if not self._is_started: + return None + + frame = self._read_frame() + if frame is None: + return None + + self._last_capture_time = time.monotonic() + + # Apply post-processing + if self.letterbox: + frame = self._letterbox(frame) + + return frame + + def _letterbox(self, frame: np.ndarray) -> np.ndarray: + """Apply letterboxing to make the frame square.""" + h, w = frame.shape[:2] + if w != h: + size = max(h, w) + return cv2.copyMakeBorder( + frame, + top=(size - h) // 2, + bottom=(size - h + 1) // 2, + left=(size - w) // 2, + right=(size - w + 1) // 2, + borderType=cv2.BORDER_CONSTANT, + value=(114, 114, 114) + ) + return frame + + def is_started(self) -> bool: + """Check if the camera is started.""" + return self._is_started + + def produce(self) -> Optional[Image.Image]: + """Alias for capture method for compatibility.""" + return self.capture() + + def __enter__(self): + """Context manager entry.""" + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.stop() + + @abstractmethod + def _open_camera(self) -> None: + """Open the camera connection. Must be implemented by subclasses.""" + pass + + @abstractmethod + def _close_camera(self) -> None: + """Close the camera connection. Must be implemented by subclasses.""" + pass + + @abstractmethod + def _read_frame(self) -> Optional[np.ndarray]: + """Read a single frame from the camera. Must be implemented by subclasses.""" + pass diff --git a/src/arduino/app_peripherals/camera/camera.py b/src/arduino/app_peripherals/camera/camera.py new file mode 100644 index 00000000..a29f3cde --- /dev/null +++ b/src/arduino/app_peripherals/camera/camera.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +from typing import Union +from urllib.parse import urlparse + +from .base_camera import BaseCamera +from .errors import CameraConfigError + + +class Camera: + """ + Unified Camera class that can be configured for different camera types. + + This class serves as both a factory and a wrapper, automatically creating + the appropriate camera implementation based on the provided configuration. + """ + + def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: + """Create a camera instance based on the source type. + + Args: + source (Union[str, int]): Camera source identifier. Supports: + - int: V4L camera index (e.g., 0, 1) + - str: Camera index as string (e.g., "0", "1") for V4L + - str: Device path (e.g., "/dev/video0") for V4L + - str: URL for IP cameras (e.g., "rtsp://...", "http://...") + - str: WebSocket URL (e.g., "ws://0.0.0.0:8080") + **kwargs: Camera-specific configuration parameters grouped by type: + Common Parameters: + resolution (tuple, optional): Frame resolution as (width, height). + Default: None (auto) + fps (int, optional): Target frames per second. Default: 10 + compression (bool, optional): Enable frame compression. Default: False + letterbox (bool, optional): Enable letterboxing for resolution changes. + Default: False + V4L Camera Parameters: + device_index (int, optional): V4L device index override + capture_format (str, optional): Video capture format (e.g., 'MJPG', 'YUYV') + buffer_size (int, optional): Number of frames to buffer + IP Camera Parameters: + username (str, optional): Authentication username + password (str, optional): Authentication password + timeout (float, optional): Connection timeout in seconds. Default: 10.0 + retry_attempts (int, optional): Number of connection retry attempts. + Default: 3 + headers (dict, optional): Additional HTTP headers + verify_ssl (bool, optional): Verify SSL certificates. Default: True + WebSocket Camera Parameters: + host (str, optional): WebSocket server host. Default: "localhost" + port (int, optional): WebSocket server port. Default: 8080 + frame_format (str, optional): Expected frame format ("base64", "binary", + "json"). Default: "base64" + max_queue_size (int, optional): Maximum frames to buffer. Default: 10 + ping_interval (int, optional): WebSocket ping interval in seconds. + Default: 20 + ping_timeout (int, optional): WebSocket ping timeout in seconds. + Default: 10 + + Returns: + BaseCamera: Appropriate camera implementation instance + + Raises: + CameraConfigError: If source type is not supported or parameters are invalid + + Examples: + V4L/USB Camera: + + ```python + camera = Camera(0, resolution=(640, 480), fps=30) + camera = Camera("/dev/video1", fps=15) + ``` + + IP Camera: + + ```python + camera = Camera("rtsp://192.168.1.100:554/stream", username="admin", password="secret", timeout=15.0) + camera = Camera("http://192.168.1.100:8080/video", retry_attempts=5) + ``` + + WebSocket Camera: + + ```python + camera = Camera("ws://0.0.0.0:8080", frame_format="json", max_queue_size=20) + camera = Camera("ws://192.168.1.100:8080", ping_interval=30) + ``` + """ + # Dynamic imports to avoid circular dependencies + if isinstance(source, int) or (isinstance(source, str) and source.isdigit()): + # V4L Camera + from .v4l_camera import V4LCamera + return V4LCamera(source, **kwargs) + elif isinstance(source, str): + parsed = urlparse(source) + if parsed.scheme in ['http', 'https', 'rtsp']: + # IP Camera + from .ip_camera import IPCamera + return IPCamera(source, **kwargs) + elif parsed.scheme in ['ws', 'wss']: + # WebSocket Camera - extract host and port from URL + from .websocket_camera import WebSocketCamera + host = parsed.hostname or "localhost" + port = parsed.port or 8080 + return WebSocketCamera(host=host, port=port, **kwargs) + elif source.startswith('/dev/video') or source.isdigit(): + # V4L device path or index as string + from .v4l_camera import V4LCamera + return V4LCamera(source, **kwargs) + else: + raise CameraConfigError(f"Unsupported camera source: {source}") + else: + raise CameraConfigError(f"Invalid source type: {type(source)}") diff --git a/src/arduino/app_peripherals/camera/errors.py b/src/arduino/app_peripherals/camera/errors.py new file mode 100644 index 00000000..9b1d0000 --- /dev/null +++ b/src/arduino/app_peripherals/camera/errors.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +class CameraError(Exception): + """Base exception for camera-related errors.""" + pass + + +class CameraOpenError(CameraError): + """Exception raised when the camera cannot be opened.""" + pass + + +class CameraReadError(CameraError): + """Exception raised when reading from camera fails.""" + pass + + +class CameraConfigError(CameraError): + """Exception raised when camera configuration is invalid.""" + pass diff --git a/src/arduino/app_peripherals/camera/examples/README.md b/src/arduino/app_peripherals/camera/examples/README.md new file mode 100644 index 00000000..7dc562da --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/README.md @@ -0,0 +1,57 @@ +# Camera Examples + +This directory contains examples demonstrating how to use the Camera abstraction for different types of cameras and protocols. + +## Files + +- `camera_examples.py` - Comprehensive examples showing all camera types and usage patterns + +## Running Examples + +```bash +python examples/camera_examples.py +``` + +## Example Types Covered + +### 1. V4L/USB Cameras +- Basic usage with camera index +- Context manager pattern +- Resolution and FPS configuration +- Frame format options + +### 2. IP Cameras +- RTSP streams +- HTTP/MJPEG streams +- Authentication +- Connection testing + +### 3. WebSocket Camera Servers +- Hosting WebSocket servers (single client only) +- Receiving frames from one connected client +- Client rejection when server is at capacity +- Multiple frame formats (base64, binary, JSON) +- Bidirectional communication with client +- Server status monitoring + +### 4. Factory Pattern +- Automatic camera type detection +- Multiple instantiation methods +- Error handling + +### 5. Advanced Configuration +- Compression settings +- Letterboxing +- Custom parameters +- Performance tuning + +## Camera Source Formats + +The Camera class automatically detects the appropriate implementation based on the source: + +- `0`, `1`, `"0"` - V4L camera indices +- `"/dev/video0"` - V4L device paths +- `"rtsp://..."` - RTSP streams +- `"http://..."` - HTTP streams +- `"ws://localhost:8080"` - WebSocket server URL +- `"localhost:9090"` - WebSocket server host:port \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/camera_examples.py b/src/arduino/app_peripherals/camera/examples/camera_examples.py new file mode 100644 index 00000000..edaa095c --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/camera_examples.py @@ -0,0 +1,282 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +""" +Camera Abstraction Usage Examples + +This file demonstrates various ways to instantiate and use the Camera abstraction +for different camera types and protocols. +""" + +import time +from arduino.app_peripherals.camera import Camera +from arduino.app_peripherals.camera.camera import CameraFactory + + +def example_v4l_camera(): + """Example: Using a V4L/USB camera""" + print("=== V4L/USB Camera Example ===") + + # Method 1: Using Camera class (recommended) + camera = Camera(0, resolution=(640, 480), fps=15) + + try: + # Start the camera + camera.start() + print(f"Camera started: {camera.get_camera_info()}") + + # Capture some frames + for i in range(5): + frame = camera.capture() + if frame: + print(f"Captured frame {i+1}: {frame.size} pixels") + else: + print(f"Failed to capture frame {i+1}") + time.sleep(0.5) + + finally: + camera.stop() + + print() + + +def example_v4l_camera_context_manager(): + """Example: Using V4L camera with context manager""" + print("=== V4L Camera with Context Manager ===") + + # Context manager automatically handles start/stop + with Camera("0", resolution=(320, 240), fps=10, letterbox=True) as camera: + print(f"Camera info: {camera.get_camera_info()}") + + # Capture a few frames + for i in range(3): + frame = camera.capture() + if frame: + print(f"Frame {i+1}: {frame.size}") + time.sleep(1.0) + + print("Camera automatically stopped\n") + + +def example_ip_camera(): + """Example: Using an IP camera (RTSP/HTTP)""" + print("=== IP Camera Example ===") + + # Example RTSP URL (replace with your camera's URL) + rtsp_url = "rtsp://admin:password@192.168.1.100:554/stream" + + # Method 1: Direct instantiation + camera = Camera(rtsp_url, fps=5) + + try: + # Test connection first + if hasattr(camera, 'test_connection') and camera.test_connection(): + print("IP camera is accessible") + + camera.start() + print(f"IP camera started: {camera.get_camera_info()}") + + # Capture frames + for i in range(3): + frame = camera.capture() + if frame: + print(f"IP frame {i+1}: {frame.size}") + else: + print(f"No frame received {i+1}") + time.sleep(2.0) + else: + print("IP camera not accessible (expected for this example)") + + except Exception as e: + print(f"IP camera error (expected): {e}") + + finally: + camera.stop() + + print() + + +def example_websocket_camera(): + """Example: Using a WebSocket camera server (single client only)""" + print("=== WebSocket Camera Server Example (Single Client) ===") + + # Create WebSocket camera server + try: + # Method 1: Direct host:port specification + camera = Camera("localhost:8080", frame_format="base64", max_queue_size=5) + + camera.start() + print(f"WebSocket camera server started: {camera.get_camera_info()}") + + # Server is now listening for client connections (max 1 client) + print("Server is waiting for ONE client to connect and send frames...") + print("Additional clients will be rejected with an error message") + print("Clients should connect to ws://localhost:8080 and send base64 encoded images") + + # Monitor for incoming frames + for i in range(10): # Check for 10 seconds + frame = camera.capture() + if frame: + print(f"Received frame {i+1}: {frame.size}") + else: + print(f"No frame received in iteration {i+1}") + + time.sleep(1.0) + + except Exception as e: + print(f"WebSocket camera server error (expected if no clients connect): {e}") + + finally: + if 'camera' in locals(): + camera.stop() + + print() + + +def example_websocket_server_with_url(): + """Example: WebSocket server using ws:// URL (single client only)""" + print("=== WebSocket Server with URL Example (Single Client) ===") + + try: + # Method 2: Using ws:// URL (server extracts host and port) + camera = Camera("ws://0.0.0.0:9090", frame_format="json") + + camera.start() + + # Wait briefly for potential connections + time.sleep(2) + + camera.stop() + print("WebSocket server stopped") + + except Exception as e: + print(f"WebSocket server URL error: {e}") + + print() + + +def example_factory_usage(): + """Example: Using CameraFactory directly""" + print("=== Camera Factory Example ===") + + # Different ways to create cameras using the factory + sources = [ + 0, # V4L camera index + "1", # V4L camera as string + "/dev/video0", # V4L device path + "rtsp://example.com/stream", # RTSP camera + "http://example.com/mjpeg", # HTTP camera + "ws://localhost:8080", # WebSocket server URL + "localhost:9090", # WebSocket server host:port + "0.0.0.0:8888", # WebSocket server on all interfaces + ] + + for source in sources: + try: + camera = CameraFactory.create_camera(source, fps=10) + print(f"Created {camera.__class__.__name__} for source: {source}") + # Don't start cameras in this example + except Exception as e: + print(f"Cannot create camera for {source}: {e}") + + print() + + +def example_advanced_configuration(): + """Example: Advanced camera configuration""" + print("=== Advanced Configuration Example ===") + + # V4L camera with all options + v4l_config = { + 'resolution': (1280, 720), + 'fps': 30, + 'compression': True, # PNG compression + 'letterbox': True, # Square images + } + + try: + with Camera(0, **v4l_config) as camera: + print(f"V4L config: {camera.get_camera_info()}") + + # Capture compressed frame + frame = camera.capture() + if frame: + print(f"Compressed frame: {frame.format} {frame.size}") + + # Capture as bytes + frame_bytes = camera.capture_bytes() + if frame_bytes: + print(f"Frame bytes length: {len(frame_bytes)}") + + except Exception as e: + print(f"Advanced config error: {e}") + + # IP camera with authentication + ip_config = { + 'username': 'admin', + 'password': 'secret', + 'timeout': 5, + 'fps': 10 + } + + try: + ip_camera = Camera("http://192.168.1.100/mjpeg", **ip_config) + print(f"IP camera with auth created: {ip_camera.__class__.__name__}") + except Exception as e: + print(f"IP camera with auth error: {e}") + + # WebSocket server with different frame formats + ws_configs = [ + {'host': 'localhost', 'port': 8080, 'frame_format': 'base64'}, + {'host': '0.0.0.0', 'port': 9090, 'frame_format': 'json'}, + {'host': '127.0.0.1', 'port': 8888, 'frame_format': 'binary'}, + ] + + for config in ws_configs: + try: + ws_camera = Camera("localhost:8080", **config) # Will use the config params + print(f"WebSocket server config: {config}") + except Exception as e: + print(f"WebSocket server config error: {e}") + + print() + + +def example_error_handling(): + """Example: Proper error handling""" + print("=== Error Handling Example ===") + + # Try to open non-existent camera + try: + camera = Camera(99) # Non-existent camera + camera.start() + except Exception as e: + print(f"Expected error for invalid camera: {e}") + + # Try invalid URL + try: + camera = Camera("invalid://url") + except Exception as e: + print(f"Expected error for invalid URL: {e}") + + print() + + +if __name__ == "__main__": + print("Camera Abstraction Examples\n") + print("Note: Some examples may show errors if cameras are not available.\n") + + # Run examples + example_factory_usage() + example_advanced_configuration() + example_error_handling() + + # Uncomment these if you have actual cameras available: + # example_v4l_camera() + # example_v4l_camera_context_manager() + # example_ip_camera() + # example_websocket_camera() + # example_websocket_server_with_url() + + print("Examples completed!") \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/hls.py b/src/arduino/app_peripherals/camera/examples/hls.py new file mode 100644 index 00000000..da1d8cde --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/hls.py @@ -0,0 +1,18 @@ +import cv2 + +# URL to an HLS playlist +hls_url = 'https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8' + +cap = cv2.VideoCapture(hls_url) + +if cap.isOpened(): + print("Successfully opened HLS stream.") + ret, frame = cap.read() + if ret: + print("Successfully read a frame from the stream.") + # You can now process the 'frame' + else: + print("Failed to read a frame.") + cap.release() +else: + print("Error: Could not open HLS stream.") \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/rtsp.py b/src/arduino/app_peripherals/camera/examples/rtsp.py new file mode 100644 index 00000000..c42e7c9a --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/rtsp.py @@ -0,0 +1,30 @@ +import cv2 + +# A freely available RTSP stream for testing. +# Note: Public streams can be unreliable and may go offline without notice. +rtsp_url = "rtsp://170.93.143.139/rtplive/470011e600ef003a004ee33696235daa" + +print(f"Attempting to connect to RTSP stream: {rtsp_url}") + +# Create a VideoCapture object, letting OpenCV automatically select the backend +cap = cv2.VideoCapture(rtsp_url) + +if not cap.isOpened(): + print("Error: Could not open RTSP stream.") +else: + print("Successfully connected to RTSP stream.") + + # Read one frame from the stream + ret, frame = cap.read() + + if ret: + print(f"Successfully read a frame. Frame dimensions: {frame.shape}") + # You could now do processing on the frame, for example: + # height, width, channels = frame.shape + # print(f"Frame details: Width={width}, Height={height}, Channels={channels}") + else: + print("Error: Failed to read a frame from the stream, it may have ended or there was a network issue.") + + # Release the capture object + cap.release() + print("Stream capture released.") \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py b/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py new file mode 100644 index 00000000..4489e19a --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +""" +WebSocket Camera Proxy + +This example demonstrates how to use a WebSocketCamera as a proxy/relay. +It receives frames from clients on one WebSocket server (127.0.0.1:8080) and +forwards them as raw JPEG binary data to a TCP server (127.0.0.1:5001) at 30fps. + +Usage: + python websocket_camera_proxy.py [--input-port PORT] [--output-host HOST] [--output-port PORT] +""" + +import asyncio +import logging +import argparse +import signal +import sys +import time + +# Add the parent directory to the path to import from arduino package +import os + +from arduino.app_peripherals.camera import Camera + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')) + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Global variables for graceful shutdown +running = False +camera = None +output_writer = None +output_reader = None + + +def signal_handler(signum, frame): + """Handle interrupt signals.""" + global running + logger.info("Received signal, initiating shutdown...") + running = False + + +async def connect_output_tcp(output_host: str, output_port: int): + """Connect to the output TCP server.""" + global output_writer, output_reader + + logger.info(f"Connecting to TCP server at {output_host}:{output_port}...") + + try: + output_reader, output_writer = await asyncio.open_connection( + output_host, output_port + ) + logger.info("TCP connection established successfully") + + return True + + except Exception as e: + logger.error(f"Failed to connect to TCP server: {e}") + return False + + +async def forward_frame(frame, quality: int): + """Forward a frame to the output TCP server as raw JPEG.""" + global output_writer + + if not output_writer or output_writer.is_closing(): + return + + try: + # Frame is already a PIL.Image.Image in JPEG format + # Convert PIL image to bytes + import io + img_bytes = io.BytesIO() + frame.save(img_bytes, format='JPEG', quality=quality) + frame_data = img_bytes.getvalue() + + # Send raw JPEG binary data + output_writer.write(frame_data) + await output_writer.drain() + + except ConnectionResetError: + logger.warning("TCP connection reset while forwarding frame") + output_writer = None + except Exception as e: + logger.error(f"Error forwarding frame: {e}") + + +async def camera_loop(fps: int, quality: int): + """Main camera capture and forwarding loop.""" + global running, camera + + frame_interval = 1.0 / fps + last_frame_time = time.time() + + try: + camera.start() + except Exception as e: + logger.error(f"Failed to start WebSocketCamera: {e}") + return + + while running: + try: + # Read frame from WebSocketCamera + frame = camera.capture() + + if frame is not None: + # Rate limiting + current_time = time.time() + time_since_last = current_time - last_frame_time + if time_since_last < frame_interval: + await asyncio.sleep(frame_interval - time_since_last) + + last_frame_time = time.time() + + # Forward frame if output TCP connection is available + await forward_frame(frame, quality) + else: + # No frame available, small delay to avoid busy waiting + await asyncio.sleep(0.01) + + except Exception as e: + logger.error(f"Error in camera loop: {e}") + await asyncio.sleep(1.0) + + +async def maintain_output_connection(output_host: str, output_port: int, reconnect_delay: float): + """Maintain TCP connection to output server with automatic reconnection.""" + global running, output_writer, output_reader + + while running: + try: + # Establish connection + if await connect_output_tcp(output_host, output_port): + logger.info("TCP connection established, maintaining...") + + # Keep connection alive + while running and output_writer and not output_writer.is_closing(): + await asyncio.sleep(1.0) + + logger.info("TCP connection lost") + + except Exception as e: + logger.error(f"TCP connection error: {e}") + finally: + # Clean up connection + if output_writer: + try: + output_writer.close() + await output_writer.wait_closed() + except: + pass + output_writer = None + output_reader = None + + # Wait before reconnecting + if running: + logger.info(f"Reconnecting to TCP server in {reconnect_delay} seconds...") + await asyncio.sleep(reconnect_delay) + + +async def main(): + """Main function.""" + global running, camera + + parser = argparse.ArgumentParser(description="WebSocket Camera Proxy") + parser.add_argument("--input-port", type=int, default=8080, + help="WebSocketCamera input port (default: 8080)") + parser.add_argument("--output-host", default="127.0.0.1", + help="Output TCP server host (default: 127.0.0.1)") + parser.add_argument("--output-port", type=int, default=5001, + help="Output TCP server port (default: 5001)") + parser.add_argument("--fps", type=int, default=30, + help="Target FPS for forwarding (default: 30)") + parser.add_argument("--quality", type=int, default=80, + help="JPEG quality 1-100 (default: 80)") + parser.add_argument("--verbose", "-v", action="store_true", + help="Enable verbose logging") + + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + # Setup signal handlers + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Setup global variables + running = True + reconnect_delay = 2.0 + + logger.info(f"Starting WebSocket camera proxy") + logger.info(f"Input: WebSocketCamera on port {args.input_port}") + logger.info(f"Output: TCP server at {args.output_host}:{args.output_port}") + logger.info(f"Target FPS: {args.fps}") + + camera = Camera("ws://0.0.0.0:5001") + + try: + # Start camera input and output connection tasks + camera_task = asyncio.create_task(camera_loop(args.fps, args.quality)) + connection_task = asyncio.create_task(maintain_output_connection(args.output_host, args.output_port, reconnect_delay)) + + # Run both tasks concurrently + await asyncio.gather(camera_task, connection_task) + + except KeyboardInterrupt: + logger.info("Received interrupt signal, shutting down...") + finally: + running = False + + # Close output TCP connection + if output_writer: + try: + output_writer.close() + await output_writer.wait_closed() + except Exception as e: + logger.warning(f"Error closing TCP connection: {e}") + + # Close camera + if camera: + try: + camera.stop() + logger.info("Camera closed") + except Exception as e: + logger.warning(f"Error closing camera: {e}") + + logger.info("Camera proxy stopped") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Interrupted by user") + except Exception as e: + logger.error(f"Unexpected error: {e}") + sys.exit(1) \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py b/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py new file mode 100644 index 00000000..8cdc48d4 --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py @@ -0,0 +1,301 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import asyncio +import websockets +import cv2 +import base64 +import json +import logging +import argparse +import signal +import sys +import time + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class WebCamStreamer: + """ + WebSocket client that streams local webcam feed to a WebSocketCamera server. + """ + + def __init__(self, host: str = "localhost", port: int = 8080, + camera_id: int = 0, fps: int = 30, quality: int = 80): + """ + Initialize the webcam streamer. + + Args: + host: WebSocket server host + port: WebSocket server port + camera_id: Local camera device ID (usually 0 for default camera) + fps: Target frames per second for streaming + quality: JPEG quality (1-100, higher = better quality) + """ + self.host = host + self.port = port + self.camera_id = camera_id + self.fps = fps + self.quality = quality + + self.websocket_url = f"ws://{host}:{port}" + self.frame_interval = 1.0 / fps + self.reconnect_delay = 2.0 + + self.running = False + self.camera = None + self.websocket = None + self.server_frame_format = "base64" + + async def start(self): + """Start the webcam streamer.""" + self.running = True + logger.info(f"Starting webcam streamer (camera_id={self.camera_id}, fps={self.fps})") + + camera_task = asyncio.create_task(self._camera_loop()) + websocket_task = asyncio.create_task(self._websocket_loop()) + + try: + await asyncio.gather(camera_task, websocket_task) + except KeyboardInterrupt: + logger.info("Received interrupt signal, shutting down...") + finally: + await self.stop() + + async def stop(self): + """Stop the webcam streamer.""" + logger.info("Stopping webcam streamer...") + self.running = False + + if self.websocket: + try: + await self.websocket.close() + except Exception as e: + logger.warning(f"Error closing WebSocket: {e}") + + if self.camera: + self.camera.release() + logger.info("Camera released") + + logger.info("Webcam streamer stopped") + + async def _camera_loop(self): + """Main camera capture loop.""" + logger.info(f"Opening camera {self.camera_id}...") + self.camera = cv2.VideoCapture(self.camera_id) + + if not self.camera.isOpened(): + logger.error(f"Failed to open camera {self.camera_id}") + return + + self.camera.set(cv2.CAP_PROP_FPS, self.fps) + self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640) + self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) + + logger.info("Camera opened successfully") + + last_frame_time = time.time() + + while self.running: + try: + ret, frame = self.camera.read() + if not ret: + logger.warning("Failed to capture frame") + await asyncio.sleep(0.1) + continue + + # Rate limiting to enforce frame rate + current_time = time.time() + time_since_last = current_time - last_frame_time + if time_since_last < self.frame_interval: + await asyncio.sleep(self.frame_interval - time_since_last) + + last_frame_time = time.time() + + if self.websocket: + try: + await self._send_frame(frame) + except websockets.exceptions.ConnectionClosed: + logger.warning("WebSocket connection lost during frame send") + self.websocket = None + + except Exception as e: + logger.error(f"Error in camera loop: {e}") + await asyncio.sleep(1.0) + + async def _websocket_loop(self): + """Main WebSocket connection loop with automatic reconnection.""" + while self.running: + try: + await self._connect_websocket() + await self._handle_websocket_messages() + except Exception as e: + logger.error(f"WebSocket error: {e}") + finally: + if self.websocket: + try: + await self.websocket.close() + except: + pass + self.websocket = None + + if self.running: + logger.info(f"Reconnecting in {self.reconnect_delay} seconds...") + await asyncio.sleep(self.reconnect_delay) + + async def _connect_websocket(self): + """Connect to the WebSocket server.""" + logger.info(f"Connecting to {self.websocket_url}...") + + try: + self.websocket = await websockets.connect( + self.websocket_url, + ping_interval=20, + ping_timeout=10, + close_timeout=5 + ) + logger.info("WebSocket connected successfully") + + except Exception as e: + raise + + async def _handle_websocket_messages(self): + """Handle incoming WebSocket messages.""" + try: + async for message in self.websocket: + try: + data = json.loads(message) + + if data.get("status") == "connected": + logger.info(f"Server welcome: {data.get('message', 'Connected')}") + self.server_frame_format = data.get('frame_format', 'base64') + logger.info(f"Server format: {self.server_frame_format}") + + elif data.get("status") == "disconnecting": + logger.info(f"Server goodbye: {data.get('message', 'Disconnecting')}") + break + + elif data.get("status") == "dropping_frames": + logger.warning(f"Server warning: {data.get('message', 'Dropping frames!')}") + + elif data.get("error"): + logger.warning(f"Server error: {data.get('message', 'Unknown error')}") + if data.get("code") == 1000: # Server busy + break + + else: + logger.warning(f"Received unknown message: {data}") + + except json.JSONDecodeError: + logger.warning(f"Received non-JSON message: {message[:100]}") + + except websockets.exceptions.ConnectionClosed: + logger.info("WebSocket connection closed by server") + except Exception as e: + logger.error(f"Error handling WebSocket messages: {e}") + raise + + async def _send_frame(self, frame): + """Send a frame to the WebSocket server using the server's preferred format.""" + try: + if self.server_frame_format == "binary": + # Encode frame as JPEG and send binary data + encode_params = [cv2.IMWRITE_JPEG_QUALITY, self.quality] + success, encoded_frame = cv2.imencode('.jpg', frame, encode_params) + + if not success: + logger.warning("Failed to encode frame") + return + + await self.websocket.send(encoded_frame.tobytes()) + + elif self.server_frame_format == "base64": + # Encode frame as JPEG and send base64 data + encode_params = [cv2.IMWRITE_JPEG_QUALITY, self.quality] + success, encoded_frame = cv2.imencode('.jpg', frame, encode_params) + + if not success: + logger.warning("Failed to encode frame") + return + + frame_b64 = base64.b64encode(encoded_frame.tobytes()).decode('utf-8') + await self.websocket.send(frame_b64) + + elif self.server_frame_format == "json": + # Encode frame as JPEG, base64 encode and wrap in JSON + encode_params = [cv2.IMWRITE_JPEG_QUALITY, self.quality] + success, encoded_frame = cv2.imencode('.jpg', frame, encode_params) + + if not success: + logger.warning("Failed to encode frame") + return + + frame_b64 = base64.b64encode(encoded_frame.tobytes()).decode('utf-8') + message = json.dumps({"image": frame_b64}) + await self.websocket.send(message) + + else: + logger.warning(f"Unknown server frame format: {self.server_frame_format}") + + except websockets.exceptions.ConnectionClosed: + logger.warning("WebSocket connection closed while sending frame") + raise + except Exception as e: + logger.error(f"Error sending frame: {e}") + + +def signal_handler(signum, frame): + """Handle interrupt signals.""" + logger.info("Received signal, initiating shutdown...") + sys.exit(0) + + +async def main(): + """Main function.""" + parser = argparse.ArgumentParser(description="WebSocket Camera Client Streamer") + parser.add_argument("--host", default="127.0.0.1", help="WebSocket server host (default: 127.0.0.1)") + parser.add_argument("--port", type=int, default=8080, help="WebSocket server port (default: 8080)") + parser.add_argument("--camera", type=int, default=0, help="Camera device ID (default: 0)") + parser.add_argument("--fps", type=int, default=30, help="Target FPS (default: 30)") + parser.add_argument("--quality", type=int, default=80, help="JPEG quality 1-100 (default: 80)") + parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") + + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + # Setup signal handlers + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Create and start streamer + streamer = WebCamStreamer( + host=args.host, + port=args.port, + camera_id=args.camera, + fps=args.fps, + quality=args.quality + ) + + try: + await streamer.start() + except KeyboardInterrupt: + pass + finally: + await streamer.stop() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Interrupted by user") + except Exception as e: + logger.error(f"Unexpected error: {e}") + sys.exit(1) diff --git a/src/arduino/app_peripherals/camera/ip_camera.py b/src/arduino/app_peripherals/camera/ip_camera.py new file mode 100644 index 00000000..78d47c3a --- /dev/null +++ b/src/arduino/app_peripherals/camera/ip_camera.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import cv2 +import numpy as np +import requests +from typing import Optional, Union, Dict +from urllib.parse import urlparse + +from arduino.app_utils import Logger + +from .camera import BaseCamera +from .errors import CameraOpenError + +logger = Logger("IPCamera") + + +class IPCamera(BaseCamera): + """ + IP Camera implementation for network-based cameras. + + Supports RTSP, HTTP, and HTTPS camera streams. + Can handle authentication and various streaming protocols. + """ + + def __init__(self, url: str, username: Optional[str] = None, + password: Optional[str] = None, timeout: int = 10, **kwargs): + """ + Initialize IP camera. + + Args: + url: Camera stream URL (rtsp://, http://, https://) + username: Optional authentication username + password: Optional authentication password + timeout: Connection timeout in seconds + **kwargs: Additional camera parameters + """ + super().__init__(**kwargs) + self.url = url + self.username = username + self.password = password + self.timeout = timeout + self._cap = None + self._validate_url() + + def _validate_url(self) -> None: + """Validate the camera URL format.""" + try: + parsed = urlparse(self.url) + if parsed.scheme not in ['http', 'https', 'rtsp']: + raise CameraOpenError(f"Unsupported URL scheme: {parsed.scheme}") + except Exception as e: + raise CameraOpenError(f"Invalid URL format: {e}") + + def _open_camera(self) -> None: + """Open the IP camera connection.""" + auth_url = self._build_authenticated_url() + + # Test connectivity first for HTTP streams + if self.url.startswith(('http://', 'https://')): + self._test_http_connectivity() + + # Open with OpenCV + self._cap = cv2.VideoCapture(auth_url) + + # Set timeout properties + self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to get latest frames + + if not self._cap.isOpened(): + raise CameraOpenError(f"Failed to open IP camera: {self.url}") + + # Test by reading one frame + ret, frame = self._cap.read() + if not ret or frame is None: + self._cap.release() + self._cap = None + raise CameraOpenError(f"Cannot read from IP camera: {self.url}") + + logger.info(f"Opened IP camera: {self.url}") + + def _build_authenticated_url(self) -> str: + """Build URL with authentication if credentials provided.""" + if not self.username or not self.password: + return self.url + + parsed = urlparse(self.url) + if parsed.username and parsed.password: + # URL already has credentials + return self.url + + # Add credentials to URL + auth_netloc = f"{self.username}:{self.password}@{parsed.hostname}" + if parsed.port: + auth_netloc += f":{parsed.port}" + + return f"{parsed.scheme}://{auth_netloc}{parsed.path}" + + def _test_http_connectivity(self) -> None: + """Test HTTP/HTTPS camera connectivity.""" + try: + auth = None + if self.username and self.password: + auth = (self.username, self.password) + + response = requests.head( + self.url, + auth=auth, + timeout=self.timeout, + allow_redirects=True + ) + + if response.status_code not in [200, 206]: # 206 for partial content + raise CameraOpenError( + f"HTTP camera returned status {response.status_code}: {self.url}" + ) + + except requests.RequestException as e: + raise CameraOpenError(f"Cannot connect to HTTP camera {self.url}: {e}") + + def _close_camera(self) -> None: + """Close the IP camera connection.""" + if self._cap is not None: + self._cap.release() + self._cap = None + + def _read_frame(self) -> Optional[np.ndarray]: + """Read a frame from the IP camera.""" + if self._cap is None: + return None + + ret, frame = self._cap.read() + if not ret or frame is None: + # For IP cameras, occasional frame drops are normal + logger.debug(f"Frame read failed from IP camera: {self.url}") + return None + + return frame + + def reconnect(self) -> None: + """Reconnect to the IP camera.""" + logger.info(f"Reconnecting to IP camera: {self.url}") + was_started = self._is_started + + if was_started: + self.stop() + + try: + if was_started: + self.start() + except Exception as e: + logger.error(f"Failed to reconnect to IP camera: {e}") + raise + + def test_connection(self) -> bool: + """ + Test if the camera is accessible without starting it. + + Returns: + True if camera is accessible, False otherwise + """ + try: + if self.url.startswith(('http://', 'https://')): + self._test_http_connectivity() + + # Quick test with OpenCV + test_cap = cv2.VideoCapture(self._build_authenticated_url()) + if test_cap.isOpened(): + ret, _ = test_cap.read() + test_cap.release() + return ret + + return False + except Exception as e: + logger.debug(f"Connection test failed: {e}") + return False \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py new file mode 100644 index 00000000..f80ed7e5 --- /dev/null +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import os +import re +import cv2 +import numpy as np +from typing import Optional, Union, Dict + +from arduino.app_utils import Logger + +from .camera import BaseCamera +from .errors import CameraOpenError, CameraReadError + +logger = Logger("V4LCamera") + + +class V4LCamera(BaseCamera): + """ + V4L (Video4Linux) camera implementation for USB and local cameras. + + This class handles USB cameras and other V4L-compatible devices on Linux systems. + It supports both device indices and device paths. + """ + + def __init__(self, camera: Union[str, int] = 0, **kwargs): + """ + Initialize V4L camera. + + Args: + camera: Camera identifier - can be: + - int: Camera index (e.g., 0, 1) + - str: Camera index as string or device path + **kwargs: Additional camera parameters + """ + super().__init__(**kwargs) + self.camera_id = self._resolve_camera_id(camera) + self._cap = None + + def _resolve_camera_id(self, camera: Union[str, int]) -> int: + """ + Resolve camera identifier to a numeric device ID. + + Args: + camera: Camera identifier + + Returns: + Numeric camera device ID + + Raises: + CameraOpenError: If camera cannot be resolved + """ + if isinstance(camera, int): + return camera + + if isinstance(camera, str): + # If it's a numeric string, convert directly + if camera.isdigit(): + device_id = int(camera) + # Validate using device index mapping + video_devices = self._get_video_devices_by_index() + if device_id in video_devices: + return int(video_devices[device_id]) + else: + # Fallback to direct device ID if mapping not available + return device_id + + # If it's a device path like "/dev/video0" + if camera.startswith('/dev/video'): + return int(camera.replace('/dev/video', '')) + + raise CameraOpenError(f"Cannot resolve camera identifier: {camera}") + + def _get_video_devices_by_index(self) -> Dict[int, str]: + """ + Map camera indices to device numbers by reading /dev/v4l/by-id/. + + Returns: + Dict mapping index to device number + """ + devices_by_index = {} + directory_path = "/dev/v4l/by-id/" + + # Check if the directory exists + if not os.path.exists(directory_path): + logger.warning(f"Directory '{directory_path}' not found.") + return devices_by_index + + try: + entries = os.listdir(directory_path) + for entry in entries: + full_path = os.path.join(directory_path, entry) + + if os.path.islink(full_path): + # Find numeric index at end of filename + match = re.search(r"index(\d+)$", entry) + if match: + try: + index = int(match.group(1)) + resolved_path = os.path.realpath(full_path) + device_name = os.path.basename(resolved_path) + device_number = device_name.replace("video", "") + devices_by_index[index] = device_number + except ValueError: + logger.warning(f"Could not parse index from '{entry}'") + continue + except OSError as e: + logger.error(f"Error accessing directory '{directory_path}': {e}") + + return devices_by_index + + def _open_camera(self) -> None: + """Open the V4L camera connection.""" + self._cap = cv2.VideoCapture(self.camera_id) + if not self._cap.isOpened(): + raise CameraOpenError(f"Failed to open V4L camera {self.camera_id}") + + # Set resolution if specified + if self.resolution and self.resolution[0] and self.resolution[1]: + self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.resolution[0]) + self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.resolution[1]) + + # Verify resolution setting + actual_width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + actual_height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + if actual_width != self.resolution[0] or actual_height != self.resolution[1]: + logger.warning( + f"Camera {self.camera_id} resolution set to {actual_width}x{actual_height} " + f"instead of requested {self.resolution[0]}x{self.resolution[1]}" + ) + + logger.info(f"Opened V4L camera {self.camera_id}") + + def _close_camera(self) -> None: + """Close the V4L camera connection.""" + if self._cap is not None: + self._cap.release() + self._cap = None + + def _read_frame(self) -> Optional[np.ndarray]: + """Read a frame from the V4L camera.""" + if self._cap is None: + return None + + ret, frame = self._cap.read() + if not ret or frame is None: + raise CameraReadError(f"Failed to read from V4L camera {self.camera_id}") + + return frame diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py new file mode 100644 index 00000000..8a3f5fc9 --- /dev/null +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -0,0 +1,338 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import json +import base64 +import threading +import queue +import time +from typing import Optional, Union +import numpy as np +import cv2 +import websockets +import asyncio + +from arduino.app_utils import Logger + +from .camera import BaseCamera +from .errors import CameraOpenError + +logger = Logger("WebSocketCamera") + + +class WebSocketCamera(BaseCamera): + """ + WebSocket Camera implementation that hosts a WebSocket server. + + This camera acts as a WebSocket server that receives frames from connected clients. + Clients can send frames in various formats: + - Base64 encoded images + - Binary image data + - JSON messages with image data + """ + + def __init__(self, host: str = "0.0.0.0", port: int = 8080, + frame_format: str = "base64", max_queue_size: int = 10, **kwargs): + """ + Initialize WebSocket camera server. + + Args: + host: Host address to bind the server to (default: "localhost") + port: Port to bind the server to (default: 8080) + frame_format: Expected frame format from clients ("base64", "binary", "json") + max_queue_size: Maximum frames to buffer + **kwargs: Additional camera parameters + """ + super().__init__(**kwargs) + + self.host = host + self.port = port + self.frame_format = frame_format + + self._frame_queue = queue.Queue(maxsize=max_queue_size) + self._server = None + self._loop = None + self._server_thread = None + self._stop_event = None + self._client: Optional[websockets.WebSocketServerProtocol] = None + + def _open_camera(self) -> None: + """Start the WebSocket server.""" + # Start server in separate thread with its own event loop + self._server_thread = threading.Thread( + target=self._start_server_thread, + daemon=True + ) + self._server_thread.start() + + # Wait for server to start + timeout = 10.0 + start_time = time.time() + while self._server is None and time.time() - start_time < timeout: + if self._server is not None: + break + time.sleep(0.1) + + if self._server is None: + raise CameraOpenError(f"Failed to start WebSocket server on {self.host}:{self.port}") + + def _start_server_thread(self) -> None: + """Run WebSocket server in its own thread with event loop.""" + try: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._start_server()) + except Exception as e: + logger.error(f"WebSocket server thread error: {e}") + finally: + if self._loop and not self._loop.is_closed(): + self._loop.close() + + async def _start_server(self) -> None: + """Start the WebSocket server.""" + try: + # Create async stop event for this event loop + self._stop_event = asyncio.Event() + + self._server = await websockets.serve( + self._ws_handler, + self.host, + self.port, + ping_interval=20, + ping_timeout=10 + ) + + logger.info(f"WebSocket camera server started on {self.host}:{self.port}") + + # Wait for stop event instead of busy loop + await self._stop_event.wait() + + except Exception as e: + logger.error(f"Error starting WebSocket server: {e}") + raise + finally: + if self._server: + self._server.close() + await self._server.wait_closed() + + async def _ws_handler(self, websocket: websockets.WebSocketServerProtocol) -> None: + """Handle a connected WebSocket client. Only one client allowed at a time.""" + client_addr = f"{websocket.remote_address[0]}:{websocket.remote_address[1]}" + + if self._client is not None: + # Reject the new client + logger.warning(f"Rejecting client {client_addr}: only one client allowed at a time") + try: + await websocket.send(json.dumps({ + "error": "Server busy", + "message": "Only one client connection allowed at a time", + "code": 1000 + })) + await websocket.close(code=1000, reason="Server busy - only one client allowed") + except Exception as e: + logger.warning(f"Error sending rejection message to {client_addr}: {e}") + return + + # Accept the client + self._client = websocket + logger.info(f"Client connected: {client_addr}") + + try: + # Send welcome message + try: + await self._send_to_client({ + "status": "connected", + "message": "You are now connected to the camera server", + "frame_format": self.frame_format, + }) + except Exception as e: + logger.warning(f"Could not send welcome message to {client_addr}: {e}") + + warning_task = None + async for message in websocket: + frame = await self._parse_message(message) + if frame is not None: + # Drop old frames until there's room for the new one + while True: + try: + self._frame_queue.put_nowait(frame) + break + except queue.Full: + # Notify client about frame dropping + try: + if warning_task is None or warning_task.done(): + warning_task = asyncio.create_task(self._send_to_client({ + "warning": "frame_dropped", + "message": "Buffer full, dropping oldest frame" + })) + except Exception: + pass + + try: + # Drop oldest frame and try again + self._frame_queue.get_nowait() + except queue.Empty: + break + + except websockets.exceptions.ConnectionClosed: + logger.info(f"Client disconnected: {client_addr}") + except Exception as e: + logger.warning(f"Error handling client {client_addr}: {e}") + finally: + if self._client == websocket: + self._client = None + logger.info(f"Client removed: {client_addr}") + + async def _parse_message(self, message) -> Optional[np.ndarray]: + """Parse WebSocket message to extract frame.""" + try: + if self.frame_format == "base64": + # Expect base64 encoded image + if isinstance(message, str): + image_data = base64.b64decode(message) + else: + image_data = base64.b64decode(message.decode()) + + # Decode image + nparr = np.frombuffer(image_data, np.uint8) + frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + return frame + + elif self.frame_format == "binary": + # Expect raw binary image data + if isinstance(message, str): + image_data = message.encode() + else: + image_data = message + + nparr = np.frombuffer(image_data, np.uint8) + frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + return frame + + elif self.frame_format == "json": + # Expect JSON with image data + if isinstance(message, bytes): + message = message.decode() + + data = json.loads(message) + + if "image" in data: + image_data = base64.b64decode(data["image"]) + nparr = np.frombuffer(image_data, np.uint8) + frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + return frame + + elif "frame" in data: + # Handle different frame data formats + frame_data = data["frame"] + if isinstance(frame_data, str): + image_data = base64.b64decode(frame_data) + nparr = np.frombuffer(image_data, np.uint8) + frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + return frame + + return None + + except Exception as e: + logger.warning(f"Error parsing message: {e}") + return None + + def _close_camera(self) -> None: + """Stop the WebSocket server.""" + # Signal async stop event if it exists + if self._stop_event and self._loop and not self._loop.is_closed(): + future = asyncio.run_coroutine_threadsafe( + self._set_async_stop_event(), + self._loop + ) + try: + future.result(timeout=1.0) + except Exception as e: + logger.warning(f"Error setting async stop event: {e}") + + # Wait for server thread to finish + if self._server_thread and self._server_thread.is_alive(): + self._server_thread.join(timeout=10.0) + + # Clear frame queue + while not self._frame_queue.empty(): + try: + self._frame_queue.get_nowait() + except queue.Empty: + break + + # Reset state + self._server = None + self._loop = None + self._client = None + self._stop_event = None + + async def _set_async_stop_event(self) -> None: + """Set the async stop event and close the client connection.""" + if self._stop_event: + self._stop_event.set() + + # Send goodbye message and close the client connection + if self._client: + try: + # Send goodbye message before closing + await self._send_to_client({ + "status": "disconnecting", + "message": "Server is shutting down. Connection will be closed.", + }) + # Give a brief moment for the message to be sent + await asyncio.sleep(0.1) + await self._client.close() + except Exception as e: + logger.warning(f"Error closing client in stop event: {e}") + + def _read_frame(self) -> Optional[np.ndarray]: + """Read a frame from the queue.""" + try: + # Get frame with short timeout to avoid blocking + frame = self._frame_queue.get(timeout=0.1) + return frame + except queue.Empty: + return None + + def _send_message_to_client(self, message: Union[str, bytes, dict]) -> None: + """ + Send a message to the connected client (if any). + + Args: + message: Message to send to the client + + Raises: + RuntimeError: If the event loop is not running or closed + ConnectionError: If no client is connected + Exception: For other communication errors + """ + if not self._loop or self._loop.is_closed(): + raise RuntimeError("WebSocket server event loop is not running") + + if self._client is None: + raise ConnectionError("No client connected to send message to") + + # Schedule message sending in the server's event loop + future = asyncio.run_coroutine_threadsafe( + self._send_to_client(message), + self._loop + ) + + try: + future.result(timeout=5.0) + except Exception as e: + logger.error(f"Error sending message to client: {e}") + raise + + async def _send_to_client(self, message: Union[str, bytes, dict]) -> None: + """Send message to a single client.""" + if isinstance(message, dict): + message = json.dumps(message) + + try: + await self._client.send(message) + except Exception as e: + logger.warning(f"Error sending to client: {e}") + raise From 9b6087c13badf61ae0173ed7f029e3bd43264b8f Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 20 Oct 2025 13:05:12 +0200 Subject: [PATCH 02/86] refactor: IPCamera --- .../app_peripherals/camera/ip_camera.py | 59 +++++-------------- 1 file changed, 14 insertions(+), 45 deletions(-) diff --git a/src/arduino/app_peripherals/camera/ip_camera.py b/src/arduino/app_peripherals/camera/ip_camera.py index 78d47c3a..2d58e122 100644 --- a/src/arduino/app_peripherals/camera/ip_camera.py +++ b/src/arduino/app_peripherals/camera/ip_camera.py @@ -5,7 +5,7 @@ import cv2 import numpy as np import requests -from typing import Optional, Union, Dict +from typing import Optional from urllib.parse import urlparse from arduino.app_utils import Logger @@ -125,52 +125,21 @@ def _close_camera(self) -> None: self._cap = None def _read_frame(self) -> Optional[np.ndarray]: - """Read a frame from the IP camera.""" + """Read a frame from the IP camera with automatic reconnection.""" if self._cap is None: - return None + logger.info(f"No connection to IP camera {self.url}, attempting to reconnect") + try: + self._open_camera() + except Exception as e: + logger.error(f"Failed to reconnect to IP camera {self.url}: {e}") + return None ret, frame = self._cap.read() - if not ret or frame is None: - # For IP cameras, occasional frame drops are normal - logger.debug(f"Frame read failed from IP camera: {self.url}") - return None - - return frame + if ret and frame is not None: + return frame - def reconnect(self) -> None: - """Reconnect to the IP camera.""" - logger.info(f"Reconnecting to IP camera: {self.url}") - was_started = self._is_started - - if was_started: - self.stop() - - try: - if was_started: - self.start() - except Exception as e: - logger.error(f"Failed to reconnect to IP camera: {e}") - raise + if not self._cap.isOpened(): + logger.warning(f"IP camera connection dropped: {self.url}") + self._close_camera() # Will reconnect on next call - def test_connection(self) -> bool: - """ - Test if the camera is accessible without starting it. - - Returns: - True if camera is accessible, False otherwise - """ - try: - if self.url.startswith(('http://', 'https://')): - self._test_http_connectivity() - - # Quick test with OpenCV - test_cap = cv2.VideoCapture(self._build_authenticated_url()) - if test_cap.isOpened(): - ret, _ = test_cap.read() - test_cap.release() - return ret - - return False - except Exception as e: - logger.debug(f"Connection test failed: {e}") - return False \ No newline at end of file + return None From 5d728528b16762cc15367e2dd080fcff8da65202 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 20 Oct 2025 13:37:35 +0200 Subject: [PATCH 03/86] refactor: streamer example --- .../camera/examples/websocket_client_streamer.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py b/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py index 8cdc48d4..830adef9 100644 --- a/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py +++ b/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py @@ -19,6 +19,9 @@ ) logger = logging.getLogger(__name__) +FRAME_WIDTH = 640 +FRAME_HEIGHT = 480 + class WebCamStreamer: """ @@ -94,8 +97,16 @@ async def _camera_loop(self): return self.camera.set(cv2.CAP_PROP_FPS, self.fps) - self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640) - self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) + self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH) + self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT) + + # Verify the resolution was set correctly + actual_width = int(self.camera.get(cv2.CAP_PROP_FRAME_WIDTH)) + actual_height = int(self.camera.get(cv2.CAP_PROP_FRAME_HEIGHT)) + actual_fps = self.camera.get(cv2.CAP_PROP_FPS) + + if actual_width != FRAME_WIDTH or actual_height != FRAME_HEIGHT: + logger.warning(f"Camera resolution mismatch! Requested {FRAME_WIDTH}x{FRAME_HEIGHT}, got {actual_width}x{actual_height}") logger.info("Camera opened successfully") From a895c44db868d91f1c2045d2a9acf1cbdd413aee Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 20 Oct 2025 18:15:52 +0200 Subject: [PATCH 04/86] perf --- src/arduino/app_bricks/camera_code_detection/detection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/arduino/app_bricks/camera_code_detection/detection.py b/src/arduino/app_bricks/camera_code_detection/detection.py index 6c67c972..e6b7991f 100644 --- a/src/arduino/app_bricks/camera_code_detection/detection.py +++ b/src/arduino/app_bricks/camera_code_detection/detection.py @@ -147,7 +147,7 @@ def on_error(self, callback: Callable[[Exception], None] | None): def loop(self): """Main loop to capture frames and detect codes.""" try: - frame = self._camera.capture() + frame = self._camera.capture_bytes() if frame is None: return except Exception as e: @@ -155,7 +155,7 @@ def loop(self): return # Use grayscale for barcode/QR code detection - gs_frame = cv2.cvtColor(np.asarray(frame), cv2.COLOR_RGB2GRAY) + gs_frame = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY) self._on_frame(frame) From fc5f098b11a6da1b153c763db8eddd15dcf4d7b2 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 21 Oct 2025 11:29:43 +0200 Subject: [PATCH 05/86] refactor: BaseCamera --- src/arduino/app_peripherals/camera/README.md | 4 -- .../app_peripherals/camera/base_camera.py | 1 - .../camera/examples/camera_examples.py | 2 +- .../camera/websocket_camera.py | 39 ++++++++++--------- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/arduino/app_peripherals/camera/README.md b/src/arduino/app_peripherals/camera/README.md index 30064dee..845666b8 100644 --- a/src/arduino/app_peripherals/camera/README.md +++ b/src/arduino/app_peripherals/camera/README.md @@ -115,10 +115,6 @@ camera = Camera("rtsp://admin:pass@192.168.1.100/stream", For hosting a WebSocket server that receives frames from clients (single client only): ```python -# Host:port format -camera = Camera("localhost:8080", frame_format="base64", max_queue_size=10) - -# URL format camera = Camera("ws://0.0.0.0:9090", frame_format="json") ``` diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index d26dc222..89da9484 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -42,7 +42,6 @@ def __init__(self, resolution: Optional[Tuple[int, int]] = None, fps: int = 10, self.fps = fps self.compression = compression self.letterbox = letterbox - self.config = kwargs self._is_started = False self._cap_lock = threading.Lock() self._last_capture_time = time.monotonic() diff --git a/src/arduino/app_peripherals/camera/examples/camera_examples.py b/src/arduino/app_peripherals/camera/examples/camera_examples.py index edaa095c..907e7ec2 100644 --- a/src/arduino/app_peripherals/camera/examples/camera_examples.py +++ b/src/arduino/app_peripherals/camera/examples/camera_examples.py @@ -104,7 +104,7 @@ def example_websocket_camera(): # Create WebSocket camera server try: # Method 1: Direct host:port specification - camera = Camera("localhost:8080", frame_format="base64", max_queue_size=5) + camera = Camera("ws://localhost:8080", frame_format="base64") camera.start() print(f"WebSocket camera server started: {camera.get_camera_info()}") diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 8a3f5fc9..374fc4c3 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -32,30 +32,30 @@ class WebSocketCamera(BaseCamera): - JSON messages with image data """ - def __init__(self, host: str = "0.0.0.0", port: int = 8080, - frame_format: str = "base64", max_queue_size: int = 10, **kwargs): + def __init__(self, host: str = "0.0.0.0", port: int = 8080, timeout: int = 10, + frame_format: str = "binary", **kwargs): """ Initialize WebSocket camera server. Args: - host: Host address to bind the server to (default: "localhost") + host: Host address to bind the server to (default: "0.0.0.0") port: Port to bind the server to (default: 8080) - frame_format: Expected frame format from clients ("base64", "binary", "json") - max_queue_size: Maximum frames to buffer + frame_format: Expected frame format from clients ("binary", "base64", "json") (default: "binary") **kwargs: Additional camera parameters """ super().__init__(**kwargs) self.host = host self.port = port + self.timeout = timeout self.frame_format = frame_format - self._frame_queue = queue.Queue(maxsize=max_queue_size) + self._frame_queue = queue.Queue(1) self._server = None self._loop = None self._server_thread = None self._stop_event = None - self._client: Optional[websockets.WebSocketServerProtocol] = None + self._client: Optional[websockets.ServerConnection] = None def _open_camera(self) -> None: """Start the WebSocket server.""" @@ -67,9 +67,8 @@ def _open_camera(self) -> None: self._server_thread.start() # Wait for server to start - timeout = 10.0 start_time = time.time() - while self._server is None and time.time() - start_time < timeout: + while self._server is None and time.time() - start_time < self.timeout: if self._server is not None: break time.sleep(0.1) @@ -92,20 +91,20 @@ def _start_server_thread(self) -> None: async def _start_server(self) -> None: """Start the WebSocket server.""" try: - # Create async stop event for this event loop self._stop_event = asyncio.Event() self._server = await websockets.serve( self._ws_handler, self.host, self.port, + open_timeout=self.timeout, + ping_timeout=self.timeout, + close_timeout=self.timeout, ping_interval=20, - ping_timeout=10 ) logger.info(f"WebSocket camera server started on {self.host}:{self.port}") - # Wait for stop event instead of busy loop await self._stop_event.wait() except Exception as e: @@ -116,26 +115,26 @@ async def _start_server(self) -> None: self._server.close() await self._server.wait_closed() - async def _ws_handler(self, websocket: websockets.WebSocketServerProtocol) -> None: + async def _ws_handler(self, conn: websockets.ServerConnection) -> None: """Handle a connected WebSocket client. Only one client allowed at a time.""" - client_addr = f"{websocket.remote_address[0]}:{websocket.remote_address[1]}" + client_addr = f"{conn.remote_address[0]}:{conn.remote_address[1]}" if self._client is not None: # Reject the new client logger.warning(f"Rejecting client {client_addr}: only one client allowed at a time") try: - await websocket.send(json.dumps({ + await conn.send(json.dumps({ "error": "Server busy", "message": "Only one client connection allowed at a time", "code": 1000 })) - await websocket.close(code=1000, reason="Server busy - only one client allowed") + await conn.close(code=1000, reason="Server busy - only one client allowed") except Exception as e: logger.warning(f"Error sending rejection message to {client_addr}: {e}") return # Accept the client - self._client = websocket + self._client = conn logger.info(f"Client connected: {client_addr}") try: @@ -145,12 +144,14 @@ async def _ws_handler(self, websocket: websockets.WebSocketServerProtocol) -> No "status": "connected", "message": "You are now connected to the camera server", "frame_format": self.frame_format, + "resolution": self.resolution, + "fps": self.fps, }) except Exception as e: logger.warning(f"Could not send welcome message to {client_addr}: {e}") warning_task = None - async for message in websocket: + async for message in conn: frame = await self._parse_message(message) if frame is not None: # Drop old frames until there's room for the new one @@ -180,7 +181,7 @@ async def _ws_handler(self, websocket: websockets.WebSocketServerProtocol) -> No except Exception as e: logger.warning(f"Error handling client {client_addr}: {e}") finally: - if self._client == websocket: + if self._client == conn: self._client = None logger.info(f"Client removed: {client_addr}") From cf3853229eedf84fed3ec6769cc2929ee53afd50 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 21 Oct 2025 11:30:13 +0200 Subject: [PATCH 06/86] refactor --- src/arduino/app_internal/pipeline/pipeline.py | 8 +- src/arduino/app_peripherals/camera/README.md | 7 +- .../app_peripherals/camera/__init__.py | 4 + .../app_peripherals/camera/base_camera.py | 106 ++--- src/arduino/app_peripherals/camera/camera.py | 17 +- src/arduino/app_peripherals/camera/errors.py | 5 + .../app_peripherals/camera/examples/README.md | 57 --- .../camera/examples/camera_examples.py | 282 -------------- .../app_peripherals/camera/examples/hls.py | 4 + .../app_peripherals/camera/examples/rtsp.py | 4 + .../camera/examples/websocket_camera_proxy.py | 1 + .../app_peripherals/camera/image_editor.py | 365 ++++++++++++++++++ .../app_peripherals/camera/ip_camera.py | 4 - .../app_peripherals/camera/pipeable.py | 126 ++++++ .../camera/test_image_editor.py | 141 +++++++ .../camera/websocket_camera.py | 10 +- 16 files changed, 694 insertions(+), 447 deletions(-) delete mode 100644 src/arduino/app_peripherals/camera/examples/README.md delete mode 100644 src/arduino/app_peripherals/camera/examples/camera_examples.py create mode 100644 src/arduino/app_peripherals/camera/image_editor.py create mode 100644 src/arduino/app_peripherals/camera/pipeable.py create mode 100644 src/arduino/app_peripherals/camera/test_image_editor.py diff --git a/src/arduino/app_internal/pipeline/pipeline.py b/src/arduino/app_internal/pipeline/pipeline.py index c265b4b6..aa4217f3 100644 --- a/src/arduino/app_internal/pipeline/pipeline.py +++ b/src/arduino/app_internal/pipeline/pipeline.py @@ -177,11 +177,13 @@ def _run_loop(self, loop_ready_event: threading.Event): self._loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) self._loop.run_until_complete(self._loop.shutdown_asyncgens()) - self._loop.close() - logger.debug("Internal event loop stopped.") except Exception as e: logger.exception(f"Error during event loop cleanup: {e}") - self._loop = None + finally: + if self._loop and not self._loop.is_closed(): + self._loop.close() + self._loop = None + logger.debug("Internal event loop stopped.") async def _async_run_pipeline(self): """The main async logic using Adapters.""" diff --git a/src/arduino/app_peripherals/camera/README.md b/src/arduino/app_peripherals/camera/README.md index 845666b8..ba2d2281 100644 --- a/src/arduino/app_peripherals/camera/README.md +++ b/src/arduino/app_peripherals/camera/README.md @@ -61,16 +61,13 @@ camera = Camera(source, **options) - `str`: Camera index, device path, or URL - `resolution`: Tuple `(width, height)` or `None` for default - `fps`: Target frames per second (default: 10) -- `compression`: Enable PNG compression (default: False) -- `letterbox`: Make images square with padding (default: False) +- `transformer`: Pipeline of transformers that adjust the captured image **Methods:** - `start()`: Initialize and start camera - `stop()`: Stop camera and release resources -- `capture()`: Capture frame as PIL Image -- `capture_bytes()`: Capture frame as bytes +- `capture()`: Capture frame as Numpy array - `is_started()`: Check if camera is running -- `get_camera_info()`: Get camera properties ### Context Manager diff --git a/src/arduino/app_peripherals/camera/__init__.py b/src/arduino/app_peripherals/camera/__init__.py index ef561db1..cc6b79d7 100644 --- a/src/arduino/app_peripherals/camera/__init__.py +++ b/src/arduino/app_peripherals/camera/__init__.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + from .camera import Camera __all__ = ["Camera"] \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index 89da9484..44de359b 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -4,16 +4,13 @@ import threading import time -import io from abc import ABC, abstractmethod -from typing import Optional, Tuple -from PIL import Image -import cv2 +from typing import Optional, Tuple, Callable import numpy as np from arduino.app_utils import Logger -from .errors import CameraOpenError +from .errors import CameraOpenError, CameraTransformError logger = Logger("Camera") @@ -27,21 +24,19 @@ class BaseCamera(ABC): """ def __init__(self, resolution: Optional[Tuple[int, int]] = None, fps: int = 10, - compression: bool = False, letterbox: bool = False, **kwargs): + transformer: Optional[Callable[[np.ndarray], np.ndarray]] = None, **kwargs): """ Initialize the camera base. Args: - resolution: Resolution as (width, height). None uses default resolution. - fps: Frames per second for the camera. - compression: Whether to compress captured images to PNG format. - letterbox: Whether to apply letterboxing to make images square. + resolution (tuple, optional): Resolution as (width, height). None uses default resolution. + fps (int): Frames per second for the camera. + transformer (callable, optional): Function to transform frames that takes a numpy array and returns a numpy array. Default: None **kwargs: Additional camera-specific parameters. """ self.resolution = resolution self.fps = fps - self.compression = compression - self.letterbox = letterbox + self.transformer = transformer self._is_started = False self._cap_lock = threading.Lock() self._last_capture_time = time.monotonic() @@ -74,52 +69,17 @@ def stop(self) -> None: except Exception as e: logger.warning(f"Error stopping camera: {e}") - def capture(self) -> Optional[Image.Image]: + def capture(self) -> Optional[np.ndarray]: """ Capture a frame from the camera, respecting the configured FPS. Returns: - PIL Image or None if no frame is available. + Numpy array or None if no frame is available. """ frame = self._extract_frame() if frame is None: return None - - try: - if self.compression: - # Convert to PNG bytes first, then to PIL Image - success, encoded = cv2.imencode('.png', frame) - if success: - return Image.open(io.BytesIO(encoded.tobytes())) - else: - return None - else: - # Convert BGR to RGB for PIL - if len(frame.shape) == 3 and frame.shape[2] == 3: - rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - else: - rgb_frame = frame - return Image.fromarray(rgb_frame) - except Exception as e: - logger.exception(f"Error converting frame to PIL Image: {e}") - return None - - def capture_bytes(self) -> Optional[bytes]: - """ - Capture a frame and return as bytes. - - Returns: - Frame as bytes or None if no frame is available. - """ - frame = self._extract_frame() - if frame is None: - return None - - if self.compression: - success, encoded = cv2.imencode('.png', frame) - return encoded.tobytes() if success else None - else: - return frame.tobytes() + return frame def _extract_frame(self) -> Optional[np.ndarray]: """Extract a frame with FPS throttling and post-processing.""" @@ -140,45 +100,24 @@ def _extract_frame(self) -> Optional[np.ndarray]: self._last_capture_time = time.monotonic() - # Apply post-processing - if self.letterbox: - frame = self._letterbox(frame) + if self.transformer is None: + return frame + + try: + frame = frame | self.transformer + except Exception as e: + raise CameraTransformError(f"Frame transformation failed ({self.transformer}): {e}") return frame - def _letterbox(self, frame: np.ndarray) -> np.ndarray: - """Apply letterboxing to make the frame square.""" - h, w = frame.shape[:2] - if w != h: - size = max(h, w) - return cv2.copyMakeBorder( - frame, - top=(size - h) // 2, - bottom=(size - h + 1) // 2, - left=(size - w) // 2, - right=(size - w + 1) // 2, - borderType=cv2.BORDER_CONSTANT, - value=(114, 114, 114) - ) - return frame - def is_started(self) -> bool: """Check if the camera is started.""" return self._is_started - def produce(self) -> Optional[Image.Image]: + def produce(self) -> Optional[np.ndarray]: """Alias for capture method for compatibility.""" return self.capture() - def __enter__(self): - """Context manager entry.""" - self.start() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" - self.stop() - @abstractmethod def _open_camera(self) -> None: """Open the camera connection. Must be implemented by subclasses.""" @@ -193,3 +132,12 @@ def _close_camera(self) -> None: def _read_frame(self) -> Optional[np.ndarray]: """Read a single frame from the camera. Must be implemented by subclasses.""" pass + + def __enter__(self): + """Context manager entry.""" + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.stop() diff --git a/src/arduino/app_peripherals/camera/camera.py b/src/arduino/app_peripherals/camera/camera.py index a29f3cde..f9ee9cd5 100644 --- a/src/arduino/app_peripherals/camera/camera.py +++ b/src/arduino/app_peripherals/camera/camera.py @@ -32,9 +32,8 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: resolution (tuple, optional): Frame resolution as (width, height). Default: None (auto) fps (int, optional): Target frames per second. Default: 10 - compression (bool, optional): Enable frame compression. Default: False - letterbox (bool, optional): Enable letterboxing for resolution changes. - Default: False + transformer (callable, optional): Function to transform frames that takes a + numpy array and returns a numpy array. Default: None V4L Camera Parameters: device_index (int, optional): V4L device index override capture_format (str, optional): Video capture format (e.g., 'MJPG', 'YUYV') @@ -43,20 +42,12 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: username (str, optional): Authentication username password (str, optional): Authentication password timeout (float, optional): Connection timeout in seconds. Default: 10.0 - retry_attempts (int, optional): Number of connection retry attempts. - Default: 3 - headers (dict, optional): Additional HTTP headers - verify_ssl (bool, optional): Verify SSL certificates. Default: True WebSocket Camera Parameters: - host (str, optional): WebSocket server host. Default: "localhost" + host (str, optional): WebSocket server host. Default: "0.0.0.0" port (int, optional): WebSocket server port. Default: 8080 + timeout (float, optional): Connection timeout in seconds. Default: 10.0 frame_format (str, optional): Expected frame format ("base64", "binary", "json"). Default: "base64" - max_queue_size (int, optional): Maximum frames to buffer. Default: 10 - ping_interval (int, optional): WebSocket ping interval in seconds. - Default: 20 - ping_timeout (int, optional): WebSocket ping timeout in seconds. - Default: 10 Returns: BaseCamera: Appropriate camera implementation instance diff --git a/src/arduino/app_peripherals/camera/errors.py b/src/arduino/app_peripherals/camera/errors.py index 9b1d0000..69745f79 100644 --- a/src/arduino/app_peripherals/camera/errors.py +++ b/src/arduino/app_peripherals/camera/errors.py @@ -20,3 +20,8 @@ class CameraReadError(CameraError): class CameraConfigError(CameraError): """Exception raised when camera configuration is invalid.""" pass + + +class CameraTransformError(CameraError): + """Exception raised when frame transformation fails.""" + pass diff --git a/src/arduino/app_peripherals/camera/examples/README.md b/src/arduino/app_peripherals/camera/examples/README.md deleted file mode 100644 index 7dc562da..00000000 --- a/src/arduino/app_peripherals/camera/examples/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Camera Examples - -This directory contains examples demonstrating how to use the Camera abstraction for different types of cameras and protocols. - -## Files - -- `camera_examples.py` - Comprehensive examples showing all camera types and usage patterns - -## Running Examples - -```bash -python examples/camera_examples.py -``` - -## Example Types Covered - -### 1. V4L/USB Cameras -- Basic usage with camera index -- Context manager pattern -- Resolution and FPS configuration -- Frame format options - -### 2. IP Cameras -- RTSP streams -- HTTP/MJPEG streams -- Authentication -- Connection testing - -### 3. WebSocket Camera Servers -- Hosting WebSocket servers (single client only) -- Receiving frames from one connected client -- Client rejection when server is at capacity -- Multiple frame formats (base64, binary, JSON) -- Bidirectional communication with client -- Server status monitoring - -### 4. Factory Pattern -- Automatic camera type detection -- Multiple instantiation methods -- Error handling - -### 5. Advanced Configuration -- Compression settings -- Letterboxing -- Custom parameters -- Performance tuning - -## Camera Source Formats - -The Camera class automatically detects the appropriate implementation based on the source: - -- `0`, `1`, `"0"` - V4L camera indices -- `"/dev/video0"` - V4L device paths -- `"rtsp://..."` - RTSP streams -- `"http://..."` - HTTP streams -- `"ws://localhost:8080"` - WebSocket server URL -- `"localhost:9090"` - WebSocket server host:port \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/camera_examples.py b/src/arduino/app_peripherals/camera/examples/camera_examples.py deleted file mode 100644 index 907e7ec2..00000000 --- a/src/arduino/app_peripherals/camera/examples/camera_examples.py +++ /dev/null @@ -1,282 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA -# -# SPDX-License-Identifier: MPL-2.0 - -""" -Camera Abstraction Usage Examples - -This file demonstrates various ways to instantiate and use the Camera abstraction -for different camera types and protocols. -""" - -import time -from arduino.app_peripherals.camera import Camera -from arduino.app_peripherals.camera.camera import CameraFactory - - -def example_v4l_camera(): - """Example: Using a V4L/USB camera""" - print("=== V4L/USB Camera Example ===") - - # Method 1: Using Camera class (recommended) - camera = Camera(0, resolution=(640, 480), fps=15) - - try: - # Start the camera - camera.start() - print(f"Camera started: {camera.get_camera_info()}") - - # Capture some frames - for i in range(5): - frame = camera.capture() - if frame: - print(f"Captured frame {i+1}: {frame.size} pixels") - else: - print(f"Failed to capture frame {i+1}") - time.sleep(0.5) - - finally: - camera.stop() - - print() - - -def example_v4l_camera_context_manager(): - """Example: Using V4L camera with context manager""" - print("=== V4L Camera with Context Manager ===") - - # Context manager automatically handles start/stop - with Camera("0", resolution=(320, 240), fps=10, letterbox=True) as camera: - print(f"Camera info: {camera.get_camera_info()}") - - # Capture a few frames - for i in range(3): - frame = camera.capture() - if frame: - print(f"Frame {i+1}: {frame.size}") - time.sleep(1.0) - - print("Camera automatically stopped\n") - - -def example_ip_camera(): - """Example: Using an IP camera (RTSP/HTTP)""" - print("=== IP Camera Example ===") - - # Example RTSP URL (replace with your camera's URL) - rtsp_url = "rtsp://admin:password@192.168.1.100:554/stream" - - # Method 1: Direct instantiation - camera = Camera(rtsp_url, fps=5) - - try: - # Test connection first - if hasattr(camera, 'test_connection') and camera.test_connection(): - print("IP camera is accessible") - - camera.start() - print(f"IP camera started: {camera.get_camera_info()}") - - # Capture frames - for i in range(3): - frame = camera.capture() - if frame: - print(f"IP frame {i+1}: {frame.size}") - else: - print(f"No frame received {i+1}") - time.sleep(2.0) - else: - print("IP camera not accessible (expected for this example)") - - except Exception as e: - print(f"IP camera error (expected): {e}") - - finally: - camera.stop() - - print() - - -def example_websocket_camera(): - """Example: Using a WebSocket camera server (single client only)""" - print("=== WebSocket Camera Server Example (Single Client) ===") - - # Create WebSocket camera server - try: - # Method 1: Direct host:port specification - camera = Camera("ws://localhost:8080", frame_format="base64") - - camera.start() - print(f"WebSocket camera server started: {camera.get_camera_info()}") - - # Server is now listening for client connections (max 1 client) - print("Server is waiting for ONE client to connect and send frames...") - print("Additional clients will be rejected with an error message") - print("Clients should connect to ws://localhost:8080 and send base64 encoded images") - - # Monitor for incoming frames - for i in range(10): # Check for 10 seconds - frame = camera.capture() - if frame: - print(f"Received frame {i+1}: {frame.size}") - else: - print(f"No frame received in iteration {i+1}") - - time.sleep(1.0) - - except Exception as e: - print(f"WebSocket camera server error (expected if no clients connect): {e}") - - finally: - if 'camera' in locals(): - camera.stop() - - print() - - -def example_websocket_server_with_url(): - """Example: WebSocket server using ws:// URL (single client only)""" - print("=== WebSocket Server with URL Example (Single Client) ===") - - try: - # Method 2: Using ws:// URL (server extracts host and port) - camera = Camera("ws://0.0.0.0:9090", frame_format="json") - - camera.start() - - # Wait briefly for potential connections - time.sleep(2) - - camera.stop() - print("WebSocket server stopped") - - except Exception as e: - print(f"WebSocket server URL error: {e}") - - print() - - -def example_factory_usage(): - """Example: Using CameraFactory directly""" - print("=== Camera Factory Example ===") - - # Different ways to create cameras using the factory - sources = [ - 0, # V4L camera index - "1", # V4L camera as string - "/dev/video0", # V4L device path - "rtsp://example.com/stream", # RTSP camera - "http://example.com/mjpeg", # HTTP camera - "ws://localhost:8080", # WebSocket server URL - "localhost:9090", # WebSocket server host:port - "0.0.0.0:8888", # WebSocket server on all interfaces - ] - - for source in sources: - try: - camera = CameraFactory.create_camera(source, fps=10) - print(f"Created {camera.__class__.__name__} for source: {source}") - # Don't start cameras in this example - except Exception as e: - print(f"Cannot create camera for {source}: {e}") - - print() - - -def example_advanced_configuration(): - """Example: Advanced camera configuration""" - print("=== Advanced Configuration Example ===") - - # V4L camera with all options - v4l_config = { - 'resolution': (1280, 720), - 'fps': 30, - 'compression': True, # PNG compression - 'letterbox': True, # Square images - } - - try: - with Camera(0, **v4l_config) as camera: - print(f"V4L config: {camera.get_camera_info()}") - - # Capture compressed frame - frame = camera.capture() - if frame: - print(f"Compressed frame: {frame.format} {frame.size}") - - # Capture as bytes - frame_bytes = camera.capture_bytes() - if frame_bytes: - print(f"Frame bytes length: {len(frame_bytes)}") - - except Exception as e: - print(f"Advanced config error: {e}") - - # IP camera with authentication - ip_config = { - 'username': 'admin', - 'password': 'secret', - 'timeout': 5, - 'fps': 10 - } - - try: - ip_camera = Camera("http://192.168.1.100/mjpeg", **ip_config) - print(f"IP camera with auth created: {ip_camera.__class__.__name__}") - except Exception as e: - print(f"IP camera with auth error: {e}") - - # WebSocket server with different frame formats - ws_configs = [ - {'host': 'localhost', 'port': 8080, 'frame_format': 'base64'}, - {'host': '0.0.0.0', 'port': 9090, 'frame_format': 'json'}, - {'host': '127.0.0.1', 'port': 8888, 'frame_format': 'binary'}, - ] - - for config in ws_configs: - try: - ws_camera = Camera("localhost:8080", **config) # Will use the config params - print(f"WebSocket server config: {config}") - except Exception as e: - print(f"WebSocket server config error: {e}") - - print() - - -def example_error_handling(): - """Example: Proper error handling""" - print("=== Error Handling Example ===") - - # Try to open non-existent camera - try: - camera = Camera(99) # Non-existent camera - camera.start() - except Exception as e: - print(f"Expected error for invalid camera: {e}") - - # Try invalid URL - try: - camera = Camera("invalid://url") - except Exception as e: - print(f"Expected error for invalid URL: {e}") - - print() - - -if __name__ == "__main__": - print("Camera Abstraction Examples\n") - print("Note: Some examples may show errors if cameras are not available.\n") - - # Run examples - example_factory_usage() - example_advanced_configuration() - example_error_handling() - - # Uncomment these if you have actual cameras available: - # example_v4l_camera() - # example_v4l_camera_context_manager() - # example_ip_camera() - # example_websocket_camera() - # example_websocket_server_with_url() - - print("Examples completed!") \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/hls.py b/src/arduino/app_peripherals/camera/examples/hls.py index da1d8cde..d944fadd 100644 --- a/src/arduino/app_peripherals/camera/examples/hls.py +++ b/src/arduino/app_peripherals/camera/examples/hls.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + import cv2 # URL to an HLS playlist diff --git a/src/arduino/app_peripherals/camera/examples/rtsp.py b/src/arduino/app_peripherals/camera/examples/rtsp.py index c42e7c9a..81de26e1 100644 --- a/src/arduino/app_peripherals/camera/examples/rtsp.py +++ b/src/arduino/app_peripherals/camera/examples/rtsp.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + import cv2 # A freely available RTSP stream for testing. diff --git a/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py b/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py index 4489e19a..e00d3f7d 100644 --- a/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py +++ b/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 + # SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA # # SPDX-License-Identifier: MPL-2.0 diff --git a/src/arduino/app_peripherals/camera/image_editor.py b/src/arduino/app_peripherals/camera/image_editor.py new file mode 100644 index 00000000..a42350c2 --- /dev/null +++ b/src/arduino/app_peripherals/camera/image_editor.py @@ -0,0 +1,365 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import cv2 +import numpy as np +from typing import Optional, Tuple +from PIL import Image + +from .pipeable import pipeable + + +class ImageEditor: + """ + Image processing utilities for camera frames. + + Handles common image operations like compression, letterboxing, resizing, and format conversions. + + This class provides traditional static methods for image processing operations. + For functional composition with pipe operators, use the standalone functions below the class. + + Examples: + Traditional API: + result = ImageEditor.letterbox(frame, target_size=(640, 640)) + + Functional API: + result = frame | letterboxed(target_size=(640, 640)) + + Chained operations: + result = frame | letterboxed(target_size=(640, 640)) | adjusted(brightness=10) + """ + + @staticmethod + def letterbox(frame: np.ndarray, + target_size: Optional[Tuple[int, int]] = None, + color: Tuple[int, int, int] = (114, 114, 114)) -> np.ndarray: + """ + Add letterboxing to frame to achieve target size while maintaining aspect ratio. + + Args: + frame (np.ndarray): Input frame + target_size (tuple, optional): Target size as (width, height). If None, makes frame square. + color (tuple): RGB color for padding borders. Default: (114, 114, 114) + + Returns: + np.ndarray: Letterboxed frame + """ + if target_size is None: + # Make square based on the larger dimension + max_dim = max(frame.shape[0], frame.shape[1]) + target_size = (max_dim, max_dim) + + target_w, target_h = target_size + h, w = frame.shape[:2] + + # Calculate scaling factor to fit image inside target size + scale = min(target_w / w, target_h / h) + new_w, new_h = int(w * scale), int(h * scale) + + # Resize frame + resized = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_LINEAR) + + # Calculate padding + pad_w = target_w - new_w + pad_h = target_h - new_h + + # Add padding + return cv2.copyMakeBorder( + resized, + top=pad_h // 2, + bottom=(pad_h + 1) // 2, + left=pad_w // 2, + right=(pad_w + 1) // 2, + borderType=cv2.BORDER_CONSTANT, + value=color + ) + + @staticmethod + def resize(frame: np.ndarray, + target_size: Tuple[int, int], + maintain_aspect: bool = False, + interpolation: int = cv2.INTER_LINEAR) -> np.ndarray: + """ + Resize frame to target size. + + Args: + frame (np.ndarray): Input frame + target_size (tuple): Target size as (width, height) + maintain_aspect (bool): If True, use letterboxing to maintain aspect ratio + interpolation (int): OpenCV interpolation method + + Returns: + np.ndarray: Resized frame + """ + if maintain_aspect: + return ImageEditor.letterbox(frame, target_size) + else: + return cv2.resize(frame, target_size, interpolation=interpolation) + + @staticmethod + def adjust(frame: np.ndarray, + brightness: float = 0.0, + contrast: float = 1.0, + saturation: float = 1.0) -> np.ndarray: + """ + Apply basic image filters. + + Args: + frame (np.ndarray): Input frame + brightness (float): Brightness adjustment (-100 to 100) + contrast (float): Contrast multiplier (0.0 to 3.0) + saturation (float): Saturation multiplier (0.0 to 3.0) + + Returns: + np.ndarray: adjusted frame + """ + # Apply brightness and contrast + result = cv2.convertScaleAbs(frame, alpha=contrast, beta=brightness) + + # Apply saturation if needed + if saturation != 1.0: + hsv = cv2.cvtColor(result, cv2.COLOR_BGR2HSV).astype(np.float32) + hsv[:, :, 1] *= saturation + hsv[:, :, 1] = np.clip(hsv[:, :, 1], 0, 255) + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR) + + return result + + @staticmethod + def greyscale(frame: np.ndarray) -> np.ndarray: + """ + Convert frame to greyscale. + + Args: + frame (np.ndarray): Input frame in BGR format + + Returns: + np.ndarray: Greyscale frame (still 3 channels for consistency) + """ + # Convert to greyscale + grey = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + # Convert back to 3 channels for consistency with other operations + return cv2.cvtColor(grey, cv2.COLOR_GRAY2BGR) + + @staticmethod + def compress_to_jpeg(frame: np.ndarray, quality: int = 90) -> Optional[bytes]: + """ + Compress frame to JPEG format. + + Args: + frame (np.ndarray): Input frame as numpy array + quality (int): JPEG quality (0-100, higher = better quality) + + Returns: + bytes: Compressed JPEG data, or None if compression failed + """ + try: + success, encoded = cv2.imencode( + '.jpg', + frame, + [cv2.IMWRITE_JPEG_QUALITY, quality] + ) + return encoded.tobytes() if success else None + except Exception: + return None + + @staticmethod + def compress_to_png(frame: np.ndarray, compression_level: int = 6) -> Optional[bytes]: + """ + Compress frame to PNG format. + + Args: + frame (np.ndarray): Input frame as numpy array + compression_level (int): PNG compression level (0-9, higher = better compression) + + Returns: + bytes: Compressed PNG data, or None if compression failed + """ + try: + success, encoded = cv2.imencode( + '.png', + frame, + [cv2.IMWRITE_PNG_COMPRESSION, compression_level] + ) + return encoded.tobytes() if success else None + except Exception: + return None + + @staticmethod + def numpy_to_pil(frame: np.ndarray) -> Image.Image: + """ + Convert numpy array to PIL Image. + + Args: + frame (np.ndarray): Input frame in BGR format (OpenCV default) + + Returns: + PIL.Image.Image: PIL Image in RGB format + """ + # Convert BGR to RGB + rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + return Image.fromarray(rgb_frame) + + @staticmethod + def pil_to_numpy(image: Image.Image) -> np.ndarray: + """ + Convert PIL Image to numpy array. + + Args: + image (PIL.Image.Image): PIL Image + + Returns: + np.ndarray: Numpy array in BGR format (OpenCV default) + """ + # Convert to RGB if not already + if image.mode != 'RGB': + image = image.convert('RGB') + + # Convert to numpy and then BGR + rgb_array = np.array(image) + return cv2.cvtColor(rgb_array, cv2.COLOR_RGB2BGR) + + @staticmethod + def get_frame_info(frame: np.ndarray) -> dict: + """ + Get information about a frame. + + Args: + frame (np.ndarray): Input frame + + Returns: + dict: Frame information including dimensions, channels, dtype, size + """ + return { + 'height': frame.shape[0], + 'width': frame.shape[1], + 'channels': frame.shape[2] if len(frame.shape) > 2 else 1, + 'dtype': str(frame.dtype), + 'size_bytes': frame.nbytes, + 'shape': frame.shape + } + + +# ============================================================================= +# Functional API - Standalone pipeable functions +# ============================================================================= + +@pipeable +def letterboxed(target_size: Optional[Tuple[int, int]] = None, + color: Tuple[int, int, int] = (114, 114, 114)): + """ + Pipeable letterbox function - apply letterboxing with pipe operator support. + + Args: + target_size (tuple, optional): Target size as (width, height). If None, makes frame square. + color (tuple): RGB color for padding borders. Default: (114, 114, 114) + + Returns: + Partial function that takes a frame and returns letterboxed frame + + Examples: + result = frame | letterboxed(target_size=(640, 640)) + result = frame | letterboxed() | adjusted(brightness=10) + """ + from functools import partial + return partial(ImageEditor.letterbox, target_size=target_size, color=color) + + +@pipeable +def resized(target_size: Tuple[int, int], + maintain_aspect: bool = False, + interpolation: int = cv2.INTER_LINEAR): + """ + Pipeable resize function - resize frame with pipe operator support. + + Args: + target_size (tuple): Target size as (width, height) + maintain_aspect (bool): If True, use letterboxing to maintain aspect ratio + interpolation (int): OpenCV interpolation method + + Returns: + Partial function that takes a frame and returns resized frame + + Examples: + result = frame | resized(target_size=(640, 480)) + result = frame | letterboxed() | resized(target_size=(320, 240)) + """ + from functools import partial + return partial(ImageEditor.resize, target_size=target_size, maintain_aspect=maintain_aspect, interpolation=interpolation) + + +@pipeable +def adjusted(brightness: float = 0.0, + contrast: float = 1.0, + saturation: float = 1.0): + """ + Pipeable filter function - apply filters with pipe operator support. + + Args: + brightness (float): Brightness adjustment (-100 to 100) + contrast (float): Contrast multiplier (0.0 to 3.0) + saturation (float): Saturation multiplier (0.0 to 3.0) + + Returns: + Partial function that takes a frame and returns the adjusted frame + + Examples: + result = frame | adjusted(brightness=10, contrast=1.2) + result = frame | letterboxed() | adjusted(brightness=5) | resized(target_size=(320, 240)) + """ + from functools import partial + return partial(ImageEditor.adjust, brightness=brightness, contrast=contrast, saturation=saturation) + + +@pipeable +def greyscaled(): + """ + Pipeable greyscale function - convert frame to greyscale with pipe operator support. + + Returns: + Function that takes a frame and returns greyscale frame + + Examples: + result = frame | greyscaled() + result = frame | letterboxed() | greyscaled() | adjusted(contrast=1.2) + """ + return ImageEditor.greyscale + + +@pipeable +def compressed_to_jpeg(quality: int = 90): + """ + Pipeable JPEG compression function - compress frame to JPEG with pipe operator support. + + Args: + quality (int): JPEG quality (0-100, higher = better quality) + + Returns: + Partial function that takes a frame and returns compressed JPEG bytes + + Examples: + jpeg_bytes = frame | compressed_to_jpeg(quality=95) + jpeg_bytes = frame | resized(target_size=(640, 480)) | compressed_to_jpeg() + """ + from functools import partial + return partial(ImageEditor.compress_to_jpeg, quality=quality) + + +@pipeable +def compressed_to_png(compression_level: int = 6): + """ + Pipeable PNG compression function - compress frame to PNG with pipe operator support. + + Args: + compression_level (int): PNG compression level (0-9, higher = better compression) + + Returns: + Partial function that takes a frame and returns compressed PNG bytes + + Examples: + png_bytes = frame | compressed_to_png(compression_level=9) + png_bytes = frame | letterboxed() | compressed_to_png() + """ + from functools import partial + return partial(ImageEditor.compress_to_png, compression_level=compression_level) diff --git a/src/arduino/app_peripherals/camera/ip_camera.py b/src/arduino/app_peripherals/camera/ip_camera.py index 2d58e122..d5a3eefd 100644 --- a/src/arduino/app_peripherals/camera/ip_camera.py +++ b/src/arduino/app_peripherals/camera/ip_camera.py @@ -61,12 +61,8 @@ def _open_camera(self) -> None: if self.url.startswith(('http://', 'https://')): self._test_http_connectivity() - # Open with OpenCV self._cap = cv2.VideoCapture(auth_url) - - # Set timeout properties self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to get latest frames - if not self._cap.isOpened(): raise CameraOpenError(f"Failed to open IP camera: {self.url}") diff --git a/src/arduino/app_peripherals/camera/pipeable.py b/src/arduino/app_peripherals/camera/pipeable.py new file mode 100644 index 00000000..cfe6a184 --- /dev/null +++ b/src/arduino/app_peripherals/camera/pipeable.py @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +""" +Decorator for adding pipe operator support to transformation functions. + +This module provides a decorator that wraps static functions to support +the | (pipe) operator for functional composition. +""" + +from typing import Callable +from functools import wraps + + +class PipeableFunction: + """ + Wrapper class that adds pipe operator support to a function. + + This allows functions to be composed using the | operator in a left-to-right manner. + """ + + def __init__(self, func: Callable, *args, **kwargs): + """ + Initialize a pipeable function. + + Args: + func: The function to wrap + *args: Positional arguments to partially apply + **kwargs: Keyword arguments to partially apply + """ + self.func = func + self.args = args + self.kwargs = kwargs + + def __call__(self, *args, **kwargs): + """Call the wrapped function with combined arguments.""" + combined_args = self.args + args + combined_kwargs = {**self.kwargs, **kwargs} + return self.func(*combined_args, **combined_kwargs) + + def __ror__(self, other): + """ + Right-hand side of pipe operator (|). + + This allows: value | pipeable_function + + Args: + other: The value being piped into this function + + Returns: + Result of applying this function to the value + """ + return self(other) + + def __or__(self, other): + """ + Left-hand side of pipe operator (|). + + This allows: pipeable_function | other_function + + Args: + other: Another function to compose with + + Returns: + A new pipeable function that combines both + """ + if not callable(other): + return NotImplemented + + def composed(value): + return other(self(value)) + + return PipeableFunction(composed) + + def __repr__(self): + """String representation of the pipeable function.""" + if self.args or self.kwargs: + args_str = ', '.join(map(str, self.args)) + kwargs_str = ', '.join(f'{k}={v}' for k, v in self.kwargs.items()) + all_args = ', '.join(filter(None, [args_str, kwargs_str])) + return f"{self.__name__}({all_args})" + return f"{self.__name__}()" + + +def pipeable(func: Callable) -> Callable: + """ + Decorator that makes a function pipeable using the | operator. + + The decorated function can be used in two ways: + 1. Normal function call: func(args) + 2. Pipe operator: value | func or func | other_func + + Args: + func: Function to make pipeable + + Returns: + Wrapped function that supports pipe operations + + Examples: + @pipeable + def add_one(x): + return x + 1 + + result = 5 | add_one | add_one -> 7 + """ + @wraps(func) + def wrapper(*args, **kwargs): + if args and kwargs: + # Both positional and keyword args - return partially applied + return PipeableFunction(func, *args, **kwargs) + elif args: + # Only positional args - return partially applied + return PipeableFunction(func, *args, **kwargs) + elif kwargs: + # Only keyword args - return partially applied + return PipeableFunction(func, **kwargs) + else: + # No args - return pipeable version of original function + return PipeableFunction(func) + + # Also add the pipeable functionality directly to the wrapper + wrapper.__ror__ = lambda self, other: func(other) + wrapper.__or__ = lambda self, other: PipeableFunction(lambda x: other(func(x))) + + return wrapper \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/test_image_editor.py b/src/arduino/app_peripherals/camera/test_image_editor.py new file mode 100644 index 00000000..299accb9 --- /dev/null +++ b/src/arduino/app_peripherals/camera/test_image_editor.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Test script to verify ImageEditor integration with Camera classes. +""" + +import numpy as np +from arduino.app_peripherals.camera import ImageEditor +from arduino.app_peripherals.camera import image_editor as ie +from arduino.app_peripherals.camera.functional_utils import compose, curry, identity + +def test_image_editor(): + """Test ImageEditor functionality.""" + # Create a test frame (100x150 RGB image) + test_frame = np.random.randint(0, 255, (100, 150, 3), dtype=np.uint8) + + print(f"Original frame shape: {test_frame.shape}") + + # Test letterboxing to make square + letterboxed = ImageEditor.letterbox(test_frame) + print(f"Letterboxed frame shape: {letterboxed.shape}") + assert letterboxed.shape[0] == letterboxed.shape[1], "Letterboxed frame should be square" + + # Test letterboxing to specific size + target_letterboxed = ImageEditor.letterbox(test_frame, target_size=(200, 200)) + print(f"Target letterboxed frame shape: {target_letterboxed.shape}") + assert target_letterboxed.shape[:2] == (200, 200), "Should match target size" + + # Test PNG compression + png_bytes = ImageEditor.compress_to_png(test_frame) + print(f"PNG compressed size: {len(png_bytes) if png_bytes else 0} bytes") + assert png_bytes is not None, "PNG compression should succeed" + + # Test JPEG compression + jpeg_bytes = ImageEditor.compress_to_jpeg(test_frame) + print(f"JPEG compressed size: {len(jpeg_bytes) if jpeg_bytes else 0} bytes") + assert jpeg_bytes is not None, "JPEG compression should succeed" + + # Test PIL conversion + pil_image = ImageEditor.numpy_to_pil(test_frame) + print(f"PIL image size: {pil_image.size}, mode: {pil_image.mode}") + assert pil_image.mode == 'RGB', "PIL image should be RGB" + + # Test numpy conversion back + numpy_frame = ImageEditor.pil_to_numpy(pil_image) + print(f"Converted back to numpy shape: {numpy_frame.shape}") + assert numpy_frame.shape == test_frame.shape, "Round-trip conversion should preserve shape" + + # Test frame info + info = ImageEditor.get_frame_info(test_frame) + print(f"Frame info: {info}") + assert info['width'] == 150 and info['height'] == 100, "Frame info should be correct" + + print("✅ All ImageEditor tests passed!") + +def test_transformers(): + """Test transformer functionality.""" + print("\n=== Testing Transformers ===") + + # Create test frame + test_frame = np.random.randint(0, 255, (100, 150, 3), dtype=np.uint8) + print(f"Original frame shape: {test_frame.shape}") + + # Test identity transformer + identity_result = identity(test_frame) + assert np.array_equal(identity_result, test_frame), "Identity should return unchanged frame" + print("✅ Identity transformer works") + + # Test module-level API + letterbox_transformer = ie.letterbox(target_size=(200, 200)) + letterboxed = letterbox_transformer(test_frame) + print(f"Letterbox transformer result: {letterboxed.shape}") + assert letterboxed.shape[:2] == (200, 200), "Transformer should produce correct size" + print("✅ Letterbox transformer works") + + # Test resize transformer + resize_transformer = ie.resize(target_size=(320, 240), maintain_aspect=False) + resized = resize_transformer(test_frame) + print(f"Resize transformer result: {resized.shape}") + assert resized.shape[:2] == (240, 320), "Resize should produce correct dimensions" + print("✅ Resize transformer works") + + # Test filter transformer + filter_transformer = ie.filters(brightness=10, contrast=1.2, saturation=1.1) + filtered = filter_transformer(test_frame) + print(f"Filter transformer result: {filtered.shape}") + assert filtered.shape == test_frame.shape, "Filter should preserve shape" + print("✅ Filter transformer works") + + # Test pipeline composition + pipeline_transformer = ie.pipeline( + ie.letterbox(target_size=(200, 200)), + ie.filters(brightness=5, contrast=1.1) + ) + pipeline_result = pipeline_transformer(test_frame) + print(f"Pipeline transformer result: {pipeline_result.shape}") + assert pipeline_result.shape[:2] == (200, 200), "Pipeline should work correctly" + print("✅ Pipeline transformer works") + + # Test standard processing + standard_transformer = ie.standard_processing(target_size=(256, 256)) + standard_result = standard_transformer(test_frame) + print(f"Standard processing result: {standard_result.shape}") + assert standard_result.shape[:2] == (256, 256), "Standard processing should work" + print("✅ Standard processing works") + + # Test webcam processing + webcam_transformer = ie.webcam_processing() + webcam_result = webcam_transformer(test_frame) + print(f"Webcam processing result: {webcam_result.shape}") + assert webcam_result.shape[:2] == (640, 640), "Webcam processing should work" + print("✅ Webcam processing works") + + # Test mobile processing + mobile_transformer = ie.mobile_processing() + mobile_result = mobile_transformer(test_frame) + print(f"Mobile processing result: {mobile_result.shape}") + assert mobile_result.shape[:2] == (480, 480), "Mobile processing should work" + print("✅ Mobile processing works") + + # Test with curry from functional_utils + manual_letterbox = curry(ImageEditor.letterbox, target_size=(300, 300), color=(128, 128, 128)) + manual_result = manual_letterbox(test_frame) + print(f"Manual curry result: {manual_result.shape}") + assert manual_result.shape[:2] == (300, 300), "Manual curry should work" + print("✅ Manual curry works") + + # Test compose from functional_utils + composed_transformer = compose( + ie.letterbox(target_size=(180, 180)), + ie.filters(brightness=8) + ) + composed_result = composed_transformer(test_frame) + print(f"Compose result: {composed_result.shape}") + assert composed_result.shape[:2] == (180, 180), "Compose should work" + print("✅ Functional compose works") + + print("✅ All transformer tests passed!") + +if __name__ == "__main__": + test_image_editor() + test_transformers() \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 374fc4c3..f914f4e0 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -28,19 +28,20 @@ class WebSocketCamera(BaseCamera): This camera acts as a WebSocket server that receives frames from connected clients. Clients can send frames in various formats: - Base64 encoded images - - Binary image data - JSON messages with image data + - Binary image data """ def __init__(self, host: str = "0.0.0.0", port: int = 8080, timeout: int = 10, - frame_format: str = "binary", **kwargs): + frame_format: str = "base64", **kwargs): """ Initialize WebSocket camera server. Args: host: Host address to bind the server to (default: "0.0.0.0") port: Port to bind the server to (default: 8080) - frame_format: Expected frame format from clients ("binary", "base64", "json") (default: "binary") + timeout: Connection timeout in seconds (default: 10) + frame_format: Expected frame format from clients ("base64", "json", "binary") (default: "base64") **kwargs: Additional camera parameters """ super().__init__(**kwargs) @@ -68,7 +69,8 @@ def _open_camera(self) -> None: # Wait for server to start start_time = time.time() - while self._server is None and time.time() - start_time < self.timeout: + start_timeout = 10 + while self._server is None and time.time() - start_time < start_timeout: if self._server is not None: break time.sleep(0.1) From dd3e5331d600df61420ddeea21da40d67cf0ef53 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Fri, 24 Oct 2025 10:05:20 +0200 Subject: [PATCH 07/86] refactor: image manipulation --- .../camera_code_detection/__init__.py | 5 ++-- .../camera_code_detection/detection.py | 8 +++---- .../app_bricks/object_detection/README.md | 19 ++++++++------- .../examples/object_detection_example.py | 21 ++++++++-------- .../brick_compose.yaml | 2 +- .../video_objectdetection/brick_compose.yaml | 2 +- .../examples/visual_anomaly_example.py | 24 +++++++++++++++++++ .../camera/examples/websocket_camera_proxy.py | 22 ++++++++--------- src/arduino/app_utils/__init__.py | 6 ----- src/arduino/app_utils/image/__init__.py | 18 ++++++++++++++ src/arduino/app_utils/{ => image}/image.py | 7 ++---- .../image}/image_editor.py | 0 .../camera => app_utils/image}/pipeable.py | 0 src/arduino/app_utils/userinput.py | 14 ----------- .../test_imageclassification.py | 2 +- .../objectdetection/test_objectdetection.py | 21 ---------------- tests/arduino/app_core/test_edge_impulse.py | 2 +- 17 files changed, 86 insertions(+), 87 deletions(-) create mode 100644 src/arduino/app_bricks/visual_anomaly_detection/examples/visual_anomaly_example.py create mode 100644 src/arduino/app_utils/image/__init__.py rename src/arduino/app_utils/{ => image}/image.py (95%) rename src/arduino/{app_peripherals/camera => app_utils/image}/image_editor.py (100%) rename src/arduino/{app_peripherals/camera => app_utils/image}/pipeable.py (100%) delete mode 100644 src/arduino/app_utils/userinput.py diff --git a/src/arduino/app_bricks/camera_code_detection/__init__.py b/src/arduino/app_bricks/camera_code_detection/__init__.py index 3bb7eb0e..c396a841 100644 --- a/src/arduino/app_bricks/camera_code_detection/__init__.py +++ b/src/arduino/app_bricks/camera_code_detection/__init__.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: MPL-2.0 -from .detection import Detection, CameraCodeDetection -from .utils import draw_bounding_boxes, draw_bounding_box +from .detection import CameraCodeDetection, Detection -__all__ = ["CameraCodeDetection", "Detection", "draw_bounding_boxes", "draw_bounding_box"] +__all__ = ["CameraCodeDetection", "Detection"] diff --git a/src/arduino/app_bricks/camera_code_detection/detection.py b/src/arduino/app_bricks/camera_code_detection/detection.py index e6b7991f..d457e8d5 100644 --- a/src/arduino/app_bricks/camera_code_detection/detection.py +++ b/src/arduino/app_bricks/camera_code_detection/detection.py @@ -11,7 +11,7 @@ import numpy as np from PIL.Image import Image -from arduino.app_peripherals.usb_camera import USBCamera +from arduino.app_peripherals.camera import Camera from arduino.app_utils import brick, Logger logger = Logger("CameraCodeDetection") @@ -55,7 +55,7 @@ class CameraCodeDetection: def __init__( self, - camera: USBCamera = None, + camera: Camera = None, detect_qr: bool = True, detect_barcode: bool = True, ): @@ -76,7 +76,7 @@ def __init__( self.already_seen_codes = set() - self._camera = camera if camera else USBCamera() + self._camera = camera if camera else Camera() def start(self): """Start the detector and begin scanning for codes.""" @@ -147,7 +147,7 @@ def on_error(self, callback: Callable[[Exception], None] | None): def loop(self): """Main loop to capture frames and detect codes.""" try: - frame = self._camera.capture_bytes() + frame = self._camera.capture() if frame is None: return except Exception as e: diff --git a/src/arduino/app_bricks/object_detection/README.md b/src/arduino/app_bricks/object_detection/README.md index 3234ca67..9489e695 100644 --- a/src/arduino/app_bricks/object_detection/README.md +++ b/src/arduino/app_bricks/object_detection/README.md @@ -23,23 +23,24 @@ The Object Detection Brick allows you to: ```python import os from arduino.app_bricks.object_detection import ObjectDetection +from arduino.app_utils.image import draw_bounding_boxes object_detection = ObjectDetection() -# Image frame can be as bytes or PIL image -frame = os.read("path/to/your/image.jpg") +# Image can be provided as bytes or PIL.Image +img = os.read("path/to/your/image.jpg") -out = object_detection.detect(frame) -# is it possible to customize image type, confidence level and box overlap -# out = object_detection.detect(frame, image_type = "png", confidence = 0.35, overlap = 0.5) +out = object_detection.detect(img) +# You can also provide a confidence level +# out = object_detection.detect(frame, confidence = 0.35) if out and "detection" in out: for i, obj_det in enumerate(out["detection"]): - # For every object detected, get its details + # For every object detected, print its details detected_object = obj_det.get("class_name", None) - bounding_box = obj_det.get("bounding_box_xyxy", None) confidence = obj_det.get("confidence", None) + bounding_box = obj_det.get("bounding_box_xyxy", None) -# draw the bounding box and key points on the image -out_image = object_detection.draw_bounding_boxes(frame, out) +# Draw the bounding boxes +out_image = draw_bounding_boxes(img, out) ``` diff --git a/src/arduino/app_bricks/object_detection/examples/object_detection_example.py b/src/arduino/app_bricks/object_detection/examples/object_detection_example.py index 166f5b7c..5edc047a 100644 --- a/src/arduino/app_bricks/object_detection/examples/object_detection_example.py +++ b/src/arduino/app_bricks/object_detection/examples/object_detection_example.py @@ -3,23 +3,24 @@ # SPDX-License-Identifier: MPL-2.0 # EXAMPLE_NAME = "Object Detection" +import os from arduino.app_bricks.object_detection import ObjectDetection +from arduino.app_utils.image import draw_bounding_boxes object_detection = ObjectDetection() -# Image frame can be as bytes or PIL image -with open("image.png", "rb") as f: - frame = f.read() +# Image can be provided as bytes or PIL.Image +img = os.read("path/to/your/image.jpg") -out = object_detection.detect(frame) -# is it possible to customize image type, confidence level and box overlap -# out = object_detection.detect(frame, image_type = "png", confidence = 0.35, overlap = 0.5) +out = object_detection.detect(img) +# You can also provide a confidence level +# out = object_detection.detect(frame, confidence = 0.35) if out and "detection" in out: for i, obj_det in enumerate(out["detection"]): - # For every object detected, get its details + # For every object detected, print its details detected_object = obj_det.get("class_name", None) - bounding_box = obj_det.get("bounding_box_xyxy", None) confidence = obj_det.get("confidence", None) + bounding_box = obj_det.get("bounding_box_xyxy", None) -# draw the bounding box and key points on the image -out_image = object_detection.draw_bounding_boxes(frame, out) +# Draw the bounding boxes +out_image = draw_bounding_boxes(img, out) \ No newline at end of file diff --git a/src/arduino/app_bricks/video_imageclassification/brick_compose.yaml b/src/arduino/app_bricks/video_imageclassification/brick_compose.yaml index 7e054acc..28f1aa73 100644 --- a/src/arduino/app_bricks/video_imageclassification/brick_compose.yaml +++ b/src/arduino/app_bricks/video_imageclassification/brick_compose.yaml @@ -13,7 +13,7 @@ services: volumes: - "${CUSTOM_MODEL_PATH:-/home/arduino/.arduino-bricks/ei-models/}:${CUSTOM_MODEL_PATH:-/home/arduino/.arduino-bricks/ei-models/}" - "/run/udev:/run/udev" - command: ["--model-file", "${EI_CLASSIFICATION_MODEL:-/models/ootb/ei/mobilenet-v2-224px.eim}", "--dont-print-predictions", "--mode", "streaming", "--preview-original-resolution", "--camera", "${VIDEO_DEVICE:-/dev/video1}"] + command: ["--model-file", "${EI_CLASSIFICATION_MODEL:-/models/ootb/ei/mobilenet-v2-224px.eim}", "--dont-print-predictions", "--mode", "streaming", "--preview-original-resolution", "--gst-launch-args", "tcpserversrc host=0.0.0.0 port=5000 ! jpegdec ! videoconvert ! video/x-raw ! jpegenc"] healthcheck: test: [ "CMD-SHELL", "wget -q --spider http://ei-video-classification-runner:4912 || exit 1" ] interval: 2s diff --git a/src/arduino/app_bricks/video_objectdetection/brick_compose.yaml b/src/arduino/app_bricks/video_objectdetection/brick_compose.yaml index 82ac93ad..648913ee 100644 --- a/src/arduino/app_bricks/video_objectdetection/brick_compose.yaml +++ b/src/arduino/app_bricks/video_objectdetection/brick_compose.yaml @@ -13,7 +13,7 @@ services: volumes: - "${CUSTOM_MODEL_PATH:-/home/arduino/.arduino-bricks/ei-models/}:${CUSTOM_MODEL_PATH:-/home/arduino/.arduino-bricks/ei-models/}" - "/run/udev:/run/udev" - command: ["--model-file", "${EI_OBJ_DETECTION_MODEL:-/models/ootb/ei/yolo-x-nano.eim}", "--dont-print-predictions", "--mode", "streaming", "--preview-original-resolution", "--camera", "${VIDEO_DEVICE:-/dev/video1}"] + command: ["--model-file", "${EI_OBJ_DETECTION_MODEL:-/models/ootb/ei/yolo-x-nano.eim}", "--dont-print-predictions", "--mode", "streaming", "--preview-original-resolution", "--gst-launch-args", "tcpserversrc host=0.0.0.0 port=5000 ! jpegdec ! videoconvert ! video/x-raw ! jpegenc"] healthcheck: test: [ "CMD-SHELL", "wget -q --spider http://ei-video-obj-detection-runner:4912 || exit 1" ] interval: 2s diff --git a/src/arduino/app_bricks/visual_anomaly_detection/examples/visual_anomaly_example.py b/src/arduino/app_bricks/visual_anomaly_detection/examples/visual_anomaly_example.py new file mode 100644 index 00000000..e5ba99e6 --- /dev/null +++ b/src/arduino/app_bricks/visual_anomaly_detection/examples/visual_anomaly_example.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +# EXAMPLE_NAME = "Visual Anomaly Detection" +import os +from arduino.app_bricks.visual_anomaly_detection import VisualAnomalyDetection +from arduino.app_utils.image import draw_anomaly_markers + +anomaly_detection = VisualAnomalyDetection() + +# Image can be provided as bytes or PIL.Image +img = os.read("path/to/your/image.jpg") + +out = anomaly_detection.detect(img) +if out and "detection" in out: + for i, anomaly in enumerate(out["detection"]): + # For every anomaly detected, print its details + detected_anomaly = anomaly.get("class_name", None) + score = anomaly.get("score", None) + bounding_box = anomaly.get("bounding_box_xyxy", None) + +# Draw the bounding boxes +out_image = draw_anomaly_markers(img, out) \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py b/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py index e00d3f7d..0901946e 100644 --- a/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py +++ b/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py @@ -9,7 +9,7 @@ This example demonstrates how to use a WebSocketCamera as a proxy/relay. It receives frames from clients on one WebSocket server (127.0.0.1:8080) and -forwards them as raw JPEG binary data to a TCP server (127.0.0.1:5001) at 30fps. +forwards them as raw JPEG binary data to a TCP server (127.0.0.1:5000) at 30fps. Usage: python websocket_camera_proxy.py [--input-port PORT] [--output-host HOST] [--output-port PORT] @@ -22,7 +22,6 @@ import sys import time -# Add the parent directory to the path to import from arduino package import os from arduino.app_peripherals.camera import Camera @@ -173,17 +172,17 @@ async def main(): global running, camera parser = argparse.ArgumentParser(description="WebSocket Camera Proxy") - parser.add_argument("--input-port", type=int, default=8080, + parser.add_argument("--input-port", type=int, default=8080, help="WebSocketCamera input port (default: 8080)") - parser.add_argument("--output-host", default="127.0.0.1", - help="Output TCP server host (default: 127.0.0.1)") - parser.add_argument("--output-port", type=int, default=5001, - help="Output TCP server port (default: 5001)") - parser.add_argument("--fps", type=int, default=30, + parser.add_argument("--output-host", default="0.0.0.0", + help="Output TCP server host (default: 0.0.0.0)") + parser.add_argument("--output-port", type=int, default=5000, + help="Output TCP server port (default: 5000)") + parser.add_argument("--fps", type=int, default=30, help="Target FPS for forwarding (default: 30)") - parser.add_argument("--quality", type=int, default=80, + parser.add_argument("--quality", type=int, default=80, help="JPEG quality 1-100 (default: 80)") - parser.add_argument("--verbose", "-v", action="store_true", + parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") args = parser.parse_args() @@ -204,7 +203,8 @@ async def main(): logger.info(f"Output: TCP server at {args.output_host}:{args.output_port}") logger.info(f"Target FPS: {args.fps}") - camera = Camera("ws://0.0.0.0:5001") + from arduino.app_utils.image.image_editor import compressed_to_jpeg + camera = Camera("ws://0.0.0.0:5000", transformer=compressed_to_jpeg(80)) try: # Start camera input and output connection tasks diff --git a/src/arduino/app_utils/__init__.py b/src/arduino/app_utils/__init__.py index 14af33a6..8e3c95e9 100644 --- a/src/arduino/app_utils/__init__.py +++ b/src/arduino/app_utils/__init__.py @@ -8,12 +8,10 @@ from .bridge import * from .folderwatch import * from .httprequest import * -from .image import * from .jsonparser import * from .logger import * from .ledmatrix import * from .slidingwindowbuffer import * -from .userinput import * from .leds import * __all__ = [ @@ -27,13 +25,9 @@ "Frame", "FrameDesigner", "HttpClient", - "draw_bounding_boxes", - "get_image_bytes", - "get_image_type", "JSONParser", "Logger", "SineGenerator", "SlidingWindowBuffer", - "UserTextInput", "Leds", ] diff --git a/src/arduino/app_utils/image/__init__.py b/src/arduino/app_utils/image/__init__.py new file mode 100644 index 00000000..71fe2ede --- /dev/null +++ b/src/arduino/app_utils/image/__init__.py @@ -0,0 +1,18 @@ +from .image import * +from .image_editor import ImageEditor +from .pipeable import PipeableFunction, pipeable + +__all__ = [ + "get_image_type", + "get_image_bytes", + "draw_bounding_boxes", + "draw_anomaly_markers", + "ImageEditor", + "pipeable", + "letterboxed", + "resized", + "adjusted", + "greyscaled", + "compressed_to_jpeg", + "compressed_to_png", +] \ No newline at end of file diff --git a/src/arduino/app_utils/image.py b/src/arduino/app_utils/image/image.py similarity index 95% rename from src/arduino/app_utils/image.py rename to src/arduino/app_utils/image/image.py index 5308cbe6..8b0b1f2d 100644 --- a/src/arduino/app_utils/image.py +++ b/src/arduino/app_utils/image/image.py @@ -41,7 +41,7 @@ def _read(file_path: str) -> bytes: with open(file_path, "rb") as f: return f.read() except Exception as e: - logger(f"Error reading image: {e}") + logger.error(f"Error reading image: {e}") return None @@ -189,7 +189,7 @@ def draw_bounding_boxes( def draw_anomaly_markers( image: Image.Image | bytes, detection: dict, - draw: ImageDraw.ImageDraw = None, + draw: ImageDraw.ImageDraw = None ) -> Image.Image | None: """Draw bounding boxes on an image using PIL. @@ -200,9 +200,6 @@ def draw_anomaly_markers( detection (dict): A dictionary containing detection results with keys 'class_name', 'bounding_box_xyxy', and 'score'. draw (ImageDraw.ImageDraw, optional): An existing ImageDraw object to use. If None, a new one is created. - label_above_box (bool, optional): If True, labels are drawn above the bounding box. Defaults to False. - colours (list, optional): List of colors to use for bounding boxes. Defaults to a predefined palette. - text_color (str, optional): Color of the text labels. Defaults to "white". """ if isinstance(image, bytes): image_box = Image.open(io.BytesIO(image)) diff --git a/src/arduino/app_peripherals/camera/image_editor.py b/src/arduino/app_utils/image/image_editor.py similarity index 100% rename from src/arduino/app_peripherals/camera/image_editor.py rename to src/arduino/app_utils/image/image_editor.py diff --git a/src/arduino/app_peripherals/camera/pipeable.py b/src/arduino/app_utils/image/pipeable.py similarity index 100% rename from src/arduino/app_peripherals/camera/pipeable.py rename to src/arduino/app_utils/image/pipeable.py diff --git a/src/arduino/app_utils/userinput.py b/src/arduino/app_utils/userinput.py deleted file mode 100644 index f92971e0..00000000 --- a/src/arduino/app_utils/userinput.py +++ /dev/null @@ -1,14 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) -# -# SPDX-License-Identifier: MPL-2.0 - - -class UserTextInput: - def __init__(self, prompt: str): - self.prompt = prompt - - def get(self): - return input(self.prompt) - - def produce(self): - return input(self.prompt) diff --git a/tests/arduino/app_bricks/imageclassification/test_imageclassification.py b/tests/arduino/app_bricks/imageclassification/test_imageclassification.py index 359fb3a3..0089a9dd 100644 --- a/tests/arduino/app_bricks/imageclassification/test_imageclassification.py +++ b/tests/arduino/app_bricks/imageclassification/test_imageclassification.py @@ -16,7 +16,7 @@ def mock_dependencies(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("arduino.app_internal.core.parse_docker_compose_variable", lambda x: [(None, None), (None, "8200")]) # make get_image_bytes a no-op for raw bytes monkeypatch.setattr( - "arduino.app_utils.get_image_bytes", + "arduino.app_utils.image.get_image_bytes", lambda x: x if isinstance(x, (bytes, bytearray)) else None, ) diff --git a/tests/arduino/app_bricks/objectdetection/test_objectdetection.py b/tests/arduino/app_bricks/objectdetection/test_objectdetection.py index 0cb3b4d5..e380d231 100644 --- a/tests/arduino/app_bricks/objectdetection/test_objectdetection.py +++ b/tests/arduino/app_bricks/objectdetection/test_objectdetection.py @@ -119,27 +119,6 @@ def fake_post( assert result["detection"] == [{"class_name": "C", "confidence": "50.00", "bounding_box_xyxy": [1.0, 2.0, 4.0, 6.0]}] -def test_draw_bounding_boxes(detector: ObjectDetection): - """Test the draw_bounding_boxes method with a valid image and detection. - - This test checks if the method returns a PIL Image object. - - Args: - detector (ObjectDetection): An instance of the ObjectDetection class. - """ - img = Image.new("RGB", (20, 20), color="white") - det = {"detection": [{"class_name": "X", "bounding_box_xyxy": [2, 2, 10, 10], "confidence": "50.0"}]} - - out = detector.draw_bounding_boxes(img, det) - assert isinstance(out, Image.Image) - - buf = io.BytesIO() - img.save(buf, format="PNG") - raw = buf.getvalue() - out2 = detector.draw_bounding_boxes(raw, det) - assert isinstance(out2, Image.Image) - - def test_process(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, detector: ObjectDetection): """Test the process method with a valid file path. diff --git a/tests/arduino/app_core/test_edge_impulse.py b/tests/arduino/app_core/test_edge_impulse.py index b5bb0e64..023d7df2 100644 --- a/tests/arduino/app_core/test_edge_impulse.py +++ b/tests/arduino/app_core/test_edge_impulse.py @@ -24,7 +24,7 @@ def mock_infra(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("arduino.app_internal.core.resolve_address", lambda h: "127.0.0.1") monkeypatch.setattr("arduino.app_internal.core.parse_docker_compose_variable", lambda s: [(None, None), (None, "1337")]) # identity for get_image_bytes - monkeypatch.setattr("arduino.app_utils.get_image_bytes", lambda b: b) + monkeypatch.setattr("arduino.app_utils.image.get_image_bytes", lambda b: b) @pytest.fixture From 13d529bf93da5c910bf2a69905de6448609dc5a2 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 27 Oct 2025 09:49:39 +0100 Subject: [PATCH 08/86] fix: pipelining --- .../app_peripherals/camera/base_camera.py | 20 +- src/arduino/app_peripherals/camera/camera.py | 2 +- .../camera/examples/websocket_camera_proxy.py | 88 ++-- .../examples/websocket_client_streamer.py | 61 +-- .../app_peripherals/camera/v4l_camera.py | 11 + .../camera/websocket_camera.py | 25 +- src/arduino/app_utils/image/__init__.py | 4 +- src/arduino/app_utils/image/image_editor.py | 117 ++--- src/arduino/app_utils/image/pipeable.py | 66 +-- .../app_utils/image/test_image_editor.py | 435 ++++++++++++++++++ .../arduino/app_utils/image/test_pipeable.py | 182 ++++++++ 11 files changed, 790 insertions(+), 221 deletions(-) create mode 100644 tests/arduino/app_utils/image/test_image_editor.py create mode 100644 tests/arduino/app_utils/image/test_pipeable.py diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index 44de359b..e848818f 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -23,20 +23,20 @@ class BaseCamera(ABC): providing a unified API regardless of the underlying camera protocol or type. """ - def __init__(self, resolution: Optional[Tuple[int, int]] = None, fps: int = 10, - transformer: Optional[Callable[[np.ndarray], np.ndarray]] = None, **kwargs): + def __init__(self, resolution: Optional[Tuple[int, int]] = (640, 480), fps: int = 10, + adjuster: Optional[Callable[[np.ndarray], np.ndarray]] = None, **kwargs): """ Initialize the camera base. Args: resolution (tuple, optional): Resolution as (width, height). None uses default resolution. fps (int): Frames per second for the camera. - transformer (callable, optional): Function to transform frames that takes a numpy array and returns a numpy array. Default: None + adjuster (callable, optional): Function pipeline to adjust frames that takes a numpy array and returns a numpy array. Default: None **kwargs: Additional camera-specific parameters. """ self.resolution = resolution self.fps = fps - self.transformer = transformer + self.adjuster = adjuster self._is_started = False self._cap_lock = threading.Lock() self._last_capture_time = time.monotonic() @@ -100,13 +100,11 @@ def _extract_frame(self) -> Optional[np.ndarray]: self._last_capture_time = time.monotonic() - if self.transformer is None: - return frame - - try: - frame = frame | self.transformer - except Exception as e: - raise CameraTransformError(f"Frame transformation failed ({self.transformer}): {e}") + if self.adjuster is not None: + try: + frame = self.adjuster(frame) + except Exception as e: + raise CameraTransformError(f"Frame transformation failed ({self.adjuster}): {e}") return frame diff --git a/src/arduino/app_peripherals/camera/camera.py b/src/arduino/app_peripherals/camera/camera.py index f9ee9cd5..bcf4823b 100644 --- a/src/arduino/app_peripherals/camera/camera.py +++ b/src/arduino/app_peripherals/camera/camera.py @@ -32,7 +32,7 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: resolution (tuple, optional): Frame resolution as (width, height). Default: None (auto) fps (int, optional): Target frames per second. Default: 10 - transformer (callable, optional): Function to transform frames that takes a + adjuster (callable, optional): Function pipeline to adjust frames that takes a numpy array and returns a numpy array. Default: None V4L Camera Parameters: device_index (int, optional): V4L device index override diff --git a/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py b/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py index 0901946e..33a11095 100644 --- a/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py +++ b/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py @@ -24,7 +24,10 @@ import os +import numpy as np + from arduino.app_peripherals.camera import Camera +from arduino.app_utils.image.image_editor import ImageEditor sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')) @@ -53,22 +56,19 @@ async def connect_output_tcp(output_host: str, output_port: int): """Connect to the output TCP server.""" global output_writer, output_reader - logger.info(f"Connecting to TCP server at {output_host}:{output_port}...") + logger.info(f"Connecting to output server at {output_host}:{output_port}...") try: output_reader, output_writer = await asyncio.open_connection( output_host, output_port ) - logger.info("TCP connection established successfully") - - return True + logger.info("Connected successfully to output server") except Exception as e: - logger.error(f"Failed to connect to TCP server: {e}") - return False + raise Exception(f"Failed to connect to output server: {e}") -async def forward_frame(frame, quality: int): +async def forward_frame(frame: np.ndarray): """Forward a frame to the output TCP server as raw JPEG.""" global output_writer @@ -76,25 +76,17 @@ async def forward_frame(frame, quality: int): return try: - # Frame is already a PIL.Image.Image in JPEG format - # Convert PIL image to bytes - import io - img_bytes = io.BytesIO() - frame.save(img_bytes, format='JPEG', quality=quality) - frame_data = img_bytes.getvalue() - - # Send raw JPEG binary data - output_writer.write(frame_data) + output_writer.write(frame.tobytes()) await output_writer.drain() except ConnectionResetError: - logger.warning("TCP connection reset while forwarding frame") + logger.warning("Output connection reset while forwarding frame") output_writer = None except Exception as e: logger.error(f"Error forwarding frame: {e}") -async def camera_loop(fps: int, quality: int): +async def camera_loop(fps: int): """Main camera capture and forwarding loop.""" global running, camera @@ -111,21 +103,26 @@ async def camera_loop(fps: int, quality: int): try: # Read frame from WebSocketCamera frame = camera.capture() - - if frame is not None: - # Rate limiting - current_time = time.time() - time_since_last = current_time - last_frame_time - if time_since_last < frame_interval: - await asyncio.sleep(frame_interval - time_since_last) - - last_frame_time = time.time() - - # Forward frame if output TCP connection is available - await forward_frame(frame, quality) - else: + # frame = ImageEditor.compress_to_jpeg(frame, 80.1) + if frame is None: # No frame available, small delay to avoid busy waiting await asyncio.sleep(0.01) + continue + + # Rate limiting + current_time = time.time() + time_since_last = current_time - last_frame_time + if time_since_last < frame_interval: + await asyncio.sleep(frame_interval - time_since_last) + + last_frame_time = time.time() + + if output_writer is None or output_writer.is_closing(): + # Output connection is not available, give room to the other tasks + await asyncio.sleep(0.01) + else: + # Forward frame if output connection is available + await forward_frame(frame) except Exception as e: logger.error(f"Error in camera loop: {e}") @@ -138,18 +135,16 @@ async def maintain_output_connection(output_host: str, output_port: int, reconne while running: try: - # Establish connection - if await connect_output_tcp(output_host, output_port): - logger.info("TCP connection established, maintaining...") + await connect_output_tcp(output_host, output_port) + + # Keep monitoring + while running and output_writer and not output_writer.is_closing(): + await asyncio.sleep(1.0) - # Keep connection alive - while running and output_writer and not output_writer.is_closing(): - await asyncio.sleep(1.0) - - logger.info("TCP connection lost") + logger.info("Lost connection to output server") except Exception as e: - logger.error(f"TCP connection error: {e}") + logger.error(e) finally: # Clean up connection if output_writer: @@ -163,7 +158,7 @@ async def maintain_output_connection(output_host: str, output_port: int, reconne # Wait before reconnecting if running: - logger.info(f"Reconnecting to TCP server in {reconnect_delay} seconds...") + logger.info(f"Reconnecting to output server in {reconnect_delay} seconds...") await asyncio.sleep(reconnect_delay) @@ -172,10 +167,12 @@ async def main(): global running, camera parser = argparse.ArgumentParser(description="WebSocket Camera Proxy") + parser.add_argument("--input-host", default="localhost", + help="WebSocketCamera input host (default: localhost)") parser.add_argument("--input-port", type=int, default=8080, help="WebSocketCamera input port (default: 8080)") - parser.add_argument("--output-host", default="0.0.0.0", - help="Output TCP server host (default: 0.0.0.0)") + parser.add_argument("--output-host", default="localhost", + help="Output TCP server host (default: localhost)") parser.add_argument("--output-port", type=int, default=5000, help="Output TCP server port (default: 5000)") parser.add_argument("--fps", type=int, default=30, @@ -204,11 +201,12 @@ async def main(): logger.info(f"Target FPS: {args.fps}") from arduino.app_utils.image.image_editor import compressed_to_jpeg - camera = Camera("ws://0.0.0.0:5000", transformer=compressed_to_jpeg(80)) + camera = Camera(f"ws://{args.input_host}:{args.input_port}", adjuster=compressed_to_jpeg(80)) + # camera = Camera(f"ws://{args.input_host}:{args.input_port}") try: # Start camera input and output connection tasks - camera_task = asyncio.create_task(camera_loop(args.fps, args.quality)) + camera_task = asyncio.create_task(camera_loop(args.fps)) connection_task = asyncio.create_task(maintain_output_connection(args.output_host, args.output_port, reconnect_delay)) # Run both tasks concurrently diff --git a/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py b/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py index 830adef9..6f28f72d 100644 --- a/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py +++ b/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py @@ -13,6 +13,9 @@ import sys import time +from arduino.app_peripherals.camera import Camera +from arduino.app_utils.image.image_editor import ImageEditor + logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' @@ -82,44 +85,33 @@ async def stop(self): logger.warning(f"Error closing WebSocket: {e}") if self.camera: - self.camera.release() - logger.info("Camera released") + self.camera.stop() + logger.info("Camera stopped") logger.info("Webcam streamer stopped") async def _camera_loop(self): """Main camera capture loop.""" logger.info(f"Opening camera {self.camera_id}...") - self.camera = cv2.VideoCapture(self.camera_id) + self.camera = Camera(self.camera_id, resolution=(FRAME_WIDTH, FRAME_HEIGHT), fps=self.fps) + self.camera.start() - if not self.camera.isOpened(): + if not self.camera.is_started(): logger.error(f"Failed to open camera {self.camera_id}") return - self.camera.set(cv2.CAP_PROP_FPS, self.fps) - self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH) - self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT) - - # Verify the resolution was set correctly - actual_width = int(self.camera.get(cv2.CAP_PROP_FRAME_WIDTH)) - actual_height = int(self.camera.get(cv2.CAP_PROP_FRAME_HEIGHT)) - actual_fps = self.camera.get(cv2.CAP_PROP_FPS) - - if actual_width != FRAME_WIDTH or actual_height != FRAME_HEIGHT: - logger.warning(f"Camera resolution mismatch! Requested {FRAME_WIDTH}x{FRAME_HEIGHT}, got {actual_width}x{actual_height}") - logger.info("Camera opened successfully") last_frame_time = time.time() while self.running: try: - ret, frame = self.camera.read() - if not ret: + frame = self.camera.capture() + if frame is None: logger.warning("Failed to capture frame") await asyncio.sleep(0.1) continue - + # Rate limiting to enforce frame rate current_time = time.time() time_since_last = current_time - last_frame_time @@ -135,6 +127,8 @@ async def _camera_loop(self): logger.warning("WebSocket connection lost during frame send") self.websocket = None + await asyncio.sleep(0.001) + except Exception as e: logger.error(f"Error in camera loop: {e}") await asyncio.sleep(1.0) @@ -191,9 +185,6 @@ async def _handle_websocket_messages(self): logger.info(f"Server goodbye: {data.get('message', 'Disconnecting')}") break - elif data.get("status") == "dropping_frames": - logger.warning(f"Server warning: {data.get('message', 'Dropping frames!')}") - elif data.get("error"): logger.warning(f"Server error: {data.get('message', 'Unknown error')}") if data.get("code") == 1000: # Server busy @@ -216,36 +207,18 @@ async def _send_frame(self, frame): try: if self.server_frame_format == "binary": # Encode frame as JPEG and send binary data - encode_params = [cv2.IMWRITE_JPEG_QUALITY, self.quality] - success, encoded_frame = cv2.imencode('.jpg', frame, encode_params) - - if not success: - logger.warning("Failed to encode frame") - return - + encoded_frame = ImageEditor.compress_to_jpeg(frame) await self.websocket.send(encoded_frame.tobytes()) elif self.server_frame_format == "base64": # Encode frame as JPEG and send base64 data - encode_params = [cv2.IMWRITE_JPEG_QUALITY, self.quality] - success, encoded_frame = cv2.imencode('.jpg', frame, encode_params) - - if not success: - logger.warning("Failed to encode frame") - return - + encoded_frame = ImageEditor.compress_to_jpeg(frame) frame_b64 = base64.b64encode(encoded_frame.tobytes()).decode('utf-8') await self.websocket.send(frame_b64) elif self.server_frame_format == "json": # Encode frame as JPEG, base64 encode and wrap in JSON - encode_params = [cv2.IMWRITE_JPEG_QUALITY, self.quality] - success, encoded_frame = cv2.imencode('.jpg', frame, encode_params) - - if not success: - logger.warning("Failed to encode frame") - return - + encoded_frame = ImageEditor.compress_to_jpeg(frame) frame_b64 = base64.b64encode(encoded_frame.tobytes()).decode('utf-8') message = json.dumps({"image": frame_b64}) await self.websocket.send(message) @@ -269,7 +242,7 @@ def signal_handler(signum, frame): async def main(): """Main function.""" parser = argparse.ArgumentParser(description="WebSocket Camera Client Streamer") - parser.add_argument("--host", default="127.0.0.1", help="WebSocket server host (default: 127.0.0.1)") + parser.add_argument("--host", default="localhost", help="WebSocket server host (default: localhost)") parser.add_argument("--port", type=int, default=8080, help="WebSocket server port (default: 8080)") parser.add_argument("--camera", type=int, default=0, help="Camera device ID (default: 0)") parser.add_argument("--fps", type=int, default=30, help="Target FPS (default: 30)") diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py index f80ed7e5..1d0b5580 100644 --- a/src/arduino/app_peripherals/camera/v4l_camera.py +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -129,6 +129,17 @@ def _open_camera(self) -> None: f"Camera {self.camera_id} resolution set to {actual_width}x{actual_height} " f"instead of requested {self.resolution[0]}x{self.resolution[1]}" ) + self.resolution = (actual_width, actual_height) + + if self.fps: + self._cap.set(cv2.CAP_PROP_FPS, self.fps) + + actual_fps = int(self._cap.get(cv2.CAP_PROP_FPS)) + if actual_fps != self.fps: + logger.warning( + f"Camera {self.camera_id} FPS set to {actual_fps} instead of requested {self.fps}" + ) + self.fps = actual_fps logger.info(f"Opened V4L camera {self.camera_id}") diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index f914f4e0..4a1f9fe6 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -55,7 +55,7 @@ def __init__(self, host: str = "0.0.0.0", port: int = 8080, timeout: int = 10, self._server = None self._loop = None self._server_thread = None - self._stop_event = None + self._stop_event = asyncio.Event() self._client: Optional[websockets.ServerConnection] = None def _open_camera(self) -> None: @@ -93,7 +93,7 @@ def _start_server_thread(self) -> None: async def _start_server(self) -> None: """Start the WebSocket server.""" try: - self._stop_event = asyncio.Event() + self._stop_event.clear() self._server = await websockets.serve( self._ws_handler, @@ -152,7 +152,6 @@ async def _ws_handler(self, conn: websockets.ServerConnection) -> None: except Exception as e: logger.warning(f"Could not send welcome message to {client_addr}: {e}") - warning_task = None async for message in conn: frame = await self._parse_message(message) if frame is not None: @@ -162,16 +161,6 @@ async def _ws_handler(self, conn: websockets.ServerConnection) -> None: self._frame_queue.put_nowait(frame) break except queue.Full: - # Notify client about frame dropping - try: - if warning_task is None or warning_task.done(): - warning_task = asyncio.create_task(self._send_to_client({ - "warning": "frame_dropped", - "message": "Buffer full, dropping oldest frame" - })) - except Exception: - pass - try: # Drop oldest frame and try again self._frame_queue.get_nowait() @@ -241,10 +230,10 @@ async def _parse_message(self, message) -> Optional[np.ndarray]: logger.warning(f"Error parsing message: {e}") return None - def _close_camera(self) -> None: + def _close_camera(self): """Stop the WebSocket server.""" # Signal async stop event if it exists - if self._stop_event and self._loop and not self._loop.is_closed(): + if self._loop and not self._loop.is_closed(): future = asyncio.run_coroutine_threadsafe( self._set_async_stop_event(), self._loop @@ -269,12 +258,10 @@ def _close_camera(self) -> None: self._server = None self._loop = None self._client = None - self._stop_event = None - async def _set_async_stop_event(self) -> None: + async def _set_async_stop_event(self): """Set the async stop event and close the client connection.""" - if self._stop_event: - self._stop_event.set() + self._stop_event.set() # Send goodbye message and close the client connection if self._client: diff --git a/src/arduino/app_utils/image/__init__.py b/src/arduino/app_utils/image/__init__.py index 71fe2ede..10564497 100644 --- a/src/arduino/app_utils/image/__init__.py +++ b/src/arduino/app_utils/image/__init__.py @@ -1,6 +1,6 @@ from .image import * from .image_editor import ImageEditor -from .pipeable import PipeableFunction, pipeable +from .pipeable import PipeableFunction __all__ = [ "get_image_type", @@ -8,7 +8,7 @@ "draw_bounding_boxes", "draw_anomaly_markers", "ImageEditor", - "pipeable", + "PipeableFunction", "letterboxed", "resized", "adjusted", diff --git a/src/arduino/app_utils/image/image_editor.py b/src/arduino/app_utils/image/image_editor.py index a42350c2..a3dddb18 100644 --- a/src/arduino/app_utils/image/image_editor.py +++ b/src/arduino/app_utils/image/image_editor.py @@ -7,7 +7,7 @@ from typing import Optional, Tuple from PIL import Image -from .pipeable import pipeable +from arduino.app_utils.image.pipeable import PipeableFunction class ImageEditor: @@ -53,6 +53,10 @@ def letterbox(frame: np.ndarray, target_w, target_h = target_size h, w = frame.shape[:2] + # Handle empty frames + if w == 0 or h == 0: + raise ValueError("Cannot letterbox empty frame") + # Calculate scaling factor to fit image inside target size scale = min(target_w / w, target_h / h) new_w, new_h = int(w * scale), int(h * scale) @@ -78,7 +82,7 @@ def letterbox(frame: np.ndarray, @staticmethod def resize(frame: np.ndarray, target_size: Tuple[int, int], - maintain_aspect: bool = False, + maintain_ratio: bool = False, interpolation: int = cv2.INTER_LINEAR) -> np.ndarray: """ Resize frame to target size. @@ -86,16 +90,16 @@ def resize(frame: np.ndarray, Args: frame (np.ndarray): Input frame target_size (tuple): Target size as (width, height) - maintain_aspect (bool): If True, use letterboxing to maintain aspect ratio + maintain_ratio (bool): If True, use letterboxing to maintain aspect ratio interpolation (int): OpenCV interpolation method Returns: np.ndarray: Resized frame """ - if maintain_aspect: + if maintain_ratio: return ImageEditor.letterbox(frame, target_size) else: - return cv2.resize(frame, target_size, interpolation=interpolation) + return cv2.resize(frame, (target_size[1], target_size[0]), interpolation=interpolation) @staticmethod def adjust(frame: np.ndarray, @@ -114,10 +118,29 @@ def adjust(frame: np.ndarray, Returns: np.ndarray: adjusted frame """ - # Apply brightness and contrast - result = cv2.convertScaleAbs(frame, alpha=contrast, beta=brightness) + original_dtype = frame.dtype + + # Convert to float for calculations to avoid overflow/underflow + result = frame.astype(np.float32) + + # Apply contrast and brightness + result = result * contrast + brightness - # Apply saturation if needed + # Clamp to valid range based on original dtype + try: + if np.issubdtype(original_dtype, np.integer): + info = np.iinfo(original_dtype) + result = np.clip(result, info.min, info.max) + else: + info = np.finfo(original_dtype) + result = np.clip(result, info.min, info.max) + except ValueError: + # If we fail, just ensure a non-negative output + result = np.clip(result, 0.0, np.inf) + + result = result.astype(original_dtype) + + # Apply saturation if saturation != 1.0: hsv = cv2.cvtColor(result, cv2.COLOR_BGR2HSV).astype(np.float32) hsv[:, :, 1] *= saturation @@ -129,21 +152,20 @@ def adjust(frame: np.ndarray, @staticmethod def greyscale(frame: np.ndarray) -> np.ndarray: """ - Convert frame to greyscale. + Convert frame to greyscale and maintain 3 channels for consistency. Args: frame (np.ndarray): Input frame in BGR format Returns: - np.ndarray: Greyscale frame (still 3 channels for consistency) + np.ndarray: Greyscale frame (3 channels, all identical) """ - # Convert to greyscale - grey = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - # Convert back to 3 channels for consistency with other operations - return cv2.cvtColor(grey, cv2.COLOR_GRAY2BGR) + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + # Convert back to 3 channels for consistency + return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) @staticmethod - def compress_to_jpeg(frame: np.ndarray, quality: int = 90) -> Optional[bytes]: + def compress_to_jpeg(frame: np.ndarray, quality: int = 80) -> Optional[np.ndarray]: """ Compress frame to JPEG format. @@ -154,18 +176,19 @@ def compress_to_jpeg(frame: np.ndarray, quality: int = 90) -> Optional[bytes]: Returns: bytes: Compressed JPEG data, or None if compression failed """ + quality = int(quality) # Gstreamer doesn't like quality to be float try: success, encoded = cv2.imencode( '.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, quality] ) - return encoded.tobytes() if success else None + return encoded if success else None except Exception: return None @staticmethod - def compress_to_png(frame: np.ndarray, compression_level: int = 6) -> Optional[bytes]: + def compress_to_png(frame: np.ndarray, compression_level: int = 6) -> Optional[np.ndarray]: """ Compress frame to PNG format. @@ -176,13 +199,14 @@ def compress_to_png(frame: np.ndarray, compression_level: int = 6) -> Optional[b Returns: bytes: Compressed PNG data, or None if compression failed """ + compression_level = int(compression_level) # Gstreamer doesn't like compression_level to be float try: success, encoded = cv2.imencode( '.png', frame, [cv2.IMWRITE_PNG_COMPRESSION, compression_level] ) - return encoded.tobytes() if success else None + return encoded if success else None except Exception: return None @@ -245,7 +269,6 @@ def get_frame_info(frame: np.ndarray) -> dict: # Functional API - Standalone pipeable functions # ============================================================================= -@pipeable def letterboxed(target_size: Optional[Tuple[int, int]] = None, color: Tuple[int, int, int] = (114, 114, 114)): """ @@ -259,37 +282,33 @@ def letterboxed(target_size: Optional[Tuple[int, int]] = None, Partial function that takes a frame and returns letterboxed frame Examples: - result = frame | letterboxed(target_size=(640, 640)) - result = frame | letterboxed() | adjusted(brightness=10) + pipe = letterboxed(target_size=(640, 640)) + pipe = letterboxed() | adjusted(brightness=10) """ - from functools import partial - return partial(ImageEditor.letterbox, target_size=target_size, color=color) + return PipeableFunction(ImageEditor.letterbox, target_size=target_size, color=color) -@pipeable def resized(target_size: Tuple[int, int], - maintain_aspect: bool = False, + maintain_ratio: bool = False, interpolation: int = cv2.INTER_LINEAR): """ Pipeable resize function - resize frame with pipe operator support. Args: target_size (tuple): Target size as (width, height) - maintain_aspect (bool): If True, use letterboxing to maintain aspect ratio + maintain_ratio (bool): If True, use letterboxing to maintain aspect ratio interpolation (int): OpenCV interpolation method Returns: Partial function that takes a frame and returns resized frame Examples: - result = frame | resized(target_size=(640, 480)) - result = frame | letterboxed() | resized(target_size=(320, 240)) + pipe = resized(target_size=(640, 480)) + pipe = letterboxed() | resized(target_size=(320, 240)) """ - from functools import partial - return partial(ImageEditor.resize, target_size=target_size, maintain_aspect=maintain_aspect, interpolation=interpolation) + return PipeableFunction(ImageEditor.resize, target_size=target_size, maintain_ratio=maintain_ratio, interpolation=interpolation) -@pipeable def adjusted(brightness: float = 0.0, contrast: float = 1.0, saturation: float = 1.0): @@ -305,14 +324,12 @@ def adjusted(brightness: float = 0.0, Partial function that takes a frame and returns the adjusted frame Examples: - result = frame | adjusted(brightness=10, contrast=1.2) - result = frame | letterboxed() | adjusted(brightness=5) | resized(target_size=(320, 240)) + pipe = adjusted(brightness=10, contrast=1.2) + pipe = letterboxed() | adjusted(brightness=5) | resized(target_size=(320, 240)) """ - from functools import partial - return partial(ImageEditor.adjust, brightness=brightness, contrast=contrast, saturation=saturation) + return PipeableFunction(ImageEditor.adjust, brightness=brightness, contrast=contrast, saturation=saturation) -@pipeable def greyscaled(): """ Pipeable greyscale function - convert frame to greyscale with pipe operator support. @@ -321,14 +338,13 @@ def greyscaled(): Function that takes a frame and returns greyscale frame Examples: - result = frame | greyscaled() - result = frame | letterboxed() | greyscaled() | adjusted(contrast=1.2) + pipe = greyscaled() + pipe = letterboxed() | greyscaled() | adjusted(contrast=1.2) """ - return ImageEditor.greyscale + return PipeableFunction(ImageEditor.greyscale) -@pipeable -def compressed_to_jpeg(quality: int = 90): +def compressed_to_jpeg(quality: int = 80): """ Pipeable JPEG compression function - compress frame to JPEG with pipe operator support. @@ -336,17 +352,15 @@ def compressed_to_jpeg(quality: int = 90): quality (int): JPEG quality (0-100, higher = better quality) Returns: - Partial function that takes a frame and returns compressed JPEG bytes + Partial function that takes a frame and returns compressed JPEG bytes as Numpy array or None Examples: - jpeg_bytes = frame | compressed_to_jpeg(quality=95) - jpeg_bytes = frame | resized(target_size=(640, 480)) | compressed_to_jpeg() + pipe = compressed_to_jpeg(quality=95) + pipe = resized(target_size=(640, 480)) | compressed_to_jpeg() """ - from functools import partial - return partial(ImageEditor.compress_to_jpeg, quality=quality) + return PipeableFunction(ImageEditor.compress_to_jpeg, quality=quality) -@pipeable def compressed_to_png(compression_level: int = 6): """ Pipeable PNG compression function - compress frame to PNG with pipe operator support. @@ -355,11 +369,10 @@ def compressed_to_png(compression_level: int = 6): compression_level (int): PNG compression level (0-9, higher = better compression) Returns: - Partial function that takes a frame and returns compressed PNG bytes + Partial function that takes a frame and returns compressed PNG bytes as Numpy array or None Examples: - png_bytes = frame | compressed_to_png(compression_level=9) - png_bytes = frame | letterboxed() | compressed_to_png() + pipe = compressed_to_png(compression_level=9) + pipe = letterboxed() | compressed_to_png() """ - from functools import partial - return partial(ImageEditor.compress_to_png, compression_level=compression_level) + return PipeableFunction(ImageEditor.compress_to_png, compression_level=compression_level) diff --git a/src/arduino/app_utils/image/pipeable.py b/src/arduino/app_utils/image/pipeable.py index cfe6a184..15c60835 100644 --- a/src/arduino/app_utils/image/pipeable.py +++ b/src/arduino/app_utils/image/pipeable.py @@ -7,10 +7,12 @@ This module provides a decorator that wraps static functions to support the | (pipe) operator for functional composition. + +Note: Due to numpy's element-wise operator behavior, using the pipe operator +with numpy arrays (array | function) is not supported. Use function(array) instead. """ from typing import Callable -from functools import wraps class PipeableFunction: @@ -66,7 +68,9 @@ def __or__(self, other): A new pipeable function that combines both """ if not callable(other): - return NotImplemented + # Raise TypeError immediately instead of returning NotImplemented + # This prevents Python from trying the reverse operation for nothing + raise TypeError(f"unsupported operand type(s) for |: '{type(self).__name__}' and '{type(other).__name__}'") def composed(value): return other(self(value)) @@ -75,52 +79,20 @@ def composed(value): def __repr__(self): """String representation of the pipeable function.""" + # Get function name safely + func_name = getattr(self.func, '__name__', None) + if func_name is None: + func_name = getattr(type(self.func), '__name__', None) + if func_name is None: + from functools import partial + if type(self.func) == partial: + func_name = "partial" + if func_name is None: + func_name = "unknown" # Fallback + if self.args or self.kwargs: args_str = ', '.join(map(str, self.args)) kwargs_str = ', '.join(f'{k}={v}' for k, v in self.kwargs.items()) all_args = ', '.join(filter(None, [args_str, kwargs_str])) - return f"{self.__name__}({all_args})" - return f"{self.__name__}()" - - -def pipeable(func: Callable) -> Callable: - """ - Decorator that makes a function pipeable using the | operator. - - The decorated function can be used in two ways: - 1. Normal function call: func(args) - 2. Pipe operator: value | func or func | other_func - - Args: - func: Function to make pipeable - - Returns: - Wrapped function that supports pipe operations - - Examples: - @pipeable - def add_one(x): - return x + 1 - - result = 5 | add_one | add_one -> 7 - """ - @wraps(func) - def wrapper(*args, **kwargs): - if args and kwargs: - # Both positional and keyword args - return partially applied - return PipeableFunction(func, *args, **kwargs) - elif args: - # Only positional args - return partially applied - return PipeableFunction(func, *args, **kwargs) - elif kwargs: - # Only keyword args - return partially applied - return PipeableFunction(func, **kwargs) - else: - # No args - return pipeable version of original function - return PipeableFunction(func) - - # Also add the pipeable functionality directly to the wrapper - wrapper.__ror__ = lambda self, other: func(other) - wrapper.__or__ = lambda self, other: PipeableFunction(lambda x: other(func(x))) - - return wrapper \ No newline at end of file + return f"{func_name}({all_args})" + return f"{func_name}()" diff --git a/tests/arduino/app_utils/image/test_image_editor.py b/tests/arduino/app_utils/image/test_image_editor.py new file mode 100644 index 00000000..4efc63fb --- /dev/null +++ b/tests/arduino/app_utils/image/test_image_editor.py @@ -0,0 +1,435 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import pytest +import numpy as np +import cv2 +from unittest.mock import patch +from arduino.app_utils.image.image_editor import ( + ImageEditor, + letterboxed, + resized, + adjusted, + greyscaled, + compressed_to_jpeg, + compressed_to_png +) +from arduino.app_utils.image.pipeable import PipeableFunction + + +class TestImageEditor: + """Test cases for the ImageEditor class.""" + + @pytest.fixture + def sample_frame(self): + """Create a sample RGB frame for testing.""" + # Create a 100x80 RGB frame with some pattern + frame = np.zeros((80, 100, 3), dtype=np.uint8) + frame[:, :40] = [255, 0, 0] # Red left section + frame[:, 40:] = [0, 255, 0] # Green right section + return frame + + @pytest.fixture + def sample_grayscale_frame(self): + """Create a sample grayscale frame for testing.""" + return np.random.randint(0, 256, (80, 100), dtype=np.uint8) + + def test_letterbox_make_square(self, sample_frame): + """Test letterboxing to make frame square.""" + result = ImageEditor.letterbox(sample_frame) + + # Should make it square based on larger dimension (100) + assert result.shape[:2] == (100, 100) + assert result.shape[2] == 3 # Still RGB + + def test_letterbox_specific_size(self, sample_frame): + """Test letterboxing to specific target size.""" + target_size = (200, 150) + result = ImageEditor.letterbox(sample_frame, target_size=target_size) + + assert result.shape[:2] == (150, 200) # Height, Width + assert result.shape[2] == 3 # Still RGB + + def test_letterbox_custom_color(self, sample_frame): + """Test letterboxing with custom padding color.""" + target_size = (200, 200) + custom_color = (255, 255, 0) # Yellow + result = ImageEditor.letterbox(sample_frame, target_size=target_size, color=custom_color) + + assert result.shape[:2] == (200, 200) + # Check that padding areas have the custom color + # Top and bottom should have yellow padding + assert np.array_equal(result[0, 0], custom_color) + + def test_resize_basic(self, sample_frame): + """Test basic resizing functionality.""" + target_size = (50, 40) # Smaller than original + result = ImageEditor.resize(sample_frame, target_size=target_size) + + assert result.shape[:2] == (50, 40) + assert result.shape[2] == 3 # Still RGB + + def test_resize_with_letterboxing(self, sample_frame): + """Test resizing with maintain_ratio==True (uses letterboxing).""" + target_size = (200, 200) + result = ImageEditor.resize(sample_frame, target_size=target_size, maintain_ratio=True) + + assert result.shape[:2] == (200, 200) + assert result.shape[2] == 3 # Still RGB + + def test_resize_interpolation_methods(self, sample_frame): + """Test different interpolation methods.""" + target_size = (50, 40) + + # Test different interpolation methods + for interpolation in [cv2.INTER_LINEAR, cv2.INTER_CUBIC, cv2.INTER_NEAREST]: + result = ImageEditor.resize(sample_frame, target_size=target_size, interpolation=interpolation) + assert result.shape[:2] == (50, 40) + + def test_adjust_brightness(self, sample_frame): + """Test brightness adjustment.""" + # Increase brightness + result = ImageEditor.adjust(sample_frame, brightness=50) + assert result.shape == sample_frame.shape + # Brightness should increase (but clipped at 255) + assert np.all(result >= sample_frame) + + # Decrease brightness - should clamp at 0, so all values <= original + result = ImageEditor.adjust(sample_frame, brightness=-50) + assert result.shape == sample_frame.shape + assert result.dtype == sample_frame.dtype + assert np.all(result <= sample_frame) + # Values should never go below 0 + assert np.all(result >= 0) + + def test_adjust_contrast(self, sample_frame): + """Test contrast adjustment.""" + # Increase contrast + result = ImageEditor.adjust(sample_frame, contrast=1.5) + assert result.shape == sample_frame.shape + + # Decrease contrast + result = ImageEditor.adjust(sample_frame, contrast=0.5) + assert result.shape == sample_frame.shape + + def test_adjust_saturation(self, sample_frame): + """Test saturation adjustment.""" + # Increase saturation + result = ImageEditor.adjust(sample_frame, saturation=1.5) + assert result.shape == sample_frame.shape + + # Decrease saturation (towards grayscale) + result = ImageEditor.adjust(sample_frame, saturation=0.5) + assert result.shape == sample_frame.shape + + # Zero saturation should be grayscale + result = ImageEditor.adjust(sample_frame, saturation=0.0) + # All channels should be equal for grayscale + assert np.allclose(result[:, :, 0], result[:, :, 1], atol=1) + assert np.allclose(result[:, :, 1], result[:, :, 2], atol=1) + + def test_adjust_combined(self, sample_frame): + """Test combined brightness, contrast, and saturation adjustment.""" + result = ImageEditor.adjust(sample_frame, brightness=10, contrast=1.2, saturation=0.8) + assert result.shape == sample_frame.shape + + def test_greyscale_conversion(self, sample_frame): + """Test grayscale conversion.""" + result = ImageEditor.greyscale(sample_frame) + + assert len(result.shape) == 3 and result.shape[2] == 3 + assert result.shape[:2] == sample_frame.shape[:2] + + @patch('cv2.imencode') + def test_compress_to_jpeg_success(self, mock_imencode, sample_frame): + """Test successful JPEG compression.""" + mock_encoded = np.array([1, 2, 3, 4], dtype=np.uint8) + mock_imencode.return_value = (True, mock_encoded) + + result = ImageEditor.compress_to_jpeg(sample_frame, quality=85) + + assert np.array_equal(result, mock_encoded) + mock_imencode.assert_called_once() + args, kwargs = mock_imencode.call_args + assert args[0] == '.jpg' + assert np.array_equal(args[1], sample_frame) + assert args[2] == [cv2.IMWRITE_JPEG_QUALITY, 85] + + @patch('cv2.imencode') + def test_compress_to_jpeg_failure(self, mock_imencode, sample_frame): + """Test failed JPEG compression.""" + mock_imencode.return_value = (False, None) + + result = ImageEditor.compress_to_jpeg(sample_frame) + + assert result is None + + @patch('cv2.imencode') + def test_compress_to_jpeg_exception(self, mock_imencode, sample_frame): + """Test JPEG compression with exception.""" + mock_imencode.side_effect = Exception("Encoding error") + + result = ImageEditor.compress_to_jpeg(sample_frame) + + assert result is None + + @patch('cv2.imencode') + def test_compress_to_png_success(self, mock_imencode, sample_frame): + """Test successful PNG compression.""" + mock_encoded = np.array([1, 2, 3, 4], dtype=np.uint8) + mock_imencode.return_value = (True, mock_encoded) + + result = ImageEditor.compress_to_png(sample_frame, compression_level=6) + + assert np.array_equal(result, mock_encoded) + mock_imencode.assert_called_once() + args, kwargs = mock_imencode.call_args + assert args[0] == '.png' + assert args[2] == [cv2.IMWRITE_PNG_COMPRESSION, 6] + + def test_compress_to_jpeg_dtype_preservation(self, sample_frame): + """Test JPEG compression preserves input dtype.""" + # Create frame with different dtype + frame_16bit = sample_frame.astype(np.uint16) * 256 + + with patch('cv2.imencode') as mock_imencode: + mock_imencode.return_value = (True, np.array([1, 2, 3])) + result = ImageEditor.compress_to_jpeg(frame_16bit) + + args, kwargs = mock_imencode.call_args + encoded_frame = args[1] + assert encoded_frame.dtype == np.uint16 + + def test_compress_to_png_dtype_preservation(self, sample_frame): + """Test PNG compression preserves input dtype.""" + # Create frame with different dtype + frame_16bit = sample_frame.astype(np.uint16) * 256 + + with patch('cv2.imencode') as mock_imencode: + mock_imencode.return_value = (True, np.array([1, 2, 3])) + result = ImageEditor.compress_to_png(frame_16bit) + + args, kwargs = mock_imencode.call_args + encoded_frame = args[1] + assert encoded_frame.dtype == np.uint16 + + +class TestPipeableFunctions: + """Test cases for the pipeable wrapper functions.""" + + @pytest.fixture + def sample_frame(self): + """Create a sample RGB frame for testing.""" + frame = np.zeros((80, 100, 3), dtype=np.uint8) + frame[:, :40] = [255, 0, 0] # Red left section + frame[:, 40:] = [0, 255, 0] # Green right section + return frame + + def test_letterboxed_function_returns_pipeable(self): + """Test that letterboxed function returns PipeableFunction.""" + result = letterboxed(target_size=(200, 200)) + assert isinstance(result, PipeableFunction) + + def test_letterboxed_pipe_operator(self, sample_frame): + """Test letterboxed function with pipe operator.""" + result = letterboxed(target_size=(200, 200))(sample_frame) + + assert result.shape[:2] == (200, 200) + assert result.shape[2] == 3 + + def test_resized_function_returns_pipeable(self): + """Test that resized function returns PipeableFunction.""" + result = resized(target_size=(50, 40)) + assert isinstance(result, PipeableFunction) + + def test_resized_pipe_operator(self, sample_frame): + """Test resized function with pipe operator.""" + result = resized(target_size=(50, 40))(sample_frame) + + assert result.shape[:2] == (50, 40) + assert result.shape[2] == 3 + + def test_adjusted_function_returns_pipeable(self): + """Test that adjusted function returns PipeableFunction.""" + result = adjusted(brightness=10, contrast=1.2) + assert isinstance(result, PipeableFunction) + + def test_adjusted_pipe_operator(self, sample_frame): + """Test adjusted function with pipe operator.""" + result = adjusted(brightness=10, contrast=1.2, saturation=0.8)(sample_frame) + + assert result.shape == sample_frame.shape + + def test_greyscaled_function_returns_pipeable(self): + """Test that greyscaled function returns PipeableFunction.""" + result = greyscaled() + assert isinstance(result, PipeableFunction) + + def test_greyscaled_pipe_operator(self, sample_frame): + """Test greyscaled function with pipe operator.""" + result = greyscaled()(sample_frame) + + # Should have three channels + assert len(result.shape) == 3 and result.shape[2] == 3 + + def test_compressed_to_jpeg_function_returns_pipeable(self): + """Test that compressed_to_jpeg function returns PipeableFunction.""" + result = compressed_to_jpeg(quality=85) + assert isinstance(result, PipeableFunction) + + @patch('cv2.imencode') + def test_compressed_to_jpeg_pipe_operator(self, mock_imencode, sample_frame): + """Test compressed_to_jpeg function with pipe operator.""" + mock_encoded = np.array([1, 2, 3, 4], dtype=np.uint8) + mock_imencode.return_value = (True, mock_encoded) + + pipe = compressed_to_jpeg(quality=85) + result = pipe(sample_frame) + + assert np.array_equal(result, mock_encoded) + + def test_compressed_to_png_function_returns_pipeable(self): + """Test that compressed_to_png function returns PipeableFunction.""" + result = compressed_to_png(compression_level=6) + assert isinstance(result, PipeableFunction) + + @patch('cv2.imencode') + def test_compressed_to_png_pipe_operator(self, mock_imencode, sample_frame): + """Test compressed_to_png function with pipe operator.""" + mock_encoded = np.array([1, 2, 3, 4], dtype=np.uint8) + mock_imencode.return_value = (True, mock_encoded) + + pipe = compressed_to_png(compression_level=6) + result = pipe(sample_frame) + + assert np.array_equal(result, mock_encoded) + + +class TestPipelineComposition: + """Test cases for complex pipeline compositions.""" + + @pytest.fixture + def sample_frame(self): + """Create a sample RGB frame for testing.""" + frame = np.zeros((80, 100, 3), dtype=np.uint8) + frame[:, :40] = [255, 0, 0] # Red left section + frame[:, 40:] = [0, 255, 0] # Green right section + return frame + + def test_simple_pipeline(self, sample_frame): + """Test simple pipeline composition.""" + # Create pipeline using function-to-function composition + pipe = letterboxed(target_size=(200, 200)) | resized(target_size=(100, 100)) + result = pipe(sample_frame) + + assert result.shape[:2] == (100, 100) + assert result.shape[2] == 3 + + def test_complex_pipeline(self, sample_frame): + """Test complex pipeline with multiple operations.""" + # Create pipeline using function-to-function composition + pipe = (letterboxed(target_size=(150, 150)) | + adjusted(brightness=10, contrast=1.1, saturation=0.9) | + resized(target_size=(75, 75))) + result = pipe(sample_frame) + + assert result.shape[:2] == (75, 75) + assert result.shape[2] == 3 + + @patch('cv2.imencode') + def test_pipeline_with_compression(self, mock_imencode, sample_frame): + """Test pipeline ending with compression.""" + mock_encoded = np.array([1, 2, 3, 4], dtype=np.uint8) + mock_imencode.return_value = (True, mock_encoded) + + # Create pipeline using function-to-function composition + pipe = (letterboxed(target_size=(100, 100)) | + adjusted(brightness=5) | + compressed_to_jpeg(quality=90)) + result = pipe(sample_frame) + + assert np.array_equal(result, mock_encoded) + + def test_pipeline_with_greyscale(self, sample_frame): + """Test pipeline with greyscale conversion.""" + # Create pipeline using function-to-function composition + pipe = (letterboxed(target_size=(100, 100)) | + greyscaled() | + adjusted(brightness=10, contrast=1.2)) + result = pipe(sample_frame) + + assert len(result.shape) == 3 and result.shape[2] == 3 + + def test_pipeline_error_propagation(self, sample_frame): + """Test that errors in pipeline are properly propagated.""" + with patch.object(ImageEditor, 'letterbox', side_effect=ValueError("Test error")): + pipe = letterboxed(target_size=(100, 100)) + with pytest.raises(ValueError, match="Test error"): + pipe(sample_frame) + + def test_pipeline_with_no_args_functions(self, sample_frame): + """Test pipeline with functions that take no additional arguments.""" + pipe = greyscaled() + result = pipe(sample_frame) + + assert len(result.shape) == 3 and result.shape[2] == 3 + + +class TestEdgeCases: + """Test cases for edge cases and error conditions.""" + + def test_empty_frame(self): + """Test handling of empty frames.""" + empty_frame = np.array([], dtype=np.uint8).reshape(0, 0, 3) + + # Most operations should handle empty frames gracefully + with pytest.raises((ValueError, cv2.error)): + ImageEditor.letterbox(empty_frame) + + def test_single_pixel_frame(self): + """Test handling of single pixel frames.""" + single_pixel = np.array([[[255, 0, 0]]], dtype=np.uint8) + + result = ImageEditor.letterbox(single_pixel, target_size=(10, 10)) + assert result.shape[:2] == (10, 10) + + def test_very_large_frame(self): + """Test handling of large frames (memory considerations).""" + # Create a moderately large frame to test without using too much memory + large_frame = np.random.randint(0, 256, (500, 600, 3), dtype=np.uint8) + + result = ImageEditor.resize(large_frame, target_size=(100, 100)) + assert result.shape[:2] == (100, 100) + + def test_invalid_target_sizes(self): + """Test handling of invalid target sizes.""" + frame = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8) + + # Zero or negative dimensions should be handled + with pytest.raises((ValueError, cv2.error)): + ImageEditor.resize(frame, target_size=(0, 100)) + + with pytest.raises((ValueError, cv2.error)): + ImageEditor.resize(frame, target_size=(-10, 100)) + + def test_extreme_adjustment_values(self, sample_frame=None): + """Test extreme adjustment values.""" + if sample_frame is None: + sample_frame = np.random.randint(0, 256, (50, 50, 3), dtype=np.uint8) + + # Extreme brightness + result = ImageEditor.adjust(sample_frame, brightness=1000) + assert result.shape == sample_frame.shape + assert np.all(result <= 255) # Should be clipped + + result = ImageEditor.adjust(sample_frame, brightness=-1000) + assert np.all(result >= 0) # Should be clipped + + # Extreme contrast + result = ImageEditor.adjust(sample_frame, contrast=100) + assert result.shape == sample_frame.shape + + # Zero contrast + result = ImageEditor.adjust(sample_frame, contrast=0) + assert result.shape == sample_frame.shape \ No newline at end of file diff --git a/tests/arduino/app_utils/image/test_pipeable.py b/tests/arduino/app_utils/image/test_pipeable.py new file mode 100644 index 00000000..c565870f --- /dev/null +++ b/tests/arduino/app_utils/image/test_pipeable.py @@ -0,0 +1,182 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import pytest +from unittest.mock import MagicMock +from arduino.app_utils.image.pipeable import PipeableFunction + + +class TestPipeableFunction: + """Test cases for the PipeableFunction class.""" + + def test_init(self): + """Test PipeableFunction initialization.""" + mock_func = MagicMock() + pf = PipeableFunction(mock_func, 1, 2, kwarg1="value1") + + assert pf.func == mock_func + assert pf.args == (1, 2) + assert pf.kwargs == {"kwarg1": "value1"} + + def test_call_no_existing_args(self): + """Test calling PipeableFunction with no existing args.""" + mock_func = MagicMock(return_value="result") + pf = PipeableFunction(mock_func) + + result = pf(1, 2, kwarg1="value1") + + mock_func.assert_called_once_with(1, 2, kwarg1="value1") + assert result == "result" + + def test_call_with_existing_args(self): + """Test calling PipeableFunction with existing args.""" + mock_func = MagicMock(return_value="result") + pf = PipeableFunction(mock_func, 1, kwarg1="value1") + + result = pf(2, 3, kwarg2="value2") + + mock_func.assert_called_once_with(1, 2, 3, kwarg1="value1", kwarg2="value2") + assert result == "result" + + def test_call_kwargs_override(self): + """Test that new kwargs override existing ones.""" + mock_func = MagicMock(return_value="result") + pf = PipeableFunction(mock_func, kwarg1="old_value") + + result = pf(kwarg1="new_value", kwarg2="value2") + + mock_func.assert_called_once_with(kwarg1="new_value", kwarg2="value2") + assert result == "result" + + def test_ror_pipe_operator(self): + """Test right-hand side pipe operator (value | function).""" + def add_one(x): + return x + 1 + + pf = PipeableFunction(add_one) + result = 5 | pf + + assert result == 6 + + def test_or_pipe_operator(self): + """Test left-hand side pipe operator (function | function).""" + def add_one(x): + return x + 1 + + def multiply_two(x): + return x * 2 + + pf1 = PipeableFunction(add_one) + pf2 = PipeableFunction(multiply_two) + + # Chain: add_one | multiply_two + composed = pf1 | pf2 + + assert isinstance(composed, PipeableFunction) + result = composed(5) # (5 + 1) * 2 = 12 + assert result == 12 + + def test_or_pipe_operator_with_non_callable(self): + """Test pipe operator with non-callable returns NotImplemented.""" + pf = PipeableFunction(lambda x: x) + with pytest.raises(TypeError, match="unsupported operand type"): + pf | "not_callable" + + def test_repr_with_function_name(self): + """Test string representation with function having __name__.""" + def test_func(): + pass + + pf = PipeableFunction(test_func) + assert repr(pf) == "test_func()" + + def test_repr_with_args_and_kwargs(self): + """Test string representation with args and kwargs.""" + def test_func(): + pass + + pf = PipeableFunction(test_func, 1, 2, kwarg1="value1", kwarg2=42) + repr_str = repr(pf) + + assert "test_func(" in repr_str + assert "1" in repr_str + assert "2" in repr_str + assert "kwarg1=value1" in repr_str + assert "kwarg2=42" in repr_str + + def test_repr_with_partial_object(self): + """Test string representation with functools.partial object.""" + from functools import partial + + def test_func(a, b): + return a + b + + partial_func = partial(test_func, b=10) + pf = PipeableFunction(partial_func) + + repr_str = repr(pf) + # Should handle partial objects gracefully + assert "test_func" in repr_str or "partial" in repr_str + + def test_repr_with_callable_without_name(self): + """Test string representation with callable without __name__.""" + class CallableClass: + def __call__(self): + pass + + callable_obj = CallableClass() + pf = PipeableFunction(callable_obj) + + repr_str = repr(pf) + assert "CallableClass" in repr_str + + +class TestPipeableIntegration: + """Integration tests for pipeable functionality.""" + + def test_real_world_data_processing(self): + """Test pipeable with real-world data processing scenario.""" + def filter_positive(numbers): + return [n for n in numbers if n > 0] + def filtered_positive(): + return PipeableFunction(filter_positive) + + def square_all(numbers): + return [n * n for n in numbers] + def squared(): + return PipeableFunction(square_all) + + def sum_all(numbers): + return sum(numbers) + def summed(): + return PipeableFunction(sum_all) + + data = [-2, -1, 0, 1, 2, 3] + + # Pipeline: filter positive -> square -> sum + result = data | filtered_positive() | squared() | summed() + # [1, 2, 3] -> [1, 4, 9] -> 14 + assert result == 14 + + def test_error_handling_in_pipeline(self): + """Test error handling within pipelines.""" + def divide_by(x, divisor): + if divisor == 0: + raise ValueError("Cannot divide by zero") + return x / divisor + def divided_by(divisor): + return PipeableFunction(divide_by, divisor=divisor) + + def round_number(x, decimals=2): + return round(x, decimals) + def rounded(decimals=2): + return PipeableFunction(round_number, decimals=decimals) + + # Test successful pipeline + result = 10 | divided_by(3) | rounded(decimals=2) + assert result == 3.33 + + # Test error propagation + with pytest.raises(ValueError, match="Cannot divide by zero"): + 10 | divided_by(0) | rounded() From 7a9e45ce73b9f66102aaea8304fb310195f7d80e Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 27 Oct 2025 18:31:03 +0100 Subject: [PATCH 09/86] refactor: remove adjust/adjusted functions --- src/arduino/app_utils/image/__init__.py | 1 - src/arduino/app_utils/image/image_editor.py | 95 +------------------ .../app_utils/image/test_image_editor.py | 94 +----------------- 3 files changed, 7 insertions(+), 183 deletions(-) diff --git a/src/arduino/app_utils/image/__init__.py b/src/arduino/app_utils/image/__init__.py index 10564497..d19a6423 100644 --- a/src/arduino/app_utils/image/__init__.py +++ b/src/arduino/app_utils/image/__init__.py @@ -11,7 +11,6 @@ "PipeableFunction", "letterboxed", "resized", - "adjusted", "greyscaled", "compressed_to_jpeg", "compressed_to_png", diff --git a/src/arduino/app_utils/image/image_editor.py b/src/arduino/app_utils/image/image_editor.py index a3dddb18..fe162476 100644 --- a/src/arduino/app_utils/image/image_editor.py +++ b/src/arduino/app_utils/image/image_editor.py @@ -27,7 +27,7 @@ class ImageEditor: result = frame | letterboxed(target_size=(640, 640)) Chained operations: - result = frame | letterboxed(target_size=(640, 640)) | adjusted(brightness=10) + result = frame | letterboxed(target_size=(640, 640)) | greyscaled() """ @staticmethod @@ -101,54 +101,6 @@ def resize(frame: np.ndarray, else: return cv2.resize(frame, (target_size[1], target_size[0]), interpolation=interpolation) - @staticmethod - def adjust(frame: np.ndarray, - brightness: float = 0.0, - contrast: float = 1.0, - saturation: float = 1.0) -> np.ndarray: - """ - Apply basic image filters. - - Args: - frame (np.ndarray): Input frame - brightness (float): Brightness adjustment (-100 to 100) - contrast (float): Contrast multiplier (0.0 to 3.0) - saturation (float): Saturation multiplier (0.0 to 3.0) - - Returns: - np.ndarray: adjusted frame - """ - original_dtype = frame.dtype - - # Convert to float for calculations to avoid overflow/underflow - result = frame.astype(np.float32) - - # Apply contrast and brightness - result = result * contrast + brightness - - # Clamp to valid range based on original dtype - try: - if np.issubdtype(original_dtype, np.integer): - info = np.iinfo(original_dtype) - result = np.clip(result, info.min, info.max) - else: - info = np.finfo(original_dtype) - result = np.clip(result, info.min, info.max) - except ValueError: - # If we fail, just ensure a non-negative output - result = np.clip(result, 0.0, np.inf) - - result = result.astype(original_dtype) - - # Apply saturation - if saturation != 1.0: - hsv = cv2.cvtColor(result, cv2.COLOR_BGR2HSV).astype(np.float32) - hsv[:, :, 1] *= saturation - hsv[:, :, 1] = np.clip(hsv[:, :, 1], 0, 255) - result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR) - - return result - @staticmethod def greyscale(frame: np.ndarray) -> np.ndarray: """ @@ -244,26 +196,6 @@ def pil_to_numpy(image: Image.Image) -> np.ndarray: rgb_array = np.array(image) return cv2.cvtColor(rgb_array, cv2.COLOR_RGB2BGR) - @staticmethod - def get_frame_info(frame: np.ndarray) -> dict: - """ - Get information about a frame. - - Args: - frame (np.ndarray): Input frame - - Returns: - dict: Frame information including dimensions, channels, dtype, size - """ - return { - 'height': frame.shape[0], - 'width': frame.shape[1], - 'channels': frame.shape[2] if len(frame.shape) > 2 else 1, - 'dtype': str(frame.dtype), - 'size_bytes': frame.nbytes, - 'shape': frame.shape - } - # ============================================================================= # Functional API - Standalone pipeable functions @@ -283,7 +215,7 @@ def letterboxed(target_size: Optional[Tuple[int, int]] = None, Examples: pipe = letterboxed(target_size=(640, 640)) - pipe = letterboxed() | adjusted(brightness=10) + pipe = letterboxed() | greyscaled() """ return PipeableFunction(ImageEditor.letterbox, target_size=target_size, color=color) @@ -309,27 +241,6 @@ def resized(target_size: Tuple[int, int], return PipeableFunction(ImageEditor.resize, target_size=target_size, maintain_ratio=maintain_ratio, interpolation=interpolation) -def adjusted(brightness: float = 0.0, - contrast: float = 1.0, - saturation: float = 1.0): - """ - Pipeable filter function - apply filters with pipe operator support. - - Args: - brightness (float): Brightness adjustment (-100 to 100) - contrast (float): Contrast multiplier (0.0 to 3.0) - saturation (float): Saturation multiplier (0.0 to 3.0) - - Returns: - Partial function that takes a frame and returns the adjusted frame - - Examples: - pipe = adjusted(brightness=10, contrast=1.2) - pipe = letterboxed() | adjusted(brightness=5) | resized(target_size=(320, 240)) - """ - return PipeableFunction(ImageEditor.adjust, brightness=brightness, contrast=contrast, saturation=saturation) - - def greyscaled(): """ Pipeable greyscale function - convert frame to greyscale with pipe operator support. @@ -339,7 +250,7 @@ def greyscaled(): Examples: pipe = greyscaled() - pipe = letterboxed() | greyscaled() | adjusted(contrast=1.2) + pipe = letterboxed() | greyscaled() | greyscaled() """ return PipeableFunction(ImageEditor.greyscale) diff --git a/tests/arduino/app_utils/image/test_image_editor.py b/tests/arduino/app_utils/image/test_image_editor.py index 4efc63fb..98134076 100644 --- a/tests/arduino/app_utils/image/test_image_editor.py +++ b/tests/arduino/app_utils/image/test_image_editor.py @@ -10,7 +10,6 @@ ImageEditor, letterboxed, resized, - adjusted, greyscaled, compressed_to_jpeg, compressed_to_png @@ -87,53 +86,6 @@ def test_resize_interpolation_methods(self, sample_frame): result = ImageEditor.resize(sample_frame, target_size=target_size, interpolation=interpolation) assert result.shape[:2] == (50, 40) - def test_adjust_brightness(self, sample_frame): - """Test brightness adjustment.""" - # Increase brightness - result = ImageEditor.adjust(sample_frame, brightness=50) - assert result.shape == sample_frame.shape - # Brightness should increase (but clipped at 255) - assert np.all(result >= sample_frame) - - # Decrease brightness - should clamp at 0, so all values <= original - result = ImageEditor.adjust(sample_frame, brightness=-50) - assert result.shape == sample_frame.shape - assert result.dtype == sample_frame.dtype - assert np.all(result <= sample_frame) - # Values should never go below 0 - assert np.all(result >= 0) - - def test_adjust_contrast(self, sample_frame): - """Test contrast adjustment.""" - # Increase contrast - result = ImageEditor.adjust(sample_frame, contrast=1.5) - assert result.shape == sample_frame.shape - - # Decrease contrast - result = ImageEditor.adjust(sample_frame, contrast=0.5) - assert result.shape == sample_frame.shape - - def test_adjust_saturation(self, sample_frame): - """Test saturation adjustment.""" - # Increase saturation - result = ImageEditor.adjust(sample_frame, saturation=1.5) - assert result.shape == sample_frame.shape - - # Decrease saturation (towards grayscale) - result = ImageEditor.adjust(sample_frame, saturation=0.5) - assert result.shape == sample_frame.shape - - # Zero saturation should be grayscale - result = ImageEditor.adjust(sample_frame, saturation=0.0) - # All channels should be equal for grayscale - assert np.allclose(result[:, :, 0], result[:, :, 1], atol=1) - assert np.allclose(result[:, :, 1], result[:, :, 2], atol=1) - - def test_adjust_combined(self, sample_frame): - """Test combined brightness, contrast, and saturation adjustment.""" - result = ImageEditor.adjust(sample_frame, brightness=10, contrast=1.2, saturation=0.8) - assert result.shape == sample_frame.shape - def test_greyscale_conversion(self, sample_frame): """Test grayscale conversion.""" result = ImageEditor.greyscale(sample_frame) @@ -250,17 +202,6 @@ def test_resized_pipe_operator(self, sample_frame): assert result.shape[:2] == (50, 40) assert result.shape[2] == 3 - def test_adjusted_function_returns_pipeable(self): - """Test that adjusted function returns PipeableFunction.""" - result = adjusted(brightness=10, contrast=1.2) - assert isinstance(result, PipeableFunction) - - def test_adjusted_pipe_operator(self, sample_frame): - """Test adjusted function with pipe operator.""" - result = adjusted(brightness=10, contrast=1.2, saturation=0.8)(sample_frame) - - assert result.shape == sample_frame.shape - def test_greyscaled_function_returns_pipeable(self): """Test that greyscaled function returns PipeableFunction.""" result = greyscaled() @@ -329,9 +270,7 @@ def test_simple_pipeline(self, sample_frame): def test_complex_pipeline(self, sample_frame): """Test complex pipeline with multiple operations.""" # Create pipeline using function-to-function composition - pipe = (letterboxed(target_size=(150, 150)) | - adjusted(brightness=10, contrast=1.1, saturation=0.9) | - resized(target_size=(75, 75))) + pipe = (letterboxed(target_size=(150, 150)) | resized(target_size=(75, 75))) result = pipe(sample_frame) assert result.shape[:2] == (75, 75) @@ -344,9 +283,7 @@ def test_pipeline_with_compression(self, mock_imencode, sample_frame): mock_imencode.return_value = (True, mock_encoded) # Create pipeline using function-to-function composition - pipe = (letterboxed(target_size=(100, 100)) | - adjusted(brightness=5) | - compressed_to_jpeg(quality=90)) + pipe = (letterboxed(target_size=(100, 100)) | compressed_to_jpeg(quality=90)) result = pipe(sample_frame) assert np.array_equal(result, mock_encoded) @@ -354,9 +291,7 @@ def test_pipeline_with_compression(self, mock_imencode, sample_frame): def test_pipeline_with_greyscale(self, sample_frame): """Test pipeline with greyscale conversion.""" # Create pipeline using function-to-function composition - pipe = (letterboxed(target_size=(100, 100)) | - greyscaled() | - adjusted(brightness=10, contrast=1.2)) + pipe = (letterboxed(target_size=(100, 100)) | greyscaled()) result = pipe(sample_frame) assert len(result.shape) == 3 and result.shape[2] == 3 @@ -411,25 +346,4 @@ def test_invalid_target_sizes(self): ImageEditor.resize(frame, target_size=(0, 100)) with pytest.raises((ValueError, cv2.error)): - ImageEditor.resize(frame, target_size=(-10, 100)) - - def test_extreme_adjustment_values(self, sample_frame=None): - """Test extreme adjustment values.""" - if sample_frame is None: - sample_frame = np.random.randint(0, 256, (50, 50, 3), dtype=np.uint8) - - # Extreme brightness - result = ImageEditor.adjust(sample_frame, brightness=1000) - assert result.shape == sample_frame.shape - assert np.all(result <= 255) # Should be clipped - - result = ImageEditor.adjust(sample_frame, brightness=-1000) - assert np.all(result >= 0) # Should be clipped - - # Extreme contrast - result = ImageEditor.adjust(sample_frame, contrast=100) - assert result.shape == sample_frame.shape - - # Zero contrast - result = ImageEditor.adjust(sample_frame, contrast=0) - assert result.shape == sample_frame.shape \ No newline at end of file + ImageEditor.resize(frame, target_size=(-10, 100)) \ No newline at end of file From 2a4bf601006bf4b362a5a80f770f36dd0118142d Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 28 Oct 2025 18:23:02 +0100 Subject: [PATCH 10/86] feat: better support BGR, BGRA and uint8, uint16, uint32 --- src/arduino/app_utils/image/__init__.py | 1 + src/arduino/app_utils/image/image_editor.py | 294 +++++-- .../app_utils/image/test_image_editor.py | 733 ++++++++++-------- .../arduino/app_utils/image/test_pipeable.py | 15 +- 4 files changed, 633 insertions(+), 410 deletions(-) diff --git a/src/arduino/app_utils/image/__init__.py b/src/arduino/app_utils/image/__init__.py index d19a6423..10564497 100644 --- a/src/arduino/app_utils/image/__init__.py +++ b/src/arduino/app_utils/image/__init__.py @@ -11,6 +11,7 @@ "PipeableFunction", "letterboxed", "resized", + "adjusted", "greyscaled", "compressed_to_jpeg", "compressed_to_png", diff --git a/src/arduino/app_utils/image/image_editor.py b/src/arduino/app_utils/image/image_editor.py index fe162476..fca45bbe 100644 --- a/src/arduino/app_utils/image/image_editor.py +++ b/src/arduino/app_utils/image/image_editor.py @@ -9,75 +9,79 @@ from arduino.app_utils.image.pipeable import PipeableFunction +# NOTE: we use the following formats for image shapes (H = height, W = width, C = channels): +# - When receiving a resolution as argument we expect (W, H) format which is more user-friendly +# - When receiving images we expect (H, W, C) format with C = BGR, BGRA or greyscale +# - When returning images we use (H, W, C) format with C = BGR, BGRA or greyscale (depending on input) +# Keep in mind OpenCV uses (W, H, C) format with C = BGR whereas numpy uses (H, W, C) format with any C. +# The below functions all support unsigned integer types used by OpenCV (uint8, uint16 and uint32). + class ImageEditor: """ - Image processing utilities for camera frames. - - Handles common image operations like compression, letterboxing, resizing, and format conversions. - - This class provides traditional static methods for image processing operations. - For functional composition with pipe operators, use the standalone functions below the class. - - Examples: - Traditional API: - result = ImageEditor.letterbox(frame, target_size=(640, 640)) - - Functional API: - result = frame | letterboxed(target_size=(640, 640)) - - Chained operations: - result = frame | letterboxed(target_size=(640, 640)) | greyscaled() + Image processing utilities handling common image operations like letterboxing, resizing, + adjusting, compressing and format conversions. + Frames are expected to be in BGR, BGRA or greyscale format. """ @staticmethod def letterbox(frame: np.ndarray, target_size: Optional[Tuple[int, int]] = None, - color: Tuple[int, int, int] = (114, 114, 114)) -> np.ndarray: + color: int | Tuple[int, int, int] = (114, 114, 114), + interpolation: int = cv2.INTER_LINEAR) -> np.ndarray: """ Add letterboxing to frame to achieve target size while maintaining aspect ratio. Args: frame (np.ndarray): Input frame target_size (tuple, optional): Target size as (width, height). If None, makes frame square. - color (tuple): RGB color for padding borders. Default: (114, 114, 114) - + color (int or tuple, optional): BGR color for padding borders, can be a scalar or a tuple + matching the frame's channel count. Default: (114, 114, 114) + interpolation (int, optional): OpenCV interpolation method. Default: cv2.INTER_LINEAR + Returns: np.ndarray: Letterboxed frame """ + original_dtype = frame.dtype + orig_h, orig_w = frame.shape[:2] + if target_size is None: - # Make square based on the larger dimension - max_dim = max(frame.shape[0], frame.shape[1]) - target_size = (max_dim, max_dim) - - target_w, target_h = target_size - h, w = frame.shape[:2] - - # Handle empty frames - if w == 0 or h == 0: - raise ValueError("Cannot letterbox empty frame") - - # Calculate scaling factor to fit image inside target size - scale = min(target_w / w, target_h / h) - new_w, new_h = int(w * scale), int(h * scale) - - # Resize frame - resized = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_LINEAR) - - # Calculate padding - pad_w = target_w - new_w - pad_h = target_h - new_h - - # Add padding - return cv2.copyMakeBorder( - resized, - top=pad_h // 2, - bottom=(pad_h + 1) // 2, - left=pad_w // 2, - right=(pad_w + 1) // 2, - borderType=cv2.BORDER_CONSTANT, - value=color - ) + # Default to a square canvas based on the longest side + max_dim = max(orig_h, orig_w) + target_w, target_h = int(max_dim), int(max_dim) + else: + target_w, target_h = int(target_size[0]), int(target_size[1]) + + scale = min(target_w / orig_w, target_h / orig_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + + resized_frame = cv2.resize(frame, (new_w, new_h), interpolation=interpolation) + + if frame.ndim == 2: + # Greyscale + if hasattr(color, '__len__'): + color = color[0] + canvas = np.full((target_h, target_w), color, dtype=original_dtype) + else: + # Colored (BGR/BGRA) + channels = frame.shape[2] + if not hasattr(color, '__len__'): + color = (color,) * channels + elif len(color) != channels: + raise ValueError( + f"color length ({len(color)}) must match frame channels ({channels})." + ) + canvas = np.full((target_h, target_w, channels), color, dtype=original_dtype) + + # Calculate offsets to center the image + y_offset = (target_h - new_h) // 2 + x_offset = (target_w - new_w) // 2 + + # Paste the resized image onto the canvas + canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized_frame + + return canvas @staticmethod def resize(frame: np.ndarray, @@ -99,23 +103,162 @@ def resize(frame: np.ndarray, if maintain_ratio: return ImageEditor.letterbox(frame, target_size) else: - return cv2.resize(frame, (target_size[1], target_size[0]), interpolation=interpolation) + return cv2.resize(frame, (target_size[0], target_size[1]), interpolation=interpolation) + + @staticmethod + def adjust(frame: np.ndarray, + brightness: float = 0.0, + contrast: float = 1.0, + saturation: float = 1.0, + gamma: float = 1.0) -> np.ndarray: + """ + Apply image adjustments to a BGR or BGRA frame, preserving channel count + and data type. + + Args: + frame (np.ndarray): Input frame (uint8, uint16, uint32). + brightness (float): -1.0 to 1.0 (default: 0.0). + contrast (float): 0.0 to N (default: 1.0). + saturation (float): 0.0 to N (default: 1.0). + gamma (float): > 0 (default: 1.0). + + Returns: + np.ndarray: The adjusted input with same dtype as frame. + """ + original_dtype = frame.dtype + dtype_info = np.iinfo(original_dtype) + max_val = dtype_info.max + + # Use float64 for int types with > 24 bits of precision (e.g., uint32) + processing_dtype = np.float64 if dtype_info.bits > 24 else np.float32 + + # Apply the adjustments in float space to reduce clipping and data loss + frame_float = frame.astype(processing_dtype) / max_val + + # If present, separate alpha channel + alpha_channel = None + if frame.ndim == 3 and frame.shape[2] == 4: + alpha_channel = frame_float[:, :, 3] + frame_float = frame_float[:, :, :3] + + # Saturation + if saturation != 1.0 and frame.ndim == 3: # Ensure frame has color channels + # This must be done with float32 so it's lossy only for uint32 + frame_float_32 = frame_float.astype(np.float32) + hsv = cv2.cvtColor(frame_float_32, cv2.COLOR_BGR2HSV) + h, s, v = ImageEditor.split_channels(hsv) + s = np.clip(s * saturation, 0.0, 1.0) + frame_float_32 = cv2.cvtColor(np.stack([h, s, v], axis=2), cv2.COLOR_HSV2BGR) + frame_float = frame_float_32.astype(processing_dtype) + + # Brightness + if brightness != 0.0: + frame_float = frame_float + brightness + + # Contrast + if contrast != 1.0: + frame_float = (frame_float - 0.5) * contrast + 0.5 + + # We need to clip before reaching gamma correction + # Clipping to 0 is mandatory to avoid handling complex numbers + # Clipping to 1 is handy to avoid clipping again after gamma correction + frame_float = np.clip(frame_float, 0.0, 1.0) + + # Gamma + if gamma != 1.0: + if gamma <= 0: + # This check is critical to prevent math errors (NaN/Inf) + raise ValueError("Gamma value must be greater than 0.") + frame_float = np.power(frame_float, gamma) + + # Convert back to original dtype + final_frame_bgr = (frame_float * max_val).astype(original_dtype) + + # If present, reattach alpha channel + if alpha_channel is not None: + final_alpha = (alpha_channel * max_val).astype(original_dtype) + b, g, r = ImageEditor.split_channels(final_frame_bgr) + final_frame = np.stack([b, g, r, final_alpha], axis=2) + else: + final_frame = final_frame_bgr + + return final_frame + + @staticmethod + def split_channels(frame: np.ndarray) -> tuple: + """ + Split a multi-channel frame into individual channels using numpy indexing. + This function provides better data type compatibility than cv2.split, + especially for uint32 data which OpenCV doesn't fully support. + + Args: + frame (np.ndarray): Input frame with 3 or 4 channels + + Returns: + tuple: Individual channel arrays. For BGR: (b, g, r). For BGRA: (b, g, r, a). + For HSV: (h, s, v). For other 3-channel: (ch0, ch1, ch2). + """ + if frame.ndim != 3: + raise ValueError("Frame must be 3-dimensional (H, W, C)") + + channels = frame.shape[2] + if channels == 3: + return frame[:, :, 0], frame[:, :, 1], frame[:, :, 2] + elif channels == 4: + return frame[:, :, 0], frame[:, :, 1], frame[:, :, 2], frame[:, :, 3] + else: + raise ValueError(f"Unsupported number of channels: {channels}. Expected 3 or 4.") @staticmethod def greyscale(frame: np.ndarray) -> np.ndarray: """ - Convert frame to greyscale and maintain 3 channels for consistency. + Converts a BGR or BGRA frame to greyscale, preserving channel count and + data type. A greyscale frame is returned unmodified. Args: - frame (np.ndarray): Input frame in BGR format + frame (np.ndarray): Input frame (uint8, uint16, uint32). Returns: - np.ndarray: Greyscale frame (3 channels, all identical) + np.ndarray: The greyscaled frame with same dtype and channel count as frame. """ - gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - # Convert back to 3 channels for consistency - return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) + # If already greyscale or unknown format, return the original frame + if frame.ndim != 3: + return frame + + original_dtype = frame.dtype + dtype_info = np.iinfo(original_dtype) + max_val = dtype_info.max + + # Use float64 for int types with > 24 bits of precision (e.g., uint32) + processing_dtype = np.float64 if dtype_info.bits > 24 else np.float32 + + # Apply the adjustments in float space to reduce clipping and data loss + frame_float = frame.astype(processing_dtype) / max_val + + # If present, separate alpha channel + alpha_channel = None + if frame.shape[2] == 4: + alpha_channel = frame_float[:, :, 3] + frame_float = frame_float[:, :, :3] + # Convert to greyscale using standard BT.709 weights + # GREY = 0.0722 * B + 0.7152 * G + 0.2126 * R + grey_float = (0.0722 * frame_float[:, :, 0] + + 0.7152 * frame_float[:, :, 1] + + 0.2126 * frame_float[:, :, 2]) + + # Convert back to original dtype + final_grey = (grey_float * max_val).astype(original_dtype) + + # If present, reattach alpha channel + if alpha_channel is not None: + final_alpha = (alpha_channel * max_val).astype(original_dtype) + final_frame = np.stack([final_grey, final_grey, final_grey, final_alpha], axis=2) + else: + final_frame = np.stack([final_grey, final_grey, final_grey], axis=2) + + return final_frame + @staticmethod def compress_to_jpeg(frame: np.ndarray, quality: int = 80) -> Optional[np.ndarray]: """ @@ -168,7 +311,7 @@ def numpy_to_pil(frame: np.ndarray) -> Image.Image: Convert numpy array to PIL Image. Args: - frame (np.ndarray): Input frame in BGR format (OpenCV default) + frame (np.ndarray): Input frame in BGR format Returns: PIL.Image.Image: PIL Image in RGB format @@ -186,9 +329,8 @@ def pil_to_numpy(image: Image.Image) -> np.ndarray: image (PIL.Image.Image): PIL Image Returns: - np.ndarray: Numpy array in BGR format (OpenCV default) + np.ndarray: Numpy array in BGR format """ - # Convert to RGB if not already if image.mode != 'RGB': image = image.convert('RGB') @@ -202,7 +344,8 @@ def pil_to_numpy(image: Image.Image) -> np.ndarray: # ============================================================================= def letterboxed(target_size: Optional[Tuple[int, int]] = None, - color: Tuple[int, int, int] = (114, 114, 114)): + color: Tuple[int, int, int] = (114, 114, 114), + interpolation: int = cv2.INTER_LINEAR): """ Pipeable letterbox function - apply letterboxing with pipe operator support. @@ -217,7 +360,7 @@ def letterboxed(target_size: Optional[Tuple[int, int]] = None, pipe = letterboxed(target_size=(640, 640)) pipe = letterboxed() | greyscaled() """ - return PipeableFunction(ImageEditor.letterbox, target_size=target_size, color=color) + return PipeableFunction(ImageEditor.letterbox, target_size=target_size, color=color, interpolation=interpolation) def resized(target_size: Tuple[int, int], @@ -241,6 +384,29 @@ def resized(target_size: Tuple[int, int], return PipeableFunction(ImageEditor.resize, target_size=target_size, maintain_ratio=maintain_ratio, interpolation=interpolation) +def adjusted(brightness: float = 0.0, + contrast: float = 1.0, + saturation: float = 1.0, + gamma: float = 1.0): + """ + Pipeable adjust function - apply image adjustments with pipe operator support. + + Args: + brightness (float): -1.0 to 1.0 (default: 0.0). + contrast (float): 0.0 to N (default: 1.0). + saturation (float): 0.0 to N (default: 1.0). + gamma (float): > 0 (default: 1.0). + + Returns: + Partial function that takes a frame and returns adjusted frame + + Examples: + pipe = adjusted(brightness=0.1, contrast=1.2) + pipe = letterboxed() | adjusted(saturation=0.8) + """ + return PipeableFunction(ImageEditor.adjust, brightness=brightness, contrast=contrast, saturation=saturation, gamma=gamma) + + def greyscaled(): """ Pipeable greyscale function - convert frame to greyscale with pipe operator support. @@ -250,7 +416,7 @@ def greyscaled(): Examples: pipe = greyscaled() - pipe = letterboxed() | greyscaled() | greyscaled() + pipe = letterboxed() | greyscaled() """ return PipeableFunction(ImageEditor.greyscale) diff --git a/tests/arduino/app_utils/image/test_image_editor.py b/tests/arduino/app_utils/image/test_image_editor.py index 98134076..49bcd982 100644 --- a/tests/arduino/app_utils/image/test_image_editor.py +++ b/tests/arduino/app_utils/image/test_image_editor.py @@ -2,348 +2,407 @@ # # SPDX-License-Identifier: MPL-2.0 -import pytest import numpy as np -import cv2 -from unittest.mock import patch -from arduino.app_utils.image.image_editor import ( - ImageEditor, - letterboxed, - resized, - greyscaled, - compressed_to_jpeg, - compressed_to_png -) -from arduino.app_utils.image.pipeable import PipeableFunction - - -class TestImageEditor: - """Test cases for the ImageEditor class.""" - - @pytest.fixture - def sample_frame(self): - """Create a sample RGB frame for testing.""" - # Create a 100x80 RGB frame with some pattern - frame = np.zeros((80, 100, 3), dtype=np.uint8) - frame[:, :40] = [255, 0, 0] # Red left section - frame[:, 40:] = [0, 255, 0] # Green right section - return frame - - @pytest.fixture - def sample_grayscale_frame(self): - """Create a sample grayscale frame for testing.""" - return np.random.randint(0, 256, (80, 100), dtype=np.uint8) - - def test_letterbox_make_square(self, sample_frame): - """Test letterboxing to make frame square.""" - result = ImageEditor.letterbox(sample_frame) - - # Should make it square based on larger dimension (100) - assert result.shape[:2] == (100, 100) - assert result.shape[2] == 3 # Still RGB - - def test_letterbox_specific_size(self, sample_frame): - """Test letterboxing to specific target size.""" - target_size = (200, 150) - result = ImageEditor.letterbox(sample_frame, target_size=target_size) - - assert result.shape[:2] == (150, 200) # Height, Width - assert result.shape[2] == 3 # Still RGB - - def test_letterbox_custom_color(self, sample_frame): - """Test letterboxing with custom padding color.""" - target_size = (200, 200) - custom_color = (255, 255, 0) # Yellow - result = ImageEditor.letterbox(sample_frame, target_size=target_size, color=custom_color) - - assert result.shape[:2] == (200, 200) - # Check that padding areas have the custom color - # Top and bottom should have yellow padding - assert np.array_equal(result[0, 0], custom_color) - - def test_resize_basic(self, sample_frame): - """Test basic resizing functionality.""" - target_size = (50, 40) # Smaller than original - result = ImageEditor.resize(sample_frame, target_size=target_size) - - assert result.shape[:2] == (50, 40) - assert result.shape[2] == 3 # Still RGB - - def test_resize_with_letterboxing(self, sample_frame): - """Test resizing with maintain_ratio==True (uses letterboxing).""" - target_size = (200, 200) - result = ImageEditor.resize(sample_frame, target_size=target_size, maintain_ratio=True) - - assert result.shape[:2] == (200, 200) - assert result.shape[2] == 3 # Still RGB - - def test_resize_interpolation_methods(self, sample_frame): - """Test different interpolation methods.""" - target_size = (50, 40) - - # Test different interpolation methods - for interpolation in [cv2.INTER_LINEAR, cv2.INTER_CUBIC, cv2.INTER_NEAREST]: - result = ImageEditor.resize(sample_frame, target_size=target_size, interpolation=interpolation) - assert result.shape[:2] == (50, 40) - - def test_greyscale_conversion(self, sample_frame): - """Test grayscale conversion.""" - result = ImageEditor.greyscale(sample_frame) - - assert len(result.shape) == 3 and result.shape[2] == 3 - assert result.shape[:2] == sample_frame.shape[:2] - - @patch('cv2.imencode') - def test_compress_to_jpeg_success(self, mock_imencode, sample_frame): - """Test successful JPEG compression.""" - mock_encoded = np.array([1, 2, 3, 4], dtype=np.uint8) - mock_imencode.return_value = (True, mock_encoded) - - result = ImageEditor.compress_to_jpeg(sample_frame, quality=85) - - assert np.array_equal(result, mock_encoded) - mock_imencode.assert_called_once() - args, kwargs = mock_imencode.call_args - assert args[0] == '.jpg' - assert np.array_equal(args[1], sample_frame) - assert args[2] == [cv2.IMWRITE_JPEG_QUALITY, 85] - - @patch('cv2.imencode') - def test_compress_to_jpeg_failure(self, mock_imencode, sample_frame): - """Test failed JPEG compression.""" - mock_imencode.return_value = (False, None) - - result = ImageEditor.compress_to_jpeg(sample_frame) - - assert result is None - - @patch('cv2.imencode') - def test_compress_to_jpeg_exception(self, mock_imencode, sample_frame): - """Test JPEG compression with exception.""" - mock_imencode.side_effect = Exception("Encoding error") - - result = ImageEditor.compress_to_jpeg(sample_frame) - - assert result is None - - @patch('cv2.imencode') - def test_compress_to_png_success(self, mock_imencode, sample_frame): - """Test successful PNG compression.""" - mock_encoded = np.array([1, 2, 3, 4], dtype=np.uint8) - mock_imencode.return_value = (True, mock_encoded) - - result = ImageEditor.compress_to_png(sample_frame, compression_level=6) - - assert np.array_equal(result, mock_encoded) - mock_imencode.assert_called_once() - args, kwargs = mock_imencode.call_args - assert args[0] == '.png' - assert args[2] == [cv2.IMWRITE_PNG_COMPRESSION, 6] - - def test_compress_to_jpeg_dtype_preservation(self, sample_frame): - """Test JPEG compression preserves input dtype.""" - # Create frame with different dtype - frame_16bit = sample_frame.astype(np.uint16) * 256 - - with patch('cv2.imencode') as mock_imencode: - mock_imencode.return_value = (True, np.array([1, 2, 3])) - result = ImageEditor.compress_to_jpeg(frame_16bit) - - args, kwargs = mock_imencode.call_args - encoded_frame = args[1] - assert encoded_frame.dtype == np.uint16 - - def test_compress_to_png_dtype_preservation(self, sample_frame): - """Test PNG compression preserves input dtype.""" - # Create frame with different dtype - frame_16bit = sample_frame.astype(np.uint16) * 256 - - with patch('cv2.imencode') as mock_imencode: - mock_imencode.return_value = (True, np.array([1, 2, 3])) - result = ImageEditor.compress_to_png(frame_16bit) - - args, kwargs = mock_imencode.call_args - encoded_frame = args[1] - assert encoded_frame.dtype == np.uint16 - - -class TestPipeableFunctions: - """Test cases for the pipeable wrapper functions.""" - - @pytest.fixture - def sample_frame(self): - """Create a sample RGB frame for testing.""" - frame = np.zeros((80, 100, 3), dtype=np.uint8) - frame[:, :40] = [255, 0, 0] # Red left section - frame[:, 40:] = [0, 255, 0] # Green right section - return frame - - def test_letterboxed_function_returns_pipeable(self): - """Test that letterboxed function returns PipeableFunction.""" - result = letterboxed(target_size=(200, 200)) - assert isinstance(result, PipeableFunction) - - def test_letterboxed_pipe_operator(self, sample_frame): - """Test letterboxed function with pipe operator.""" - result = letterboxed(target_size=(200, 200))(sample_frame) - - assert result.shape[:2] == (200, 200) - assert result.shape[2] == 3 - - def test_resized_function_returns_pipeable(self): - """Test that resized function returns PipeableFunction.""" - result = resized(target_size=(50, 40)) - assert isinstance(result, PipeableFunction) - - def test_resized_pipe_operator(self, sample_frame): - """Test resized function with pipe operator.""" - result = resized(target_size=(50, 40))(sample_frame) - - assert result.shape[:2] == (50, 40) - assert result.shape[2] == 3 - - def test_greyscaled_function_returns_pipeable(self): - """Test that greyscaled function returns PipeableFunction.""" - result = greyscaled() - assert isinstance(result, PipeableFunction) - - def test_greyscaled_pipe_operator(self, sample_frame): - """Test greyscaled function with pipe operator.""" - result = greyscaled()(sample_frame) - - # Should have three channels - assert len(result.shape) == 3 and result.shape[2] == 3 - - def test_compressed_to_jpeg_function_returns_pipeable(self): - """Test that compressed_to_jpeg function returns PipeableFunction.""" - result = compressed_to_jpeg(quality=85) - assert isinstance(result, PipeableFunction) - - @patch('cv2.imencode') - def test_compressed_to_jpeg_pipe_operator(self, mock_imencode, sample_frame): - """Test compressed_to_jpeg function with pipe operator.""" - mock_encoded = np.array([1, 2, 3, 4], dtype=np.uint8) - mock_imencode.return_value = (True, mock_encoded) - - pipe = compressed_to_jpeg(quality=85) - result = pipe(sample_frame) - - assert np.array_equal(result, mock_encoded) - - def test_compressed_to_png_function_returns_pipeable(self): - """Test that compressed_to_png function returns PipeableFunction.""" - result = compressed_to_png(compression_level=6) - assert isinstance(result, PipeableFunction) - - @patch('cv2.imencode') - def test_compressed_to_png_pipe_operator(self, mock_imencode, sample_frame): - """Test compressed_to_png function with pipe operator.""" - mock_encoded = np.array([1, 2, 3, 4], dtype=np.uint8) - mock_imencode.return_value = (True, mock_encoded) - - pipe = compressed_to_png(compression_level=6) - result = pipe(sample_frame) - - assert np.array_equal(result, mock_encoded) - - -class TestPipelineComposition: - """Test cases for complex pipeline compositions.""" - - @pytest.fixture - def sample_frame(self): - """Create a sample RGB frame for testing.""" - frame = np.zeros((80, 100, 3), dtype=np.uint8) - frame[:, :40] = [255, 0, 0] # Red left section - frame[:, 40:] = [0, 255, 0] # Green right section - return frame - - def test_simple_pipeline(self, sample_frame): - """Test simple pipeline composition.""" - # Create pipeline using function-to-function composition - pipe = letterboxed(target_size=(200, 200)) | resized(target_size=(100, 100)) - result = pipe(sample_frame) - - assert result.shape[:2] == (100, 100) - assert result.shape[2] == 3 - - def test_complex_pipeline(self, sample_frame): - """Test complex pipeline with multiple operations.""" - # Create pipeline using function-to-function composition - pipe = (letterboxed(target_size=(150, 150)) | resized(target_size=(75, 75))) - result = pipe(sample_frame) - - assert result.shape[:2] == (75, 75) - assert result.shape[2] == 3 +import pytest +from arduino.app_utils.image.image_editor import ImageEditor + + +# FIXTURES + +def create_gradient_frame(dtype): + """Helper: Creates a 100x100 3-channel (BGR) frame with gradients.""" + iinfo = np.iinfo(dtype) + max_val = iinfo.max + frame = np.zeros((100, 100, 3), dtype=dtype) + frame[:, :, 0] = np.linspace(0, max_val // 2, 100, dtype=dtype) # Blue + frame[:, :, 1] = np.linspace(0, max_val, 100, dtype=dtype) # Green + frame[:, :, 2] = np.linspace(max_val // 2, max_val, 100, dtype=dtype) # Red + return frame + +def create_greyscale_frame(dtype): + """Helper: Creates a 100x100 1-channel (greyscale) frame.""" + iinfo = np.iinfo(dtype) + max_val = iinfo.max + frame = np.zeros((100, 100), dtype=dtype) + frame[:, :] = np.linspace(0, max_val, 100, dtype=dtype) + return frame + +def create_bgra_frame(dtype): + """Helper: Creates a 100x100 4-channel (BGRA) frame.""" + iinfo = np.iinfo(dtype) + max_val = iinfo.max + bgr = create_gradient_frame(dtype) + alpha = np.zeros((100, 100), dtype=dtype) + alpha[:, :] = np.linspace(max_val // 4, max_val, 100, dtype=dtype) + frame = np.stack([bgr[:,:,0], bgr[:,:,1], bgr[:,:,2], alpha], axis=2) + return frame + +# Fixture for a 100x100 uint8 BGR frame +@pytest.fixture +def frame_bgr_uint8(): + return create_gradient_frame(np.uint8) + +# Fixture for a 100x100 uint8 BGRA frame +@pytest.fixture +def frame_bgra_uint8(): + return create_bgra_frame(np.uint8) + +# Fixture for a 100x100 uint8 greyscale frame +@pytest.fixture +def frame_grey_uint8(): + return create_greyscale_frame(np.uint8) + +# Fixtures for high bit-depth frames +@pytest.fixture +def frame_bgr_uint16(): + return create_gradient_frame(np.uint16) + +@pytest.fixture +def frame_bgr_uint32(): + return create_gradient_frame(np.uint32) + +@pytest.fixture +def frame_bgra_uint16(): + return create_bgra_frame(np.uint16) + +@pytest.fixture +def frame_bgra_uint32(): + return create_bgra_frame(np.uint32) + +# Fixture for a 200x100 (wide) uint8 BGR frame +@pytest.fixture +def frame_bgr_wide(): + frame = np.zeros((100, 200, 3), dtype=np.uint8) + frame[:, :, 2] = 255 # Solid Red + return frame + +# Fixture for a 100x200 (tall) uint8 BGR frame +@pytest.fixture +def frame_bgr_tall(): + frame = np.zeros((200, 100, 3), dtype=np.uint8) + frame[:, :, 1] = 255 # Solid Green + return frame + +# A parameterized fixture to test multiple data types +@pytest.fixture(params=[np.uint8, np.uint16, np.uint32]) +def frame_any_dtype(request): + """Provides a gradient frame for uint8, uint16, and uint32.""" + return create_gradient_frame(request.param) + + +# TESTS + +def test_adjust_dtype_preservation(frame_any_dtype): + """Tests that the dtype of the frame is preserved.""" + dtype = frame_any_dtype.dtype + adjusted = ImageEditor.adjust(frame_any_dtype, brightness=0.1) + assert adjusted.dtype == dtype + +def test_adjust_no_op(frame_bgr_uint8): + """Tests that default parameters do not change the frame.""" + adjusted = ImageEditor.adjust(frame_bgr_uint8) + assert np.array_equal(frame_bgr_uint8, adjusted) + +def test_adjust_brightness(frame_bgr_uint8): + """Tests brightness adjustment.""" + brighter = ImageEditor.adjust(frame_bgr_uint8, brightness=0.1) + darker = ImageEditor.adjust(frame_bgr_uint8, brightness=-0.1) + assert np.mean(brighter) > np.mean(frame_bgr_uint8) + assert np.mean(darker) < np.mean(frame_bgr_uint8) + +def test_adjust_contrast(frame_bgr_uint8): + """Tests contrast adjustment.""" + higher_contrast = ImageEditor.adjust(frame_bgr_uint8, contrast=1.5) + lower_contrast = ImageEditor.adjust(frame_bgr_uint8, contrast=0.5) + assert np.std(higher_contrast) > np.std(frame_bgr_uint8) + assert np.std(lower_contrast) < np.std(frame_bgr_uint8) + +def test_adjust_gamma(frame_bgr_uint8): + """Tests gamma correction.""" + # Gamma < 1.0 (e.g., 0.5) ==> brightens + brighter = ImageEditor.adjust(frame_bgr_uint8, gamma=0.5) + # Gamma > 1.0 (e.g., 2.0) ==> darkens + darker = ImageEditor.adjust(frame_bgr_uint8, gamma=2.0) + assert np.mean(brighter) > np.mean(frame_bgr_uint8) + assert np.mean(darker) < np.mean(frame_bgr_uint8) + +def test_adjust_saturation_to_greyscale(frame_bgr_uint8): + """Tests that saturation=0.0 makes all color channels equal.""" + desaturated = ImageEditor.adjust(frame_bgr_uint8, saturation=0.0) + b, g, r = ImageEditor.split_channels(desaturated) + assert np.allclose(b, g, atol=1) + assert np.allclose(g, r, atol=1) + +def test_adjust_greyscale_input(frame_grey_uint8): + """Tests that greyscale frames are handled safely.""" + adjusted = ImageEditor.adjust(frame_grey_uint8, saturation=1.5, brightness=0.1) + assert adjusted.ndim == 2 + assert adjusted.dtype == np.uint8 + assert np.mean(adjusted) > np.mean(frame_grey_uint8) + +def test_adjust_bgra_input(frame_bgra_uint8): + """Tests that BGRA frames are handled safely and alpha is preserved.""" + original_alpha = frame_bgra_uint8[:,:,3] - @patch('cv2.imencode') - def test_pipeline_with_compression(self, mock_imencode, sample_frame): - """Test pipeline ending with compression.""" - mock_encoded = np.array([1, 2, 3, 4], dtype=np.uint8) - mock_imencode.return_value = (True, mock_encoded) - - # Create pipeline using function-to-function composition - pipe = (letterboxed(target_size=(100, 100)) | compressed_to_jpeg(quality=90)) - result = pipe(sample_frame) - - assert np.array_equal(result, mock_encoded) + adjusted = ImageEditor.adjust(frame_bgra_uint8, saturation=0.0, brightness=0.1) - def test_pipeline_with_greyscale(self, sample_frame): - """Test pipeline with greyscale conversion.""" - # Create pipeline using function-to-function composition - pipe = (letterboxed(target_size=(100, 100)) | greyscaled()) - result = pipe(sample_frame) - - assert len(result.shape) == 3 and result.shape[2] == 3 + assert adjusted.ndim == 3 + assert adjusted.shape[2] == 4 + assert adjusted.dtype == np.uint8 - def test_pipeline_error_propagation(self, sample_frame): - """Test that errors in pipeline are properly propagated.""" - with patch.object(ImageEditor, 'letterbox', side_effect=ValueError("Test error")): - pipe = letterboxed(target_size=(100, 100)) - with pytest.raises(ValueError, match="Test error"): - pipe(sample_frame) + b, g, r, a = ImageEditor.split_channels(adjusted) + assert np.allclose(b, g, atol=1) # Check desaturation + assert np.allclose(g, r, atol=1) # Check desaturation + assert np.array_equal(original_alpha, a) # Check alpha preservation + +def test_adjust_gamma_zero_error(frame_bgr_uint8): + """Tests that gamma <= 0 raises a ValueError.""" + with pytest.raises(ValueError, match="Gamma value must be greater than 0."): + ImageEditor.adjust(frame_bgr_uint8, gamma=0.0) - def test_pipeline_with_no_args_functions(self, sample_frame): - """Test pipeline with functions that take no additional arguments.""" - pipe = greyscaled() - result = pipe(sample_frame) - - assert len(result.shape) == 3 and result.shape[2] == 3 + with pytest.raises(ValueError, match="Gamma value must be greater than 0."): + ImageEditor.adjust(frame_bgr_uint8, gamma=-1.0) +def test_adjust_high_bit_depth_bgr(frame_bgr_uint16, frame_bgr_uint32): + """ + Tests that brightness/contrast logic is correct on high bit-depth images. + This validates that the float64 conversion is working. + """ + # Test uint16 + brighter_16 = ImageEditor.adjust(frame_bgr_uint16, brightness=0.1) + darker_16 = ImageEditor.adjust(frame_bgr_uint16, brightness=-0.1) + assert np.mean(brighter_16) > np.mean(frame_bgr_uint16) + assert np.mean(darker_16) < np.mean(frame_bgr_uint16) -class TestEdgeCases: - """Test cases for edge cases and error conditions.""" - - def test_empty_frame(self): - """Test handling of empty frames.""" - empty_frame = np.array([], dtype=np.uint8).reshape(0, 0, 3) - - # Most operations should handle empty frames gracefully - with pytest.raises((ValueError, cv2.error)): - ImageEditor.letterbox(empty_frame) - - def test_single_pixel_frame(self): - """Test handling of single pixel frames.""" - single_pixel = np.array([[[255, 0, 0]]], dtype=np.uint8) - - result = ImageEditor.letterbox(single_pixel, target_size=(10, 10)) - assert result.shape[:2] == (10, 10) - - def test_very_large_frame(self): - """Test handling of large frames (memory considerations).""" - # Create a moderately large frame to test without using too much memory - large_frame = np.random.randint(0, 256, (500, 600, 3), dtype=np.uint8) - - result = ImageEditor.resize(large_frame, target_size=(100, 100)) - assert result.shape[:2] == (100, 100) + # Test uint32 + brighter_32 = ImageEditor.adjust(frame_bgr_uint32, brightness=0.1) + darker_32 = ImageEditor.adjust(frame_bgr_uint32, brightness=-0.1) + assert np.mean(brighter_32) > np.mean(frame_bgr_uint32) + assert np.mean(darker_32) < np.mean(frame_bgr_uint32) + +def test_adjust_high_bit_depth_bgra(frame_bgra_uint16, frame_bgra_uint32): + """ + Tests that brightness/contrast logic is correct on high bit-depth + BGRA images and that the alpha channel is preserved. + """ + # Test uint16 + original_alpha_16 = frame_bgra_uint16[:,:,3] + brighter_16 = ImageEditor.adjust(frame_bgra_uint16, brightness=0.1) + assert brighter_16.dtype == np.uint16 + assert brighter_16.shape == frame_bgra_uint16.shape + _, _, _, a16 = ImageEditor.split_channels(brighter_16) + assert np.array_equal(original_alpha_16, a16) + assert np.mean(brighter_16) > np.mean(frame_bgra_uint16) + + # Test uint32 + original_alpha_32 = frame_bgra_uint32[:,:,3] + brighter_32 = ImageEditor.adjust(frame_bgra_uint32, brightness=0.1) + assert brighter_32.dtype == np.uint32 + assert brighter_32.shape == frame_bgra_uint32.shape + _, _, _, a32 = ImageEditor.split_channels(brighter_32) + assert np.array_equal(original_alpha_32, a32) + assert np.mean(original_alpha_32) > np.mean(frame_bgra_uint32) + + +def test_greyscale(frame_bgr_uint8, frame_bgra_uint8, frame_grey_uint8): + """Tests the standalone greyscale function.""" + # Test on BGR + greyscaled_bgr = ImageEditor.greyscale(frame_bgr_uint8) + assert greyscaled_bgr.ndim == 3 + assert greyscaled_bgr.shape[2] == 3 + b, g, r = ImageEditor.split_channels(greyscaled_bgr) + assert np.allclose(b, g, atol=1) + assert np.allclose(g, r, atol=1) + + # Test on BGRA + original_alpha = frame_bgra_uint8[:,:,3] + greyscaled_bgra = ImageEditor.greyscale(frame_bgra_uint8) + assert greyscaled_bgra.ndim == 3 + assert greyscaled_bgra.shape[2] == 4 + b, g, r, a = ImageEditor.split_channels(greyscaled_bgra) + assert np.allclose(b, g, atol=1) + assert np.allclose(g, r, atol=1) + assert np.array_equal(original_alpha, a) + + # Test on 2D Greyscale (should be no-op) + greyscaled_grey = ImageEditor.greyscale(frame_grey_uint8) + assert np.array_equal(frame_grey_uint8, greyscaled_grey) + assert greyscaled_grey.ndim == 2 + +def test_greyscale_dtype_preservation(frame_any_dtype): + """Tests that the dtype of the frame is preserved.""" + dtype = frame_any_dtype.dtype + adjusted = ImageEditor.adjust(frame_any_dtype, brightness=0.1) + assert adjusted.dtype == dtype + +def test_greyscale_high_bit_depth(frame_bgr_uint16, frame_bgr_uint32): + """ + Tests that greyscale logic is correct on high bit-depth images. + """ + # Test uint16 + greyscaled_16 = ImageEditor.greyscale(frame_bgr_uint16) + assert greyscaled_16.dtype == np.uint16 + assert greyscaled_16.shape == frame_bgr_uint16.shape + b16, g16, r16 = ImageEditor.split_channels(greyscaled_16) + assert np.allclose(b16, g16, atol=1) + assert np.allclose(g16, r16, atol=1) + assert np.mean(b16) != np.mean(frame_bgr_uint16[:,:,0]) + + # Test uint32 + greyscaled_32 = ImageEditor.greyscale(frame_bgr_uint32) + assert greyscaled_32.dtype == np.uint32 + assert greyscaled_32.shape == frame_bgr_uint32.shape + b32, g32, r32 = ImageEditor.split_channels(greyscaled_32) + assert np.allclose(b32, g32, atol=1) + assert np.allclose(g32, r32, atol=1) + assert np.mean(b32) != np.mean(frame_bgr_uint32[:,:,0]) + +def test_high_bit_depth_greyscale_bgra_content(frame_bgra_uint16, frame_bgra_uint32): + """ + Tests that greyscale logic is correct on high bit-depth + BGRA images and that the alpha channel is preserved. + """ + # Test uint16 + original_alpha_16 = frame_bgra_uint16[:,:,3] + greyscaled_16 = ImageEditor.greyscale(frame_bgra_uint16) + assert greyscaled_16.dtype == np.uint16 + assert greyscaled_16.shape == frame_bgra_uint16.shape + b16, g16, r16, a16 = ImageEditor.split_channels(greyscaled_16) + assert np.allclose(b16, g16, atol=1) + assert np.allclose(g16, r16, atol=1) + assert np.array_equal(original_alpha_16, a16) + + # Test uint32 + original_alpha_32 = frame_bgra_uint32[:,:,3] + greyscaled_32 = ImageEditor.greyscale(frame_bgra_uint32) + assert greyscaled_32.dtype == np.uint32 + assert greyscaled_32.shape == frame_bgra_uint32.shape + b32, g32, r32, a32 = ImageEditor.split_channels(greyscaled_32) + assert np.allclose(b32, g32, atol=1) + assert np.allclose(g32, r32, atol=1) + assert np.array_equal(original_alpha_32, a32) + + +def test_resize_shape_and_dtype(frame_bgr_uint8, frame_bgra_uint8, frame_grey_uint8): + """Tests that resize produces the correct shape and preserves dtype.""" + target_w, target_h = 50, 75 + + # Test BGR + resized_bgr = ImageEditor.resize(frame_bgr_uint8, (target_w, target_h)) + assert resized_bgr.shape == (target_h, target_w, 3) + assert resized_bgr.dtype == frame_bgr_uint8.dtype + + # Test BGRA + resized_bgra = ImageEditor.resize(frame_bgra_uint8, (target_w, target_h)) + assert resized_bgra.shape == (target_h, target_w, 4) + assert resized_bgra.dtype == frame_bgra_uint8.dtype + + # Test Greyscale + resized_grey = ImageEditor.resize(frame_grey_uint8, (target_w, target_h)) + assert resized_grey.shape == (target_h, target_w) + assert resized_grey.dtype == frame_grey_uint8.dtype + +def test_letterbox_wide_image(frame_bgr_wide): + """Tests letterboxing a wide image (200x100) into a square (200x200).""" + target_w, target_h = 200, 200 + # Frame is 200x100, solid red (255) + # Scale = min(200/200, 200/100) = min(1, 2) = 1 + # new_w = 200 * 1 = 200 + # new_h = 100 * 1 = 100 + # y_offset = (200 - 100) // 2 = 50 + # x_offset = (200 - 200) // 2 = 0 + + letterboxed = ImageEditor.letterbox(frame_bgr_wide, (target_w, target_h), color=0) + + assert letterboxed.shape == (target_h, target_w, 3) + assert letterboxed.dtype == frame_bgr_wide.dtype + + # Check padding (top row, black) + assert np.all(letterboxed[0, 0] == [0, 0, 0]) + # Check padding (bottom row, black) + assert np.all(letterboxed[199, 199] == [0, 0, 0]) + # Check image data (center row, red) + assert np.all(letterboxed[100, 100] == [0, 0, 255]) + # Check image edge (no left/right padding) + assert np.all(letterboxed[100, 0] == [0, 0, 255]) + +def test_letterbox_tall_image(frame_bgr_tall): + """Tests letterboxing a tall image (100x200) into a square (200x200).""" + target_w, target_h = 200, 200 + # Frame is 100x200, solid green (255) + # Scale = min(200/100, 200/200) = min(2, 1) = 1 + # new_w = 100 * 1 = 100 + # new_h = 200 * 1 = 200 + # y_offset = (200 - 200) // 2 = 0 + # x_offset = (200 - 100) // 2 = 50 + + letterboxed = ImageEditor.letterbox(frame_bgr_tall, (target_w, target_h), color=0) - def test_invalid_target_sizes(self): - """Test handling of invalid target sizes.""" - frame = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8) - - # Zero or negative dimensions should be handled - with pytest.raises((ValueError, cv2.error)): - ImageEditor.resize(frame, target_size=(0, 100)) - - with pytest.raises((ValueError, cv2.error)): - ImageEditor.resize(frame, target_size=(-10, 100)) \ No newline at end of file + assert letterboxed.shape == (target_h, target_w, 3) + assert letterboxed.dtype == frame_bgr_tall.dtype + + # Check padding (left column, black) + assert np.all(letterboxed[0, 0] == [0, 0, 0]) + # Check padding (right column, black) + assert np.all(letterboxed[199, 199] == [0, 0, 0]) + # Check image data (center column, green) + assert np.all(letterboxed[100, 100] == [0, 255, 0]) + # Check image edge (no top/bottom padding) + assert np.all(letterboxed[0, 100] == [0, 255, 0]) + +def test_letterbox_color(frame_bgr_tall): + """Tests letterboxing with a non-default color.""" + white = (255, 255, 255) + letterboxed = ImageEditor.letterbox(frame_bgr_tall, (200, 200), color=white) + + # Check padding (left column, white) + assert np.all(letterboxed[0, 0] == white) + # Check image data (center column, green) + assert np.all(letterboxed[100, 100] == [0, 255, 0]) + +def test_letterbox_bgra(frame_bgra_uint8): + """Tests letterboxing on a 4-channel BGRA image.""" + target_w, target_h = 200, 200 + # Opaque black padding + padding = (0, 0, 0, 255) + + letterboxed = ImageEditor.letterbox(frame_bgra_uint8, (target_w, target_h), color=padding) + + assert letterboxed.shape == (target_h, target_w, 4) + # Check no padding (corner, original BGRA point) + assert np.array_equal(letterboxed[0, 0], frame_bgra_uint8[0, 0]) + # Check image data (center, from fixture) + assert np.array_equal(letterboxed[100, 100], frame_bgra_uint8[50, 50]) + +def test_letterbox_greyscale(frame_grey_uint8): + """Tests letterboxing on a 2D greyscale image.""" + target_w, target_h = 200, 200 + letterboxed = ImageEditor.letterbox(frame_grey_uint8, (target_w, target_h), color=0) + + assert letterboxed.shape == (target_h, target_w) + assert letterboxed.ndim == 2 + # Check padding (corner, black) + assert letterboxed[0, 0] == 0 + # Check image data (center) + assert letterboxed[100, 100] == frame_grey_uint8[50, 50] + +def test_letterbox_none_target_size(frame_bgr_wide, frame_bgr_tall): + """Tests that target_size=None creates a square based on the longest side.""" + # frame_bgr_wide is 200x100, longest side is 200 + letterboxed_wide = ImageEditor.letterbox(frame_bgr_wide, target_size=None) + assert letterboxed_wide.shape == (200, 200, 3) + + # frame_bgr_tall is 100x200, longest side is 200 + letterboxed_tall = ImageEditor.letterbox(frame_bgr_tall, target_size=None) + assert letterboxed_tall.shape == (200, 200, 3) + +def test_letterbox_color_tuple_error(frame_bgr_uint8): + """Tests that a mismatched padding tuple raises a ValueError.""" + with pytest.raises(ValueError, match="color length"): + # BGR (3-ch) frame with 4-ch padding + ImageEditor.letterbox(frame_bgr_uint8, (200, 200), color=(0, 0, 0, 0)) + + with pytest.raises(ValueError, match="color length"): + # BGRA (4-ch) frame with 3-ch padding + frame_bgra = create_bgra_frame(np.uint8) + ImageEditor.letterbox(frame_bgra, (200, 200), color=(0, 0, 0)) diff --git a/tests/arduino/app_utils/image/test_pipeable.py b/tests/arduino/app_utils/image/test_pipeable.py index c565870f..27707e8b 100644 --- a/tests/arduino/app_utils/image/test_pipeable.py +++ b/tests/arduino/app_utils/image/test_pipeable.py @@ -116,7 +116,6 @@ def test_func(a, b): pf = PipeableFunction(partial_func) repr_str = repr(pf) - # Should handle partial objects gracefully assert "test_func" in repr_str or "partial" in repr_str def test_repr_with_callable_without_name(self): @@ -132,8 +131,8 @@ def __call__(self): assert "CallableClass" in repr_str -class TestPipeableIntegration: - """Integration tests for pipeable functionality.""" +class TestPipeableFunctionIntegration: + """Integration tests for the PipeableFunction class.""" def test_real_world_data_processing(self): """Test pipeable with real-world data processing scenario.""" @@ -154,17 +153,15 @@ def summed(): data = [-2, -1, 0, 1, 2, 3] - # Pipeline: filter positive -> square -> sum + # Pipeline: filter positive -> square -> sum + # [1, 2, 3] -> [1, 4, 9] -> 14 result = data | filtered_positive() | squared() | summed() - # [1, 2, 3] -> [1, 4, 9] -> 14 assert result == 14 def test_error_handling_in_pipeline(self): """Test error handling within pipelines.""" def divide_by(x, divisor): - if divisor == 0: - raise ValueError("Cannot divide by zero") - return x / divisor + return x / divisor # May raise ZeroDivisionError def divided_by(divisor): return PipeableFunction(divide_by, divisor=divisor) @@ -178,5 +175,5 @@ def rounded(decimals=2): assert result == 3.33 # Test error propagation - with pytest.raises(ValueError, match="Cannot divide by zero"): + with pytest.raises(ZeroDivisionError): 10 | divided_by(0) | rounded() From 2f8df8ca4cdf473a6ae5a9552e2e093d6c3916b2 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 12:19:17 +0100 Subject: [PATCH 11/86] refactor: deprecate USBCamera in favor of Camera and make it compatible --- .../app_peripherals/camera/__init__.py | 12 +- .../app_peripherals/usb_camera/README.md | 3 + .../app_peripherals/usb_camera/__init__.py | 201 ++---------------- 3 files changed, 35 insertions(+), 181 deletions(-) diff --git a/src/arduino/app_peripherals/camera/__init__.py b/src/arduino/app_peripherals/camera/__init__.py index cc6b79d7..177f779b 100644 --- a/src/arduino/app_peripherals/camera/__init__.py +++ b/src/arduino/app_peripherals/camera/__init__.py @@ -3,5 +3,13 @@ # SPDX-License-Identifier: MPL-2.0 from .camera import Camera - -__all__ = ["Camera"] \ No newline at end of file +from .errors import * + +__all__ = [ + "Camera", + "CameraError", + "CameraReadError", + "CameraOpenError", + "CameraConfigError", + "CameraTransformError", +] \ No newline at end of file diff --git a/src/arduino/app_peripherals/usb_camera/README.md b/src/arduino/app_peripherals/usb_camera/README.md index 502517ae..88d6cd83 100644 --- a/src/arduino/app_peripherals/usb_camera/README.md +++ b/src/arduino/app_peripherals/usb_camera/README.md @@ -1,5 +1,8 @@ # USB Camera +> [!NOTE] +> This peripheral is deprecated, use the Camera peripheral instead. + The `USBCamera` peripheral captures images and videos from a connected USB camera. ## Features diff --git a/src/arduino/app_peripherals/usb_camera/__init__.py b/src/arduino/app_peripherals/usb_camera/__init__.py index 0b83ca16..fe70e118 100644 --- a/src/arduino/app_peripherals/usb_camera/__init__.py +++ b/src/arduino/app_peripherals/usb_camera/__init__.py @@ -2,30 +2,21 @@ # # SPDX-License-Identifier: MPL-2.0 -import threading -import time -import cv2 import io -import os -import re +import warnings from PIL import Image +from arduino.app_peripherals.camera import Camera, CameraReadError as CRE, CameraOpenError as COE +from arduino.app_utils.image.image_editor import compressed_to_png, letterboxed from arduino.app_utils import Logger logger = Logger("USB Camera") +CameraReadError = CRE -class CameraReadError(Exception): - """Exception raised when the specified camera cannot be found.""" - - pass - - -class CameraOpenError(Exception): - """Exception raised when the camera cannot be opened.""" - - pass +CameraOpenError = COE +@warnings.deprecated("Use the Camera peripheral instead of this one") class USBCamera: """Represents an input peripheral for capturing images from a USB camera device. This class uses OpenCV to interface with the camera and capture images. @@ -34,7 +25,7 @@ class USBCamera: def __init__( self, camera: int = 0, - resolution: tuple[int, int] = (None, None), + resolution: tuple[int, int] = None, fps: int = 10, compression: bool = False, letterbox: bool = False, @@ -48,27 +39,18 @@ def __init__( compression (bool): Whether to compress the captured images. If True, images are compressed to PNG format. letterbox (bool): Whether to apply letterboxing to the captured images. """ - video_devices = self._get_video_devices_by_index() - if camera in video_devices: - self.camera = int(video_devices[camera]) - else: - raise CameraOpenError( - f"Not available camera at index 0 {camera}. Verify the connected cameras and fi cameras are listed " - f"inside devices listed here: /dev/v4l/by-id" - ) - - self.resolution = resolution - self.fps = fps self.compression = compression - self.letterbox = letterbox - self._cap = None - self._cap_lock = threading.Lock() - self._last_capture_time_monotonic = time.monotonic() - if self.fps > 0: - self.desired_interval = 1.0 / self.fps - else: - # Capture as fast as possible - self.desired_interval = 0 + + pipe = None + if compression: + pipe = compressed_to_png() + if letterbox: + pipe = pipe | letterboxed() if pipe else letterboxed() + + self._wrapped_camera = Camera(source=camera, + resolution=resolution, + fps=fps, + adjustments=pipe) def capture(self) -> Image.Image | None: """Captures a frame from the camera, blocking to respect the configured FPS. @@ -76,7 +58,7 @@ def capture(self) -> Image.Image | None: Returns: PIL.Image.Image | None: The captured frame as a PIL Image, or None if no frame is available. """ - image_bytes = self._extract_frame() + image_bytes = self._wrapped_camera.capture() if image_bytes is None: return None try: @@ -95,157 +77,18 @@ def capture_bytes(self) -> bytes | None: Returns: bytes | None: The captured frame as a bytes array, or None if no frame is available. """ - frame = self._extract_frame() + frame = self._wrapped_camera.capture() if frame is None: return None return frame.tobytes() - def _extract_frame(self) -> cv2.typing.MatLike | None: - # Without locking, 'elapsed_time' could be a stale value but this scenario is unlikely to be noticeable in - # practice, also its effects would disappear in the next capture. This optimization prevents us from calling - # time.sleep while holding a lock. - current_time_monotonic = time.monotonic() - elapsed_time = current_time_monotonic - self._last_capture_time_monotonic - if elapsed_time < self.desired_interval: - sleep_duration = self.desired_interval - elapsed_time - time.sleep(sleep_duration) # Keep time.sleep out of the locked section! - - with self._cap_lock: - if self._cap is None: - return None - - ret, bgr_frame = self._cap.read() - if not ret: - raise CameraReadError(f"Failed to read from camera {self.camera}.") - self._last_capture_time_monotonic = time.monotonic() - if bgr_frame is None: - # No frame available, skip this iteration - return None - - try: - if self.letterbox: - bgr_frame = self._letterbox(bgr_frame) - if self.compression: - success, rgb_frame = cv2.imencode(".png", bgr_frame) - if success: - return rgb_frame - else: - return None - else: - return cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2RGB) - except cv2.error as e: - logger.exception(f"Error converting frame: {e}") - return None - - def _letterbox(self, frame: cv2.typing.MatLike) -> cv2.typing.MatLike: - """Applies letterboxing to the frame to make it square. - - Args: - frame (cv2.typing.MatLike): The input frame to be letterboxed (as cv2 supported format - numpy like). - - Returns: - cv2.typing.MatLike: The letterboxed frame (as cv2 supported format - numpy like). - """ - h, w = frame.shape[:2] - if w != h: - # Letterbox: add padding to make it square (yolo colors) - size = max(h, w) - return cv2.copyMakeBorder( - frame, - top=(size - h) // 2, - bottom=(size - h + 1) // 2, - left=(size - w) // 2, - right=(size - w + 1) // 2, - borderType=cv2.BORDER_CONSTANT, - value=(114, 114, 114), - ) - else: - return frame - - def _get_video_devices_by_index(self): - """Reads symbolic links in /dev/v4l/by-id/, resolves them, and returns a - dictionary mapping the numeric index to the system /dev/videoX device. - - Returns: - dict[int, str]: a dict where keys are ordinal integer indices (e.g., 0, 1) and values are the - /dev/videoX device names (e.g., "0", "1"). - """ - devices_by_index = {} - directory_path = "/dev/v4l/by-id/" - - # Check if the directory exists - if not os.path.exists(directory_path): - logger.error(f"Error: Directory '{directory_path}' not found.") - return devices_by_index - - try: - # List all entries in the directory - entries = os.listdir(directory_path) - - for entry in entries: - full_path = os.path.join(directory_path, entry) - - # Check if the entry is a symbolic link - if os.path.islink(full_path): - # Use a regular expression to find the numeric index at the end of the filename - match = re.search(r"index(\d+)$", entry) - if match: - index_str = match.group(1) - try: - index = int(index_str) - - # Resolve the symbolic link to its absolute path - resolved_path = os.path.realpath(full_path) - - # Get just the filename (e.g., "video0") from the resolved path - device_name = os.path.basename(resolved_path) - - # Remove the "video" prefix to get just the number - device_number = device_name.replace("video", "") - - # Add the index and device number to the dictionary - devices_by_index[index] = device_number - - except ValueError: - logger.warning(f"Warning: Could not convert index '{index_str}' to an integer for '{entry}'. Skipping.") - continue - except OSError as e: - logger.error(f"Error accessing directory '{directory_path}': {e}") - return devices_by_index - - return devices_by_index - def start(self): """Starts the camera capture.""" - with self._cap_lock: - if self._cap is not None: - return - - temp_cap = cv2.VideoCapture(self.camera) - if not temp_cap.isOpened(): - raise CameraOpenError(f"Failed to open camera {self.camera}.") - - self._cap = temp_cap # Assign only after successful initialization - self._last_capture_time_monotonic = time.monotonic() - - if self.resolution[0] is not None and self.resolution[1] is not None: - self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.resolution[0]) - self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.resolution[1]) - # Verify if setting resolution was successful - actual_width = self._cap.get(cv2.CAP_PROP_FRAME_WIDTH) - actual_height = self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT) - if actual_width != self.resolution[0] or actual_height != self.resolution[1]: - logger.warning( - f"Camera {self.camera} could not be set to {self.resolution[0]}x{self.resolution[1]}, " - f"actual resolution: {int(actual_width)}x{int(actual_height)}", - ) + self._wrapped_camera.start() def stop(self): """Stops the camera and releases its resources.""" - with self._cap_lock: - if self._cap is not None: - self._cap.release() - self._cap = None + self._wrapped_camera.stop() def produce(self): """Alias for capture method.""" From c645390946602ef35b98e811bf6622d03ec7bf53 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 12:23:35 +0100 Subject: [PATCH 12/86] doc: update docstrings --- src/arduino/app_peripherals/camera/base_camera.py | 13 +++++++------ src/arduino/app_peripherals/camera/camera.py | 2 +- src/arduino/app_peripherals/camera/ip_camera.py | 2 +- src/arduino/app_peripherals/camera/v4l_camera.py | 2 +- .../app_peripherals/camera/websocket_camera.py | 2 +- src/arduino/app_utils/image/__init__.py | 4 ++++ 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index e848818f..4865e962 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -24,19 +24,20 @@ class BaseCamera(ABC): """ def __init__(self, resolution: Optional[Tuple[int, int]] = (640, 480), fps: int = 10, - adjuster: Optional[Callable[[np.ndarray], np.ndarray]] = None, **kwargs): + adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, **kwargs): """ Initialize the camera base. Args: resolution (tuple, optional): Resolution as (width, height). None uses default resolution. fps (int): Frames per second for the camera. - adjuster (callable, optional): Function pipeline to adjust frames that takes a numpy array and returns a numpy array. Default: None + adjustments (callable, optional): Function or function pipeline to adjust frames that takes + a numpy array and returns a numpy array. Default: None **kwargs: Additional camera-specific parameters. """ self.resolution = resolution self.fps = fps - self.adjuster = adjuster + self.adjustments = adjustments self._is_started = False self._cap_lock = threading.Lock() self._last_capture_time = time.monotonic() @@ -100,11 +101,11 @@ def _extract_frame(self) -> Optional[np.ndarray]: self._last_capture_time = time.monotonic() - if self.adjuster is not None: + if self.adjustments is not None: try: - frame = self.adjuster(frame) + frame = self.adjustments(frame) except Exception as e: - raise CameraTransformError(f"Frame transformation failed ({self.adjuster}): {e}") + raise CameraTransformError(f"Frame transformation failed ({self.adjustments}): {e}") return frame diff --git a/src/arduino/app_peripherals/camera/camera.py b/src/arduino/app_peripherals/camera/camera.py index bcf4823b..13171d2d 100644 --- a/src/arduino/app_peripherals/camera/camera.py +++ b/src/arduino/app_peripherals/camera/camera.py @@ -32,7 +32,7 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: resolution (tuple, optional): Frame resolution as (width, height). Default: None (auto) fps (int, optional): Target frames per second. Default: 10 - adjuster (callable, optional): Function pipeline to adjust frames that takes a + adjustments (callable, optional): Function pipeline to adjust frames that takes a numpy array and returns a numpy array. Default: None V4L Camera Parameters: device_index (int, optional): V4L device index override diff --git a/src/arduino/app_peripherals/camera/ip_camera.py b/src/arduino/app_peripherals/camera/ip_camera.py index d5a3eefd..f08ea5e0 100644 --- a/src/arduino/app_peripherals/camera/ip_camera.py +++ b/src/arduino/app_peripherals/camera/ip_camera.py @@ -34,7 +34,7 @@ def __init__(self, url: str, username: Optional[str] = None, username: Optional authentication username password: Optional authentication password timeout: Connection timeout in seconds - **kwargs: Additional camera parameters + **kwargs: Additional camera parameters propagated to BaseCamera """ super().__init__(**kwargs) self.url = url diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py index 1d0b5580..a892e832 100644 --- a/src/arduino/app_peripherals/camera/v4l_camera.py +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -32,7 +32,7 @@ def __init__(self, camera: Union[str, int] = 0, **kwargs): camera: Camera identifier - can be: - int: Camera index (e.g., 0, 1) - str: Camera index as string or device path - **kwargs: Additional camera parameters + **kwargs: Additional camera parameters propagated to BaseCamera """ super().__init__(**kwargs) self.camera_id = self._resolve_camera_id(camera) diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 4a1f9fe6..1eb85936 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -42,7 +42,7 @@ def __init__(self, host: str = "0.0.0.0", port: int = 8080, timeout: int = 10, port: Port to bind the server to (default: 8080) timeout: Connection timeout in seconds (default: 10) frame_format: Expected frame format from clients ("base64", "json", "binary") (default: "base64") - **kwargs: Additional camera parameters + **kwargs: Additional camera parameters propagated to BaseCamera """ super().__init__(**kwargs) diff --git a/src/arduino/app_utils/image/__init__.py b/src/arduino/app_utils/image/__init__.py index 10564497..6c4cd4c5 100644 --- a/src/arduino/app_utils/image/__init__.py +++ b/src/arduino/app_utils/image/__init__.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + from .image import * from .image_editor import ImageEditor from .pipeable import PipeableFunction From 7e74fe320e3b0648c86f34ab1cdd9c8af3c28337 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 18:32:26 +0100 Subject: [PATCH 13/86] tidy-up --- .../app_peripherals/camera/examples/websocket_client_streamer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py b/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py index 6f28f72d..97ae7e51 100644 --- a/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py +++ b/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py @@ -4,7 +4,6 @@ import asyncio import websockets -import cv2 import base64 import json import logging From 1aa331dcbcf041abb0d158cbcd5060df17a3808f Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 18:33:21 +0100 Subject: [PATCH 14/86] refactor: clearer APIs and doc --- .../app_peripherals/camera/__init__.py | 6 + .../app_peripherals/camera/base_camera.py | 35 +++-- src/arduino/app_peripherals/camera/camera.py | 30 ++-- .../app_peripherals/camera/ip_camera.py | 46 +++--- .../camera/test_image_editor.py | 141 ------------------ .../app_peripherals/camera/v4l_camera.py | 63 ++++---- .../camera/websocket_camera.py | 30 ++-- 7 files changed, 131 insertions(+), 220 deletions(-) delete mode 100644 src/arduino/app_peripherals/camera/test_image_editor.py diff --git a/src/arduino/app_peripherals/camera/__init__.py b/src/arduino/app_peripherals/camera/__init__.py index 177f779b..be1c27a5 100644 --- a/src/arduino/app_peripherals/camera/__init__.py +++ b/src/arduino/app_peripherals/camera/__init__.py @@ -3,10 +3,16 @@ # SPDX-License-Identifier: MPL-2.0 from .camera import Camera +from .v4l_camera import V4LCamera +from .ip_camera import IPCamera +from .websocket_camera import WebSocketCamera from .errors import * __all__ = [ "Camera", + "V4LCamera", + "IPCamera", + "WebSocketCamera", "CameraError", "CameraReadError", "CameraOpenError", diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index 4865e962..57dd9984 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -23,29 +23,34 @@ class BaseCamera(ABC): providing a unified API regardless of the underlying camera protocol or type. """ - def __init__(self, resolution: Optional[Tuple[int, int]] = (640, 480), fps: int = 10, - adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, **kwargs): + def __init__( + self, + resolution: Optional[Tuple[int, int]] = (640, 480), + fps: int = 10, + adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, + ): """ Initialize the camera base. Args: resolution (tuple, optional): Resolution as (width, height). None uses default resolution. - fps (int): Frames per second for the camera. + fps (int): Frames per second to capture from the camera. adjustments (callable, optional): Function or function pipeline to adjust frames that takes a numpy array and returns a numpy array. Default: None - **kwargs: Additional camera-specific parameters. """ self.resolution = resolution self.fps = fps self.adjustments = adjustments + self.logger = logger # This will be overridden by subclasses if needed + + self._camera_lock = threading.Lock() self._is_started = False - self._cap_lock = threading.Lock() self._last_capture_time = time.monotonic() - self.desired_interval = 1.0 / fps if fps > 0 else 0 + self._desired_interval = 1.0 / fps if fps > 0 else 0 def start(self) -> None: """Start the camera capture.""" - with self._cap_lock: + with self._camera_lock: if self._is_started: return @@ -53,22 +58,22 @@ def start(self) -> None: self._open_camera() self._is_started = True self._last_capture_time = time.monotonic() - logger.info(f"Successfully started {self.__class__.__name__}") + self.logger.info(f"Successfully started {self.__class__.__name__}") except Exception as e: raise CameraOpenError(f"Failed to start camera: {e}") def stop(self) -> None: """Stop the camera and release resources.""" - with self._cap_lock: + with self._camera_lock: if not self._is_started: return try: self._close_camera() self._is_started = False - logger.info(f"Stopped {self.__class__.__name__}") + self.logger.info(f"Stopped {self.__class__.__name__}") except Exception as e: - logger.warning(f"Error stopping camera: {e}") + self.logger.warning(f"Error stopping camera: {e}") def capture(self) -> Optional[np.ndarray]: """ @@ -85,13 +90,13 @@ def capture(self) -> Optional[np.ndarray]: def _extract_frame(self) -> Optional[np.ndarray]: """Extract a frame with FPS throttling and post-processing.""" # FPS throttling - if self.desired_interval > 0: + if self._desired_interval > 0: current_time = time.monotonic() elapsed = current_time - self._last_capture_time - if elapsed < self.desired_interval: - time.sleep(self.desired_interval - elapsed) + if elapsed < self._desired_interval: + time.sleep(self._desired_interval - elapsed) - with self._cap_lock: + with self._camera_lock: if not self._is_started: return None diff --git a/src/arduino/app_peripherals/camera/camera.py b/src/arduino/app_peripherals/camera/camera.py index 13171d2d..eda32885 100644 --- a/src/arduino/app_peripherals/camera/camera.py +++ b/src/arduino/app_peripherals/camera/camera.py @@ -15,6 +15,14 @@ class Camera: This class serves as both a factory and a wrapper, automatically creating the appropriate camera implementation based on the provided configuration. + + Supports: + - V4L Cameras (local cameras connected to the system), the default + - IP Cameras (network-based cameras via RTSP, HLS) + - WebSocket Cameras (input streams via WebSocket client) + + Note: constructor arguments (except source) must be provided in keyword + format to forward them correctly to the specific camera implementations. """ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: @@ -23,22 +31,20 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: Args: source (Union[str, int]): Camera source identifier. Supports: - int: V4L camera index (e.g., 0, 1) - - str: Camera index as string (e.g., "0", "1") for V4L - - str: Device path (e.g., "/dev/video0") for V4L + - str: V4L camera index (e.g., "0", "1") or device path (e.g., "/dev/video0") - str: URL for IP cameras (e.g., "rtsp://...", "http://...") - - str: WebSocket URL (e.g., "ws://0.0.0.0:8080") + - str: WebSocket URL for input streams (e.g., "ws://0.0.0.0:8080") **kwargs: Camera-specific configuration parameters grouped by type: Common Parameters: resolution (tuple, optional): Frame resolution as (width, height). - Default: None (auto) + Default: (640, 480) fps (int, optional): Target frames per second. Default: 10 adjustments (callable, optional): Function pipeline to adjust frames that takes a numpy array and returns a numpy array. Default: None V4L Camera Parameters: - device_index (int, optional): V4L device index override - capture_format (str, optional): Video capture format (e.g., 'MJPG', 'YUYV') - buffer_size (int, optional): Number of frames to buffer + device (int, optional): V4L device index override. Default: 0. IP Camera Parameters: + url (str): Camera stream URL username (str, optional): Authentication username password (str, optional): Authentication password timeout (float, optional): Connection timeout in seconds. Default: 10.0 @@ -54,9 +60,10 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: Raises: CameraConfigError: If source type is not supported or parameters are invalid + CameraOpenError: If the camera cannot be opened Examples: - V4L/USB Camera: + V4L Camera: ```python camera = Camera(0, resolution=(640, 480), fps=30) @@ -67,17 +74,16 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: ```python camera = Camera("rtsp://192.168.1.100:554/stream", username="admin", password="secret", timeout=15.0) - camera = Camera("http://192.168.1.100:8080/video", retry_attempts=5) + camera = Camera("http://192.168.1.100:8080/video.mp4") ``` WebSocket Camera: ```python - camera = Camera("ws://0.0.0.0:8080", frame_format="json", max_queue_size=20) - camera = Camera("ws://192.168.1.100:8080", ping_interval=30) + camera = Camera("ws://0.0.0.0:8080", frame_format="json") + camera = Camera("ws://192.168.1.100:8080", timeout=5) ``` """ - # Dynamic imports to avoid circular dependencies if isinstance(source, int) or (isinstance(source, str) and source.isdigit()): # V4L Camera from .v4l_camera import V4LCamera diff --git a/src/arduino/app_peripherals/camera/ip_camera.py b/src/arduino/app_peripherals/camera/ip_camera.py index f08ea5e0..9b494bac 100644 --- a/src/arduino/app_peripherals/camera/ip_camera.py +++ b/src/arduino/app_peripherals/camera/ip_camera.py @@ -5,13 +5,13 @@ import cv2 import numpy as np import requests -from typing import Optional +from typing import Callable, Optional, Tuple from urllib.parse import urlparse from arduino.app_utils import Logger from .camera import BaseCamera -from .errors import CameraOpenError +from .errors import CameraConfigError, CameraOpenError logger = Logger("IPCamera") @@ -24,24 +24,38 @@ class IPCamera(BaseCamera): Can handle authentication and various streaming protocols. """ - def __init__(self, url: str, username: Optional[str] = None, - password: Optional[str] = None, timeout: int = 10, **kwargs): + def __init__( + self, + url: str, + username: Optional[str] = None, + password: Optional[str] = None, + timeout: int = 10, + resolution: Optional[Tuple[int, int]] = (640, 480), + fps: int = 10, + adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, + ): """ Initialize IP camera. Args: - url: Camera stream URL (rtsp://, http://, https://) + url: Camera stream URL (i.e. rtsp://..., http://..., https://...) username: Optional authentication username password: Optional authentication password timeout: Connection timeout in seconds - **kwargs: Additional camera parameters propagated to BaseCamera + resolution (tuple, optional): Resolution as (width, height). None uses default resolution. + fps (int): Frames per second to capture from the camera. + adjustments (callable, optional): Function or function pipeline to adjust frames that takes + a numpy array and returns a numpy array. Default: None """ - super().__init__(**kwargs) + super().__init__(resolution, fps, adjustments) self.url = url self.username = username self.password = password self.timeout = timeout + self.logger = logger + self._cap = None + self._validate_url() def _validate_url(self) -> None: @@ -49,19 +63,19 @@ def _validate_url(self) -> None: try: parsed = urlparse(self.url) if parsed.scheme not in ['http', 'https', 'rtsp']: - raise CameraOpenError(f"Unsupported URL scheme: {parsed.scheme}") + raise CameraConfigError(f"Unsupported URL scheme: {parsed.scheme}") except Exception as e: - raise CameraOpenError(f"Invalid URL format: {e}") + raise CameraConfigError(f"Invalid URL format: {e}") def _open_camera(self) -> None: """Open the IP camera connection.""" - auth_url = self._build_authenticated_url() + url = self._build_url() # Test connectivity first for HTTP streams if self.url.startswith(('http://', 'https://')): self._test_http_connectivity() - self._cap = cv2.VideoCapture(auth_url) + self._cap = cv2.VideoCapture(url) self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to get latest frames if not self._cap.isOpened(): raise CameraOpenError(f"Failed to open IP camera: {self.url}") @@ -75,17 +89,15 @@ def _open_camera(self) -> None: logger.info(f"Opened IP camera: {self.url}") - def _build_authenticated_url(self) -> str: + def _build_url(self) -> str: """Build URL with authentication if credentials provided.""" + # If no username or password provided as parameters, return original URL if not self.username or not self.password: return self.url parsed = urlparse(self.url) - if parsed.username and parsed.password: - # URL already has credentials - return self.url - - # Add credentials to URL + + # Override any URL credentials if credentials are provided auth_netloc = f"{self.username}:{self.password}@{parsed.hostname}" if parsed.port: auth_netloc += f":{parsed.port}" diff --git a/src/arduino/app_peripherals/camera/test_image_editor.py b/src/arduino/app_peripherals/camera/test_image_editor.py deleted file mode 100644 index 299accb9..00000000 --- a/src/arduino/app_peripherals/camera/test_image_editor.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify ImageEditor integration with Camera classes. -""" - -import numpy as np -from arduino.app_peripherals.camera import ImageEditor -from arduino.app_peripherals.camera import image_editor as ie -from arduino.app_peripherals.camera.functional_utils import compose, curry, identity - -def test_image_editor(): - """Test ImageEditor functionality.""" - # Create a test frame (100x150 RGB image) - test_frame = np.random.randint(0, 255, (100, 150, 3), dtype=np.uint8) - - print(f"Original frame shape: {test_frame.shape}") - - # Test letterboxing to make square - letterboxed = ImageEditor.letterbox(test_frame) - print(f"Letterboxed frame shape: {letterboxed.shape}") - assert letterboxed.shape[0] == letterboxed.shape[1], "Letterboxed frame should be square" - - # Test letterboxing to specific size - target_letterboxed = ImageEditor.letterbox(test_frame, target_size=(200, 200)) - print(f"Target letterboxed frame shape: {target_letterboxed.shape}") - assert target_letterboxed.shape[:2] == (200, 200), "Should match target size" - - # Test PNG compression - png_bytes = ImageEditor.compress_to_png(test_frame) - print(f"PNG compressed size: {len(png_bytes) if png_bytes else 0} bytes") - assert png_bytes is not None, "PNG compression should succeed" - - # Test JPEG compression - jpeg_bytes = ImageEditor.compress_to_jpeg(test_frame) - print(f"JPEG compressed size: {len(jpeg_bytes) if jpeg_bytes else 0} bytes") - assert jpeg_bytes is not None, "JPEG compression should succeed" - - # Test PIL conversion - pil_image = ImageEditor.numpy_to_pil(test_frame) - print(f"PIL image size: {pil_image.size}, mode: {pil_image.mode}") - assert pil_image.mode == 'RGB', "PIL image should be RGB" - - # Test numpy conversion back - numpy_frame = ImageEditor.pil_to_numpy(pil_image) - print(f"Converted back to numpy shape: {numpy_frame.shape}") - assert numpy_frame.shape == test_frame.shape, "Round-trip conversion should preserve shape" - - # Test frame info - info = ImageEditor.get_frame_info(test_frame) - print(f"Frame info: {info}") - assert info['width'] == 150 and info['height'] == 100, "Frame info should be correct" - - print("✅ All ImageEditor tests passed!") - -def test_transformers(): - """Test transformer functionality.""" - print("\n=== Testing Transformers ===") - - # Create test frame - test_frame = np.random.randint(0, 255, (100, 150, 3), dtype=np.uint8) - print(f"Original frame shape: {test_frame.shape}") - - # Test identity transformer - identity_result = identity(test_frame) - assert np.array_equal(identity_result, test_frame), "Identity should return unchanged frame" - print("✅ Identity transformer works") - - # Test module-level API - letterbox_transformer = ie.letterbox(target_size=(200, 200)) - letterboxed = letterbox_transformer(test_frame) - print(f"Letterbox transformer result: {letterboxed.shape}") - assert letterboxed.shape[:2] == (200, 200), "Transformer should produce correct size" - print("✅ Letterbox transformer works") - - # Test resize transformer - resize_transformer = ie.resize(target_size=(320, 240), maintain_aspect=False) - resized = resize_transformer(test_frame) - print(f"Resize transformer result: {resized.shape}") - assert resized.shape[:2] == (240, 320), "Resize should produce correct dimensions" - print("✅ Resize transformer works") - - # Test filter transformer - filter_transformer = ie.filters(brightness=10, contrast=1.2, saturation=1.1) - filtered = filter_transformer(test_frame) - print(f"Filter transformer result: {filtered.shape}") - assert filtered.shape == test_frame.shape, "Filter should preserve shape" - print("✅ Filter transformer works") - - # Test pipeline composition - pipeline_transformer = ie.pipeline( - ie.letterbox(target_size=(200, 200)), - ie.filters(brightness=5, contrast=1.1) - ) - pipeline_result = pipeline_transformer(test_frame) - print(f"Pipeline transformer result: {pipeline_result.shape}") - assert pipeline_result.shape[:2] == (200, 200), "Pipeline should work correctly" - print("✅ Pipeline transformer works") - - # Test standard processing - standard_transformer = ie.standard_processing(target_size=(256, 256)) - standard_result = standard_transformer(test_frame) - print(f"Standard processing result: {standard_result.shape}") - assert standard_result.shape[:2] == (256, 256), "Standard processing should work" - print("✅ Standard processing works") - - # Test webcam processing - webcam_transformer = ie.webcam_processing() - webcam_result = webcam_transformer(test_frame) - print(f"Webcam processing result: {webcam_result.shape}") - assert webcam_result.shape[:2] == (640, 640), "Webcam processing should work" - print("✅ Webcam processing works") - - # Test mobile processing - mobile_transformer = ie.mobile_processing() - mobile_result = mobile_transformer(test_frame) - print(f"Mobile processing result: {mobile_result.shape}") - assert mobile_result.shape[:2] == (480, 480), "Mobile processing should work" - print("✅ Mobile processing works") - - # Test with curry from functional_utils - manual_letterbox = curry(ImageEditor.letterbox, target_size=(300, 300), color=(128, 128, 128)) - manual_result = manual_letterbox(test_frame) - print(f"Manual curry result: {manual_result.shape}") - assert manual_result.shape[:2] == (300, 300), "Manual curry should work" - print("✅ Manual curry works") - - # Test compose from functional_utils - composed_transformer = compose( - ie.letterbox(target_size=(180, 180)), - ie.filters(brightness=8) - ) - composed_result = composed_transformer(test_frame) - print(f"Compose result: {composed_result.shape}") - assert composed_result.shape[:2] == (180, 180), "Compose should work" - print("✅ Functional compose works") - - print("✅ All transformer tests passed!") - -if __name__ == "__main__": - test_image_editor() - test_transformers() \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py index a892e832..b95e8b03 100644 --- a/src/arduino/app_peripherals/camera/v4l_camera.py +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -6,7 +6,7 @@ import re import cv2 import numpy as np -from typing import Optional, Union, Dict +from typing import Callable, Optional, Tuple, Union, Dict from arduino.app_utils import Logger @@ -24,26 +24,37 @@ class V4LCamera(BaseCamera): It supports both device indices and device paths. """ - def __init__(self, camera: Union[str, int] = 0, **kwargs): + def __init__( + self, + device: Union[str, int] = 0, + resolution: Optional[Tuple[int, int]] = (640, 480), + fps: int = 10, + adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, + ): """ Initialize V4L camera. Args: - camera: Camera identifier - can be: - - int: Camera index (e.g., 0, 1) + device: Camera identifier - can be: + - int: Camera index (e.g., 0, 1) - str: Camera index as string or device path - **kwargs: Additional camera parameters propagated to BaseCamera + resolution (tuple, optional): Resolution as (width, height). None uses default resolution. + fps (int, optional): Frames per second to capture from the camera. Default: 10. + adjustments (callable, optional): Function or function pipeline to adjust frames that takes + a numpy array and returns a numpy array. Default: None """ - super().__init__(**kwargs) - self.camera_id = self._resolve_camera_id(camera) + super().__init__(resolution, fps, adjustments) + self.device_index = self._resolve_camera_id(device) + self.logger = logger + self._cap = None - def _resolve_camera_id(self, camera: Union[str, int]) -> int: + def _resolve_camera_id(self, device: Union[str, int]) -> int: """ Resolve camera identifier to a numeric device ID. Args: - camera: Camera identifier + device: Camera identifier Returns: Numeric camera device ID @@ -51,26 +62,26 @@ def _resolve_camera_id(self, camera: Union[str, int]) -> int: Raises: CameraOpenError: If camera cannot be resolved """ - if isinstance(camera, int): - return camera + if isinstance(device, int): + return device - if isinstance(camera, str): + if isinstance(device, str): # If it's a numeric string, convert directly - if camera.isdigit(): - device_id = int(camera) + if device.isdigit(): + device_idx = int(device) # Validate using device index mapping video_devices = self._get_video_devices_by_index() - if device_id in video_devices: - return int(video_devices[device_id]) + if device_idx in video_devices: + return int(video_devices[device_idx]) else: # Fallback to direct device ID if mapping not available - return device_id + return device_idx # If it's a device path like "/dev/video0" - if camera.startswith('/dev/video'): - return int(camera.replace('/dev/video', '')) + if device.startswith('/dev/video'): + return int(device.replace('/dev/video', '')) - raise CameraOpenError(f"Cannot resolve camera identifier: {camera}") + raise CameraOpenError(f"Cannot resolve camera identifier: {device}") def _get_video_devices_by_index(self) -> Dict[int, str]: """ @@ -112,9 +123,9 @@ def _get_video_devices_by_index(self) -> Dict[int, str]: def _open_camera(self) -> None: """Open the V4L camera connection.""" - self._cap = cv2.VideoCapture(self.camera_id) + self._cap = cv2.VideoCapture(self.device_index) if not self._cap.isOpened(): - raise CameraOpenError(f"Failed to open V4L camera {self.camera_id}") + raise CameraOpenError(f"Failed to open V4L camera {self.device_index}") # Set resolution if specified if self.resolution and self.resolution[0] and self.resolution[1]: @@ -126,7 +137,7 @@ def _open_camera(self) -> None: actual_height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) if actual_width != self.resolution[0] or actual_height != self.resolution[1]: logger.warning( - f"Camera {self.camera_id} resolution set to {actual_width}x{actual_height} " + f"Camera {self.device_index} resolution set to {actual_width}x{actual_height} " f"instead of requested {self.resolution[0]}x{self.resolution[1]}" ) self.resolution = (actual_width, actual_height) @@ -137,11 +148,11 @@ def _open_camera(self) -> None: actual_fps = int(self._cap.get(cv2.CAP_PROP_FPS)) if actual_fps != self.fps: logger.warning( - f"Camera {self.camera_id} FPS set to {actual_fps} instead of requested {self.fps}" + f"Camera {self.device_index} FPS set to {actual_fps} instead of requested {self.fps}" ) self.fps = actual_fps - logger.info(f"Opened V4L camera {self.camera_id}") + logger.info(f"Opened V4L camera with index {self.device_index}") def _close_camera(self) -> None: """Close the V4L camera connection.""" @@ -156,6 +167,6 @@ def _read_frame(self) -> Optional[np.ndarray]: ret, frame = self._cap.read() if not ret or frame is None: - raise CameraReadError(f"Failed to read from V4L camera {self.camera_id}") + raise CameraReadError(f"Failed to read from V4L camera {self.device_index}") return frame diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 1eb85936..8eb57760 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -7,7 +7,7 @@ import threading import queue import time -from typing import Optional, Union +from typing import Callable, Optional, Tuple, Union import numpy as np import cv2 import websockets @@ -32,24 +32,36 @@ class WebSocketCamera(BaseCamera): - Binary image data """ - def __init__(self, host: str = "0.0.0.0", port: int = 8080, timeout: int = 10, - frame_format: str = "base64", **kwargs): + def __init__( + self, + host: str = "0.0.0.0", + port: int = 8080, + timeout: int = 10, + frame_format: str = "base64", + resolution: Optional[Tuple[int, int]] = (640, 480), + fps: int = 10, + adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, + ): """ Initialize WebSocket camera server. Args: - host: Host address to bind the server to (default: "0.0.0.0") - port: Port to bind the server to (default: 8080) - timeout: Connection timeout in seconds (default: 10) - frame_format: Expected frame format from clients ("base64", "json", "binary") (default: "base64") - **kwargs: Additional camera parameters propagated to BaseCamera + host (str): Host address to bind the server to (default: "0.0.0.0") + port (int): Port to bind the server to (default: 8080) + timeout (int): Connection timeout in seconds (default: 10) + frame_format (str): Expected frame format from clients ("base64", "json", "binary") (default: "base64") + resolution (tuple, optional): Resolution as (width, height). None uses default resolution. + fps (int): Frames per second to capture from the camera. + adjustments (callable, optional): Function or function pipeline to adjust frames that takes + a numpy array and returns a numpy array. Default: None """ - super().__init__(**kwargs) + super().__init__(resolution, fps, adjustments) self.host = host self.port = port self.timeout = timeout self.frame_format = frame_format + self.logger = logger self._frame_queue = queue.Queue(1) self._server = None From 954d1e3483aa21e1f879acd14d9af3b37f775b2d Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 18:33:54 +0100 Subject: [PATCH 15/86] add examples --- .../camera/examples/1_initialize.py | 17 ++++++++++ .../camera/examples/2_capture_image.py | 14 ++++++++ .../camera/examples/3_capture_video.py | 21 ++++++++++++ .../camera/examples/4_capture_hls.py | 23 +++++++++++++ .../camera/examples/5_capture_rtsp.py | 23 +++++++++++++ .../camera/examples/6_capture_websocket.py | 20 +++++++++++ .../app_peripherals/camera/examples/hls.py | 22 ------------ .../app_peripherals/camera/examples/rtsp.py | 34 ------------------- 8 files changed, 118 insertions(+), 56 deletions(-) create mode 100644 src/arduino/app_peripherals/camera/examples/1_initialize.py create mode 100644 src/arduino/app_peripherals/camera/examples/2_capture_image.py create mode 100644 src/arduino/app_peripherals/camera/examples/3_capture_video.py create mode 100644 src/arduino/app_peripherals/camera/examples/4_capture_hls.py create mode 100644 src/arduino/app_peripherals/camera/examples/5_capture_rtsp.py create mode 100644 src/arduino/app_peripherals/camera/examples/6_capture_websocket.py delete mode 100644 src/arduino/app_peripherals/camera/examples/hls.py delete mode 100644 src/arduino/app_peripherals/camera/examples/rtsp.py diff --git a/src/arduino/app_peripherals/camera/examples/1_initialize.py b/src/arduino/app_peripherals/camera/examples/1_initialize.py new file mode 100644 index 00000000..24a2ae9b --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/1_initialize.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +# EXAMPLE_NAME = "Initialize camera input" +# EXAMPLE_REQUIRES = "Requires a connected camera" +from arduino.app_peripherals.camera import Camera, V4LCamera + + +default = Camera() # Uses default camera (V4L) + +# The following two are equivalent +camera = Camera(2, resolution=(640, 480), fps=15) # Infers camera type +v4l = V4LCamera(2, (640, 480), 15) # Explicitly requests V4L camera + +# Note: constructor arguments (except source) must be provided in keyword +# format to forward them correctly to the specific camera implementations. \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/2_capture_image.py b/src/arduino/app_peripherals/camera/examples/2_capture_image.py new file mode 100644 index 00000000..439b4636 --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/2_capture_image.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +# EXAMPLE_NAME = "Capture an image" +# EXAMPLE_REQUIRES = "Requires a connected camera" +import numpy as np +from arduino.app_peripherals.camera import Camera + + +camera = Camera() +camera.start() +image: np.ndarray = camera.capture() +camera.stop() \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/3_capture_video.py b/src/arduino/app_peripherals/camera/examples/3_capture_video.py new file mode 100644 index 00000000..75fdbd01 --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/3_capture_video.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +# EXAMPLE_NAME = "Capture a video" +# EXAMPLE_REQUIRES = "Requires a connected camera" +import time +import numpy as np +from arduino.app_peripherals.camera import Camera + + +# Capture a video for 5 seconds at 15 FPS +camera = Camera(fps=15) +camera.start() + +start_time = time.time() +while time.time() - start_time < 5: + image: np.ndarray = camera.capture() + # You can process the image here if needed, e.g save it + +camera.stop() \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/4_capture_hls.py b/src/arduino/app_peripherals/camera/examples/4_capture_hls.py new file mode 100644 index 00000000..0371e94b --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/4_capture_hls.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +# EXAMPLE_NAME = "Capture an HLS (HTTP Live Stream) video" +import time +import numpy as np +from arduino.app_peripherals.camera import Camera + + +# Capture a freely available HLS playlist for testing +# Note: Public streams can be unreliable and may go offline without notice. +url = 'https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8' + +camera = Camera(url) +camera.start() + +start_time = time.time() +while time.time() - start_time < 5: + image: np.ndarray = camera.capture() + # You can process the image here if needed, e.g save it + +camera.stop() \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/5_capture_rtsp.py b/src/arduino/app_peripherals/camera/examples/5_capture_rtsp.py new file mode 100644 index 00000000..a5f15754 --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/5_capture_rtsp.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +# EXAMPLE_NAME = "Capture an RTSP (Real-Time Streaming Protocol) video" +import time +import numpy as np +from arduino.app_peripherals.camera import Camera + + +# Capture a freely available RTSP stream for testing +# Note: Public streams can be unreliable and may go offline without notice. +url = "rtsp://170.93.143.139/rtplive/470011e600ef003a004ee33696235daa" + +camera = Camera(url) +camera.start() + +start_time = time.time() +while time.time() - start_time < 5: + image: np.ndarray = camera.capture() + # You can process the image here if needed, e.g save it + +camera.stop() \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py b/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py new file mode 100644 index 00000000..5028f4f1 --- /dev/null +++ b/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +# EXAMPLE_NAME = "Capture an input WebSocket video" +import time +import numpy as np +from arduino.app_peripherals.camera import Camera + + +# Expose a WebSocket camera stream for clients to connect to and consume it +camera = Camera("ws://0.0.0.0:8080", timeout=5) +camera.start() + +start_time = time.time() +while time.time() - start_time < 5: + image: np.ndarray = camera.capture() + # You can process the image here if needed, e.g save it + +camera.stop() \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/hls.py b/src/arduino/app_peripherals/camera/examples/hls.py deleted file mode 100644 index d944fadd..00000000 --- a/src/arduino/app_peripherals/camera/examples/hls.py +++ /dev/null @@ -1,22 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA -# -# SPDX-License-Identifier: MPL-2.0 - -import cv2 - -# URL to an HLS playlist -hls_url = 'https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8' - -cap = cv2.VideoCapture(hls_url) - -if cap.isOpened(): - print("Successfully opened HLS stream.") - ret, frame = cap.read() - if ret: - print("Successfully read a frame from the stream.") - # You can now process the 'frame' - else: - print("Failed to read a frame.") - cap.release() -else: - print("Error: Could not open HLS stream.") \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/rtsp.py b/src/arduino/app_peripherals/camera/examples/rtsp.py deleted file mode 100644 index 81de26e1..00000000 --- a/src/arduino/app_peripherals/camera/examples/rtsp.py +++ /dev/null @@ -1,34 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA -# -# SPDX-License-Identifier: MPL-2.0 - -import cv2 - -# A freely available RTSP stream for testing. -# Note: Public streams can be unreliable and may go offline without notice. -rtsp_url = "rtsp://170.93.143.139/rtplive/470011e600ef003a004ee33696235daa" - -print(f"Attempting to connect to RTSP stream: {rtsp_url}") - -# Create a VideoCapture object, letting OpenCV automatically select the backend -cap = cv2.VideoCapture(rtsp_url) - -if not cap.isOpened(): - print("Error: Could not open RTSP stream.") -else: - print("Successfully connected to RTSP stream.") - - # Read one frame from the stream - ret, frame = cap.read() - - if ret: - print(f"Successfully read a frame. Frame dimensions: {frame.shape}") - # You could now do processing on the frame, for example: - # height, width, channels = frame.shape - # print(f"Frame details: Width={width}, Height={height}, Channels={channels}") - else: - print("Error: Failed to read a frame from the stream, it may have ended or there was a network issue.") - - # Release the capture object - cap.release() - print("Stream capture released.") \ No newline at end of file From fc1f26b50094245a2ce5e9bccbb8cd9ffb8a64c8 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 18:34:20 +0100 Subject: [PATCH 16/86] refactor: directly use V4LCamera --- src/arduino/app_peripherals/usb_camera/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/arduino/app_peripherals/usb_camera/__init__.py b/src/arduino/app_peripherals/usb_camera/__init__.py index fe70e118..12373bab 100644 --- a/src/arduino/app_peripherals/usb_camera/__init__.py +++ b/src/arduino/app_peripherals/usb_camera/__init__.py @@ -6,6 +6,7 @@ import warnings from PIL import Image from arduino.app_peripherals.camera import Camera, CameraReadError as CRE, CameraOpenError as COE +from arduino.app_peripherals.camera.v4l_camera import V4LCamera from arduino.app_utils.image.image_editor import compressed_to_png, letterboxed from arduino.app_utils import Logger @@ -47,10 +48,7 @@ def __init__( if letterbox: pipe = pipe | letterboxed() if pipe else letterboxed() - self._wrapped_camera = Camera(source=camera, - resolution=resolution, - fps=fps, - adjustments=pipe) + self._wrapped_camera = V4LCamera(camera, resolution, fps, pipe) def capture(self) -> Image.Image | None: """Captures a frame from the camera, blocking to respect the configured FPS. From 16e9ae765cb036e1b0721644cb816fe5a8d6f483 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 18:36:51 +0100 Subject: [PATCH 17/86] remove test examples --- .../camera/examples/websocket_camera_proxy.py | 246 --------------- .../examples/websocket_client_streamer.py | 284 ------------------ 2 files changed, 530 deletions(-) delete mode 100644 src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py delete mode 100644 src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py diff --git a/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py b/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py deleted file mode 100644 index 33a11095..00000000 --- a/src/arduino/app_peripherals/camera/examples/websocket_camera_proxy.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env python3 - -# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA -# -# SPDX-License-Identifier: MPL-2.0 - -""" -WebSocket Camera Proxy - -This example demonstrates how to use a WebSocketCamera as a proxy/relay. -It receives frames from clients on one WebSocket server (127.0.0.1:8080) and -forwards them as raw JPEG binary data to a TCP server (127.0.0.1:5000) at 30fps. - -Usage: - python websocket_camera_proxy.py [--input-port PORT] [--output-host HOST] [--output-port PORT] -""" - -import asyncio -import logging -import argparse -import signal -import sys -import time - -import os - -import numpy as np - -from arduino.app_peripherals.camera import Camera -from arduino.app_utils.image.image_editor import ImageEditor - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')) - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# Global variables for graceful shutdown -running = False -camera = None -output_writer = None -output_reader = None - - -def signal_handler(signum, frame): - """Handle interrupt signals.""" - global running - logger.info("Received signal, initiating shutdown...") - running = False - - -async def connect_output_tcp(output_host: str, output_port: int): - """Connect to the output TCP server.""" - global output_writer, output_reader - - logger.info(f"Connecting to output server at {output_host}:{output_port}...") - - try: - output_reader, output_writer = await asyncio.open_connection( - output_host, output_port - ) - logger.info("Connected successfully to output server") - - except Exception as e: - raise Exception(f"Failed to connect to output server: {e}") - - -async def forward_frame(frame: np.ndarray): - """Forward a frame to the output TCP server as raw JPEG.""" - global output_writer - - if not output_writer or output_writer.is_closing(): - return - - try: - output_writer.write(frame.tobytes()) - await output_writer.drain() - - except ConnectionResetError: - logger.warning("Output connection reset while forwarding frame") - output_writer = None - except Exception as e: - logger.error(f"Error forwarding frame: {e}") - - -async def camera_loop(fps: int): - """Main camera capture and forwarding loop.""" - global running, camera - - frame_interval = 1.0 / fps - last_frame_time = time.time() - - try: - camera.start() - except Exception as e: - logger.error(f"Failed to start WebSocketCamera: {e}") - return - - while running: - try: - # Read frame from WebSocketCamera - frame = camera.capture() - # frame = ImageEditor.compress_to_jpeg(frame, 80.1) - if frame is None: - # No frame available, small delay to avoid busy waiting - await asyncio.sleep(0.01) - continue - - # Rate limiting - current_time = time.time() - time_since_last = current_time - last_frame_time - if time_since_last < frame_interval: - await asyncio.sleep(frame_interval - time_since_last) - - last_frame_time = time.time() - - if output_writer is None or output_writer.is_closing(): - # Output connection is not available, give room to the other tasks - await asyncio.sleep(0.01) - else: - # Forward frame if output connection is available - await forward_frame(frame) - - except Exception as e: - logger.error(f"Error in camera loop: {e}") - await asyncio.sleep(1.0) - - -async def maintain_output_connection(output_host: str, output_port: int, reconnect_delay: float): - """Maintain TCP connection to output server with automatic reconnection.""" - global running, output_writer, output_reader - - while running: - try: - await connect_output_tcp(output_host, output_port) - - # Keep monitoring - while running and output_writer and not output_writer.is_closing(): - await asyncio.sleep(1.0) - - logger.info("Lost connection to output server") - - except Exception as e: - logger.error(e) - finally: - # Clean up connection - if output_writer: - try: - output_writer.close() - await output_writer.wait_closed() - except: - pass - output_writer = None - output_reader = None - - # Wait before reconnecting - if running: - logger.info(f"Reconnecting to output server in {reconnect_delay} seconds...") - await asyncio.sleep(reconnect_delay) - - -async def main(): - """Main function.""" - global running, camera - - parser = argparse.ArgumentParser(description="WebSocket Camera Proxy") - parser.add_argument("--input-host", default="localhost", - help="WebSocketCamera input host (default: localhost)") - parser.add_argument("--input-port", type=int, default=8080, - help="WebSocketCamera input port (default: 8080)") - parser.add_argument("--output-host", default="localhost", - help="Output TCP server host (default: localhost)") - parser.add_argument("--output-port", type=int, default=5000, - help="Output TCP server port (default: 5000)") - parser.add_argument("--fps", type=int, default=30, - help="Target FPS for forwarding (default: 30)") - parser.add_argument("--quality", type=int, default=80, - help="JPEG quality 1-100 (default: 80)") - parser.add_argument("--verbose", "-v", action="store_true", - help="Enable verbose logging") - - args = parser.parse_args() - - if args.verbose: - logging.getLogger().setLevel(logging.DEBUG) - - # Setup signal handlers - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - # Setup global variables - running = True - reconnect_delay = 2.0 - - logger.info(f"Starting WebSocket camera proxy") - logger.info(f"Input: WebSocketCamera on port {args.input_port}") - logger.info(f"Output: TCP server at {args.output_host}:{args.output_port}") - logger.info(f"Target FPS: {args.fps}") - - from arduino.app_utils.image.image_editor import compressed_to_jpeg - camera = Camera(f"ws://{args.input_host}:{args.input_port}", adjuster=compressed_to_jpeg(80)) - # camera = Camera(f"ws://{args.input_host}:{args.input_port}") - - try: - # Start camera input and output connection tasks - camera_task = asyncio.create_task(camera_loop(args.fps)) - connection_task = asyncio.create_task(maintain_output_connection(args.output_host, args.output_port, reconnect_delay)) - - # Run both tasks concurrently - await asyncio.gather(camera_task, connection_task) - - except KeyboardInterrupt: - logger.info("Received interrupt signal, shutting down...") - finally: - running = False - - # Close output TCP connection - if output_writer: - try: - output_writer.close() - await output_writer.wait_closed() - except Exception as e: - logger.warning(f"Error closing TCP connection: {e}") - - # Close camera - if camera: - try: - camera.stop() - logger.info("Camera closed") - except Exception as e: - logger.warning(f"Error closing camera: {e}") - - logger.info("Camera proxy stopped") - - -if __name__ == "__main__": - try: - asyncio.run(main()) - except KeyboardInterrupt: - logger.info("Interrupted by user") - except Exception as e: - logger.error(f"Unexpected error: {e}") - sys.exit(1) \ No newline at end of file diff --git a/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py b/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py deleted file mode 100644 index 97ae7e51..00000000 --- a/src/arduino/app_peripherals/camera/examples/websocket_client_streamer.py +++ /dev/null @@ -1,284 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA -# -# SPDX-License-Identifier: MPL-2.0 - -import asyncio -import websockets -import base64 -import json -import logging -import argparse -import signal -import sys -import time - -from arduino.app_peripherals.camera import Camera -from arduino.app_utils.image.image_editor import ImageEditor - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -FRAME_WIDTH = 640 -FRAME_HEIGHT = 480 - - -class WebCamStreamer: - """ - WebSocket client that streams local webcam feed to a WebSocketCamera server. - """ - - def __init__(self, host: str = "localhost", port: int = 8080, - camera_id: int = 0, fps: int = 30, quality: int = 80): - """ - Initialize the webcam streamer. - - Args: - host: WebSocket server host - port: WebSocket server port - camera_id: Local camera device ID (usually 0 for default camera) - fps: Target frames per second for streaming - quality: JPEG quality (1-100, higher = better quality) - """ - self.host = host - self.port = port - self.camera_id = camera_id - self.fps = fps - self.quality = quality - - self.websocket_url = f"ws://{host}:{port}" - self.frame_interval = 1.0 / fps - self.reconnect_delay = 2.0 - - self.running = False - self.camera = None - self.websocket = None - self.server_frame_format = "base64" - - async def start(self): - """Start the webcam streamer.""" - self.running = True - logger.info(f"Starting webcam streamer (camera_id={self.camera_id}, fps={self.fps})") - - camera_task = asyncio.create_task(self._camera_loop()) - websocket_task = asyncio.create_task(self._websocket_loop()) - - try: - await asyncio.gather(camera_task, websocket_task) - except KeyboardInterrupt: - logger.info("Received interrupt signal, shutting down...") - finally: - await self.stop() - - async def stop(self): - """Stop the webcam streamer.""" - logger.info("Stopping webcam streamer...") - self.running = False - - if self.websocket: - try: - await self.websocket.close() - except Exception as e: - logger.warning(f"Error closing WebSocket: {e}") - - if self.camera: - self.camera.stop() - logger.info("Camera stopped") - - logger.info("Webcam streamer stopped") - - async def _camera_loop(self): - """Main camera capture loop.""" - logger.info(f"Opening camera {self.camera_id}...") - self.camera = Camera(self.camera_id, resolution=(FRAME_WIDTH, FRAME_HEIGHT), fps=self.fps) - self.camera.start() - - if not self.camera.is_started(): - logger.error(f"Failed to open camera {self.camera_id}") - return - - logger.info("Camera opened successfully") - - last_frame_time = time.time() - - while self.running: - try: - frame = self.camera.capture() - if frame is None: - logger.warning("Failed to capture frame") - await asyncio.sleep(0.1) - continue - - # Rate limiting to enforce frame rate - current_time = time.time() - time_since_last = current_time - last_frame_time - if time_since_last < self.frame_interval: - await asyncio.sleep(self.frame_interval - time_since_last) - - last_frame_time = time.time() - - if self.websocket: - try: - await self._send_frame(frame) - except websockets.exceptions.ConnectionClosed: - logger.warning("WebSocket connection lost during frame send") - self.websocket = None - - await asyncio.sleep(0.001) - - except Exception as e: - logger.error(f"Error in camera loop: {e}") - await asyncio.sleep(1.0) - - async def _websocket_loop(self): - """Main WebSocket connection loop with automatic reconnection.""" - while self.running: - try: - await self._connect_websocket() - await self._handle_websocket_messages() - except Exception as e: - logger.error(f"WebSocket error: {e}") - finally: - if self.websocket: - try: - await self.websocket.close() - except: - pass - self.websocket = None - - if self.running: - logger.info(f"Reconnecting in {self.reconnect_delay} seconds...") - await asyncio.sleep(self.reconnect_delay) - - async def _connect_websocket(self): - """Connect to the WebSocket server.""" - logger.info(f"Connecting to {self.websocket_url}...") - - try: - self.websocket = await websockets.connect( - self.websocket_url, - ping_interval=20, - ping_timeout=10, - close_timeout=5 - ) - logger.info("WebSocket connected successfully") - - except Exception as e: - raise - - async def _handle_websocket_messages(self): - """Handle incoming WebSocket messages.""" - try: - async for message in self.websocket: - try: - data = json.loads(message) - - if data.get("status") == "connected": - logger.info(f"Server welcome: {data.get('message', 'Connected')}") - self.server_frame_format = data.get('frame_format', 'base64') - logger.info(f"Server format: {self.server_frame_format}") - - elif data.get("status") == "disconnecting": - logger.info(f"Server goodbye: {data.get('message', 'Disconnecting')}") - break - - elif data.get("error"): - logger.warning(f"Server error: {data.get('message', 'Unknown error')}") - if data.get("code") == 1000: # Server busy - break - - else: - logger.warning(f"Received unknown message: {data}") - - except json.JSONDecodeError: - logger.warning(f"Received non-JSON message: {message[:100]}") - - except websockets.exceptions.ConnectionClosed: - logger.info("WebSocket connection closed by server") - except Exception as e: - logger.error(f"Error handling WebSocket messages: {e}") - raise - - async def _send_frame(self, frame): - """Send a frame to the WebSocket server using the server's preferred format.""" - try: - if self.server_frame_format == "binary": - # Encode frame as JPEG and send binary data - encoded_frame = ImageEditor.compress_to_jpeg(frame) - await self.websocket.send(encoded_frame.tobytes()) - - elif self.server_frame_format == "base64": - # Encode frame as JPEG and send base64 data - encoded_frame = ImageEditor.compress_to_jpeg(frame) - frame_b64 = base64.b64encode(encoded_frame.tobytes()).decode('utf-8') - await self.websocket.send(frame_b64) - - elif self.server_frame_format == "json": - # Encode frame as JPEG, base64 encode and wrap in JSON - encoded_frame = ImageEditor.compress_to_jpeg(frame) - frame_b64 = base64.b64encode(encoded_frame.tobytes()).decode('utf-8') - message = json.dumps({"image": frame_b64}) - await self.websocket.send(message) - - else: - logger.warning(f"Unknown server frame format: {self.server_frame_format}") - - except websockets.exceptions.ConnectionClosed: - logger.warning("WebSocket connection closed while sending frame") - raise - except Exception as e: - logger.error(f"Error sending frame: {e}") - - -def signal_handler(signum, frame): - """Handle interrupt signals.""" - logger.info("Received signal, initiating shutdown...") - sys.exit(0) - - -async def main(): - """Main function.""" - parser = argparse.ArgumentParser(description="WebSocket Camera Client Streamer") - parser.add_argument("--host", default="localhost", help="WebSocket server host (default: localhost)") - parser.add_argument("--port", type=int, default=8080, help="WebSocket server port (default: 8080)") - parser.add_argument("--camera", type=int, default=0, help="Camera device ID (default: 0)") - parser.add_argument("--fps", type=int, default=30, help="Target FPS (default: 30)") - parser.add_argument("--quality", type=int, default=80, help="JPEG quality 1-100 (default: 80)") - parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") - - args = parser.parse_args() - - if args.verbose: - logging.getLogger().setLevel(logging.DEBUG) - - # Setup signal handlers - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - # Create and start streamer - streamer = WebCamStreamer( - host=args.host, - port=args.port, - camera_id=args.camera, - fps=args.fps, - quality=args.quality - ) - - try: - await streamer.start() - except KeyboardInterrupt: - pass - finally: - await streamer.stop() - - -if __name__ == "__main__": - try: - asyncio.run(main()) - except KeyboardInterrupt: - logger.info("Interrupted by user") - except Exception as e: - logger.error(f"Unexpected error: {e}") - sys.exit(1) From 321eed22f95eeedb2552182e355f09a8454d8ac2 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 00:15:51 +0100 Subject: [PATCH 18/86] fix: race condition --- .../camera/examples/6_capture_websocket.py | 2 +- .../camera/websocket_camera.py | 23 ++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py b/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py index 5028f4f1..44f7bb77 100644 --- a/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py +++ b/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py @@ -8,7 +8,7 @@ from arduino.app_peripherals.camera import Camera -# Expose a WebSocket camera stream for clients to connect to and consume it +# Expose a WebSocket camera stream for clients to connect to camera = Camera("ws://0.0.0.0:8080", timeout=5) camera.start() diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 8eb57760..58765287 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -12,6 +12,7 @@ import cv2 import websockets import asyncio +from concurrent.futures import CancelledError, TimeoutError from arduino.app_utils import Logger @@ -26,7 +27,9 @@ class WebSocketCamera(BaseCamera): WebSocket Camera implementation that hosts a WebSocket server. This camera acts as a WebSocket server that receives frames from connected clients. - Clients can send frames in various formats: + Only one client can be connected at a time. + + Clients can send frames in various 8-bit (e.g. JPEG, PNG 8-bit) formats: - Base64 encoded images - JSON messages with image data - Binary image data @@ -252,6 +255,10 @@ def _close_camera(self): ) try: future.result(timeout=1.0) + except CancelledError as e: + logger.debug(f"Error setting async stop event: CancelledError") + except TimeoutError as e: + logger.debug(f"Error setting async stop event: TimeoutError") except Exception as e: logger.warning(f"Error setting async stop event: {e}") @@ -260,11 +267,11 @@ def _close_camera(self): self._server_thread.join(timeout=10.0) # Clear frame queue - while not self._frame_queue.empty(): - try: + try: + while True: self._frame_queue.get_nowait() - except queue.Empty: - break + except queue.Empty: + pass # Reset state self._server = None @@ -273,8 +280,6 @@ def _close_camera(self): async def _set_async_stop_event(self): """Set the async stop event and close the client connection.""" - self._stop_event.set() - # Send goodbye message and close the client connection if self._client: try: @@ -285,9 +290,11 @@ async def _set_async_stop_event(self): }) # Give a brief moment for the message to be sent await asyncio.sleep(0.1) - await self._client.close() except Exception as e: logger.warning(f"Error closing client in stop event: {e}") + finally: + await self._client.close() + self._stop_event.set() def _read_frame(self) -> Optional[np.ndarray]: """Read a frame from the queue.""" From dd7dc938e75f89c682bf8e4e72cb44e8aea80e5d Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 00:16:07 +0100 Subject: [PATCH 19/86] doc: update readme --- src/arduino/app_peripherals/camera/README.md | 209 ++++++------------ .../app_peripherals/camera/base_camera.py | 14 +- .../camera/websocket_camera.py | 42 ++-- 3 files changed, 93 insertions(+), 172 deletions(-) diff --git a/src/arduino/app_peripherals/camera/README.md b/src/arduino/app_peripherals/camera/README.md index ba2d2281..1bb32852 100644 --- a/src/arduino/app_peripherals/camera/README.md +++ b/src/arduino/app_peripherals/camera/README.md @@ -5,200 +5,117 @@ The `Camera` peripheral provides a unified abstraction for capturing images from ## Features - **Universal Interface**: Single API for V4L/USB, IP cameras, and WebSocket cameras -- **Automatic Detection**: Automatically selects appropriate camera implementation based on source +- **Automatic Detection**: Selects appropriate camera implementation based on source - **Multiple Protocols**: Supports V4L, RTSP, HTTP/MJPEG, and WebSocket streams -- **Flexible Configuration**: Resolution, FPS, compression, and protocol-specific settings - **Thread-Safe**: Safe concurrent access with proper locking -- **Context Manager**: Automatic resource management with `with` statements +- **Context Manager**: Automatic resource management ## Quick Start -### Basic Usage - +Instantiate the default camera: ```python from arduino.app_peripherals.camera import Camera -# USB/V4L camera (index 0) -camera = Camera(0, resolution=(640, 480), fps=15) - -with camera: - frame = camera.capture() # Returns PIL Image - if frame: - frame.save("captured.png") +# Default camera (V4L camera at index 0) +camera = Camera() ``` -### Different Camera Types +Camera needs to be started and stopped explicitly: ```python -# V4L/USB cameras -usb_camera = Camera(0) # Camera index -usb_camera = Camera("1") # Index as string -usb_camera = Camera("/dev/video0") # Device path - -# IP cameras -ip_camera = Camera("rtsp://192.168.1.100/stream") -ip_camera = Camera("http://camera.local/mjpeg", - username="admin", password="secret") - -# WebSocket cameras -- `"ws://localhost:8080"` - WebSocket server URL (extracts host and port) -- `"localhost:9090"` - WebSocket server host:port format -``` - -## API Reference - -### Camera Class +# Specify camera and configuration +camera = Camera(0, resolution=(640, 480), fps=15) +camera.start() -The main `Camera` class acts as a factory that creates the appropriate camera implementation: +image = camera.capture() -```python -camera = Camera(source, **options) +camera.stop() ``` -**Parameters:** -- `source`: Camera source identifier - - `int`: V4L camera index (0, 1, 2...) - - `str`: Camera index, device path, or URL -- `resolution`: Tuple `(width, height)` or `None` for default -- `fps`: Target frames per second (default: 10) -- `transformer`: Pipeline of transformers that adjust the captured image - -**Methods:** -- `start()`: Initialize and start camera -- `stop()`: Stop camera and release resources -- `capture()`: Capture frame as Numpy array -- `is_started()`: Check if camera is running - -### Context Manager - +Or you can leverage context support for doing that automatically: ```python with Camera(source, **options) as camera: frame = camera.capture() + if frame is not None: + print(f"Captured frame with shape: {frame.shape}") # Camera automatically stopped when exiting ``` ## Camera Types +The Camera class provides automatic camera type detection based on the format of its source argument. keyword arguments will be propagated to the underlying implementation. -### V4L/USB Cameras +Note: constructor arguments (except source) must be provided in keyword format to forward them correctly to the specific camera implementations. -For local USB cameras and V4L-compatible devices: +The underlying camera implementations can be instantiated explicitly (V4LCamera, IPCamera and WebSocketCamera), if needed. -```python -camera = Camera(0, resolution=(1280, 720), fps=30) -``` +### V4L Cameras +For local USB cameras and V4L-compatible devices. **Features:** -- Device enumeration via `/dev/v4l/by-id/` -- Resolution validation -- Backend information - -### IP Cameras - -For network cameras supporting RTSP or HTTP streams: +- Supports cameras compatible with the Video4Linux2 drivers ```python -camera = Camera("rtsp://admin:pass@192.168.1.100/stream", - timeout=10, fps=5) +camera = Camera(0) # Camera index +camera = Camera("/dev/video0") # Device path +camera = V4LCamera(0) ``` +### IP Cameras +For network cameras supporting RTSP (Real-Time Streaming Protocol) and HLS (HTTP Live Streaming). + **Features:** -- RTSP, HTTP, HTTPS protocols +- Supports capturing RTSP, HLS streams - Authentication support -- Connection testing - Automatic reconnection -### WebSocket Cameras - -For hosting a WebSocket server that receives frames from clients (single client only): - ```python -camera = Camera("ws://0.0.0.0:9090", frame_format="json") +camera = Camera("rtsp://admin:secret@192.168.1.100/stream") +camera = Camera("http://camera.local/stream", + username="admin", password="secret") +camera = IPCamera("http://camera.local/stream", + username="admin", password="secret") ``` +### WebSocket Cameras +For hosting a WebSocket server that receives frames from a single client at a time. + **Features:** -- Hosts WebSocket server (not client) - **Single client limitation**: Only one client can connect at a time -- Additional clients are rejected with error message -- Receives frames from connected client +- Stream data from any client with WebSockets support - Base64, binary, and JSON frame formats -- Frame buffering and queue management -- Bidirectional communication with connected client - -**Client Connection:** -Only one client can connect at a time. Additional clients receive an error: -```javascript -// JavaScript client example -const ws = new WebSocket('ws://localhost:8080'); -ws.onmessage = function(event) { - const data = JSON.parse(event.data); - if (data.error) { - console.log('Connection rejected:', data.message); - } -}; -ws.send(base64EncodedImageData); -``` - -## Advanced Usage - -### Custom Configuration +- Supports 8-bit images (e.g. JPEG, PNG 8-bit) ```python -camera = Camera( - source="rtsp://camera.local/stream", - resolution=(1920, 1080), - fps=15, - compression=True, # PNG compression - letterbox=True, # Square images - username="admin", # IP camera auth - password="secret", - timeout=5, # Connection timeout - max_queue_size=20 # WebSocket buffer -) +camera = Camera("ws://0.0.0.0:8080", timeout=5) +camera = WebSocketCamera("0.0.0.0", 8080, timeout=5) ``` -### Error Handling - +Client implementation example: ```python -from arduino.app_peripherals.camera.camera import CameraError - -try: - with Camera("invalid://source") as camera: - frame = camera.capture() -except CameraError as e: - print(f"Camera error: {e}") +import time +import base64 +import cv2 +import websockets.sync.client as wsclient +import websockets.exceptions as wsexc + + +# Open camera +camera = cv2.VideoCapture(0) +with wsclient.connect("ws://:8080") as websocket: + while True: + time.sleep(1.0 / 15.0) # 15 FPS + ret, frame = camera.read() + if ret: + # Compress frame to JPEG + _, buffer = cv2.imencode('.jpg', frame) + # Convert to base64 + jpeg_b64 = base64.b64encode(buffer).decode('utf-8') + try: + websocket.send(jpeg_b64) + except wsexc.ConnectionClosed: + break ``` -### Factory Pattern - -```python -from arduino.app_peripherals.camera.camera import CameraFactory - -# Create camera directly via factory -camera = CameraFactory.create_camera( - source="ws://localhost:8080/stream", - frame_format="json" -) -``` - -## Dependencies - -### Core Dependencies -- `opencv-python` (cv2) - Image processing and V4L/IP camera support -- `Pillow` (PIL) - Image format handling -- `requests` - HTTP camera connectivity testing - -### Optional Dependencies -- `websockets` - WebSocket server support (install with `pip install websockets`) - -## Examples - -See the `examples/` directory for comprehensive usage examples: -- Basic camera operations -- Different camera types -- Advanced configuration -- Error handling -- Context managers - ## Migration from Legacy Camera -The new Camera abstraction is backward compatible with the existing Camera implementation. Existing code using the old API will continue to work, but new code should use the improved abstraction for better flexibility and features. +The new Camera abstraction is backward compatible with the existing Camera implementation. Existing code using the old API will continue to work, but will use the new Camera backend. New code should use the improved abstraction for better flexibility and features. diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index 57dd9984..68a454a5 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -89,14 +89,14 @@ def capture(self) -> Optional[np.ndarray]: def _extract_frame(self) -> Optional[np.ndarray]: """Extract a frame with FPS throttling and post-processing.""" - # FPS throttling - if self._desired_interval > 0: - current_time = time.monotonic() - elapsed = current_time - self._last_capture_time - if elapsed < self._desired_interval: - time.sleep(self._desired_interval - elapsed) - with self._camera_lock: + # FPS throttling + if self._desired_interval > 0: + current_time = time.monotonic() + elapsed = current_time - self._last_capture_time + if elapsed < self._desired_interval: + time.sleep(self._desired_interval - elapsed) + if not self._is_started: return None diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 58765287..65995be4 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -72,6 +72,7 @@ def __init__( self._server_thread = None self._stop_event = asyncio.Event() self._client: Optional[websockets.ServerConnection] = None + self._client_lock = asyncio.Lock() def _open_camera(self) -> None: """Start the WebSocket server.""" @@ -136,22 +137,24 @@ async def _ws_handler(self, conn: websockets.ServerConnection) -> None: """Handle a connected WebSocket client. Only one client allowed at a time.""" client_addr = f"{conn.remote_address[0]}:{conn.remote_address[1]}" - if self._client is not None: - # Reject the new client - logger.warning(f"Rejecting client {client_addr}: only one client allowed at a time") - try: - await conn.send(json.dumps({ - "error": "Server busy", - "message": "Only one client connection allowed at a time", - "code": 1000 - })) - await conn.close(code=1000, reason="Server busy - only one client allowed") - except Exception as e: - logger.warning(f"Error sending rejection message to {client_addr}: {e}") - return + async with self._client_lock: + if self._client is not None: + # Reject the new client + logger.warning(f"Rejecting client {client_addr}: only one client allowed at a time") + try: + await conn.send(json.dumps({ + "error": "Server busy", + "message": "Only one client connection allowed at a time", + "code": 1000 + })) + await conn.close(code=1000, reason="Server busy - only one client allowed") + except Exception as e: + logger.warning(f"Error sending rejection message to {client_addr}: {e}") + return + + # Accept the client + self._client = conn - # Accept the client - self._client = conn logger.info(f"Client connected: {client_addr}") try: @@ -180,16 +183,17 @@ async def _ws_handler(self, conn: websockets.ServerConnection) -> None: # Drop oldest frame and try again self._frame_queue.get_nowait() except queue.Empty: - break + continue except websockets.exceptions.ConnectionClosed: logger.info(f"Client disconnected: {client_addr}") except Exception as e: logger.warning(f"Error handling client {client_addr}: {e}") finally: - if self._client == conn: - self._client = None - logger.info(f"Client removed: {client_addr}") + async with self._client_lock: + if self._client == conn: + self._client = None + logger.info(f"Client removed: {client_addr}") async def _parse_message(self, message) -> Optional[np.ndarray]: """Parse WebSocket message to extract frame.""" From 1dc472596abf86d60e0bd7c5bcd1f4ef42e0c43d Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 00:45:13 +0100 Subject: [PATCH 20/86] refactor --- .../app_peripherals/usb_camera/__init__.py | 2 +- src/arduino/app_utils/image/__init__.py | 11 +- src/arduino/app_utils/image/adjustments.py | 454 +++++++++++++++++ src/arduino/app_utils/image/image_editor.py | 455 ------------------ .../app_utils/image/test_image_editor.py | 100 ++-- 5 files changed, 513 insertions(+), 509 deletions(-) create mode 100644 src/arduino/app_utils/image/adjustments.py delete mode 100644 src/arduino/app_utils/image/image_editor.py diff --git a/src/arduino/app_peripherals/usb_camera/__init__.py b/src/arduino/app_peripherals/usb_camera/__init__.py index 12373bab..27042d45 100644 --- a/src/arduino/app_peripherals/usb_camera/__init__.py +++ b/src/arduino/app_peripherals/usb_camera/__init__.py @@ -7,7 +7,7 @@ from PIL import Image from arduino.app_peripherals.camera import Camera, CameraReadError as CRE, CameraOpenError as COE from arduino.app_peripherals.camera.v4l_camera import V4LCamera -from arduino.app_utils.image.image_editor import compressed_to_png, letterboxed +from arduino.app_utils.image import letterboxed, compressed_to_png from arduino.app_utils import Logger logger = Logger("USB Camera") diff --git a/src/arduino/app_utils/image/__init__.py b/src/arduino/app_utils/image/__init__.py index 6c4cd4c5..30827152 100644 --- a/src/arduino/app_utils/image/__init__.py +++ b/src/arduino/app_utils/image/__init__.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: MPL-2.0 from .image import * -from .image_editor import ImageEditor +from .adjustments import * from .pipeable import PipeableFunction __all__ = [ @@ -11,12 +11,17 @@ "get_image_bytes", "draw_bounding_boxes", "draw_anomaly_markers", - "ImageEditor", - "PipeableFunction", + "letterbox", + "resize", + "adjust", + "greyscale", + "compress_to_jpeg", + "compress_to_png", "letterboxed", "resized", "adjusted", "greyscaled", "compressed_to_jpeg", "compressed_to_png", + "PipeableFunction", ] \ No newline at end of file diff --git a/src/arduino/app_utils/image/adjustments.py b/src/arduino/app_utils/image/adjustments.py new file mode 100644 index 00000000..7093b845 --- /dev/null +++ b/src/arduino/app_utils/image/adjustments.py @@ -0,0 +1,454 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import cv2 +import numpy as np +from typing import Optional, Tuple +from PIL import Image + +from arduino.app_utils.image.pipeable import PipeableFunction + +# NOTE: we use the following formats for image shapes (H = height, W = width, C = channels): +# - When receiving a resolution as argument we expect (W, H) format which is more user-friendly +# - When receiving images we expect (H, W, C) format with C = BGR, BGRA or greyscale +# - When returning images we use (H, W, C) format with C = BGR, BGRA or greyscale (depending on input) +# Keep in mind OpenCV uses (W, H, C) format with C = BGR whereas numpy uses (H, W, C) format with any C. +# The below functions all support unsigned integer types used by OpenCV (uint8, uint16 and uint32). + + +""" +Image processing utilities handling common image operations like letterboxing, resizing, +adjusting, compressing and format conversions. +Frames are expected to be in BGR, BGRA or greyscale format. +""" + + +def letterbox(frame: np.ndarray, + target_size: Optional[Tuple[int, int]] = None, + color: int | Tuple[int, int, int] = (114, 114, 114), + interpolation: int = cv2.INTER_LINEAR) -> np.ndarray: + """ + Add letterboxing to frame to achieve target size while maintaining aspect ratio. + + Args: + frame (np.ndarray): Input frame + target_size (tuple, optional): Target size as (width, height). If None, makes frame square. + color (int or tuple, optional): BGR color for padding borders, can be a scalar or a tuple + matching the frame's channel count. Default: (114, 114, 114) + interpolation (int, optional): OpenCV interpolation method. Default: cv2.INTER_LINEAR + + Returns: + np.ndarray: Letterboxed frame + """ + original_dtype = frame.dtype + orig_h, orig_w = frame.shape[:2] + + if target_size is None: + # Default to a square canvas based on the longest side + max_dim = max(orig_h, orig_w) + target_w, target_h = int(max_dim), int(max_dim) + else: + target_w, target_h = int(target_size[0]), int(target_size[1]) + + scale = min(target_w / orig_w, target_h / orig_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + + resized_frame = cv2.resize(frame, (new_w, new_h), interpolation=interpolation) + + if frame.ndim == 2: + # Greyscale + if hasattr(color, '__len__'): + color = color[0] + canvas = np.full((target_h, target_w), color, dtype=original_dtype) + else: + # Colored (BGR/BGRA) + channels = frame.shape[2] + if not hasattr(color, '__len__'): + color = (color,) * channels + elif len(color) != channels: + raise ValueError( + f"color length ({len(color)}) must match frame channels ({channels})." + ) + canvas = np.full((target_h, target_w, channels), color, dtype=original_dtype) + + # Calculate offsets to center the image + y_offset = (target_h - new_h) // 2 + x_offset = (target_w - new_w) // 2 + + # Paste the resized image onto the canvas + canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized_frame + + return canvas + + +def resize(frame: np.ndarray, + target_size: Tuple[int, int], + maintain_ratio: bool = False, + interpolation: int = cv2.INTER_LINEAR) -> np.ndarray: + """ + Resize frame to target size. + + Args: + frame (np.ndarray): Input frame + target_size (tuple): Target size as (width, height) + maintain_ratio (bool): If True, use letterboxing to maintain aspect ratio + interpolation (int): OpenCV interpolation method + + Returns: + np.ndarray: Resized frame + """ + if maintain_ratio: + return letterbox(frame, target_size) + else: + return cv2.resize(frame, (target_size[0], target_size[1]), interpolation=interpolation) + + +def adjust(frame: np.ndarray, + brightness: float = 0.0, + contrast: float = 1.0, + saturation: float = 1.0, + gamma: float = 1.0) -> np.ndarray: + """ + Apply image adjustments to a BGR or BGRA frame, preserving channel count + and data type. + + Args: + frame (np.ndarray): Input frame (uint8, uint16, uint32). + brightness (float): -1.0 to 1.0 (default: 0.0). + contrast (float): 0.0 to N (default: 1.0). + saturation (float): 0.0 to N (default: 1.0). + gamma (float): > 0 (default: 1.0). + + Returns: + np.ndarray: The adjusted input with same dtype as frame. + """ + original_dtype = frame.dtype + dtype_info = np.iinfo(original_dtype) + max_val = dtype_info.max + + # Use float64 for int types with > 24 bits of precision (e.g., uint32) + processing_dtype = np.float64 if dtype_info.bits > 24 else np.float32 + + # Apply the adjustments in float space to reduce clipping and data loss + frame_float = frame.astype(processing_dtype) / max_val + + # If present, separate alpha channel + alpha_channel = None + if frame.ndim == 3 and frame.shape[2] == 4: + alpha_channel = frame_float[:, :, 3] + frame_float = frame_float[:, :, :3] + + # Saturation + if saturation != 1.0 and frame.ndim == 3: # Ensure frame has color channels + # This must be done with float32 so it's lossy only for uint32 + frame_float_32 = frame_float.astype(np.float32) + hsv = cv2.cvtColor(frame_float_32, cv2.COLOR_BGR2HSV) + h, s, v = split_channels(hsv) + s = np.clip(s * saturation, 0.0, 1.0) + frame_float_32 = cv2.cvtColor(np.stack([h, s, v], axis=2), cv2.COLOR_HSV2BGR) + frame_float = frame_float_32.astype(processing_dtype) + + # Brightness + if brightness != 0.0: + frame_float = frame_float + brightness + + # Contrast + if contrast != 1.0: + frame_float = (frame_float - 0.5) * contrast + 0.5 + + # We need to clip before reaching gamma correction + # Clipping to 0 is mandatory to avoid handling complex numbers + # Clipping to 1 is handy to avoid clipping again after gamma correction + frame_float = np.clip(frame_float, 0.0, 1.0) + + # Gamma + if gamma != 1.0: + if gamma <= 0: + # This check is critical to prevent math errors (NaN/Inf) + raise ValueError("Gamma value must be greater than 0.") + frame_float = np.power(frame_float, gamma) + + # Convert back to original dtype + final_frame_bgr = (frame_float * max_val).astype(original_dtype) + + # If present, reattach alpha channel + if alpha_channel is not None: + final_alpha = (alpha_channel * max_val).astype(original_dtype) + b, g, r = split_channels(final_frame_bgr) + final_frame = np.stack([b, g, r, final_alpha], axis=2) + else: + final_frame = final_frame_bgr + + return final_frame + + +def split_channels(frame: np.ndarray) -> tuple: + """ + Split a multi-channel frame into individual channels using numpy indexing. + This function provides better data type compatibility than cv2.split, + especially for uint32 data which OpenCV doesn't fully support. + + Args: + frame (np.ndarray): Input frame with 3 or 4 channels + + Returns: + tuple: Individual channel arrays. For BGR: (b, g, r). For BGRA: (b, g, r, a). + For HSV: (h, s, v). For other 3-channel: (ch0, ch1, ch2). + """ + if frame.ndim != 3: + raise ValueError("Frame must be 3-dimensional (H, W, C)") + + channels = frame.shape[2] + if channels == 3: + return frame[:, :, 0], frame[:, :, 1], frame[:, :, 2] + elif channels == 4: + return frame[:, :, 0], frame[:, :, 1], frame[:, :, 2], frame[:, :, 3] + else: + raise ValueError(f"Unsupported number of channels: {channels}. Expected 3 or 4.") + + +def greyscale(frame: np.ndarray) -> np.ndarray: + """ + Converts a BGR or BGRA frame to greyscale, preserving channel count and + data type. A greyscale frame is returned unmodified. + + Args: + frame (np.ndarray): Input frame (uint8, uint16, uint32). + + Returns: + np.ndarray: The greyscaled frame with same dtype and channel count as frame. + """ + # If already greyscale or unknown format, return the original frame + if frame.ndim != 3: + return frame + + original_dtype = frame.dtype + dtype_info = np.iinfo(original_dtype) + max_val = dtype_info.max + + # Use float64 for int types with > 24 bits of precision (e.g., uint32) + processing_dtype = np.float64 if dtype_info.bits > 24 else np.float32 + + # Apply the adjustments in float space to reduce clipping and data loss + frame_float = frame.astype(processing_dtype) / max_val + + # If present, separate alpha channel + alpha_channel = None + if frame.shape[2] == 4: + alpha_channel = frame_float[:, :, 3] + frame_float = frame_float[:, :, :3] + + # Convert to greyscale using standard BT.709 weights + # GREY = 0.0722 * B + 0.7152 * G + 0.2126 * R + grey_float = (0.0722 * frame_float[:, :, 0] + + 0.7152 * frame_float[:, :, 1] + + 0.2126 * frame_float[:, :, 2]) + + # Convert back to original dtype + final_grey = (grey_float * max_val).astype(original_dtype) + + # If present, reattach alpha channel + if alpha_channel is not None: + final_alpha = (alpha_channel * max_val).astype(original_dtype) + final_frame = np.stack([final_grey, final_grey, final_grey, final_alpha], axis=2) + else: + final_frame = np.stack([final_grey, final_grey, final_grey], axis=2) + + return final_frame + +def compress_to_jpeg(frame: np.ndarray, quality: int = 80) -> Optional[np.ndarray]: + """ + Compress frame to JPEG format. + + Args: + frame (np.ndarray): Input frame as numpy array + quality (int): JPEG quality (0-100, higher = better quality) + + Returns: + bytes: Compressed JPEG data, or None if compression failed + """ + quality = int(quality) # Gstreamer doesn't like quality to be float + try: + success, encoded = cv2.imencode( + '.jpg', + frame, + [cv2.IMWRITE_JPEG_QUALITY, quality] + ) + return encoded if success else None + except Exception: + return None + + +def compress_to_png(frame: np.ndarray, compression_level: int = 6) -> Optional[np.ndarray]: + """ + Compress frame to PNG format. + + Args: + frame (np.ndarray): Input frame as numpy array + compression_level (int): PNG compression level (0-9, higher = better compression) + + Returns: + bytes: Compressed PNG data, or None if compression failed + """ + compression_level = int(compression_level) # Gstreamer doesn't like compression_level to be float + try: + success, encoded = cv2.imencode( + '.png', + frame, + [cv2.IMWRITE_PNG_COMPRESSION, compression_level] + ) + return encoded if success else None + except Exception: + return None + + +def numpy_to_pil(frame: np.ndarray) -> Image.Image: + """ + Convert numpy array to PIL Image. + + Args: + frame (np.ndarray): Input frame in BGR format + + Returns: + PIL.Image.Image: PIL Image in RGB format + """ + # Convert BGR to RGB + rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + return Image.fromarray(rgb_frame) + + +def pil_to_numpy(image: Image.Image) -> np.ndarray: + """ + Convert PIL Image to numpy array. + + Args: + image (PIL.Image.Image): PIL Image + + Returns: + np.ndarray: Numpy array in BGR format + """ + if image.mode != 'RGB': + image = image.convert('RGB') + + # Convert to numpy and then BGR + rgb_array = np.array(image) + return cv2.cvtColor(rgb_array, cv2.COLOR_RGB2BGR) + + +# ============================================================================= +# Functional API - Standalone pipeable functions +# ============================================================================= + +def letterboxed(target_size: Optional[Tuple[int, int]] = None, + color: Tuple[int, int, int] = (114, 114, 114), + interpolation: int = cv2.INTER_LINEAR): + """ + Pipeable letterbox function - apply letterboxing with pipe operator support. + + Args: + target_size (tuple, optional): Target size as (width, height). If None, makes frame square. + color (tuple): RGB color for padding borders. Default: (114, 114, 114) + + Returns: + Partial function that takes a frame and returns letterboxed frame + + Examples: + pipe = letterboxed(target_size=(640, 640)) + pipe = letterboxed() | greyscaled() + """ + return PipeableFunction(letterbox, target_size=target_size, color=color, interpolation=interpolation) + + +def resized(target_size: Tuple[int, int], + maintain_ratio: bool = False, + interpolation: int = cv2.INTER_LINEAR): + """ + Pipeable resize function - resize frame with pipe operator support. + + Args: + target_size (tuple): Target size as (width, height) + maintain_ratio (bool): If True, use letterboxing to maintain aspect ratio + interpolation (int): OpenCV interpolation method + + Returns: + Partial function that takes a frame and returns resized frame + + Examples: + pipe = resized(target_size=(640, 480)) + pipe = letterboxed() | resized(target_size=(320, 240)) + """ + return PipeableFunction(resize, target_size=target_size, maintain_ratio=maintain_ratio, interpolation=interpolation) + + +def adjusted(brightness: float = 0.0, + contrast: float = 1.0, + saturation: float = 1.0, + gamma: float = 1.0): + """ + Pipeable adjust function - apply image adjustments with pipe operator support. + + Args: + brightness (float): -1.0 to 1.0 (default: 0.0). + contrast (float): 0.0 to N (default: 1.0). + saturation (float): 0.0 to N (default: 1.0). + gamma (float): > 0 (default: 1.0). + + Returns: + Partial function that takes a frame and returns adjusted frame + + Examples: + pipe = adjusted(brightness=0.1, contrast=1.2) + pipe = letterboxed() | adjusted(saturation=0.8) + """ + return PipeableFunction(adjust, brightness=brightness, contrast=contrast, saturation=saturation, gamma=gamma) + + +def greyscaled(): + """ + Pipeable greyscale function - convert frame to greyscale with pipe operator support. + + Returns: + Function that takes a frame and returns greyscale frame + + Examples: + pipe = greyscaled() + pipe = letterboxed() | greyscaled() + """ + return PipeableFunction(greyscale) + + +def compressed_to_jpeg(quality: int = 80): + """ + Pipeable JPEG compression function - compress frame to JPEG with pipe operator support. + + Args: + quality (int): JPEG quality (0-100, higher = better quality) + + Returns: + Partial function that takes a frame and returns compressed JPEG bytes as Numpy array or None + + Examples: + pipe = compressed_to_jpeg(quality=95) + pipe = resized(target_size=(640, 480)) | compressed_to_jpeg() + """ + return PipeableFunction(compress_to_jpeg, quality=quality) + + +def compressed_to_png(compression_level: int = 6): + """ + Pipeable PNG compression function - compress frame to PNG with pipe operator support. + + Args: + compression_level (int): PNG compression level (0-9, higher = better compression) + + Returns: + Partial function that takes a frame and returns compressed PNG bytes as Numpy array or None + + Examples: + pipe = compressed_to_png(compression_level=9) + pipe = letterboxed() | compressed_to_png() + """ + return PipeableFunction(compress_to_png, compression_level=compression_level) + diff --git a/src/arduino/app_utils/image/image_editor.py b/src/arduino/app_utils/image/image_editor.py deleted file mode 100644 index fca45bbe..00000000 --- a/src/arduino/app_utils/image/image_editor.py +++ /dev/null @@ -1,455 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA -# -# SPDX-License-Identifier: MPL-2.0 - -import cv2 -import numpy as np -from typing import Optional, Tuple -from PIL import Image - -from arduino.app_utils.image.pipeable import PipeableFunction - -# NOTE: we use the following formats for image shapes (H = height, W = width, C = channels): -# - When receiving a resolution as argument we expect (W, H) format which is more user-friendly -# - When receiving images we expect (H, W, C) format with C = BGR, BGRA or greyscale -# - When returning images we use (H, W, C) format with C = BGR, BGRA or greyscale (depending on input) -# Keep in mind OpenCV uses (W, H, C) format with C = BGR whereas numpy uses (H, W, C) format with any C. -# The below functions all support unsigned integer types used by OpenCV (uint8, uint16 and uint32). - - -class ImageEditor: - """ - Image processing utilities handling common image operations like letterboxing, resizing, - adjusting, compressing and format conversions. - Frames are expected to be in BGR, BGRA or greyscale format. - """ - - @staticmethod - def letterbox(frame: np.ndarray, - target_size: Optional[Tuple[int, int]] = None, - color: int | Tuple[int, int, int] = (114, 114, 114), - interpolation: int = cv2.INTER_LINEAR) -> np.ndarray: - """ - Add letterboxing to frame to achieve target size while maintaining aspect ratio. - - Args: - frame (np.ndarray): Input frame - target_size (tuple, optional): Target size as (width, height). If None, makes frame square. - color (int or tuple, optional): BGR color for padding borders, can be a scalar or a tuple - matching the frame's channel count. Default: (114, 114, 114) - interpolation (int, optional): OpenCV interpolation method. Default: cv2.INTER_LINEAR - - Returns: - np.ndarray: Letterboxed frame - """ - original_dtype = frame.dtype - orig_h, orig_w = frame.shape[:2] - - if target_size is None: - # Default to a square canvas based on the longest side - max_dim = max(orig_h, orig_w) - target_w, target_h = int(max_dim), int(max_dim) - else: - target_w, target_h = int(target_size[0]), int(target_size[1]) - - scale = min(target_w / orig_w, target_h / orig_h) - new_w = int(orig_w * scale) - new_h = int(orig_h * scale) - - resized_frame = cv2.resize(frame, (new_w, new_h), interpolation=interpolation) - - if frame.ndim == 2: - # Greyscale - if hasattr(color, '__len__'): - color = color[0] - canvas = np.full((target_h, target_w), color, dtype=original_dtype) - else: - # Colored (BGR/BGRA) - channels = frame.shape[2] - if not hasattr(color, '__len__'): - color = (color,) * channels - elif len(color) != channels: - raise ValueError( - f"color length ({len(color)}) must match frame channels ({channels})." - ) - canvas = np.full((target_h, target_w, channels), color, dtype=original_dtype) - - # Calculate offsets to center the image - y_offset = (target_h - new_h) // 2 - x_offset = (target_w - new_w) // 2 - - # Paste the resized image onto the canvas - canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized_frame - - return canvas - - @staticmethod - def resize(frame: np.ndarray, - target_size: Tuple[int, int], - maintain_ratio: bool = False, - interpolation: int = cv2.INTER_LINEAR) -> np.ndarray: - """ - Resize frame to target size. - - Args: - frame (np.ndarray): Input frame - target_size (tuple): Target size as (width, height) - maintain_ratio (bool): If True, use letterboxing to maintain aspect ratio - interpolation (int): OpenCV interpolation method - - Returns: - np.ndarray: Resized frame - """ - if maintain_ratio: - return ImageEditor.letterbox(frame, target_size) - else: - return cv2.resize(frame, (target_size[0], target_size[1]), interpolation=interpolation) - - @staticmethod - def adjust(frame: np.ndarray, - brightness: float = 0.0, - contrast: float = 1.0, - saturation: float = 1.0, - gamma: float = 1.0) -> np.ndarray: - """ - Apply image adjustments to a BGR or BGRA frame, preserving channel count - and data type. - - Args: - frame (np.ndarray): Input frame (uint8, uint16, uint32). - brightness (float): -1.0 to 1.0 (default: 0.0). - contrast (float): 0.0 to N (default: 1.0). - saturation (float): 0.0 to N (default: 1.0). - gamma (float): > 0 (default: 1.0). - - Returns: - np.ndarray: The adjusted input with same dtype as frame. - """ - original_dtype = frame.dtype - dtype_info = np.iinfo(original_dtype) - max_val = dtype_info.max - - # Use float64 for int types with > 24 bits of precision (e.g., uint32) - processing_dtype = np.float64 if dtype_info.bits > 24 else np.float32 - - # Apply the adjustments in float space to reduce clipping and data loss - frame_float = frame.astype(processing_dtype) / max_val - - # If present, separate alpha channel - alpha_channel = None - if frame.ndim == 3 and frame.shape[2] == 4: - alpha_channel = frame_float[:, :, 3] - frame_float = frame_float[:, :, :3] - - # Saturation - if saturation != 1.0 and frame.ndim == 3: # Ensure frame has color channels - # This must be done with float32 so it's lossy only for uint32 - frame_float_32 = frame_float.astype(np.float32) - hsv = cv2.cvtColor(frame_float_32, cv2.COLOR_BGR2HSV) - h, s, v = ImageEditor.split_channels(hsv) - s = np.clip(s * saturation, 0.0, 1.0) - frame_float_32 = cv2.cvtColor(np.stack([h, s, v], axis=2), cv2.COLOR_HSV2BGR) - frame_float = frame_float_32.astype(processing_dtype) - - # Brightness - if brightness != 0.0: - frame_float = frame_float + brightness - - # Contrast - if contrast != 1.0: - frame_float = (frame_float - 0.5) * contrast + 0.5 - - # We need to clip before reaching gamma correction - # Clipping to 0 is mandatory to avoid handling complex numbers - # Clipping to 1 is handy to avoid clipping again after gamma correction - frame_float = np.clip(frame_float, 0.0, 1.0) - - # Gamma - if gamma != 1.0: - if gamma <= 0: - # This check is critical to prevent math errors (NaN/Inf) - raise ValueError("Gamma value must be greater than 0.") - frame_float = np.power(frame_float, gamma) - - # Convert back to original dtype - final_frame_bgr = (frame_float * max_val).astype(original_dtype) - - # If present, reattach alpha channel - if alpha_channel is not None: - final_alpha = (alpha_channel * max_val).astype(original_dtype) - b, g, r = ImageEditor.split_channels(final_frame_bgr) - final_frame = np.stack([b, g, r, final_alpha], axis=2) - else: - final_frame = final_frame_bgr - - return final_frame - - @staticmethod - def split_channels(frame: np.ndarray) -> tuple: - """ - Split a multi-channel frame into individual channels using numpy indexing. - This function provides better data type compatibility than cv2.split, - especially for uint32 data which OpenCV doesn't fully support. - - Args: - frame (np.ndarray): Input frame with 3 or 4 channels - - Returns: - tuple: Individual channel arrays. For BGR: (b, g, r). For BGRA: (b, g, r, a). - For HSV: (h, s, v). For other 3-channel: (ch0, ch1, ch2). - """ - if frame.ndim != 3: - raise ValueError("Frame must be 3-dimensional (H, W, C)") - - channels = frame.shape[2] - if channels == 3: - return frame[:, :, 0], frame[:, :, 1], frame[:, :, 2] - elif channels == 4: - return frame[:, :, 0], frame[:, :, 1], frame[:, :, 2], frame[:, :, 3] - else: - raise ValueError(f"Unsupported number of channels: {channels}. Expected 3 or 4.") - - @staticmethod - def greyscale(frame: np.ndarray) -> np.ndarray: - """ - Converts a BGR or BGRA frame to greyscale, preserving channel count and - data type. A greyscale frame is returned unmodified. - - Args: - frame (np.ndarray): Input frame (uint8, uint16, uint32). - - Returns: - np.ndarray: The greyscaled frame with same dtype and channel count as frame. - """ - # If already greyscale or unknown format, return the original frame - if frame.ndim != 3: - return frame - - original_dtype = frame.dtype - dtype_info = np.iinfo(original_dtype) - max_val = dtype_info.max - - # Use float64 for int types with > 24 bits of precision (e.g., uint32) - processing_dtype = np.float64 if dtype_info.bits > 24 else np.float32 - - # Apply the adjustments in float space to reduce clipping and data loss - frame_float = frame.astype(processing_dtype) / max_val - - # If present, separate alpha channel - alpha_channel = None - if frame.shape[2] == 4: - alpha_channel = frame_float[:, :, 3] - frame_float = frame_float[:, :, :3] - - # Convert to greyscale using standard BT.709 weights - # GREY = 0.0722 * B + 0.7152 * G + 0.2126 * R - grey_float = (0.0722 * frame_float[:, :, 0] + - 0.7152 * frame_float[:, :, 1] + - 0.2126 * frame_float[:, :, 2]) - - # Convert back to original dtype - final_grey = (grey_float * max_val).astype(original_dtype) - - # If present, reattach alpha channel - if alpha_channel is not None: - final_alpha = (alpha_channel * max_val).astype(original_dtype) - final_frame = np.stack([final_grey, final_grey, final_grey, final_alpha], axis=2) - else: - final_frame = np.stack([final_grey, final_grey, final_grey], axis=2) - - return final_frame - - @staticmethod - def compress_to_jpeg(frame: np.ndarray, quality: int = 80) -> Optional[np.ndarray]: - """ - Compress frame to JPEG format. - - Args: - frame (np.ndarray): Input frame as numpy array - quality (int): JPEG quality (0-100, higher = better quality) - - Returns: - bytes: Compressed JPEG data, or None if compression failed - """ - quality = int(quality) # Gstreamer doesn't like quality to be float - try: - success, encoded = cv2.imencode( - '.jpg', - frame, - [cv2.IMWRITE_JPEG_QUALITY, quality] - ) - return encoded if success else None - except Exception: - return None - - @staticmethod - def compress_to_png(frame: np.ndarray, compression_level: int = 6) -> Optional[np.ndarray]: - """ - Compress frame to PNG format. - - Args: - frame (np.ndarray): Input frame as numpy array - compression_level (int): PNG compression level (0-9, higher = better compression) - - Returns: - bytes: Compressed PNG data, or None if compression failed - """ - compression_level = int(compression_level) # Gstreamer doesn't like compression_level to be float - try: - success, encoded = cv2.imencode( - '.png', - frame, - [cv2.IMWRITE_PNG_COMPRESSION, compression_level] - ) - return encoded if success else None - except Exception: - return None - - @staticmethod - def numpy_to_pil(frame: np.ndarray) -> Image.Image: - """ - Convert numpy array to PIL Image. - - Args: - frame (np.ndarray): Input frame in BGR format - - Returns: - PIL.Image.Image: PIL Image in RGB format - """ - # Convert BGR to RGB - rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - return Image.fromarray(rgb_frame) - - @staticmethod - def pil_to_numpy(image: Image.Image) -> np.ndarray: - """ - Convert PIL Image to numpy array. - - Args: - image (PIL.Image.Image): PIL Image - - Returns: - np.ndarray: Numpy array in BGR format - """ - if image.mode != 'RGB': - image = image.convert('RGB') - - # Convert to numpy and then BGR - rgb_array = np.array(image) - return cv2.cvtColor(rgb_array, cv2.COLOR_RGB2BGR) - - -# ============================================================================= -# Functional API - Standalone pipeable functions -# ============================================================================= - -def letterboxed(target_size: Optional[Tuple[int, int]] = None, - color: Tuple[int, int, int] = (114, 114, 114), - interpolation: int = cv2.INTER_LINEAR): - """ - Pipeable letterbox function - apply letterboxing with pipe operator support. - - Args: - target_size (tuple, optional): Target size as (width, height). If None, makes frame square. - color (tuple): RGB color for padding borders. Default: (114, 114, 114) - - Returns: - Partial function that takes a frame and returns letterboxed frame - - Examples: - pipe = letterboxed(target_size=(640, 640)) - pipe = letterboxed() | greyscaled() - """ - return PipeableFunction(ImageEditor.letterbox, target_size=target_size, color=color, interpolation=interpolation) - - -def resized(target_size: Tuple[int, int], - maintain_ratio: bool = False, - interpolation: int = cv2.INTER_LINEAR): - """ - Pipeable resize function - resize frame with pipe operator support. - - Args: - target_size (tuple): Target size as (width, height) - maintain_ratio (bool): If True, use letterboxing to maintain aspect ratio - interpolation (int): OpenCV interpolation method - - Returns: - Partial function that takes a frame and returns resized frame - - Examples: - pipe = resized(target_size=(640, 480)) - pipe = letterboxed() | resized(target_size=(320, 240)) - """ - return PipeableFunction(ImageEditor.resize, target_size=target_size, maintain_ratio=maintain_ratio, interpolation=interpolation) - - -def adjusted(brightness: float = 0.0, - contrast: float = 1.0, - saturation: float = 1.0, - gamma: float = 1.0): - """ - Pipeable adjust function - apply image adjustments with pipe operator support. - - Args: - brightness (float): -1.0 to 1.0 (default: 0.0). - contrast (float): 0.0 to N (default: 1.0). - saturation (float): 0.0 to N (default: 1.0). - gamma (float): > 0 (default: 1.0). - - Returns: - Partial function that takes a frame and returns adjusted frame - - Examples: - pipe = adjusted(brightness=0.1, contrast=1.2) - pipe = letterboxed() | adjusted(saturation=0.8) - """ - return PipeableFunction(ImageEditor.adjust, brightness=brightness, contrast=contrast, saturation=saturation, gamma=gamma) - - -def greyscaled(): - """ - Pipeable greyscale function - convert frame to greyscale with pipe operator support. - - Returns: - Function that takes a frame and returns greyscale frame - - Examples: - pipe = greyscaled() - pipe = letterboxed() | greyscaled() - """ - return PipeableFunction(ImageEditor.greyscale) - - -def compressed_to_jpeg(quality: int = 80): - """ - Pipeable JPEG compression function - compress frame to JPEG with pipe operator support. - - Args: - quality (int): JPEG quality (0-100, higher = better quality) - - Returns: - Partial function that takes a frame and returns compressed JPEG bytes as Numpy array or None - - Examples: - pipe = compressed_to_jpeg(quality=95) - pipe = resized(target_size=(640, 480)) | compressed_to_jpeg() - """ - return PipeableFunction(ImageEditor.compress_to_jpeg, quality=quality) - - -def compressed_to_png(compression_level: int = 6): - """ - Pipeable PNG compression function - compress frame to PNG with pipe operator support. - - Args: - compression_level (int): PNG compression level (0-9, higher = better compression) - - Returns: - Partial function that takes a frame and returns compressed PNG bytes as Numpy array or None - - Examples: - pipe = compressed_to_png(compression_level=9) - pipe = letterboxed() | compressed_to_png() - """ - return PipeableFunction(ImageEditor.compress_to_png, compression_level=compression_level) diff --git a/tests/arduino/app_utils/image/test_image_editor.py b/tests/arduino/app_utils/image/test_image_editor.py index 49bcd982..d9e33819 100644 --- a/tests/arduino/app_utils/image/test_image_editor.py +++ b/tests/arduino/app_utils/image/test_image_editor.py @@ -4,7 +4,7 @@ import numpy as np import pytest -from arduino.app_utils.image.image_editor import ImageEditor +from arduino.app_utils.image.adjustments import letterbox, resize, adjust, split_channels, greyscale # FIXTURES @@ -95,47 +95,47 @@ def frame_any_dtype(request): def test_adjust_dtype_preservation(frame_any_dtype): """Tests that the dtype of the frame is preserved.""" dtype = frame_any_dtype.dtype - adjusted = ImageEditor.adjust(frame_any_dtype, brightness=0.1) + adjusted = adjust(frame_any_dtype, brightness=0.1) assert adjusted.dtype == dtype def test_adjust_no_op(frame_bgr_uint8): """Tests that default parameters do not change the frame.""" - adjusted = ImageEditor.adjust(frame_bgr_uint8) + adjusted = adjust(frame_bgr_uint8) assert np.array_equal(frame_bgr_uint8, adjusted) def test_adjust_brightness(frame_bgr_uint8): """Tests brightness adjustment.""" - brighter = ImageEditor.adjust(frame_bgr_uint8, brightness=0.1) - darker = ImageEditor.adjust(frame_bgr_uint8, brightness=-0.1) + brighter = adjust(frame_bgr_uint8, brightness=0.1) + darker = adjust(frame_bgr_uint8, brightness=-0.1) assert np.mean(brighter) > np.mean(frame_bgr_uint8) assert np.mean(darker) < np.mean(frame_bgr_uint8) def test_adjust_contrast(frame_bgr_uint8): """Tests contrast adjustment.""" - higher_contrast = ImageEditor.adjust(frame_bgr_uint8, contrast=1.5) - lower_contrast = ImageEditor.adjust(frame_bgr_uint8, contrast=0.5) + higher_contrast = adjust(frame_bgr_uint8, contrast=1.5) + lower_contrast = adjust(frame_bgr_uint8, contrast=0.5) assert np.std(higher_contrast) > np.std(frame_bgr_uint8) assert np.std(lower_contrast) < np.std(frame_bgr_uint8) def test_adjust_gamma(frame_bgr_uint8): """Tests gamma correction.""" # Gamma < 1.0 (e.g., 0.5) ==> brightens - brighter = ImageEditor.adjust(frame_bgr_uint8, gamma=0.5) + brighter = adjust(frame_bgr_uint8, gamma=0.5) # Gamma > 1.0 (e.g., 2.0) ==> darkens - darker = ImageEditor.adjust(frame_bgr_uint8, gamma=2.0) + darker = adjust(frame_bgr_uint8, gamma=2.0) assert np.mean(brighter) > np.mean(frame_bgr_uint8) assert np.mean(darker) < np.mean(frame_bgr_uint8) def test_adjust_saturation_to_greyscale(frame_bgr_uint8): """Tests that saturation=0.0 makes all color channels equal.""" - desaturated = ImageEditor.adjust(frame_bgr_uint8, saturation=0.0) - b, g, r = ImageEditor.split_channels(desaturated) + desaturated = adjust(frame_bgr_uint8, saturation=0.0) + b, g, r = split_channels(desaturated) assert np.allclose(b, g, atol=1) assert np.allclose(g, r, atol=1) def test_adjust_greyscale_input(frame_grey_uint8): """Tests that greyscale frames are handled safely.""" - adjusted = ImageEditor.adjust(frame_grey_uint8, saturation=1.5, brightness=0.1) + adjusted = adjust(frame_grey_uint8, saturation=1.5, brightness=0.1) assert adjusted.ndim == 2 assert adjusted.dtype == np.uint8 assert np.mean(adjusted) > np.mean(frame_grey_uint8) @@ -144,13 +144,13 @@ def test_adjust_bgra_input(frame_bgra_uint8): """Tests that BGRA frames are handled safely and alpha is preserved.""" original_alpha = frame_bgra_uint8[:,:,3] - adjusted = ImageEditor.adjust(frame_bgra_uint8, saturation=0.0, brightness=0.1) + adjusted = adjust(frame_bgra_uint8, saturation=0.0, brightness=0.1) assert adjusted.ndim == 3 assert adjusted.shape[2] == 4 assert adjusted.dtype == np.uint8 - b, g, r, a = ImageEditor.split_channels(adjusted) + b, g, r, a = split_channels(adjusted) assert np.allclose(b, g, atol=1) # Check desaturation assert np.allclose(g, r, atol=1) # Check desaturation assert np.array_equal(original_alpha, a) # Check alpha preservation @@ -158,10 +158,10 @@ def test_adjust_bgra_input(frame_bgra_uint8): def test_adjust_gamma_zero_error(frame_bgr_uint8): """Tests that gamma <= 0 raises a ValueError.""" with pytest.raises(ValueError, match="Gamma value must be greater than 0."): - ImageEditor.adjust(frame_bgr_uint8, gamma=0.0) + adjust(frame_bgr_uint8, gamma=0.0) with pytest.raises(ValueError, match="Gamma value must be greater than 0."): - ImageEditor.adjust(frame_bgr_uint8, gamma=-1.0) + adjust(frame_bgr_uint8, gamma=-1.0) def test_adjust_high_bit_depth_bgr(frame_bgr_uint16, frame_bgr_uint32): """ @@ -169,14 +169,14 @@ def test_adjust_high_bit_depth_bgr(frame_bgr_uint16, frame_bgr_uint32): This validates that the float64 conversion is working. """ # Test uint16 - brighter_16 = ImageEditor.adjust(frame_bgr_uint16, brightness=0.1) - darker_16 = ImageEditor.adjust(frame_bgr_uint16, brightness=-0.1) + brighter_16 = adjust(frame_bgr_uint16, brightness=0.1) + darker_16 = adjust(frame_bgr_uint16, brightness=-0.1) assert np.mean(brighter_16) > np.mean(frame_bgr_uint16) assert np.mean(darker_16) < np.mean(frame_bgr_uint16) # Test uint32 - brighter_32 = ImageEditor.adjust(frame_bgr_uint32, brightness=0.1) - darker_32 = ImageEditor.adjust(frame_bgr_uint32, brightness=-0.1) + brighter_32 = adjust(frame_bgr_uint32, brightness=0.1) + darker_32 = adjust(frame_bgr_uint32, brightness=-0.1) assert np.mean(brighter_32) > np.mean(frame_bgr_uint32) assert np.mean(darker_32) < np.mean(frame_bgr_uint32) @@ -187,19 +187,19 @@ def test_adjust_high_bit_depth_bgra(frame_bgra_uint16, frame_bgra_uint32): """ # Test uint16 original_alpha_16 = frame_bgra_uint16[:,:,3] - brighter_16 = ImageEditor.adjust(frame_bgra_uint16, brightness=0.1) + brighter_16 = adjust(frame_bgra_uint16, brightness=0.1) assert brighter_16.dtype == np.uint16 assert brighter_16.shape == frame_bgra_uint16.shape - _, _, _, a16 = ImageEditor.split_channels(brighter_16) + _, _, _, a16 = split_channels(brighter_16) assert np.array_equal(original_alpha_16, a16) assert np.mean(brighter_16) > np.mean(frame_bgra_uint16) # Test uint32 original_alpha_32 = frame_bgra_uint32[:,:,3] - brighter_32 = ImageEditor.adjust(frame_bgra_uint32, brightness=0.1) + brighter_32 = adjust(frame_bgra_uint32, brightness=0.1) assert brighter_32.dtype == np.uint32 assert brighter_32.shape == frame_bgra_uint32.shape - _, _, _, a32 = ImageEditor.split_channels(brighter_32) + _, _, _, a32 = split_channels(brighter_32) assert np.array_equal(original_alpha_32, a32) assert np.mean(original_alpha_32) > np.mean(frame_bgra_uint32) @@ -207,32 +207,32 @@ def test_adjust_high_bit_depth_bgra(frame_bgra_uint16, frame_bgra_uint32): def test_greyscale(frame_bgr_uint8, frame_bgra_uint8, frame_grey_uint8): """Tests the standalone greyscale function.""" # Test on BGR - greyscaled_bgr = ImageEditor.greyscale(frame_bgr_uint8) + greyscaled_bgr = greyscale(frame_bgr_uint8) assert greyscaled_bgr.ndim == 3 assert greyscaled_bgr.shape[2] == 3 - b, g, r = ImageEditor.split_channels(greyscaled_bgr) + b, g, r = split_channels(greyscaled_bgr) assert np.allclose(b, g, atol=1) assert np.allclose(g, r, atol=1) # Test on BGRA original_alpha = frame_bgra_uint8[:,:,3] - greyscaled_bgra = ImageEditor.greyscale(frame_bgra_uint8) + greyscaled_bgra = greyscale(frame_bgra_uint8) assert greyscaled_bgra.ndim == 3 assert greyscaled_bgra.shape[2] == 4 - b, g, r, a = ImageEditor.split_channels(greyscaled_bgra) + b, g, r, a = split_channels(greyscaled_bgra) assert np.allclose(b, g, atol=1) assert np.allclose(g, r, atol=1) assert np.array_equal(original_alpha, a) # Test on 2D Greyscale (should be no-op) - greyscaled_grey = ImageEditor.greyscale(frame_grey_uint8) + greyscaled_grey = greyscale(frame_grey_uint8) assert np.array_equal(frame_grey_uint8, greyscaled_grey) assert greyscaled_grey.ndim == 2 def test_greyscale_dtype_preservation(frame_any_dtype): """Tests that the dtype of the frame is preserved.""" dtype = frame_any_dtype.dtype - adjusted = ImageEditor.adjust(frame_any_dtype, brightness=0.1) + adjusted = adjust(frame_any_dtype, brightness=0.1) assert adjusted.dtype == dtype def test_greyscale_high_bit_depth(frame_bgr_uint16, frame_bgr_uint32): @@ -240,19 +240,19 @@ def test_greyscale_high_bit_depth(frame_bgr_uint16, frame_bgr_uint32): Tests that greyscale logic is correct on high bit-depth images. """ # Test uint16 - greyscaled_16 = ImageEditor.greyscale(frame_bgr_uint16) + greyscaled_16 = greyscale(frame_bgr_uint16) assert greyscaled_16.dtype == np.uint16 assert greyscaled_16.shape == frame_bgr_uint16.shape - b16, g16, r16 = ImageEditor.split_channels(greyscaled_16) + b16, g16, r16 = split_channels(greyscaled_16) assert np.allclose(b16, g16, atol=1) assert np.allclose(g16, r16, atol=1) assert np.mean(b16) != np.mean(frame_bgr_uint16[:,:,0]) # Test uint32 - greyscaled_32 = ImageEditor.greyscale(frame_bgr_uint32) + greyscaled_32 = greyscale(frame_bgr_uint32) assert greyscaled_32.dtype == np.uint32 assert greyscaled_32.shape == frame_bgr_uint32.shape - b32, g32, r32 = ImageEditor.split_channels(greyscaled_32) + b32, g32, r32 = split_channels(greyscaled_32) assert np.allclose(b32, g32, atol=1) assert np.allclose(g32, r32, atol=1) assert np.mean(b32) != np.mean(frame_bgr_uint32[:,:,0]) @@ -264,20 +264,20 @@ def test_high_bit_depth_greyscale_bgra_content(frame_bgra_uint16, frame_bgra_uin """ # Test uint16 original_alpha_16 = frame_bgra_uint16[:,:,3] - greyscaled_16 = ImageEditor.greyscale(frame_bgra_uint16) + greyscaled_16 = greyscale(frame_bgra_uint16) assert greyscaled_16.dtype == np.uint16 assert greyscaled_16.shape == frame_bgra_uint16.shape - b16, g16, r16, a16 = ImageEditor.split_channels(greyscaled_16) + b16, g16, r16, a16 = split_channels(greyscaled_16) assert np.allclose(b16, g16, atol=1) assert np.allclose(g16, r16, atol=1) assert np.array_equal(original_alpha_16, a16) # Test uint32 original_alpha_32 = frame_bgra_uint32[:,:,3] - greyscaled_32 = ImageEditor.greyscale(frame_bgra_uint32) + greyscaled_32 = greyscale(frame_bgra_uint32) assert greyscaled_32.dtype == np.uint32 assert greyscaled_32.shape == frame_bgra_uint32.shape - b32, g32, r32, a32 = ImageEditor.split_channels(greyscaled_32) + b32, g32, r32, a32 = split_channels(greyscaled_32) assert np.allclose(b32, g32, atol=1) assert np.allclose(g32, r32, atol=1) assert np.array_equal(original_alpha_32, a32) @@ -288,17 +288,17 @@ def test_resize_shape_and_dtype(frame_bgr_uint8, frame_bgra_uint8, frame_grey_ui target_w, target_h = 50, 75 # Test BGR - resized_bgr = ImageEditor.resize(frame_bgr_uint8, (target_w, target_h)) + resized_bgr = resize(frame_bgr_uint8, (target_w, target_h)) assert resized_bgr.shape == (target_h, target_w, 3) assert resized_bgr.dtype == frame_bgr_uint8.dtype # Test BGRA - resized_bgra = ImageEditor.resize(frame_bgra_uint8, (target_w, target_h)) + resized_bgra = resize(frame_bgra_uint8, (target_w, target_h)) assert resized_bgra.shape == (target_h, target_w, 4) assert resized_bgra.dtype == frame_bgra_uint8.dtype # Test Greyscale - resized_grey = ImageEditor.resize(frame_grey_uint8, (target_w, target_h)) + resized_grey = resize(frame_grey_uint8, (target_w, target_h)) assert resized_grey.shape == (target_h, target_w) assert resized_grey.dtype == frame_grey_uint8.dtype @@ -312,7 +312,7 @@ def test_letterbox_wide_image(frame_bgr_wide): # y_offset = (200 - 100) // 2 = 50 # x_offset = (200 - 200) // 2 = 0 - letterboxed = ImageEditor.letterbox(frame_bgr_wide, (target_w, target_h), color=0) + letterboxed = letterbox(frame_bgr_wide, (target_w, target_h), color=0) assert letterboxed.shape == (target_h, target_w, 3) assert letterboxed.dtype == frame_bgr_wide.dtype @@ -336,7 +336,7 @@ def test_letterbox_tall_image(frame_bgr_tall): # y_offset = (200 - 200) // 2 = 0 # x_offset = (200 - 100) // 2 = 50 - letterboxed = ImageEditor.letterbox(frame_bgr_tall, (target_w, target_h), color=0) + letterboxed = letterbox(frame_bgr_tall, (target_w, target_h), color=0) assert letterboxed.shape == (target_h, target_w, 3) assert letterboxed.dtype == frame_bgr_tall.dtype @@ -353,7 +353,7 @@ def test_letterbox_tall_image(frame_bgr_tall): def test_letterbox_color(frame_bgr_tall): """Tests letterboxing with a non-default color.""" white = (255, 255, 255) - letterboxed = ImageEditor.letterbox(frame_bgr_tall, (200, 200), color=white) + letterboxed = letterbox(frame_bgr_tall, (200, 200), color=white) # Check padding (left column, white) assert np.all(letterboxed[0, 0] == white) @@ -366,7 +366,7 @@ def test_letterbox_bgra(frame_bgra_uint8): # Opaque black padding padding = (0, 0, 0, 255) - letterboxed = ImageEditor.letterbox(frame_bgra_uint8, (target_w, target_h), color=padding) + letterboxed = letterbox(frame_bgra_uint8, (target_w, target_h), color=padding) assert letterboxed.shape == (target_h, target_w, 4) # Check no padding (corner, original BGRA point) @@ -377,7 +377,7 @@ def test_letterbox_bgra(frame_bgra_uint8): def test_letterbox_greyscale(frame_grey_uint8): """Tests letterboxing on a 2D greyscale image.""" target_w, target_h = 200, 200 - letterboxed = ImageEditor.letterbox(frame_grey_uint8, (target_w, target_h), color=0) + letterboxed = letterbox(frame_grey_uint8, (target_w, target_h), color=0) assert letterboxed.shape == (target_h, target_w) assert letterboxed.ndim == 2 @@ -389,20 +389,20 @@ def test_letterbox_greyscale(frame_grey_uint8): def test_letterbox_none_target_size(frame_bgr_wide, frame_bgr_tall): """Tests that target_size=None creates a square based on the longest side.""" # frame_bgr_wide is 200x100, longest side is 200 - letterboxed_wide = ImageEditor.letterbox(frame_bgr_wide, target_size=None) + letterboxed_wide = letterbox(frame_bgr_wide, target_size=None) assert letterboxed_wide.shape == (200, 200, 3) # frame_bgr_tall is 100x200, longest side is 200 - letterboxed_tall = ImageEditor.letterbox(frame_bgr_tall, target_size=None) + letterboxed_tall = letterbox(frame_bgr_tall, target_size=None) assert letterboxed_tall.shape == (200, 200, 3) def test_letterbox_color_tuple_error(frame_bgr_uint8): """Tests that a mismatched padding tuple raises a ValueError.""" with pytest.raises(ValueError, match="color length"): # BGR (3-ch) frame with 4-ch padding - ImageEditor.letterbox(frame_bgr_uint8, (200, 200), color=(0, 0, 0, 0)) + letterbox(frame_bgr_uint8, (200, 200), color=(0, 0, 0, 0)) with pytest.raises(ValueError, match="color length"): # BGRA (4-ch) frame with 3-ch padding frame_bgra = create_bgra_frame(np.uint8) - ImageEditor.letterbox(frame_bgra, (200, 200), color=(0, 0, 0)) + letterbox(frame_bgra, (200, 200), color=(0, 0, 0)) From 4ef3eedbd512dbc19b14f60640e5e68bf0865931 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 00:52:34 +0100 Subject: [PATCH 21/86] doc: explain adjustments argument --- src/arduino/app_peripherals/camera/README.md | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/arduino/app_peripherals/camera/README.md b/src/arduino/app_peripherals/camera/README.md index 1bb32852..a349e347 100644 --- a/src/arduino/app_peripherals/camera/README.md +++ b/src/arduino/app_peripherals/camera/README.md @@ -41,6 +41,34 @@ with Camera(source, **options) as camera: # Camera automatically stopped when exiting ``` +## Frame Adjustments + +The `adjustments` parameter allows you to apply custom transformations to captured frames. This parameter accepts a callable that takes a numpy array (the frame) and returns a modified numpy array. It's also possible to build adjustment pipelines by concatenating these functions with the pipe (|) operator + +```python +import cv2 +from arduino.app_peripherals.camera import Camera +from arduino.app_utils.image import greyscaled + + +def blurred(): + def apply_blur(frame): + return cv2.GaussianBlur(frame, (15, 15), 0) + return PipeableFunction(apply_blur) + +# Using adjustments with Camera +with Camera(0, adjustments=greyscaled) as camera: + frame = camera.capture() + # frame is now grayscale + +# Or with multiple transformations +with Camera(0, adjustments=greyscaled | blurred) as camera: + frame = camera.capture() + # frame is now greyscaled and blurred +``` + +See the arduino.app_utils.image module for more supported adjustments. + ## Camera Types The Camera class provides automatic camera type detection based on the format of its source argument. keyword arguments will be propagated to the underlying implementation. From 563a711b74f872cdd185bcafb95e9b5dfbcc5666 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 01:05:38 +0100 Subject: [PATCH 22/86] run fmt --- .../examples/object_detection_example.py | 2 +- .../examples/visual_anomaly_example.py | 2 +- .../app_peripherals/camera/__init__.py | 4 +- .../app_peripherals/camera/base_camera.py | 16 +-- src/arduino/app_peripherals/camera/camera.py | 38 ++++--- src/arduino/app_peripherals/camera/errors.py | 6 ++ .../camera/examples/1_initialize.py | 4 +- .../camera/examples/2_capture_image.py | 2 +- .../camera/examples/3_capture_video.py | 2 +- .../camera/examples/4_capture_hls.py | 4 +- .../camera/examples/5_capture_rtsp.py | 2 +- .../camera/examples/6_capture_websocket.py | 2 +- .../app_peripherals/camera/ip_camera.py | 39 +++---- .../app_peripherals/camera/v4l_camera.py | 32 +++--- .../camera/websocket_camera.py | 87 +++++++-------- src/arduino/app_utils/image/__init__.py | 2 +- src/arduino/app_utils/image/adjustments.py | 67 ++++-------- src/arduino/app_utils/image/image.py | 6 +- src/arduino/app_utils/image/pipeable.py | 41 +++---- .../app_utils/image/test_image_editor.py | 100 ++++++++++++------ .../arduino/app_utils/image/test_pipeable.py | 90 +++++++++------- 21 files changed, 278 insertions(+), 270 deletions(-) diff --git a/src/arduino/app_bricks/object_detection/examples/object_detection_example.py b/src/arduino/app_bricks/object_detection/examples/object_detection_example.py index 5edc047a..51a4b4b4 100644 --- a/src/arduino/app_bricks/object_detection/examples/object_detection_example.py +++ b/src/arduino/app_bricks/object_detection/examples/object_detection_example.py @@ -23,4 +23,4 @@ bounding_box = obj_det.get("bounding_box_xyxy", None) # Draw the bounding boxes -out_image = draw_bounding_boxes(img, out) \ No newline at end of file +out_image = draw_bounding_boxes(img, out) diff --git a/src/arduino/app_bricks/visual_anomaly_detection/examples/visual_anomaly_example.py b/src/arduino/app_bricks/visual_anomaly_detection/examples/visual_anomaly_example.py index e5ba99e6..cbab3310 100644 --- a/src/arduino/app_bricks/visual_anomaly_detection/examples/visual_anomaly_example.py +++ b/src/arduino/app_bricks/visual_anomaly_detection/examples/visual_anomaly_example.py @@ -21,4 +21,4 @@ bounding_box = anomaly.get("bounding_box_xyxy", None) # Draw the bounding boxes -out_image = draw_anomaly_markers(img, out) \ No newline at end of file +out_image = draw_anomaly_markers(img, out) diff --git a/src/arduino/app_peripherals/camera/__init__.py b/src/arduino/app_peripherals/camera/__init__.py index be1c27a5..1142ae66 100644 --- a/src/arduino/app_peripherals/camera/__init__.py +++ b/src/arduino/app_peripherals/camera/__init__.py @@ -7,7 +7,7 @@ from .ip_camera import IPCamera from .websocket_camera import WebSocketCamera from .errors import * - + __all__ = [ "Camera", "V4LCamera", @@ -18,4 +18,4 @@ "CameraOpenError", "CameraConfigError", "CameraTransformError", -] \ No newline at end of file +] diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index 68a454a5..71789267 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -18,7 +18,7 @@ class BaseCamera(ABC): """ Abstract base class for camera implementations. - + This class defines the common interface that all camera implementations must follow, providing a unified API regardless of the underlying camera protocol or type. """ @@ -26,7 +26,7 @@ class BaseCamera(ABC): def __init__( self, resolution: Optional[Tuple[int, int]] = (640, 480), - fps: int = 10, + fps: int = 10, adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, ): """ @@ -53,7 +53,7 @@ def start(self) -> None: with self._camera_lock: if self._is_started: return - + try: self._open_camera() self._is_started = True @@ -67,7 +67,7 @@ def stop(self) -> None: with self._camera_lock: if not self._is_started: return - + try: self._close_camera() self._is_started = False @@ -99,19 +99,19 @@ def _extract_frame(self) -> Optional[np.ndarray]: if not self._is_started: return None - + frame = self._read_frame() if frame is None: return None - + self._last_capture_time = time.monotonic() - + if self.adjustments is not None: try: frame = self.adjustments(frame) except Exception as e: raise CameraTransformError(f"Frame transformation failed ({self.adjustments}): {e}") - + return frame def is_started(self) -> bool: diff --git a/src/arduino/app_peripherals/camera/camera.py b/src/arduino/app_peripherals/camera/camera.py index eda32885..c978b34d 100644 --- a/src/arduino/app_peripherals/camera/camera.py +++ b/src/arduino/app_peripherals/camera/camera.py @@ -12,7 +12,7 @@ class Camera: """ Unified Camera class that can be configured for different camera types. - + This class serves as both a factory and a wrapper, automatically creating the appropriate camera implementation based on the provided configuration. @@ -24,10 +24,10 @@ class Camera: Note: constructor arguments (except source) must be provided in keyword format to forward them correctly to the specific camera implementations. """ - + def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: """Create a camera instance based on the source type. - + Args: source (Union[str, int]): Camera source identifier. Supports: - int: V4L camera index (e.g., 0, 1) @@ -36,7 +36,7 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: - str: WebSocket URL for input streams (e.g., "ws://0.0.0.0:8080") **kwargs: Camera-specific configuration parameters grouped by type: Common Parameters: - resolution (tuple, optional): Frame resolution as (width, height). + resolution (tuple, optional): Frame resolution as (width, height). Default: (640, 480) fps (int, optional): Target frames per second. Default: 10 adjustments (callable, optional): Function pipeline to adjust frames that takes a @@ -52,34 +52,34 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: host (str, optional): WebSocket server host. Default: "0.0.0.0" port (int, optional): WebSocket server port. Default: 8080 timeout (float, optional): Connection timeout in seconds. Default: 10.0 - frame_format (str, optional): Expected frame format ("base64", "binary", + frame_format (str, optional): Expected frame format ("base64", "binary", "json"). Default: "base64" - + Returns: BaseCamera: Appropriate camera implementation instance - + Raises: CameraConfigError: If source type is not supported or parameters are invalid CameraOpenError: If the camera cannot be opened - + Examples: V4L Camera: - + ```python camera = Camera(0, resolution=(640, 480), fps=30) camera = Camera("/dev/video1", fps=15) ``` - + IP Camera: - + ```python camera = Camera("rtsp://192.168.1.100:554/stream", username="admin", password="secret", timeout=15.0) camera = Camera("http://192.168.1.100:8080/video.mp4") ``` - + WebSocket Camera: - - ```python + + ```python camera = Camera("ws://0.0.0.0:8080", frame_format="json") camera = Camera("ws://192.168.1.100:8080", timeout=5) ``` @@ -87,22 +87,26 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: if isinstance(source, int) or (isinstance(source, str) and source.isdigit()): # V4L Camera from .v4l_camera import V4LCamera + return V4LCamera(source, **kwargs) elif isinstance(source, str): parsed = urlparse(source) - if parsed.scheme in ['http', 'https', 'rtsp']: + if parsed.scheme in ["http", "https", "rtsp"]: # IP Camera from .ip_camera import IPCamera + return IPCamera(source, **kwargs) - elif parsed.scheme in ['ws', 'wss']: + elif parsed.scheme in ["ws", "wss"]: # WebSocket Camera - extract host and port from URL from .websocket_camera import WebSocketCamera + host = parsed.hostname or "localhost" port = parsed.port or 8080 return WebSocketCamera(host=host, port=port, **kwargs) - elif source.startswith('/dev/video') or source.isdigit(): + elif source.startswith("/dev/video") or source.isdigit(): # V4L device path or index as string from .v4l_camera import V4LCamera + return V4LCamera(source, **kwargs) else: raise CameraConfigError(f"Unsupported camera source: {source}") diff --git a/src/arduino/app_peripherals/camera/errors.py b/src/arduino/app_peripherals/camera/errors.py index 69745f79..6b20999f 100644 --- a/src/arduino/app_peripherals/camera/errors.py +++ b/src/arduino/app_peripherals/camera/errors.py @@ -2,26 +2,32 @@ # # SPDX-License-Identifier: MPL-2.0 + class CameraError(Exception): """Base exception for camera-related errors.""" + pass class CameraOpenError(CameraError): """Exception raised when the camera cannot be opened.""" + pass class CameraReadError(CameraError): """Exception raised when reading from camera fails.""" + pass class CameraConfigError(CameraError): """Exception raised when camera configuration is invalid.""" + pass class CameraTransformError(CameraError): """Exception raised when frame transformation fails.""" + pass diff --git a/src/arduino/app_peripherals/camera/examples/1_initialize.py b/src/arduino/app_peripherals/camera/examples/1_initialize.py index 24a2ae9b..85ed8dd0 100644 --- a/src/arduino/app_peripherals/camera/examples/1_initialize.py +++ b/src/arduino/app_peripherals/camera/examples/1_initialize.py @@ -7,11 +7,11 @@ from arduino.app_peripherals.camera import Camera, V4LCamera -default = Camera() # Uses default camera (V4L) +default = Camera() # Uses default camera (V4L) # The following two are equivalent camera = Camera(2, resolution=(640, 480), fps=15) # Infers camera type v4l = V4LCamera(2, (640, 480), 15) # Explicitly requests V4L camera # Note: constructor arguments (except source) must be provided in keyword -# format to forward them correctly to the specific camera implementations. \ No newline at end of file +# format to forward them correctly to the specific camera implementations. diff --git a/src/arduino/app_peripherals/camera/examples/2_capture_image.py b/src/arduino/app_peripherals/camera/examples/2_capture_image.py index 439b4636..f0e92f10 100644 --- a/src/arduino/app_peripherals/camera/examples/2_capture_image.py +++ b/src/arduino/app_peripherals/camera/examples/2_capture_image.py @@ -11,4 +11,4 @@ camera = Camera() camera.start() image: np.ndarray = camera.capture() -camera.stop() \ No newline at end of file +camera.stop() diff --git a/src/arduino/app_peripherals/camera/examples/3_capture_video.py b/src/arduino/app_peripherals/camera/examples/3_capture_video.py index 75fdbd01..4e38ad03 100644 --- a/src/arduino/app_peripherals/camera/examples/3_capture_video.py +++ b/src/arduino/app_peripherals/camera/examples/3_capture_video.py @@ -18,4 +18,4 @@ image: np.ndarray = camera.capture() # You can process the image here if needed, e.g save it -camera.stop() \ No newline at end of file +camera.stop() diff --git a/src/arduino/app_peripherals/camera/examples/4_capture_hls.py b/src/arduino/app_peripherals/camera/examples/4_capture_hls.py index 0371e94b..0a7a5e5d 100644 --- a/src/arduino/app_peripherals/camera/examples/4_capture_hls.py +++ b/src/arduino/app_peripherals/camera/examples/4_capture_hls.py @@ -10,7 +10,7 @@ # Capture a freely available HLS playlist for testing # Note: Public streams can be unreliable and may go offline without notice. -url = 'https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8' +url = "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8" camera = Camera(url) camera.start() @@ -20,4 +20,4 @@ image: np.ndarray = camera.capture() # You can process the image here if needed, e.g save it -camera.stop() \ No newline at end of file +camera.stop() diff --git a/src/arduino/app_peripherals/camera/examples/5_capture_rtsp.py b/src/arduino/app_peripherals/camera/examples/5_capture_rtsp.py index a5f15754..955e5e66 100644 --- a/src/arduino/app_peripherals/camera/examples/5_capture_rtsp.py +++ b/src/arduino/app_peripherals/camera/examples/5_capture_rtsp.py @@ -20,4 +20,4 @@ image: np.ndarray = camera.capture() # You can process the image here if needed, e.g save it -camera.stop() \ No newline at end of file +camera.stop() diff --git a/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py b/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py index 44f7bb77..14235760 100644 --- a/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py +++ b/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py @@ -17,4 +17,4 @@ image: np.ndarray = camera.capture() # You can process the image here if needed, e.g save it -camera.stop() \ No newline at end of file +camera.stop() diff --git a/src/arduino/app_peripherals/camera/ip_camera.py b/src/arduino/app_peripherals/camera/ip_camera.py index 9b494bac..5d20a75e 100644 --- a/src/arduino/app_peripherals/camera/ip_camera.py +++ b/src/arduino/app_peripherals/camera/ip_camera.py @@ -19,7 +19,7 @@ class IPCamera(BaseCamera): """ IP Camera implementation for network-based cameras. - + Supports RTSP, HTTP, and HTTPS camera streams. Can handle authentication and various streaming protocols. """ @@ -27,11 +27,11 @@ class IPCamera(BaseCamera): def __init__( self, url: str, - username: Optional[str] = None, + username: Optional[str] = None, password: Optional[str] = None, timeout: int = 10, resolution: Optional[Tuple[int, int]] = (640, 480), - fps: int = 10, + fps: int = 10, adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, ): """ @@ -40,7 +40,7 @@ def __init__( Args: url: Camera stream URL (i.e. rtsp://..., http://..., https://...) username: Optional authentication username - password: Optional authentication password + password: Optional authentication password timeout: Connection timeout in seconds resolution (tuple, optional): Resolution as (width, height). None uses default resolution. fps (int): Frames per second to capture from the camera. @@ -55,14 +55,14 @@ def __init__( self.logger = logger self._cap = None - + self._validate_url() def _validate_url(self) -> None: """Validate the camera URL format.""" try: parsed = urlparse(self.url) - if parsed.scheme not in ['http', 'https', 'rtsp']: + if parsed.scheme not in ["http", "https", "rtsp"]: raise CameraConfigError(f"Unsupported URL scheme: {parsed.scheme}") except Exception as e: raise CameraConfigError(f"Invalid URL format: {e}") @@ -70,11 +70,11 @@ def _validate_url(self) -> None: def _open_camera(self) -> None: """Open the IP camera connection.""" url = self._build_url() - + # Test connectivity first for HTTP streams - if self.url.startswith(('http://', 'https://')): + if self.url.startswith(("http://", "https://")): self._test_http_connectivity() - + self._cap = cv2.VideoCapture(url) self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to get latest frames if not self._cap.isOpened(): @@ -94,14 +94,14 @@ def _build_url(self) -> str: # If no username or password provided as parameters, return original URL if not self.username or not self.password: return self.url - + parsed = urlparse(self.url) # Override any URL credentials if credentials are provided auth_netloc = f"{self.username}:{self.password}@{parsed.hostname}" if parsed.port: auth_netloc += f":{parsed.port}" - + return f"{parsed.scheme}://{auth_netloc}{parsed.path}" def _test_http_connectivity(self) -> None: @@ -110,19 +110,12 @@ def _test_http_connectivity(self) -> None: auth = None if self.username and self.password: auth = (self.username, self.password) - - response = requests.head( - self.url, - auth=auth, - timeout=self.timeout, - allow_redirects=True - ) - + + response = requests.head(self.url, auth=auth, timeout=self.timeout, allow_redirects=True) + if response.status_code not in [200, 206]: # 206 for partial content - raise CameraOpenError( - f"HTTP camera returned status {response.status_code}: {self.url}" - ) - + raise CameraOpenError(f"HTTP camera returned status {response.status_code}: {self.url}") + except requests.RequestException as e: raise CameraOpenError(f"Cannot connect to HTTP camera {self.url}: {e}") diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py index b95e8b03..06afde17 100644 --- a/src/arduino/app_peripherals/camera/v4l_camera.py +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -19,7 +19,7 @@ class V4LCamera(BaseCamera): """ V4L (Video4Linux) camera implementation for USB and local cameras. - + This class handles USB cameras and other V4L-compatible devices on Linux systems. It supports both device indices and device paths. """ @@ -28,7 +28,7 @@ def __init__( self, device: Union[str, int] = 0, resolution: Optional[Tuple[int, int]] = (640, 480), - fps: int = 10, + fps: int = 10, adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, ): """ @@ -46,25 +46,25 @@ def __init__( super().__init__(resolution, fps, adjustments) self.device_index = self._resolve_camera_id(device) self.logger = logger - + self._cap = None def _resolve_camera_id(self, device: Union[str, int]) -> int: """ Resolve camera identifier to a numeric device ID. - + Args: device: Camera identifier - + Returns: Numeric camera device ID - + Raises: CameraOpenError: If camera cannot be resolved """ if isinstance(device, int): return device - + if isinstance(device, str): # If it's a numeric string, convert directly if device.isdigit(): @@ -76,17 +76,17 @@ def _resolve_camera_id(self, device: Union[str, int]) -> int: else: # Fallback to direct device ID if mapping not available return device_idx - + # If it's a device path like "/dev/video0" - if device.startswith('/dev/video'): - return int(device.replace('/dev/video', '')) - + if device.startswith("/dev/video"): + return int(device.replace("/dev/video", "")) + raise CameraOpenError(f"Cannot resolve camera identifier: {device}") def _get_video_devices_by_index(self) -> Dict[int, str]: """ Map camera indices to device numbers by reading /dev/v4l/by-id/. - + Returns: Dict mapping index to device number """ @@ -131,7 +131,7 @@ def _open_camera(self) -> None: if self.resolution and self.resolution[0] and self.resolution[1]: self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.resolution[0]) self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.resolution[1]) - + # Verify resolution setting actual_width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)) actual_height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) @@ -141,15 +141,13 @@ def _open_camera(self) -> None: f"instead of requested {self.resolution[0]}x{self.resolution[1]}" ) self.resolution = (actual_width, actual_height) - + if self.fps: self._cap.set(cv2.CAP_PROP_FPS, self.fps) actual_fps = int(self._cap.get(cv2.CAP_PROP_FPS)) if actual_fps != self.fps: - logger.warning( - f"Camera {self.device_index} FPS set to {actual_fps} instead of requested {self.fps}" - ) + logger.warning(f"Camera {self.device_index} FPS set to {actual_fps} instead of requested {self.fps}") self.fps = actual_fps logger.info(f"Opened V4L camera with index {self.device_index}") diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 65995be4..194fcd05 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -25,7 +25,7 @@ class WebSocketCamera(BaseCamera): """ WebSocket Camera implementation that hosts a WebSocket server. - + This camera acts as a WebSocket server that receives frames from connected clients. Only one client can be connected at a time. @@ -42,7 +42,7 @@ def __init__( timeout: int = 10, frame_format: str = "base64", resolution: Optional[Tuple[int, int]] = (640, 480), - fps: int = 10, + fps: int = 10, adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, ): """ @@ -59,13 +59,13 @@ def __init__( a numpy array and returns a numpy array. Default: None """ super().__init__(resolution, fps, adjustments) - + self.host = host self.port = port self.timeout = timeout self.frame_format = frame_format self.logger = logger - + self._frame_queue = queue.Queue(1) self._server = None self._loop = None @@ -77,12 +77,9 @@ def __init__( def _open_camera(self) -> None: """Start the WebSocket server.""" # Start server in separate thread with its own event loop - self._server_thread = threading.Thread( - target=self._start_server_thread, - daemon=True - ) + self._server_thread = threading.Thread(target=self._start_server_thread, daemon=True) self._server_thread.start() - + # Wait for server to start start_time = time.time() start_timeout = 10 @@ -90,7 +87,7 @@ def _open_camera(self) -> None: if self._server is not None: break time.sleep(0.1) - + if self._server is None: raise CameraOpenError(f"Failed to start WebSocket server on {self.host}:{self.port}") @@ -110,7 +107,7 @@ async def _start_server(self) -> None: """Start the WebSocket server.""" try: self._stop_event.clear() - + self._server = await websockets.serve( self._ws_handler, self.host, @@ -120,11 +117,11 @@ async def _start_server(self) -> None: close_timeout=self.timeout, ping_interval=20, ) - + logger.info(f"WebSocket camera server started on {self.host}:{self.port}") - + await self._stop_event.wait() - + except Exception as e: logger.error(f"Error starting WebSocket server: {e}") raise @@ -136,27 +133,23 @@ async def _start_server(self) -> None: async def _ws_handler(self, conn: websockets.ServerConnection) -> None: """Handle a connected WebSocket client. Only one client allowed at a time.""" client_addr = f"{conn.remote_address[0]}:{conn.remote_address[1]}" - + async with self._client_lock: if self._client is not None: # Reject the new client logger.warning(f"Rejecting client {client_addr}: only one client allowed at a time") try: - await conn.send(json.dumps({ - "error": "Server busy", - "message": "Only one client connection allowed at a time", - "code": 1000 - })) + await conn.send(json.dumps({"error": "Server busy", "message": "Only one client connection allowed at a time", "code": 1000})) await conn.close(code=1000, reason="Server busy - only one client allowed") except Exception as e: logger.warning(f"Error sending rejection message to {client_addr}: {e}") return - + # Accept the client self._client = conn - + logger.info(f"Client connected: {client_addr}") - + try: # Send welcome message try: @@ -184,7 +177,7 @@ async def _ws_handler(self, conn: websockets.ServerConnection) -> None: self._frame_queue.get_nowait() except queue.Empty: continue - + except websockets.exceptions.ConnectionClosed: logger.info(f"Client disconnected: {client_addr}") except Exception as e: @@ -204,36 +197,36 @@ async def _parse_message(self, message) -> Optional[np.ndarray]: image_data = base64.b64decode(message) else: image_data = base64.b64decode(message.decode()) - + # Decode image nparr = np.frombuffer(image_data, np.uint8) frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) return frame - + elif self.frame_format == "binary": # Expect raw binary image data if isinstance(message, str): image_data = message.encode() else: image_data = message - + nparr = np.frombuffer(image_data, np.uint8) frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) return frame - + elif self.frame_format == "json": # Expect JSON with image data if isinstance(message, bytes): message = message.decode() - + data = json.loads(message) - + if "image" in data: image_data = base64.b64decode(data["image"]) nparr = np.frombuffer(image_data, np.uint8) frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) return frame - + elif "frame" in data: # Handle different frame data formats frame_data = data["frame"] @@ -242,9 +235,9 @@ async def _parse_message(self, message) -> Optional[np.ndarray]: nparr = np.frombuffer(image_data, np.uint8) frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) return frame - + return None - + except Exception as e: logger.warning(f"Error parsing message: {e}") return None @@ -253,10 +246,7 @@ def _close_camera(self): """Stop the WebSocket server.""" # Signal async stop event if it exists if self._loop and not self._loop.is_closed(): - future = asyncio.run_coroutine_threadsafe( - self._set_async_stop_event(), - self._loop - ) + future = asyncio.run_coroutine_threadsafe(self._set_async_stop_event(), self._loop) try: future.result(timeout=1.0) except CancelledError as e: @@ -265,18 +255,18 @@ def _close_camera(self): logger.debug(f"Error setting async stop event: TimeoutError") except Exception as e: logger.warning(f"Error setting async stop event: {e}") - + # Wait for server thread to finish if self._server_thread and self._server_thread.is_alive(): self._server_thread.join(timeout=10.0) - + # Clear frame queue try: while True: self._frame_queue.get_nowait() except queue.Empty: pass - + # Reset state self._server = None self._loop = None @@ -312,10 +302,10 @@ def _read_frame(self) -> Optional[np.ndarray]: def _send_message_to_client(self, message: Union[str, bytes, dict]) -> None: """ Send a message to the connected client (if any). - + Args: message: Message to send to the client - + Raises: RuntimeError: If the event loop is not running or closed ConnectionError: If no client is connected @@ -323,16 +313,13 @@ def _send_message_to_client(self, message: Union[str, bytes, dict]) -> None: """ if not self._loop or self._loop.is_closed(): raise RuntimeError("WebSocket server event loop is not running") - + if self._client is None: raise ConnectionError("No client connected to send message to") - + # Schedule message sending in the server's event loop - future = asyncio.run_coroutine_threadsafe( - self._send_to_client(message), - self._loop - ) - + future = asyncio.run_coroutine_threadsafe(self._send_to_client(message), self._loop) + try: future.result(timeout=5.0) except Exception as e: @@ -343,7 +330,7 @@ async def _send_to_client(self, message: Union[str, bytes, dict]) -> None: """Send message to a single client.""" if isinstance(message, dict): message = json.dumps(message) - + try: await self._client.send(message) except Exception as e: diff --git a/src/arduino/app_utils/image/__init__.py b/src/arduino/app_utils/image/__init__.py index 30827152..e9fc4196 100644 --- a/src/arduino/app_utils/image/__init__.py +++ b/src/arduino/app_utils/image/__init__.py @@ -24,4 +24,4 @@ "compressed_to_jpeg", "compressed_to_png", "PipeableFunction", -] \ No newline at end of file +] diff --git a/src/arduino/app_utils/image/adjustments.py b/src/arduino/app_utils/image/adjustments.py index 7093b845..06f109dd 100644 --- a/src/arduino/app_utils/image/adjustments.py +++ b/src/arduino/app_utils/image/adjustments.py @@ -24,10 +24,12 @@ """ -def letterbox(frame: np.ndarray, - target_size: Optional[Tuple[int, int]] = None, - color: int | Tuple[int, int, int] = (114, 114, 114), - interpolation: int = cv2.INTER_LINEAR) -> np.ndarray: +def letterbox( + frame: np.ndarray, + target_size: Optional[Tuple[int, int]] = None, + color: int | Tuple[int, int, int] = (114, 114, 114), + interpolation: int = cv2.INTER_LINEAR, +) -> np.ndarray: """ Add letterboxing to frame to achieve target size while maintaining aspect ratio. @@ -59,18 +61,16 @@ def letterbox(frame: np.ndarray, if frame.ndim == 2: # Greyscale - if hasattr(color, '__len__'): + if hasattr(color, "__len__"): color = color[0] canvas = np.full((target_h, target_w), color, dtype=original_dtype) else: # Colored (BGR/BGRA) channels = frame.shape[2] - if not hasattr(color, '__len__'): + if not hasattr(color, "__len__"): color = (color,) * channels elif len(color) != channels: - raise ValueError( - f"color length ({len(color)}) must match frame channels ({channels})." - ) + raise ValueError(f"color length ({len(color)}) must match frame channels ({channels}).") canvas = np.full((target_h, target_w, channels), color, dtype=original_dtype) # Calculate offsets to center the image @@ -78,15 +78,12 @@ def letterbox(frame: np.ndarray, x_offset = (target_w - new_w) // 2 # Paste the resized image onto the canvas - canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized_frame + canvas[y_offset : y_offset + new_h, x_offset : x_offset + new_w] = resized_frame return canvas -def resize(frame: np.ndarray, - target_size: Tuple[int, int], - maintain_ratio: bool = False, - interpolation: int = cv2.INTER_LINEAR) -> np.ndarray: +def resize(frame: np.ndarray, target_size: Tuple[int, int], maintain_ratio: bool = False, interpolation: int = cv2.INTER_LINEAR) -> np.ndarray: """ Resize frame to target size. @@ -105,11 +102,7 @@ def resize(frame: np.ndarray, return cv2.resize(frame, (target_size[0], target_size[1]), interpolation=interpolation) -def adjust(frame: np.ndarray, - brightness: float = 0.0, - contrast: float = 1.0, - saturation: float = 1.0, - gamma: float = 1.0) -> np.ndarray: +def adjust(frame: np.ndarray, brightness: float = 0.0, contrast: float = 1.0, saturation: float = 1.0, gamma: float = 1.0) -> np.ndarray: """ Apply image adjustments to a BGR or BGRA frame, preserving channel count and data type. @@ -242,9 +235,7 @@ def greyscale(frame: np.ndarray) -> np.ndarray: # Convert to greyscale using standard BT.709 weights # GREY = 0.0722 * B + 0.7152 * G + 0.2126 * R - grey_float = (0.0722 * frame_float[:, :, 0] + - 0.7152 * frame_float[:, :, 1] + - 0.2126 * frame_float[:, :, 2]) + grey_float = 0.0722 * frame_float[:, :, 0] + 0.7152 * frame_float[:, :, 1] + 0.2126 * frame_float[:, :, 2] # Convert back to original dtype final_grey = (grey_float * max_val).astype(original_dtype) @@ -258,6 +249,7 @@ def greyscale(frame: np.ndarray) -> np.ndarray: return final_frame + def compress_to_jpeg(frame: np.ndarray, quality: int = 80) -> Optional[np.ndarray]: """ Compress frame to JPEG format. @@ -271,11 +263,7 @@ def compress_to_jpeg(frame: np.ndarray, quality: int = 80) -> Optional[np.ndarra """ quality = int(quality) # Gstreamer doesn't like quality to be float try: - success, encoded = cv2.imencode( - '.jpg', - frame, - [cv2.IMWRITE_JPEG_QUALITY, quality] - ) + success, encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality]) return encoded if success else None except Exception: return None @@ -294,11 +282,7 @@ def compress_to_png(frame: np.ndarray, compression_level: int = 6) -> Optional[n """ compression_level = int(compression_level) # Gstreamer doesn't like compression_level to be float try: - success, encoded = cv2.imencode( - '.png', - frame, - [cv2.IMWRITE_PNG_COMPRESSION, compression_level] - ) + success, encoded = cv2.imencode(".png", frame, [cv2.IMWRITE_PNG_COMPRESSION, compression_level]) return encoded if success else None except Exception: return None @@ -329,8 +313,8 @@ def pil_to_numpy(image: Image.Image) -> np.ndarray: Returns: np.ndarray: Numpy array in BGR format """ - if image.mode != 'RGB': - image = image.convert('RGB') + if image.mode != "RGB": + image = image.convert("RGB") # Convert to numpy and then BGR rgb_array = np.array(image) @@ -341,9 +325,8 @@ def pil_to_numpy(image: Image.Image) -> np.ndarray: # Functional API - Standalone pipeable functions # ============================================================================= -def letterboxed(target_size: Optional[Tuple[int, int]] = None, - color: Tuple[int, int, int] = (114, 114, 114), - interpolation: int = cv2.INTER_LINEAR): + +def letterboxed(target_size: Optional[Tuple[int, int]] = None, color: Tuple[int, int, int] = (114, 114, 114), interpolation: int = cv2.INTER_LINEAR): """ Pipeable letterbox function - apply letterboxing with pipe operator support. @@ -361,9 +344,7 @@ def letterboxed(target_size: Optional[Tuple[int, int]] = None, return PipeableFunction(letterbox, target_size=target_size, color=color, interpolation=interpolation) -def resized(target_size: Tuple[int, int], - maintain_ratio: bool = False, - interpolation: int = cv2.INTER_LINEAR): +def resized(target_size: Tuple[int, int], maintain_ratio: bool = False, interpolation: int = cv2.INTER_LINEAR): """ Pipeable resize function - resize frame with pipe operator support. @@ -382,10 +363,7 @@ def resized(target_size: Tuple[int, int], return PipeableFunction(resize, target_size=target_size, maintain_ratio=maintain_ratio, interpolation=interpolation) -def adjusted(brightness: float = 0.0, - contrast: float = 1.0, - saturation: float = 1.0, - gamma: float = 1.0): +def adjusted(brightness: float = 0.0, contrast: float = 1.0, saturation: float = 1.0, gamma: float = 1.0): """ Pipeable adjust function - apply image adjustments with pipe operator support. @@ -451,4 +429,3 @@ def compressed_to_png(compression_level: int = 6): pipe = letterboxed() | compressed_to_png() """ return PipeableFunction(compress_to_png, compression_level=compression_level) - diff --git a/src/arduino/app_utils/image/image.py b/src/arduino/app_utils/image/image.py index 8b0b1f2d..5d55504f 100644 --- a/src/arduino/app_utils/image/image.py +++ b/src/arduino/app_utils/image/image.py @@ -186,11 +186,7 @@ def draw_bounding_boxes( return image_box -def draw_anomaly_markers( - image: Image.Image | bytes, - detection: dict, - draw: ImageDraw.ImageDraw = None -) -> Image.Image | None: +def draw_anomaly_markers(image: Image.Image | bytes, detection: dict, draw: ImageDraw.ImageDraw = None) -> Image.Image | None: """Draw bounding boxes on an image using PIL. The thickness of the box and font size are scaled based on image size. diff --git a/src/arduino/app_utils/image/pipeable.py b/src/arduino/app_utils/image/pipeable.py index 15c60835..da31c49c 100644 --- a/src/arduino/app_utils/image/pipeable.py +++ b/src/arduino/app_utils/image/pipeable.py @@ -21,11 +21,11 @@ class PipeableFunction: This allows functions to be composed using the | operator in a left-to-right manner. """ - + def __init__(self, func: Callable, *args, **kwargs): """ Initialize a pipeable function. - + Args: func: The function to wrap *args: Positional arguments to partially apply @@ -34,36 +34,36 @@ def __init__(self, func: Callable, *args, **kwargs): self.func = func self.args = args self.kwargs = kwargs - + def __call__(self, *args, **kwargs): """Call the wrapped function with combined arguments.""" combined_args = self.args + args combined_kwargs = {**self.kwargs, **kwargs} return self.func(*combined_args, **combined_kwargs) - + def __ror__(self, other): """ Right-hand side of pipe operator (|). - + This allows: value | pipeable_function - + Args: other: The value being piped into this function - + Returns: Result of applying this function to the value """ return self(other) - + def __or__(self, other): """ Left-hand side of pipe operator (|). - + This allows: pipeable_function | other_function - + Args: other: Another function to compose with - + Returns: A new pipeable function that combines both """ @@ -71,28 +71,29 @@ def __or__(self, other): # Raise TypeError immediately instead of returning NotImplemented # This prevents Python from trying the reverse operation for nothing raise TypeError(f"unsupported operand type(s) for |: '{type(self).__name__}' and '{type(other).__name__}'") - + def composed(value): return other(self(value)) - + return PipeableFunction(composed) - + def __repr__(self): """String representation of the pipeable function.""" # Get function name safely - func_name = getattr(self.func, '__name__', None) + func_name = getattr(self.func, "__name__", None) if func_name is None: - func_name = getattr(type(self.func), '__name__', None) + func_name = getattr(type(self.func), "__name__", None) if func_name is None: from functools import partial + if type(self.func) == partial: func_name = "partial" if func_name is None: func_name = "unknown" # Fallback - + if self.args or self.kwargs: - args_str = ', '.join(map(str, self.args)) - kwargs_str = ', '.join(f'{k}={v}' for k, v in self.kwargs.items()) - all_args = ', '.join(filter(None, [args_str, kwargs_str])) + args_str = ", ".join(map(str, self.args)) + kwargs_str = ", ".join(f"{k}={v}" for k, v in self.kwargs.items()) + all_args = ", ".join(filter(None, [args_str, kwargs_str])) return f"{func_name}({all_args})" return f"{func_name}()" diff --git a/tests/arduino/app_utils/image/test_image_editor.py b/tests/arduino/app_utils/image/test_image_editor.py index d9e33819..ab207ba5 100644 --- a/tests/arduino/app_utils/image/test_image_editor.py +++ b/tests/arduino/app_utils/image/test_image_editor.py @@ -9,16 +9,18 @@ # FIXTURES + def create_gradient_frame(dtype): """Helper: Creates a 100x100 3-channel (BGR) frame with gradients.""" iinfo = np.iinfo(dtype) max_val = iinfo.max frame = np.zeros((100, 100, 3), dtype=dtype) - frame[:, :, 0] = np.linspace(0, max_val // 2, 100, dtype=dtype) # Blue - frame[:, :, 1] = np.linspace(0, max_val, 100, dtype=dtype) # Green - frame[:, :, 2] = np.linspace(max_val // 2, max_val, 100, dtype=dtype) # Red + frame[:, :, 0] = np.linspace(0, max_val // 2, 100, dtype=dtype) # Blue + frame[:, :, 1] = np.linspace(0, max_val, 100, dtype=dtype) # Green + frame[:, :, 2] = np.linspace(max_val // 2, max_val, 100, dtype=dtype) # Red return frame + def create_greyscale_frame(dtype): """Helper: Creates a 100x100 1-channel (greyscale) frame.""" iinfo = np.iinfo(dtype) @@ -27,6 +29,7 @@ def create_greyscale_frame(dtype): frame[:, :] = np.linspace(0, max_val, 100, dtype=dtype) return frame + def create_bgra_frame(dtype): """Helper: Creates a 100x100 4-channel (BGRA) frame.""" iinfo = np.iinfo(dtype) @@ -34,55 +37,65 @@ def create_bgra_frame(dtype): bgr = create_gradient_frame(dtype) alpha = np.zeros((100, 100), dtype=dtype) alpha[:, :] = np.linspace(max_val // 4, max_val, 100, dtype=dtype) - frame = np.stack([bgr[:,:,0], bgr[:,:,1], bgr[:,:,2], alpha], axis=2) + frame = np.stack([bgr[:, :, 0], bgr[:, :, 1], bgr[:, :, 2], alpha], axis=2) return frame + # Fixture for a 100x100 uint8 BGR frame @pytest.fixture def frame_bgr_uint8(): return create_gradient_frame(np.uint8) + # Fixture for a 100x100 uint8 BGRA frame @pytest.fixture def frame_bgra_uint8(): return create_bgra_frame(np.uint8) + # Fixture for a 100x100 uint8 greyscale frame @pytest.fixture def frame_grey_uint8(): return create_greyscale_frame(np.uint8) + # Fixtures for high bit-depth frames @pytest.fixture def frame_bgr_uint16(): return create_gradient_frame(np.uint16) + @pytest.fixture def frame_bgr_uint32(): return create_gradient_frame(np.uint32) + @pytest.fixture def frame_bgra_uint16(): return create_bgra_frame(np.uint16) + @pytest.fixture def frame_bgra_uint32(): return create_bgra_frame(np.uint32) + # Fixture for a 200x100 (wide) uint8 BGR frame @pytest.fixture def frame_bgr_wide(): frame = np.zeros((100, 200, 3), dtype=np.uint8) - frame[:, :, 2] = 255 # Solid Red + frame[:, :, 2] = 255 # Solid Red return frame + # Fixture for a 100x200 (tall) uint8 BGR frame @pytest.fixture def frame_bgr_tall(): frame = np.zeros((200, 100, 3), dtype=np.uint8) - frame[:, :, 1] = 255 # Solid Green + frame[:, :, 1] = 255 # Solid Green return frame + # A parameterized fixture to test multiple data types @pytest.fixture(params=[np.uint8, np.uint16, np.uint32]) def frame_any_dtype(request): @@ -92,17 +105,20 @@ def frame_any_dtype(request): # TESTS + def test_adjust_dtype_preservation(frame_any_dtype): """Tests that the dtype of the frame is preserved.""" dtype = frame_any_dtype.dtype adjusted = adjust(frame_any_dtype, brightness=0.1) assert adjusted.dtype == dtype + def test_adjust_no_op(frame_bgr_uint8): """Tests that default parameters do not change the frame.""" adjusted = adjust(frame_bgr_uint8) assert np.array_equal(frame_bgr_uint8, adjusted) + def test_adjust_brightness(frame_bgr_uint8): """Tests brightness adjustment.""" brighter = adjust(frame_bgr_uint8, brightness=0.1) @@ -110,6 +126,7 @@ def test_adjust_brightness(frame_bgr_uint8): assert np.mean(brighter) > np.mean(frame_bgr_uint8) assert np.mean(darker) < np.mean(frame_bgr_uint8) + def test_adjust_contrast(frame_bgr_uint8): """Tests contrast adjustment.""" higher_contrast = adjust(frame_bgr_uint8, contrast=1.5) @@ -117,6 +134,7 @@ def test_adjust_contrast(frame_bgr_uint8): assert np.std(higher_contrast) > np.std(frame_bgr_uint8) assert np.std(lower_contrast) < np.std(frame_bgr_uint8) + def test_adjust_gamma(frame_bgr_uint8): """Tests gamma correction.""" # Gamma < 1.0 (e.g., 0.5) ==> brightens @@ -125,7 +143,8 @@ def test_adjust_gamma(frame_bgr_uint8): darker = adjust(frame_bgr_uint8, gamma=2.0) assert np.mean(brighter) > np.mean(frame_bgr_uint8) assert np.mean(darker) < np.mean(frame_bgr_uint8) - + + def test_adjust_saturation_to_greyscale(frame_bgr_uint8): """Tests that saturation=0.0 makes all color channels equal.""" desaturated = adjust(frame_bgr_uint8, saturation=0.0) @@ -133,6 +152,7 @@ def test_adjust_saturation_to_greyscale(frame_bgr_uint8): assert np.allclose(b, g, atol=1) assert np.allclose(g, r, atol=1) + def test_adjust_greyscale_input(frame_grey_uint8): """Tests that greyscale frames are handled safely.""" adjusted = adjust(frame_grey_uint8, saturation=1.5, brightness=0.1) @@ -140,29 +160,32 @@ def test_adjust_greyscale_input(frame_grey_uint8): assert adjusted.dtype == np.uint8 assert np.mean(adjusted) > np.mean(frame_grey_uint8) + def test_adjust_bgra_input(frame_bgra_uint8): """Tests that BGRA frames are handled safely and alpha is preserved.""" - original_alpha = frame_bgra_uint8[:,:,3] - + original_alpha = frame_bgra_uint8[:, :, 3] + adjusted = adjust(frame_bgra_uint8, saturation=0.0, brightness=0.1) - + assert adjusted.ndim == 3 assert adjusted.shape[2] == 4 assert adjusted.dtype == np.uint8 - + b, g, r, a = split_channels(adjusted) - assert np.allclose(b, g, atol=1) # Check desaturation - assert np.allclose(g, r, atol=1) # Check desaturation - assert np.array_equal(original_alpha, a) # Check alpha preservation + assert np.allclose(b, g, atol=1) # Check desaturation + assert np.allclose(g, r, atol=1) # Check desaturation + assert np.array_equal(original_alpha, a) # Check alpha preservation + def test_adjust_gamma_zero_error(frame_bgr_uint8): """Tests that gamma <= 0 raises a ValueError.""" with pytest.raises(ValueError, match="Gamma value must be greater than 0."): adjust(frame_bgr_uint8, gamma=0.0) - + with pytest.raises(ValueError, match="Gamma value must be greater than 0."): adjust(frame_bgr_uint8, gamma=-1.0) + def test_adjust_high_bit_depth_bgr(frame_bgr_uint16, frame_bgr_uint32): """ Tests that brightness/contrast logic is correct on high bit-depth images. @@ -180,13 +203,14 @@ def test_adjust_high_bit_depth_bgr(frame_bgr_uint16, frame_bgr_uint32): assert np.mean(brighter_32) > np.mean(frame_bgr_uint32) assert np.mean(darker_32) < np.mean(frame_bgr_uint32) + def test_adjust_high_bit_depth_bgra(frame_bgra_uint16, frame_bgra_uint32): """ Tests that brightness/contrast logic is correct on high bit-depth BGRA images and that the alpha channel is preserved. """ # Test uint16 - original_alpha_16 = frame_bgra_uint16[:,:,3] + original_alpha_16 = frame_bgra_uint16[:, :, 3] brighter_16 = adjust(frame_bgra_uint16, brightness=0.1) assert brighter_16.dtype == np.uint16 assert brighter_16.shape == frame_bgra_uint16.shape @@ -195,7 +219,7 @@ def test_adjust_high_bit_depth_bgra(frame_bgra_uint16, frame_bgra_uint32): assert np.mean(brighter_16) > np.mean(frame_bgra_uint16) # Test uint32 - original_alpha_32 = frame_bgra_uint32[:,:,3] + original_alpha_32 = frame_bgra_uint32[:, :, 3] brighter_32 = adjust(frame_bgra_uint32, brightness=0.1) assert brighter_32.dtype == np.uint32 assert brighter_32.shape == frame_bgra_uint32.shape @@ -215,7 +239,7 @@ def test_greyscale(frame_bgr_uint8, frame_bgra_uint8, frame_grey_uint8): assert np.allclose(g, r, atol=1) # Test on BGRA - original_alpha = frame_bgra_uint8[:,:,3] + original_alpha = frame_bgra_uint8[:, :, 3] greyscaled_bgra = greyscale(frame_bgra_uint8) assert greyscaled_bgra.ndim == 3 assert greyscaled_bgra.shape[2] == 4 @@ -229,12 +253,14 @@ def test_greyscale(frame_bgr_uint8, frame_bgra_uint8, frame_grey_uint8): assert np.array_equal(frame_grey_uint8, greyscaled_grey) assert greyscaled_grey.ndim == 2 + def test_greyscale_dtype_preservation(frame_any_dtype): """Tests that the dtype of the frame is preserved.""" dtype = frame_any_dtype.dtype adjusted = adjust(frame_any_dtype, brightness=0.1) assert adjusted.dtype == dtype + def test_greyscale_high_bit_depth(frame_bgr_uint16, frame_bgr_uint32): """ Tests that greyscale logic is correct on high bit-depth images. @@ -246,7 +272,7 @@ def test_greyscale_high_bit_depth(frame_bgr_uint16, frame_bgr_uint32): b16, g16, r16 = split_channels(greyscaled_16) assert np.allclose(b16, g16, atol=1) assert np.allclose(g16, r16, atol=1) - assert np.mean(b16) != np.mean(frame_bgr_uint16[:,:,0]) + assert np.mean(b16) != np.mean(frame_bgr_uint16[:, :, 0]) # Test uint32 greyscaled_32 = greyscale(frame_bgr_uint32) @@ -255,7 +281,8 @@ def test_greyscale_high_bit_depth(frame_bgr_uint16, frame_bgr_uint32): b32, g32, r32 = split_channels(greyscaled_32) assert np.allclose(b32, g32, atol=1) assert np.allclose(g32, r32, atol=1) - assert np.mean(b32) != np.mean(frame_bgr_uint32[:,:,0]) + assert np.mean(b32) != np.mean(frame_bgr_uint32[:, :, 0]) + def test_high_bit_depth_greyscale_bgra_content(frame_bgra_uint16, frame_bgra_uint32): """ @@ -263,7 +290,7 @@ def test_high_bit_depth_greyscale_bgra_content(frame_bgra_uint16, frame_bgra_uin BGRA images and that the alpha channel is preserved. """ # Test uint16 - original_alpha_16 = frame_bgra_uint16[:,:,3] + original_alpha_16 = frame_bgra_uint16[:, :, 3] greyscaled_16 = greyscale(frame_bgra_uint16) assert greyscaled_16.dtype == np.uint16 assert greyscaled_16.shape == frame_bgra_uint16.shape @@ -273,7 +300,7 @@ def test_high_bit_depth_greyscale_bgra_content(frame_bgra_uint16, frame_bgra_uin assert np.array_equal(original_alpha_16, a16) # Test uint32 - original_alpha_32 = frame_bgra_uint32[:,:,3] + original_alpha_32 = frame_bgra_uint32[:, :, 3] greyscaled_32 = greyscale(frame_bgra_uint32) assert greyscaled_32.dtype == np.uint32 assert greyscaled_32.shape == frame_bgra_uint32.shape @@ -286,12 +313,12 @@ def test_high_bit_depth_greyscale_bgra_content(frame_bgra_uint16, frame_bgra_uin def test_resize_shape_and_dtype(frame_bgr_uint8, frame_bgra_uint8, frame_grey_uint8): """Tests that resize produces the correct shape and preserves dtype.""" target_w, target_h = 50, 75 - + # Test BGR resized_bgr = resize(frame_bgr_uint8, (target_w, target_h)) assert resized_bgr.shape == (target_h, target_w, 3) assert resized_bgr.dtype == frame_bgr_uint8.dtype - + # Test BGRA resized_bgra = resize(frame_bgra_uint8, (target_w, target_h)) assert resized_bgra.shape == (target_h, target_w, 4) @@ -302,6 +329,7 @@ def test_resize_shape_and_dtype(frame_bgr_uint8, frame_bgra_uint8, frame_grey_ui assert resized_grey.shape == (target_h, target_w) assert resized_grey.dtype == frame_grey_uint8.dtype + def test_letterbox_wide_image(frame_bgr_wide): """Tests letterboxing a wide image (200x100) into a square (200x200).""" target_w, target_h = 200, 200 @@ -311,12 +339,12 @@ def test_letterbox_wide_image(frame_bgr_wide): # new_h = 100 * 1 = 100 # y_offset = (200 - 100) // 2 = 50 # x_offset = (200 - 200) // 2 = 0 - + letterboxed = letterbox(frame_bgr_wide, (target_w, target_h), color=0) - + assert letterboxed.shape == (target_h, target_w, 3) assert letterboxed.dtype == frame_bgr_wide.dtype - + # Check padding (top row, black) assert np.all(letterboxed[0, 0] == [0, 0, 0]) # Check padding (bottom row, black) @@ -326,6 +354,7 @@ def test_letterbox_wide_image(frame_bgr_wide): # Check image edge (no left/right padding) assert np.all(letterboxed[100, 0] == [0, 0, 255]) + def test_letterbox_tall_image(frame_bgr_tall): """Tests letterboxing a tall image (100x200) into a square (200x200).""" target_w, target_h = 200, 200 @@ -337,7 +366,7 @@ def test_letterbox_tall_image(frame_bgr_tall): # x_offset = (200 - 100) // 2 = 50 letterboxed = letterbox(frame_bgr_tall, (target_w, target_h), color=0) - + assert letterboxed.shape == (target_h, target_w, 3) assert letterboxed.dtype == frame_bgr_tall.dtype @@ -350,35 +379,38 @@ def test_letterbox_tall_image(frame_bgr_tall): # Check image edge (no top/bottom padding) assert np.all(letterboxed[0, 100] == [0, 255, 0]) + def test_letterbox_color(frame_bgr_tall): """Tests letterboxing with a non-default color.""" white = (255, 255, 255) letterboxed = letterbox(frame_bgr_tall, (200, 200), color=white) - + # Check padding (left column, white) assert np.all(letterboxed[0, 0] == white) # Check image data (center column, green) assert np.all(letterboxed[100, 100] == [0, 255, 0]) + def test_letterbox_bgra(frame_bgra_uint8): """Tests letterboxing on a 4-channel BGRA image.""" target_w, target_h = 200, 200 # Opaque black padding padding = (0, 0, 0, 255) - + letterboxed = letterbox(frame_bgra_uint8, (target_w, target_h), color=padding) - + assert letterboxed.shape == (target_h, target_w, 4) # Check no padding (corner, original BGRA point) assert np.array_equal(letterboxed[0, 0], frame_bgra_uint8[0, 0]) # Check image data (center, from fixture) assert np.array_equal(letterboxed[100, 100], frame_bgra_uint8[50, 50]) + def test_letterbox_greyscale(frame_grey_uint8): """Tests letterboxing on a 2D greyscale image.""" target_w, target_h = 200, 200 letterboxed = letterbox(frame_grey_uint8, (target_w, target_h), color=0) - + assert letterboxed.shape == (target_h, target_w) assert letterboxed.ndim == 2 # Check padding (corner, black) @@ -386,16 +418,18 @@ def test_letterbox_greyscale(frame_grey_uint8): # Check image data (center) assert letterboxed[100, 100] == frame_grey_uint8[50, 50] + def test_letterbox_none_target_size(frame_bgr_wide, frame_bgr_tall): """Tests that target_size=None creates a square based on the longest side.""" # frame_bgr_wide is 200x100, longest side is 200 letterboxed_wide = letterbox(frame_bgr_wide, target_size=None) assert letterboxed_wide.shape == (200, 200, 3) - + # frame_bgr_tall is 100x200, longest side is 200 letterboxed_tall = letterbox(frame_bgr_tall, target_size=None) assert letterboxed_tall.shape == (200, 200, 3) + def test_letterbox_color_tuple_error(frame_bgr_uint8): """Tests that a mismatched padding tuple raises a ValueError.""" with pytest.raises(ValueError, match="color length"): diff --git a/tests/arduino/app_utils/image/test_pipeable.py b/tests/arduino/app_utils/image/test_pipeable.py index 27707e8b..29cf5a97 100644 --- a/tests/arduino/app_utils/image/test_pipeable.py +++ b/tests/arduino/app_utils/image/test_pipeable.py @@ -9,74 +9,76 @@ class TestPipeableFunction: """Test cases for the PipeableFunction class.""" - + def test_init(self): """Test PipeableFunction initialization.""" mock_func = MagicMock() pf = PipeableFunction(mock_func, 1, 2, kwarg1="value1") - + assert pf.func == mock_func assert pf.args == (1, 2) assert pf.kwargs == {"kwarg1": "value1"} - + def test_call_no_existing_args(self): """Test calling PipeableFunction with no existing args.""" mock_func = MagicMock(return_value="result") pf = PipeableFunction(mock_func) - + result = pf(1, 2, kwarg1="value1") - + mock_func.assert_called_once_with(1, 2, kwarg1="value1") assert result == "result" - + def test_call_with_existing_args(self): """Test calling PipeableFunction with existing args.""" mock_func = MagicMock(return_value="result") pf = PipeableFunction(mock_func, 1, kwarg1="value1") - + result = pf(2, 3, kwarg2="value2") - + mock_func.assert_called_once_with(1, 2, 3, kwarg1="value1", kwarg2="value2") assert result == "result" - + def test_call_kwargs_override(self): """Test that new kwargs override existing ones.""" mock_func = MagicMock(return_value="result") pf = PipeableFunction(mock_func, kwarg1="old_value") - + result = pf(kwarg1="new_value", kwarg2="value2") - + mock_func.assert_called_once_with(kwarg1="new_value", kwarg2="value2") assert result == "result" - + def test_ror_pipe_operator(self): """Test right-hand side pipe operator (value | function).""" + def add_one(x): return x + 1 - + pf = PipeableFunction(add_one) result = 5 | pf - + assert result == 6 - + def test_or_pipe_operator(self): """Test left-hand side pipe operator (function | function).""" + def add_one(x): return x + 1 - + def multiply_two(x): return x * 2 - + pf1 = PipeableFunction(add_one) pf2 = PipeableFunction(multiply_two) - + # Chain: add_one | multiply_two composed = pf1 | pf2 - + assert isinstance(composed, PipeableFunction) result = composed(5) # (5 + 1) * 2 = 12 assert result == 12 - + def test_or_pipe_operator_with_non_callable(self): """Test pipe operator with non-callable returns NotImplemented.""" pf = PipeableFunction(lambda x: x) @@ -85,95 +87,105 @@ def test_or_pipe_operator_with_non_callable(self): def test_repr_with_function_name(self): """Test string representation with function having __name__.""" + def test_func(): pass - + pf = PipeableFunction(test_func) assert repr(pf) == "test_func()" - + def test_repr_with_args_and_kwargs(self): """Test string representation with args and kwargs.""" + def test_func(): pass - + pf = PipeableFunction(test_func, 1, 2, kwarg1="value1", kwarg2=42) repr_str = repr(pf) - + assert "test_func(" in repr_str assert "1" in repr_str assert "2" in repr_str assert "kwarg1=value1" in repr_str assert "kwarg2=42" in repr_str - + def test_repr_with_partial_object(self): """Test string representation with functools.partial object.""" from functools import partial - + def test_func(a, b): return a + b - + partial_func = partial(test_func, b=10) pf = PipeableFunction(partial_func) - + repr_str = repr(pf) assert "test_func" in repr_str or "partial" in repr_str - + def test_repr_with_callable_without_name(self): """Test string representation with callable without __name__.""" + class CallableClass: def __call__(self): pass - + callable_obj = CallableClass() pf = PipeableFunction(callable_obj) - + repr_str = repr(pf) assert "CallableClass" in repr_str class TestPipeableFunctionIntegration: """Integration tests for the PipeableFunction class.""" - + def test_real_world_data_processing(self): """Test pipeable with real-world data processing scenario.""" + def filter_positive(numbers): return [n for n in numbers if n > 0] + def filtered_positive(): return PipeableFunction(filter_positive) def square_all(numbers): return [n * n for n in numbers] + def squared(): return PipeableFunction(square_all) - + def sum_all(numbers): return sum(numbers) + def summed(): return PipeableFunction(sum_all) - + data = [-2, -1, 0, 1, 2, 3] - + # Pipeline: filter positive -> square -> sum # [1, 2, 3] -> [1, 4, 9] -> 14 result = data | filtered_positive() | squared() | summed() assert result == 14 - + def test_error_handling_in_pipeline(self): """Test error handling within pipelines.""" + def divide_by(x, divisor): return x / divisor # May raise ZeroDivisionError + def divided_by(divisor): - return PipeableFunction(divide_by, divisor=divisor) + return PipeableFunction(divide_by, divisor=divisor) def round_number(x, decimals=2): return round(x, decimals) + def rounded(decimals=2): return PipeableFunction(round_number, decimals=decimals) - + # Test successful pipeline result = 10 | divided_by(3) | rounded(decimals=2) assert result == 3.33 - + # Test error propagation with pytest.raises(ZeroDivisionError): 10 | divided_by(0) | rounded() From f965fa4b4e759c73e6c5b828573cb95e2b1def03 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 01:19:59 +0100 Subject: [PATCH 23/86] run linter --- src/arduino/app_peripherals/camera/websocket_camera.py | 4 ++-- src/arduino/app_peripherals/usb_camera/__init__.py | 2 +- src/arduino/app_utils/image/adjustments.py | 3 ++- src/arduino/app_utils/image/pipeable.py | 2 +- .../app_bricks/objectdetection/test_objectdetection.py | 2 -- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 194fcd05..fe244d79 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -249,9 +249,9 @@ def _close_camera(self): future = asyncio.run_coroutine_threadsafe(self._set_async_stop_event(), self._loop) try: future.result(timeout=1.0) - except CancelledError as e: + except CancelledError: logger.debug(f"Error setting async stop event: CancelledError") - except TimeoutError as e: + except TimeoutError: logger.debug(f"Error setting async stop event: TimeoutError") except Exception as e: logger.warning(f"Error setting async stop event: {e}") diff --git a/src/arduino/app_peripherals/usb_camera/__init__.py b/src/arduino/app_peripherals/usb_camera/__init__.py index 27042d45..bc0392f8 100644 --- a/src/arduino/app_peripherals/usb_camera/__init__.py +++ b/src/arduino/app_peripherals/usb_camera/__init__.py @@ -5,7 +5,7 @@ import io import warnings from PIL import Image -from arduino.app_peripherals.camera import Camera, CameraReadError as CRE, CameraOpenError as COE +from arduino.app_peripherals.camera import Camera as Camera, CameraReadError as CRE, CameraOpenError as COE from arduino.app_peripherals.camera.v4l_camera import V4LCamera from arduino.app_utils.image import letterboxed, compressed_to_png from arduino.app_utils import Logger diff --git a/src/arduino/app_utils/image/adjustments.py b/src/arduino/app_utils/image/adjustments.py index 06f109dd..9e9a2e29 100644 --- a/src/arduino/app_utils/image/adjustments.py +++ b/src/arduino/app_utils/image/adjustments.py @@ -333,6 +333,7 @@ def letterboxed(target_size: Optional[Tuple[int, int]] = None, color: Tuple[int, Args: target_size (tuple, optional): Target size as (width, height). If None, makes frame square. color (tuple): RGB color for padding borders. Default: (114, 114, 114) + interpolation (int): OpenCV interpolation method. Default: cv2.INTER_LINEAR Returns: Partial function that takes a frame and returns letterboxed frame @@ -351,7 +352,7 @@ def resized(target_size: Tuple[int, int], maintain_ratio: bool = False, interpol Args: target_size (tuple): Target size as (width, height) maintain_ratio (bool): If True, use letterboxing to maintain aspect ratio - interpolation (int): OpenCV interpolation method + interpolation (int): OpenCV interpolation method. Default: cv2.INTER_LINEAR Returns: Partial function that takes a frame and returns resized frame diff --git a/src/arduino/app_utils/image/pipeable.py b/src/arduino/app_utils/image/pipeable.py index da31c49c..86e0bad9 100644 --- a/src/arduino/app_utils/image/pipeable.py +++ b/src/arduino/app_utils/image/pipeable.py @@ -86,7 +86,7 @@ def __repr__(self): if func_name is None: from functools import partial - if type(self.func) == partial: + if type(self.func) is partial: func_name = "partial" if func_name is None: func_name = "unknown" # Fallback diff --git a/tests/arduino/app_bricks/objectdetection/test_objectdetection.py b/tests/arduino/app_bricks/objectdetection/test_objectdetection.py index e380d231..00fffc3f 100644 --- a/tests/arduino/app_bricks/objectdetection/test_objectdetection.py +++ b/tests/arduino/app_bricks/objectdetection/test_objectdetection.py @@ -4,8 +4,6 @@ import pytest from pathlib import Path -import io -from PIL import Image from arduino.app_bricks.object_detection import ObjectDetection From d3a57ebd2ae2c89d6dc7a97cb01f51d6429a31f6 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 01:28:34 +0100 Subject: [PATCH 24/86] fix: wrong import --- src/arduino/app_internal/core/ei.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/arduino/app_internal/core/ei.py b/src/arduino/app_internal/core/ei.py index 15ab6d2f..3cea9c07 100644 --- a/src/arduino/app_internal/core/ei.py +++ b/src/arduino/app_internal/core/ei.py @@ -5,8 +5,8 @@ import requests import io from arduino.app_internal.core import load_brick_compose_file, resolve_address -from arduino.app_utils import get_image_bytes, get_image_type, HttpClient -from arduino.app_utils import Logger +from arduino.app_utils.image import get_image_bytes, get_image_type +from arduino.app_utils import Logger, HttpClient logger = Logger(__name__) From 0d3bee17e64550409c76b63e46a775de8519ab27 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 01:45:06 +0100 Subject: [PATCH 25/86] perf --- src/arduino/app_utils/image/adjustments.py | 5 ++++- .../image/{test_image_editor.py => test_adjustments.py} | 0 2 files changed, 4 insertions(+), 1 deletion(-) rename tests/arduino/app_utils/image/{test_image_editor.py => test_adjustments.py} (100%) diff --git a/src/arduino/app_utils/image/adjustments.py b/src/arduino/app_utils/image/adjustments.py index 9e9a2e29..34f66674 100644 --- a/src/arduino/app_utils/image/adjustments.py +++ b/src/arduino/app_utils/image/adjustments.py @@ -57,7 +57,10 @@ def letterbox( new_w = int(orig_w * scale) new_h = int(orig_h * scale) - resized_frame = cv2.resize(frame, (new_w, new_h), interpolation=interpolation) + if new_w == orig_w and new_h == orig_h: + resized_frame = frame + else: + resized_frame = cv2.resize(frame, (new_w, new_h), interpolation=interpolation) if frame.ndim == 2: # Greyscale diff --git a/tests/arduino/app_utils/image/test_image_editor.py b/tests/arduino/app_utils/image/test_adjustments.py similarity index 100% rename from tests/arduino/app_utils/image/test_image_editor.py rename to tests/arduino/app_utils/image/test_adjustments.py From 01b3e416e5911f6eb737b9e17643a40a4ba95590 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 02:08:56 +0100 Subject: [PATCH 26/86] fix: numeric issue --- tests/arduino/app_utils/image/test_adjustments.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/arduino/app_utils/image/test_adjustments.py b/tests/arduino/app_utils/image/test_adjustments.py index ab207ba5..10b742a5 100644 --- a/tests/arduino/app_utils/image/test_adjustments.py +++ b/tests/arduino/app_utils/image/test_adjustments.py @@ -402,8 +402,8 @@ def test_letterbox_bgra(frame_bgra_uint8): assert letterboxed.shape == (target_h, target_w, 4) # Check no padding (corner, original BGRA point) assert np.array_equal(letterboxed[0, 0], frame_bgra_uint8[0, 0]) - # Check image data (center, from fixture) - assert np.array_equal(letterboxed[100, 100], frame_bgra_uint8[50, 50]) + # Check image data (center, from fixture) - allow small tolerance for numerical precision differences + assert np.allclose(letterboxed[100, 100], frame_bgra_uint8[50, 50], atol=1) def test_letterbox_greyscale(frame_grey_uint8): @@ -415,8 +415,8 @@ def test_letterbox_greyscale(frame_grey_uint8): assert letterboxed.ndim == 2 # Check padding (corner, black) assert letterboxed[0, 0] == 0 - # Check image data (center) - assert letterboxed[100, 100] == frame_grey_uint8[50, 50] + # Check image data (center) - allow small tolerance for numerical precision differences + assert np.allclose(letterboxed[100, 100], frame_grey_uint8[50, 50], atol=1) def test_letterbox_none_target_size(frame_bgr_wide, frame_bgr_tall): From 325c2a72396e0915234d352a746c23c4822fe73f Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 14:55:22 +0100 Subject: [PATCH 27/86] refactor: change default image serialization format --- src/arduino/app_peripherals/camera/websocket_camera.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index fe244d79..5333cca6 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -40,7 +40,7 @@ def __init__( host: str = "0.0.0.0", port: int = 8080, timeout: int = 10, - frame_format: str = "base64", + frame_format: str = "binary", resolution: Optional[Tuple[int, int]] = (640, 480), fps: int = 10, adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, @@ -52,7 +52,7 @@ def __init__( host (str): Host address to bind the server to (default: "0.0.0.0") port (int): Port to bind the server to (default: 8080) timeout (int): Connection timeout in seconds (default: 10) - frame_format (str): Expected frame format from clients ("base64", "json", "binary") (default: "base64") + frame_format (str): Expected frame format from clients ("base64", "json", "binary") (default: "binary") resolution (tuple, optional): Resolution as (width, height). None uses default resolution. fps (int): Frames per second to capture from the camera. adjustments (callable, optional): Function or function pipeline to adjust frames that takes From 1fc5dec30b7251e7240bbf5183b6e141e2a02764 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 14:56:08 +0100 Subject: [PATCH 28/86] perf: reduce buffer size to lower latency --- src/arduino/app_peripherals/camera/ip_camera.py | 3 ++- src/arduino/app_peripherals/camera/v4l_camera.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/arduino/app_peripherals/camera/ip_camera.py b/src/arduino/app_peripherals/camera/ip_camera.py index 5d20a75e..72858006 100644 --- a/src/arduino/app_peripherals/camera/ip_camera.py +++ b/src/arduino/app_peripherals/camera/ip_camera.py @@ -76,9 +76,10 @@ def _open_camera(self) -> None: self._test_http_connectivity() self._cap = cv2.VideoCapture(url) - self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to get latest frames if not self._cap.isOpened(): raise CameraOpenError(f"Failed to open IP camera: {self.url}") + + self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to minimize latency # Test by reading one frame ret, frame = self._cap.read() diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py index 06afde17..8d879b67 100644 --- a/src/arduino/app_peripherals/camera/v4l_camera.py +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -126,7 +126,9 @@ def _open_camera(self) -> None: self._cap = cv2.VideoCapture(self.device_index) if not self._cap.isOpened(): raise CameraOpenError(f"Failed to open V4L camera {self.device_index}") - + + self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to minimize latency + # Set resolution if specified if self.resolution and self.resolution[0] and self.resolution[1]: self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.resolution[0]) From 19e7129ee175a88e565fbbda94e5134659fec14d Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Fri, 31 Oct 2025 08:42:30 +0100 Subject: [PATCH 29/86] doc: better clarify supported image formats --- .../app_peripherals/camera/websocket_camera.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 5333cca6..35629365 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -29,10 +29,17 @@ class WebSocketCamera(BaseCamera): This camera acts as a WebSocket server that receives frames from connected clients. Only one client can be connected at a time. - Clients can send frames in various 8-bit (e.g. JPEG, PNG 8-bit) formats: + Clients must encode video frames in one of these formats: + - JPEG + - PNG + - WebP + - BMP + - TIFF + + The frames can be serialized in one of the following formats: + - Binary image data - Base64 encoded images - JSON messages with image data - - Binary image data """ def __init__( @@ -52,7 +59,7 @@ def __init__( host (str): Host address to bind the server to (default: "0.0.0.0") port (int): Port to bind the server to (default: 8080) timeout (int): Connection timeout in seconds (default: 10) - frame_format (str): Expected frame format from clients ("base64", "json", "binary") (default: "binary") + frame_format (str): Expected frame format from clients ("binary", "base64", "json") (default: "binary") resolution (tuple, optional): Resolution as (width, height). None uses default resolution. fps (int): Frames per second to capture from the camera. adjustments (callable, optional): Function or function pipeline to adjust frames that takes From 836d69f3ddc21228c50856fc9e5ce710512d3b66 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Fri, 31 Oct 2025 08:52:33 +0100 Subject: [PATCH 30/86] feat: allow also image formats with higher bit depth and preserve all channels --- src/arduino/app_peripherals/camera/websocket_camera.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 35629365..c4350ed1 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -207,7 +207,7 @@ async def _parse_message(self, message) -> Optional[np.ndarray]: # Decode image nparr = np.frombuffer(image_data, np.uint8) - frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + frame = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED) return frame elif self.frame_format == "binary": @@ -218,7 +218,7 @@ async def _parse_message(self, message) -> Optional[np.ndarray]: image_data = message nparr = np.frombuffer(image_data, np.uint8) - frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + frame = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED) return frame elif self.frame_format == "json": @@ -231,7 +231,7 @@ async def _parse_message(self, message) -> Optional[np.ndarray]: if "image" in data: image_data = base64.b64decode(data["image"]) nparr = np.frombuffer(image_data, np.uint8) - frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + frame = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED) return frame elif "frame" in data: @@ -240,7 +240,7 @@ async def _parse_message(self, message) -> Optional[np.ndarray]: if isinstance(frame_data, str): image_data = base64.b64decode(frame_data) nparr = np.frombuffer(image_data, np.uint8) - frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + frame = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED) return frame return None From 6fa846f9801d1eeac368395bbfd8c01da33720f6 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Fri, 31 Oct 2025 09:20:40 +0100 Subject: [PATCH 31/86] chore: run fmt --- src/arduino/app_peripherals/camera/ip_camera.py | 2 +- src/arduino/app_peripherals/camera/v4l_camera.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/arduino/app_peripherals/camera/ip_camera.py b/src/arduino/app_peripherals/camera/ip_camera.py index 72858006..79f24530 100644 --- a/src/arduino/app_peripherals/camera/ip_camera.py +++ b/src/arduino/app_peripherals/camera/ip_camera.py @@ -78,7 +78,7 @@ def _open_camera(self) -> None: self._cap = cv2.VideoCapture(url) if not self._cap.isOpened(): raise CameraOpenError(f"Failed to open IP camera: {self.url}") - + self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to minimize latency # Test by reading one frame diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py index 8d879b67..af130816 100644 --- a/src/arduino/app_peripherals/camera/v4l_camera.py +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -126,9 +126,9 @@ def _open_camera(self) -> None: self._cap = cv2.VideoCapture(self.device_index) if not self._cap.isOpened(): raise CameraOpenError(f"Failed to open V4L camera {self.device_index}") - + self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to minimize latency - + # Set resolution if specified if self.resolution and self.resolution[0] and self.resolution[1]: self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.resolution[0]) From 297ad64a73fc4dc9bd06e0e551f36a1d0c7e7de5 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Sat, 1 Nov 2025 00:32:04 +0100 Subject: [PATCH 32/86] refactor: Camera args --- src/arduino/app_peripherals/camera/README.md | 4 +-- .../app_peripherals/camera/base_camera.py | 8 ++--- src/arduino/app_peripherals/camera/camera.py | 32 ++++++++++++------- .../camera/examples/1_initialize.py | 5 +-- .../app_peripherals/camera/ip_camera.py | 12 +++---- .../app_peripherals/camera/v4l_camera.py | 14 ++++---- .../camera/websocket_camera.py | 16 +++++----- 7 files changed, 50 insertions(+), 41 deletions(-) diff --git a/src/arduino/app_peripherals/camera/README.md b/src/arduino/app_peripherals/camera/README.md index a349e347..a6512a2d 100644 --- a/src/arduino/app_peripherals/camera/README.md +++ b/src/arduino/app_peripherals/camera/README.md @@ -72,9 +72,9 @@ See the arduino.app_utils.image module for more supported adjustments. ## Camera Types The Camera class provides automatic camera type detection based on the format of its source argument. keyword arguments will be propagated to the underlying implementation. -Note: constructor arguments (except source) must be provided in keyword format to forward them correctly to the specific camera implementations. +Note: Camera's constructor arguments (except those in its signature) must be provided in keyword format to forward them correctly to the specific camera implementations. -The underlying camera implementations can be instantiated explicitly (V4LCamera, IPCamera and WebSocketCamera), if needed. +The underlying camera implementations can also be instantiated explicitly (V4LCamera, IPCamera and WebSocketCamera), if needed. ### V4L Cameras For local USB cameras and V4L-compatible devices. diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index 71789267..f7ea8d62 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -5,7 +5,7 @@ import threading import time from abc import ABC, abstractmethod -from typing import Optional, Tuple, Callable +from typing import Optional, Callable import numpy as np from arduino.app_utils import Logger @@ -25,9 +25,9 @@ class BaseCamera(ABC): def __init__( self, - resolution: Optional[Tuple[int, int]] = (640, 480), + resolution: tuple[int, int] = (640, 480), fps: int = 10, - adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, + adjustments: Callable[[np.ndarray], np.ndarray] = None, ): """ Initialize the camera base. @@ -87,7 +87,7 @@ def capture(self) -> Optional[np.ndarray]: return None return frame - def _extract_frame(self) -> Optional[np.ndarray]: + def _extract_frame(self) -> np.ndarray | None: """Extract a frame with FPS throttling and post-processing.""" with self._camera_lock: # FPS throttling diff --git a/src/arduino/app_peripherals/camera/camera.py b/src/arduino/app_peripherals/camera/camera.py index c978b34d..bd55befe 100644 --- a/src/arduino/app_peripherals/camera/camera.py +++ b/src/arduino/app_peripherals/camera/camera.py @@ -2,9 +2,11 @@ # # SPDX-License-Identifier: MPL-2.0 -from typing import Union +from collections.abc import Callable from urllib.parse import urlparse +import numpy as np + from .base_camera import BaseCamera from .errors import CameraConfigError @@ -25,7 +27,14 @@ class Camera: format to forward them correctly to the specific camera implementations. """ - def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: + def __new__( + cls, + source: str | int = 0, + resolution: tuple[int, int] = (640, 480), + fps: int = 10, + adjustments: Callable[[np.ndarray], np.ndarray] = None, + **kwargs, + ) -> BaseCamera: """Create a camera instance based on the source type. Args: @@ -34,13 +43,12 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: - str: V4L camera index (e.g., "0", "1") or device path (e.g., "/dev/video0") - str: URL for IP cameras (e.g., "rtsp://...", "http://...") - str: WebSocket URL for input streams (e.g., "ws://0.0.0.0:8080") + resolution (tuple, optional): Frame resolution as (width, height). + Default: (640, 480) + fps (int, optional): Target frames per second. Default: 10 + adjustments (callable, optional): Function pipeline to adjust frames that takes a + numpy array and returns a numpy array. Default: None **kwargs: Camera-specific configuration parameters grouped by type: - Common Parameters: - resolution (tuple, optional): Frame resolution as (width, height). - Default: (640, 480) - fps (int, optional): Target frames per second. Default: 10 - adjustments (callable, optional): Function pipeline to adjust frames that takes a - numpy array and returns a numpy array. Default: None V4L Camera Parameters: device (int, optional): V4L device index override. Default: 0. IP Camera Parameters: @@ -88,26 +96,26 @@ def __new__(cls, source: Union[str, int] = 0, **kwargs) -> BaseCamera: # V4L Camera from .v4l_camera import V4LCamera - return V4LCamera(source, **kwargs) + return V4LCamera(source, resolution=resolution, fps=fps, adjustments=adjustments, **kwargs) elif isinstance(source, str): parsed = urlparse(source) if parsed.scheme in ["http", "https", "rtsp"]: # IP Camera from .ip_camera import IPCamera - return IPCamera(source, **kwargs) + return IPCamera(source, resolution=resolution, fps=fps, adjustments=adjustments, **kwargs) elif parsed.scheme in ["ws", "wss"]: # WebSocket Camera - extract host and port from URL from .websocket_camera import WebSocketCamera host = parsed.hostname or "localhost" port = parsed.port or 8080 - return WebSocketCamera(host=host, port=port, **kwargs) + return WebSocketCamera(host=host, port=port, resolution=resolution, fps=fps, adjustments=adjustments, **kwargs) elif source.startswith("/dev/video") or source.isdigit(): # V4L device path or index as string from .v4l_camera import V4LCamera - return V4LCamera(source, **kwargs) + return V4LCamera(source, resolution=resolution, fps=fps, adjustments=adjustments, **kwargs) else: raise CameraConfigError(f"Unsupported camera source: {source}") else: diff --git a/src/arduino/app_peripherals/camera/examples/1_initialize.py b/src/arduino/app_peripherals/camera/examples/1_initialize.py index 85ed8dd0..f720708c 100644 --- a/src/arduino/app_peripherals/camera/examples/1_initialize.py +++ b/src/arduino/app_peripherals/camera/examples/1_initialize.py @@ -13,5 +13,6 @@ camera = Camera(2, resolution=(640, 480), fps=15) # Infers camera type v4l = V4LCamera(2, (640, 480), 15) # Explicitly requests V4L camera -# Note: constructor arguments (except source) must be provided in keyword -# format to forward them correctly to the specific camera implementations. +# Note: Camera's constructor arguments (except those in its signature) +# must be provided in keyword format to forward them correctly to the +# specific camera implementations. diff --git a/src/arduino/app_peripherals/camera/ip_camera.py b/src/arduino/app_peripherals/camera/ip_camera.py index 79f24530..3043439f 100644 --- a/src/arduino/app_peripherals/camera/ip_camera.py +++ b/src/arduino/app_peripherals/camera/ip_camera.py @@ -5,8 +5,8 @@ import cv2 import numpy as np import requests -from typing import Callable, Optional, Tuple from urllib.parse import urlparse +from collections.abc import Callable from arduino.app_utils import Logger @@ -27,12 +27,12 @@ class IPCamera(BaseCamera): def __init__( self, url: str, - username: Optional[str] = None, - password: Optional[str] = None, + username: str | None = None, + password: str | None = None, timeout: int = 10, - resolution: Optional[Tuple[int, int]] = (640, 480), + resolution: tuple[int, int] = (640, 480), fps: int = 10, - adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, + adjustments: Callable[[np.ndarray], np.ndarray] = None, ): """ Initialize IP camera. @@ -126,7 +126,7 @@ def _close_camera(self) -> None: self._cap.release() self._cap = None - def _read_frame(self) -> Optional[np.ndarray]: + def _read_frame(self) -> np.ndarray | None: """Read a frame from the IP camera with automatic reconnection.""" if self._cap is None: logger.info(f"No connection to IP camera {self.url}, attempting to reconnect") diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py index af130816..196707b3 100644 --- a/src/arduino/app_peripherals/camera/v4l_camera.py +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -6,7 +6,7 @@ import re import cv2 import numpy as np -from typing import Callable, Optional, Tuple, Union, Dict +from collections.abc import Callable from arduino.app_utils import Logger @@ -26,10 +26,10 @@ class V4LCamera(BaseCamera): def __init__( self, - device: Union[str, int] = 0, - resolution: Optional[Tuple[int, int]] = (640, 480), + device: str | int = 0, + resolution: tuple[int, int] = (640, 480), fps: int = 10, - adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, + adjustments: Callable[[np.ndarray], np.ndarray] = None, ): """ Initialize V4L camera. @@ -49,7 +49,7 @@ def __init__( self._cap = None - def _resolve_camera_id(self, device: Union[str, int]) -> int: + def _resolve_camera_id(self, device: str | int) -> int: """ Resolve camera identifier to a numeric device ID. @@ -83,7 +83,7 @@ def _resolve_camera_id(self, device: Union[str, int]) -> int: raise CameraOpenError(f"Cannot resolve camera identifier: {device}") - def _get_video_devices_by_index(self) -> Dict[int, str]: + def _get_video_devices_by_index(self) -> dict[int, str]: """ Map camera indices to device numbers by reading /dev/v4l/by-id/. @@ -160,7 +160,7 @@ def _close_camera(self) -> None: self._cap.release() self._cap = None - def _read_frame(self) -> Optional[np.ndarray]: + def _read_frame(self) -> np.ndarray | None: """Read a frame from the V4L camera.""" if self._cap is None: return None diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index c4350ed1..3b57ab99 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -7,11 +7,11 @@ import threading import queue import time -from typing import Callable, Optional, Tuple, Union import numpy as np import cv2 import websockets import asyncio +from collections.abc import Callable from concurrent.futures import CancelledError, TimeoutError from arduino.app_utils import Logger @@ -48,9 +48,9 @@ def __init__( port: int = 8080, timeout: int = 10, frame_format: str = "binary", - resolution: Optional[Tuple[int, int]] = (640, 480), + resolution: tuple[int, int] = (640, 480), fps: int = 10, - adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, + adjustments: Callable[[np.ndarray], np.ndarray] = None, ): """ Initialize WebSocket camera server. @@ -78,7 +78,7 @@ def __init__( self._loop = None self._server_thread = None self._stop_event = asyncio.Event() - self._client: Optional[websockets.ServerConnection] = None + self._client: websockets.ServerConnection = None self._client_lock = asyncio.Lock() def _open_camera(self) -> None: @@ -195,7 +195,7 @@ async def _ws_handler(self, conn: websockets.ServerConnection) -> None: self._client = None logger.info(f"Client removed: {client_addr}") - async def _parse_message(self, message) -> Optional[np.ndarray]: + async def _parse_message(self, message) -> np.ndarray | None: """Parse WebSocket message to extract frame.""" try: if self.frame_format == "base64": @@ -297,7 +297,7 @@ async def _set_async_stop_event(self): await self._client.close() self._stop_event.set() - def _read_frame(self) -> Optional[np.ndarray]: + def _read_frame(self) -> np.ndarray | None: """Read a frame from the queue.""" try: # Get frame with short timeout to avoid blocking @@ -306,7 +306,7 @@ def _read_frame(self) -> Optional[np.ndarray]: except queue.Empty: return None - def _send_message_to_client(self, message: Union[str, bytes, dict]) -> None: + def _send_message_to_client(self, message: str | bytes | dict) -> None: """ Send a message to the connected client (if any). @@ -333,7 +333,7 @@ def _send_message_to_client(self, message: Union[str, bytes, dict]) -> None: logger.error(f"Error sending message to client: {e}") raise - async def _send_to_client(self, message: Union[str, bytes, dict]) -> None: + async def _send_to_client(self, message: str | bytes | dict) -> None: """Send message to a single client.""" if isinstance(message, dict): message = json.dumps(message) From 564dc7b5780f99a0c261c20099c50833aae03c3a Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 3 Nov 2025 13:32:26 +0100 Subject: [PATCH 33/86] perf: make resize a no-op if frame has already target size --- src/arduino/app_utils/image/adjustments.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/arduino/app_utils/image/adjustments.py b/src/arduino/app_utils/image/adjustments.py index 34f66674..d8a9119e 100644 --- a/src/arduino/app_utils/image/adjustments.py +++ b/src/arduino/app_utils/image/adjustments.py @@ -93,12 +93,15 @@ def resize(frame: np.ndarray, target_size: Tuple[int, int], maintain_ratio: bool Args: frame (np.ndarray): Input frame target_size (tuple): Target size as (width, height) - maintain_ratio (bool): If True, use letterboxing to maintain aspect ratio - interpolation (int): OpenCV interpolation method + maintain_ratio (bool): If True, use letterboxing to maintain aspect ratio. Default: False. + interpolation (int): OpenCV interpolation method. Default: cv2.INTER_LINEAR. Returns: np.ndarray: Resized frame """ + if frame.shape[1] == target_size[0] and frame.shape[0] == target_size[1]: + return frame + if maintain_ratio: return letterbox(frame, target_size) else: From 12d53d6bfd1e38e8b6f6c7b0ae674927db8ae057 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 4 Nov 2025 16:27:54 +0100 Subject: [PATCH 34/86] refactor --- .../app_peripherals/camera/__init__.py | 4 +-- .../app_peripherals/camera/base_camera.py | 27 +++++++++++++------ src/arduino/app_peripherals/camera/camera.py | 6 ++--- .../app_peripherals/camera/v4l_camera.py | 14 +++++----- src/arduino/app_utils/image/adjustments.py | 2 +- 5 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/arduino/app_peripherals/camera/__init__.py b/src/arduino/app_peripherals/camera/__init__.py index 1142ae66..ada0b326 100644 --- a/src/arduino/app_peripherals/camera/__init__.py +++ b/src/arduino/app_peripherals/camera/__init__.py @@ -14,8 +14,8 @@ "IPCamera", "WebSocketCamera", "CameraError", - "CameraReadError", - "CameraOpenError", "CameraConfigError", + "CameraOpenError", + "CameraReadError", "CameraTransformError", ] diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index f7ea8d62..f37e51ce 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -87,6 +87,25 @@ def capture(self) -> Optional[np.ndarray]: return None return frame + def is_started(self) -> bool: + """Check if the camera is started.""" + return self._is_started + + def stream(self): + """ + Continuously capture frames from the camera. + + This is a generator that yields frames continuously while the camera is started. + Built on top of capture() for convenience. + + Yields: + np.ndarray: Video frames as numpy arrays. + """ + while self._is_started: + frame = self.capture() + if frame is not None: + yield frame + def _extract_frame(self) -> np.ndarray | None: """Extract a frame with FPS throttling and post-processing.""" with self._camera_lock: @@ -114,14 +133,6 @@ def _extract_frame(self) -> np.ndarray | None: return frame - def is_started(self) -> bool: - """Check if the camera is started.""" - return self._is_started - - def produce(self) -> Optional[np.ndarray]: - """Alias for capture method for compatibility.""" - return self.capture() - @abstractmethod def _open_camera(self) -> None: """Open the camera connection. Must be implemented by subclasses.""" diff --git a/src/arduino/app_peripherals/camera/camera.py b/src/arduino/app_peripherals/camera/camera.py index bd55befe..733062e4 100644 --- a/src/arduino/app_peripherals/camera/camera.py +++ b/src/arduino/app_peripherals/camera/camera.py @@ -21,10 +21,10 @@ class Camera: Supports: - V4L Cameras (local cameras connected to the system), the default - IP Cameras (network-based cameras via RTSP, HLS) - - WebSocket Cameras (input streams via WebSocket client) + - WebSocket Cameras (input video streams via WebSocket client) - Note: constructor arguments (except source) must be provided in keyword - format to forward them correctly to the specific camera implementations. + Note: constructor arguments (except those in signature) must be provided in + keyword format to forward them correctly to the specific camera implementations. """ def __new__( diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py index 196707b3..0256f401 100644 --- a/src/arduino/app_peripherals/camera/v4l_camera.py +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -44,7 +44,7 @@ def __init__( a numpy array and returns a numpy array. Default: None """ super().__init__(resolution, fps, adjustments) - self.device_index = self._resolve_camera_id(device) + self.device = self._resolve_camera_id(device) self.logger = logger self._cap = None @@ -123,9 +123,9 @@ def _get_video_devices_by_index(self) -> dict[int, str]: def _open_camera(self) -> None: """Open the V4L camera connection.""" - self._cap = cv2.VideoCapture(self.device_index) + self._cap = cv2.VideoCapture(self.device) if not self._cap.isOpened(): - raise CameraOpenError(f"Failed to open V4L camera {self.device_index}") + raise CameraOpenError(f"Failed to open V4L camera {self.device}") self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to minimize latency @@ -139,7 +139,7 @@ def _open_camera(self) -> None: actual_height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) if actual_width != self.resolution[0] or actual_height != self.resolution[1]: logger.warning( - f"Camera {self.device_index} resolution set to {actual_width}x{actual_height} " + f"Camera {self.device} resolution set to {actual_width}x{actual_height} " f"instead of requested {self.resolution[0]}x{self.resolution[1]}" ) self.resolution = (actual_width, actual_height) @@ -149,10 +149,10 @@ def _open_camera(self) -> None: actual_fps = int(self._cap.get(cv2.CAP_PROP_FPS)) if actual_fps != self.fps: - logger.warning(f"Camera {self.device_index} FPS set to {actual_fps} instead of requested {self.fps}") + logger.warning(f"Camera {self.device} FPS set to {actual_fps} instead of requested {self.fps}") self.fps = actual_fps - logger.info(f"Opened V4L camera with index {self.device_index}") + logger.info(f"Opened V4L camera with index {self.device}") def _close_camera(self) -> None: """Close the V4L camera connection.""" @@ -167,6 +167,6 @@ def _read_frame(self) -> np.ndarray | None: ret, frame = self._cap.read() if not ret or frame is None: - raise CameraReadError(f"Failed to read from V4L camera {self.device_index}") + raise CameraReadError(f"Failed to read from V4L camera {self.device}") return frame diff --git a/src/arduino/app_utils/image/adjustments.py b/src/arduino/app_utils/image/adjustments.py index d8a9119e..97a63392 100644 --- a/src/arduino/app_utils/image/adjustments.py +++ b/src/arduino/app_utils/image/adjustments.py @@ -101,7 +101,7 @@ def resize(frame: np.ndarray, target_size: Tuple[int, int], maintain_ratio: bool """ if frame.shape[1] == target_size[0] and frame.shape[0] == target_size[1]: return frame - + if maintain_ratio: return letterbox(frame, target_size) else: From f3e6578e59f8f85b2eb4fc60998f49dabb1472f3 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 4 Nov 2025 17:23:00 +0100 Subject: [PATCH 35/86] feat: update EI container to add TCP streaming mode --- containers/ei-models-runner/Dockerfile | 2 +- .../app_bricks/video_imageclassification/brick_compose.yaml | 5 +++-- .../app_bricks/video_objectdetection/brick_compose.yaml | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/containers/ei-models-runner/Dockerfile b/containers/ei-models-runner/Dockerfile index b7a542fa..4607a020 100644 --- a/containers/ei-models-runner/Dockerfile +++ b/containers/ei-models-runner/Dockerfile @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MPL-2.0 -FROM public.ecr.aws/z9b3d4t5/inference-container-qc-adreno-702:4d7979284677b6bdb557abe8948fa1395dc89a63 +FROM public.ecr.aws/z9b3d4t5/inference-container-qc-adreno-702:39bcebb78de783cb602e1b361b71d6dafbc959b4 # Create the user and group needed to run the container as non-root RUN set -ex; \ diff --git a/src/arduino/app_bricks/video_imageclassification/brick_compose.yaml b/src/arduino/app_bricks/video_imageclassification/brick_compose.yaml index 28f1aa73..ff2a1495 100644 --- a/src/arduino/app_bricks/video_imageclassification/brick_compose.yaml +++ b/src/arduino/app_bricks/video_imageclassification/brick_compose.yaml @@ -9,11 +9,12 @@ services: max-size: "5m" max-file: "2" ports: - - ${BIND_ADDRESS:-0.0.0.0}:4912:4912 + - ${BIND_ADDRESS:-0.0.0.0}:5050:5050 # TCP input for video frames + - ${BIND_ADDRESS:-0.0.0.0}:4912:4912 # Embedded UI port volumes: - "${CUSTOM_MODEL_PATH:-/home/arduino/.arduino-bricks/ei-models/}:${CUSTOM_MODEL_PATH:-/home/arduino/.arduino-bricks/ei-models/}" - "/run/udev:/run/udev" - command: ["--model-file", "${EI_CLASSIFICATION_MODEL:-/models/ootb/ei/mobilenet-v2-224px.eim}", "--dont-print-predictions", "--mode", "streaming", "--preview-original-resolution", "--gst-launch-args", "tcpserversrc host=0.0.0.0 port=5000 ! jpegdec ! videoconvert ! video/x-raw ! jpegenc"] + command: ["--model-file", "${EI_CLASSIFICATION_MODEL:-/models/ootb/ei/mobilenet-v2-224px.eim}", "--dont-print-predictions", "--mode", "streaming-tcp-server", "--preview-original-resolution"] healthcheck: test: [ "CMD-SHELL", "wget -q --spider http://ei-video-classification-runner:4912 || exit 1" ] interval: 2s diff --git a/src/arduino/app_bricks/video_objectdetection/brick_compose.yaml b/src/arduino/app_bricks/video_objectdetection/brick_compose.yaml index 648913ee..053e05e9 100644 --- a/src/arduino/app_bricks/video_objectdetection/brick_compose.yaml +++ b/src/arduino/app_bricks/video_objectdetection/brick_compose.yaml @@ -9,11 +9,12 @@ services: max-size: "5m" max-file: "2" ports: - - ${BIND_ADDRESS:-0.0.0.0}:4912:4912 + - ${BIND_ADDRESS:-0.0.0.0}:5050:5050 # TCP input for video frames + - ${BIND_ADDRESS:-0.0.0.0}:4912:4912 # Embedded UI port volumes: - "${CUSTOM_MODEL_PATH:-/home/arduino/.arduino-bricks/ei-models/}:${CUSTOM_MODEL_PATH:-/home/arduino/.arduino-bricks/ei-models/}" - "/run/udev:/run/udev" - command: ["--model-file", "${EI_OBJ_DETECTION_MODEL:-/models/ootb/ei/yolo-x-nano.eim}", "--dont-print-predictions", "--mode", "streaming", "--preview-original-resolution", "--gst-launch-args", "tcpserversrc host=0.0.0.0 port=5000 ! jpegdec ! videoconvert ! video/x-raw ! jpegenc"] + command: ["--model-file", "${EI_OBJ_DETECTION_MODEL:-/models/ootb/ei/yolo-x-nano.eim}", "--dont-print-predictions", "--mode", "streaming-tcp-server", "--preview-original-resolution"] healthcheck: test: [ "CMD-SHELL", "wget -q --spider http://ei-video-obj-detection-runner:4912 || exit 1" ] interval: 2s From 1c91f6210ecd52178c408988b056a9a25bf99093 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 4 Nov 2025 17:50:59 +0100 Subject: [PATCH 36/86] refactor: migrate camera_code_detection --- .../camera_code_detection/README.md | 28 +++++++++++++++---- .../camera_code_detection/detection.py | 18 ++++++------ .../examples/2_detection_list.py | 2 +- .../examples/3_detection_with_overrides.py | 4 +-- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/arduino/app_bricks/camera_code_detection/README.md b/src/arduino/app_bricks/camera_code_detection/README.md index 786da81d..0b1b10c5 100644 --- a/src/arduino/app_bricks/camera_code_detection/README.md +++ b/src/arduino/app_bricks/camera_code_detection/README.md @@ -6,8 +6,8 @@ This Brick enables real-time barcode and QR code scanning from a camera video st The Camera Code Detection Brick allows you to: -- Capture frames from a USB camera. -- Configure camera settings (resolution and frame rate). +- Capture frames from a Camera (see Camera peripheral for supported cameras). +- Configure Camera settings (resolution and frame rate). - Define the type of code to detect: barcodes and/or QR codes. - Process detections with customizable callbacks. @@ -22,7 +22,7 @@ The Camera Code Detection Brick allows you to: ## Prerequisites -To use this Brick you should have a USB camera connected to your board. +To use this Brick you can choose to plug a camera to your board or use a network-connected camera. **Tip**: Use a USB-C® Hub with USB-A connectors to support commercial web cameras. @@ -37,9 +37,25 @@ def render_frame(frame): def handle_detected_code(frame, detection): ... -# Select the camera you want to use, its resolution and the max fps -detection = CameraCodeDetection(camera=0, resolution=(640, 360), fps=10) +detection = CameraCodeDetection() detection.on_frame(render_frame) detection.on_detection(handle_detected_code) -detection.start() + +App.run() ``` + +You can also select a specific camera to use: + +```python +from arduino.app_bricks.camera_code_detection import CameraCodeDetection + +def handle_detected_code(frame, detection): + ... + +# Select the camera you want to use, its resolution and the max fps +camera = Camera(camera="rtsp://...", resolution=(640, 360), fps=10) +detection = CameraCodeDetection(camera) +detection.on_detection(handle_detected_code) + +App.run() +``` \ No newline at end of file diff --git a/src/arduino/app_bricks/camera_code_detection/detection.py b/src/arduino/app_bricks/camera_code_detection/detection.py index d457e8d5..746c9d6b 100644 --- a/src/arduino/app_bricks/camera_code_detection/detection.py +++ b/src/arduino/app_bricks/camera_code_detection/detection.py @@ -6,12 +6,12 @@ import threading from typing import Callable -import cv2 from pyzbar.pyzbar import decode, ZBarSymbol, PyZbarError import numpy as np -from PIL.Image import Image +from PIL.Image import Image, fromarray from arduino.app_peripherals.camera import Camera +from arduino.app_utils.image import greyscale from arduino.app_utils import brick, Logger logger = Logger("CameraCodeDetection") @@ -44,7 +44,7 @@ class CameraCodeDetection: """Scans a camera video feed for QR codes and/or barcodes. Args: - camera (USBCamera): The USB camera instance. If None, a default camera will be initialized. + camera (Camera): The camera instance to use for capturing video. If None, a default camera will be initialized. detect_qr (bool): Whether to detect QR codes. Defaults to True. detect_barcode (bool): Whether to detect barcodes. Defaults to True. @@ -154,13 +154,13 @@ def loop(self): self._on_error(e) return - # Use grayscale for barcode/QR code detection - gs_frame = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY) - - self._on_frame(frame) + pil_frame = fromarray(frame) + self._on_frame(pil_frame) + # Use grayscale for barcode/QR code detection + gs_frame = greyscale(frame) detections = self._scan_frame(gs_frame) - self._on_detect(frame, detections) + self._on_detect(pil_frame, detections) def _on_frame(self, frame: Image): if self._on_frame_cb: @@ -170,7 +170,7 @@ def _on_frame(self, frame: Image): logger.error(f"Failed to run on_frame callback: {e}") self._on_error(e) - def _scan_frame(self, frame: cv2.typing.MatLike) -> list[Detection]: + def _scan_frame(self, frame: np.ndarray) -> list[Detection]: """Scan the frame for a single barcode or QR code.""" detections = [] diff --git a/src/arduino/app_bricks/camera_code_detection/examples/2_detection_list.py b/src/arduino/app_bricks/camera_code_detection/examples/2_detection_list.py index 7d63bb46..554b0f46 100644 --- a/src/arduino/app_bricks/camera_code_detection/examples/2_detection_list.py +++ b/src/arduino/app_bricks/camera_code_detection/examples/2_detection_list.py @@ -19,4 +19,4 @@ def on_codes_detected(frame: Image, detections: list[Detection]): detector = CameraCodeDetection() detector.on_detect(on_codes_detected) -App.run() # This will block until the app is stopped +App.run() diff --git a/src/arduino/app_bricks/camera_code_detection/examples/3_detection_with_overrides.py b/src/arduino/app_bricks/camera_code_detection/examples/3_detection_with_overrides.py index d128cd96..4d15cadd 100644 --- a/src/arduino/app_bricks/camera_code_detection/examples/3_detection_with_overrides.py +++ b/src/arduino/app_bricks/camera_code_detection/examples/3_detection_with_overrides.py @@ -6,7 +6,7 @@ # EXAMPLE_REQUIRES = "Requires an USB webcam connected to the Arduino board." from PIL.Image import Image from arduino.app_utils.app import App -from arduino.app_peripherals.usb_camera import USBCamera +from arduino.app_peripherals.usb_camera import Camera from arduino.app_bricks.camera_code_detection import CameraCodeDetection, Detection @@ -17,7 +17,7 @@ def on_code_detected(frame: Image, detection: Detection): # e.g., draw a bounding box, save it to a database or log it. -camera = USBCamera(camera=0, resolution=(640, 360), fps=10) +camera = Camera(camera=2, resolution=(640, 360), fps=10) detector = CameraCodeDetection(camera) detector.on_detect(on_code_detected) From af81594fb9f58beda94bab4ce9a926657b797b29 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 4 Nov 2025 23:48:00 +0100 Subject: [PATCH 37/86] refactor: migrate vide_objectdetection --- .../video_imageclassification/__init__.py | 101 +++++++++++------- 1 file changed, 65 insertions(+), 36 deletions(-) diff --git a/src/arduino/app_bricks/video_imageclassification/__init__.py b/src/arduino/app_bricks/video_imageclassification/__init__.py index e479939b..fe4ce75b 100644 --- a/src/arduino/app_bricks/video_imageclassification/__init__.py +++ b/src/arduino/app_bricks/video_imageclassification/__init__.py @@ -2,16 +2,21 @@ # # SPDX-License-Identifier: MPL-2.0 -from arduino.app_utils import brick, Logger -from arduino.app_internal.core import load_brick_compose_file, resolve_address -from arduino.app_internal.core import EdgeImpulseRunnerFacade -import threading import time +import json +import inspect +import threading +import socket from typing import Callable + from websockets.sync.client import connect, ClientConnection from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError -import json -import inspect + +from arduino.app_peripherals.camera import Camera +from arduino.app_internal.core import load_brick_compose_file, resolve_address +from arduino.app_internal.core import EdgeImpulseRunnerFacade +from arduino.app_utils.image import compress_to_jpeg +from arduino.app_utils import brick, Logger logger = Logger("VideoImageClassification") @@ -25,10 +30,11 @@ class VideoImageClassification: ALL_HANDLERS_KEY = "__ALL" - def __init__(self, confidence: float = 0.3, debounce_sec: float = 0.0): + def __init__(self, camera: Camera = None, confidence: float = 0.3, debounce_sec: float = 0.0): """Initialize the VideoImageClassification class. Args: + camera (Camera): The camera instance to use for capturing video. If None, a default camera will be initialized. confidence (float): The minimum confidence level for a classification to be considered valid. Default is 0.3. debounce_sec (float): The minimum time in seconds between consecutive detections of the same object to avoid multiple triggers. Default is 0 seconds. @@ -36,6 +42,8 @@ def __init__(self, confidence: float = 0.3, debounce_sec: float = 0.0): Raises: RuntimeError: If the host address could not be resolved. """ + self._camera = camera if camera else Camera() + self._confidence = confidence self._debounce_sec = debounce_sec self._last_detected = {} @@ -114,40 +122,26 @@ def on_detect(self, object: str, callback: Callable[[], None]): self._handlers[object] = callback def start(self): - """Start the classification stream. - - This only sets the internal running flag. You must call - `execute` in a loop or a separate thread to actually begin receiving classification results. - """ + """Start the classification.""" + self._camera.start() self._is_running.set() def stop(self): - """Stop the classification stream and release resources. - - This clears the running flag. Any active `execute` loop - will exit gracefully at its next iteration. - """ + """Stop the classification and release resources.""" self._is_running.clear() + self._camera.stop() - def execute(self): - """Run the main classification loop. - - Behavior: - - Opens a WebSocket connection to the model runner. - - Receives classification messages in real time. - - Filters classifications below the confidence threshold. - - Applies debounce rules before invoking callbacks. - - Retries on transient connection errors until stopped. - - Exceptions: - ConnectionClosedOK: - Raised to exit when the server closes the connection cleanly. - ConnectionClosedError, TimeoutError, ConnectionRefusedError: - Logged and retried with backoff. + @brick.execute + def classification_loop(self): + """Classification main loop. + + Maintains WebSocket connection to the model runner and processes classification messages. + Retries on connection errors until stopped. """ while self._is_running.is_set(): try: with connect(self._uri) as ws: + logger.info("WebSocket connection established") while self._is_running.is_set(): try: message = ws.recv() @@ -157,21 +151,56 @@ def execute(self): except ConnectionClosedOK: raise except (TimeoutError, ConnectionRefusedError, ConnectionClosedError): - logger.warning(f"Connection lost. Retrying...") + logger.warning(f"WebSocket connection lost. Retrying...") raise except Exception as e: logger.exception(f"Failed to process detection: {e}") except ConnectionClosedOK: - logger.debug(f"Disconnected cleanly, exiting WebSocket read loop.") + logger.debug(f"WebSocket disconnected cleanly, exiting loop.") return except (TimeoutError, ConnectionRefusedError, ConnectionClosedError): logger.debug(f"Waiting for model runner. Retrying...") - import time - time.sleep(2) continue except Exception as e: logger.exception(f"Failed to establish WebSocket connection to {self._host}: {e}") + time.sleep(2) + + @brick.execute + def camera_loop(self): + """Camera main loop. + + Captures images from the camera and forwards them over the TCP connection. + Retries on connection errors until stopped. + """ + while self._is_running.is_set(): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as tcp_socket: + tcp_socket.connect((self._host, "5050")) + logger.info(f"TCP connection established to {self._host}:5050") + + while self._is_running.is_set(): + try: + frame = self._camera.capture() + if frame is None: + time.sleep(0.01) # Brief sleep if no image available + continue + + jpeg_frame = compress_to_jpeg(frame) + tcp_socket.sendall(jpeg_frame.tobytes()) + + except (BrokenPipeError, ConnectionResetError, OSError) as e: + logger.warning(f"TCP connection lost: {e}. Retrying...") + break + except Exception as e: + logger.exception(f"Error capturing/sending image: {e}") + + except (ConnectionRefusedError, OSError) as e: + logger.debug(f"TCP connection failed: {e}. Retrying in 2 seconds...") + time.sleep(2) + except Exception as e: + logger.exception(f"Unexpected error in TCP loop: {e}") + time.sleep(2) def _process_message(self, ws: ClientConnection, message: str): jmsg = json.loads(message) From cbe478047008ad0645dd20fe1792587d8d1fa7e8 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 5 Nov 2025 00:03:44 +0100 Subject: [PATCH 38/86] refactor: migrate video_objectdetection --- .../video_objectdetection/__init__.py | 90 +++++++++++++------ 1 file changed, 63 insertions(+), 27 deletions(-) diff --git a/src/arduino/app_bricks/video_objectdetection/__init__.py b/src/arduino/app_bricks/video_objectdetection/__init__.py index 2f48de1c..0a5c3837 100644 --- a/src/arduino/app_bricks/video_objectdetection/__init__.py +++ b/src/arduino/app_bricks/video_objectdetection/__init__.py @@ -2,16 +2,21 @@ # # SPDX-License-Identifier: MPL-2.0 -from arduino.app_utils import brick, Logger -from arduino.app_internal.core import load_brick_compose_file, resolve_address -from arduino.app_internal.core import EdgeImpulseRunnerFacade import time +import json +import inspect import threading +import socket from typing import Callable + from websockets.sync.client import connect, ClientConnection from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError -import json -import inspect + +from arduino.app_peripherals.camera import Camera +from arduino.app_internal.core import load_brick_compose_file, resolve_address +from arduino.app_internal.core import EdgeImpulseRunnerFacade +from arduino.app_utils.image.adjustments import compress_to_jpeg +from arduino.app_utils import brick, Logger logger = Logger("VideoObjectDetection") @@ -30,16 +35,19 @@ class VideoObjectDetection: ALL_HANDLERS_KEY = "__ALL" - def __init__(self, confidence: float = 0.3, debounce_sec: float = 0.0): + def __init__(self, camera: Camera = None, confidence: float = 0.3, debounce_sec: float = 0.0): """Initialize the VideoObjectDetection class. Args: + camera (Camera): The camera instance to use for capturing video. If None, a default camera will be initialized. confidence (float): Confidence level for detection. Default is 0.3 (30%). debounce_sec (float): Minimum seconds between repeated detections of the same object. Default is 0 seconds. Raises: RuntimeError: If the host address could not be resolved. """ + self._camera = camera if camera else Camera() + self._confidence = confidence self._debounce_sec = debounce_sec self._last_detected: dict[str, float] = {} @@ -107,32 +115,25 @@ def on_detect_all(self, callback: Callable[[dict], None]): def start(self): """Start the video object detection process.""" + self._camera.start() self._is_running.set() def stop(self): - """Stop the video object detection process.""" + """Stop the video object detection process and release resources.""" self._is_running.clear() + self._camera.stop() + + @brick.execute + def object_detection_loop(self): + """Object detection main loop. - def execute(self): - """Connect to the model runner and process messages until `stop` is called. - - Behavior: - - Establishes a WebSocket connection to the runner. - - Parses ``"hello"`` messages to capture model metadata and optionally - performs a threshold override to align the runner with the local setting. - - Parses ``"classification"`` messages, filters detections by confidence, - applies debounce, then invokes registered callbacks. - - Retries on transient WebSocket errors while running. - - Exceptions: - ConnectionClosedOK: - Propagated to exit cleanly when the server closes the connection. - ConnectionClosedError, TimeoutError, ConnectionRefusedError: - Logged and retried with a short backoff while running. + Maintains WebSocket connection to the model runner and processes object detection messages. + Retries on connection errors until stopped. """ while self._is_running.is_set(): try: with connect(self._uri) as ws: + logger.info("WebSocket connection established") while self._is_running.is_set(): try: message = ws.recv() @@ -142,21 +143,56 @@ def execute(self): except ConnectionClosedOK: raise except (TimeoutError, ConnectionRefusedError, ConnectionClosedError): - logger.warning(f"Connection lost. Retrying...") + logger.warning(f"WebSocket connection lost. Retrying...") raise except Exception as e: logger.exception(f"Failed to process detection: {e}") except ConnectionClosedOK: - logger.debug(f"Disconnected cleanly, exiting WebSocket read loop.") + logger.debug(f"WebSocket disconnected cleanly, exiting loop.") return except (TimeoutError, ConnectionRefusedError, ConnectionClosedError): logger.debug(f"Waiting for model runner. Retrying...") - import time - time.sleep(2) continue except Exception as e: logger.exception(f"Failed to establish WebSocket connection to {self._host}: {e}") + time.sleep(2) + + @brick.execute + def camera_loop(self): + """Camera main loop. + + Captures images from the camera and forwards them over the TCP connection. + Retries on connection errors until stopped. + """ + while self._is_running.is_set(): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as tcp_socket: + tcp_socket.connect((self._host, "5050")) + logger.info(f"TCP connection established to {self._host}:5050") + + while self._is_running.is_set(): + try: + frame = self._camera.capture() + if frame is None: + time.sleep(0.01) # Brief sleep if no image available + continue + + jpeg_frame = compress_to_jpeg(frame) + tcp_socket.sendall(jpeg_frame.tobytes()) + + except (BrokenPipeError, ConnectionResetError, OSError) as e: + logger.warning(f"TCP connection lost: {e}. Retrying...") + break + except Exception as e: + logger.exception(f"Error capturing/sending image: {e}") + + except (ConnectionRefusedError, OSError) as e: + logger.debug(f"TCP connection failed: {e}. Retrying in 2 seconds...") + time.sleep(2) + except Exception as e: + logger.exception(f"Unexpected error in TCP loop: {e}") + time.sleep(2) def _process_message(self, ws: ClientConnection, message: str): jmsg = json.loads(message) From 7badfe29240f18f7de12cc620d67bbbc0a36fedd Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 6 Nov 2025 01:40:25 +0100 Subject: [PATCH 39/86] refactor --- .../app_peripherals/camera/base_camera.py | 11 +++-- .../camera/examples/1_initialize.py | 2 +- .../camera/examples/6_capture_websocket.py | 3 +- .../camera/websocket_camera.py | 43 ++++--------------- 4 files changed, 19 insertions(+), 40 deletions(-) diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index f37e51ce..43655efe 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -87,10 +87,6 @@ def capture(self) -> Optional[np.ndarray]: return None return frame - def is_started(self) -> bool: - """Check if the camera is started.""" - return self._is_started - def stream(self): """ Continuously capture frames from the camera. @@ -105,6 +101,13 @@ def stream(self): frame = self.capture() if frame is not None: yield frame + else: + # Avoid busy-waiting if no frame available + time.sleep(0.001) + + def is_started(self) -> bool: + """Check if the camera is started.""" + return self._is_started def _extract_frame(self) -> np.ndarray | None: """Extract a frame with FPS throttling and post-processing.""" diff --git a/src/arduino/app_peripherals/camera/examples/1_initialize.py b/src/arduino/app_peripherals/camera/examples/1_initialize.py index f720708c..677b14f9 100644 --- a/src/arduino/app_peripherals/camera/examples/1_initialize.py +++ b/src/arduino/app_peripherals/camera/examples/1_initialize.py @@ -11,7 +11,7 @@ # The following two are equivalent camera = Camera(2, resolution=(640, 480), fps=15) # Infers camera type -v4l = V4LCamera(2, (640, 480), 15) # Explicitly requests V4L camera +v4l = V4LCamera(2, (640, 480), 15) # Explicitly request V4L camera # Note: Camera's constructor arguments (except those in its signature) # must be provided in keyword format to forward them correctly to the diff --git a/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py b/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py index 14235760..b7d9f2db 100644 --- a/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py +++ b/src/arduino/app_peripherals/camera/examples/6_capture_websocket.py @@ -2,7 +2,8 @@ # # SPDX-License-Identifier: MPL-2.0 -# EXAMPLE_NAME = "Capture an input WebSocket video" +# EXAMPLE_NAME = "Capture an input WebSocket video stream" +# EXAMPLE_REQUIRES = "Requires a connected camera" import time import numpy as np from arduino.app_peripherals.camera import Camera diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 3b57ab99..54f408d3 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -7,6 +7,7 @@ import threading import queue import time +from typing import Literal import numpy as np import cv2 import websockets @@ -47,7 +48,7 @@ def __init__( host: str = "0.0.0.0", port: int = 8080, timeout: int = 10, - frame_format: str = "binary", + frame_format: Literal["binary", "base64", "json"] = "binary", resolution: tuple[int, int] = (640, 480), fps: int = 10, adjustments: Callable[[np.ndarray], np.ndarray] = None, @@ -83,7 +84,6 @@ def __init__( def _open_camera(self) -> None: """Start the WebSocket server.""" - # Start server in separate thread with its own event loop self._server_thread = threading.Thread(target=self._start_server_thread, daemon=True) self._server_thread.start() @@ -251,17 +251,16 @@ async def _parse_message(self, message) -> np.ndarray | None: def _close_camera(self): """Stop the WebSocket server.""" - # Signal async stop event if it exists if self._loop and not self._loop.is_closed(): - future = asyncio.run_coroutine_threadsafe(self._set_async_stop_event(), self._loop) try: + future = asyncio.run_coroutine_threadsafe(self._stop_and_disconnect_client(), self._loop) future.result(timeout=1.0) except CancelledError: - logger.debug(f"Error setting async stop event: CancelledError") + logger.debug(f"Error stopping WebSocket server: CancelledError") except TimeoutError: - logger.debug(f"Error setting async stop event: TimeoutError") + logger.debug(f"Error stopping WebSocket server: TimeoutError") except Exception as e: - logger.warning(f"Error setting async stop event: {e}") + logger.warning(f"Error stopping WebSocket server: {e}") # Wait for server thread to finish if self._server_thread and self._server_thread.is_alive(): @@ -279,7 +278,7 @@ def _close_camera(self): self._loop = None self._client = None - async def _set_async_stop_event(self): + async def _stop_and_disconnect_client(self): """Set the async stop event and close the client connection.""" # Send goodbye message and close the client connection if self._client: @@ -306,35 +305,11 @@ def _read_frame(self) -> np.ndarray | None: except queue.Empty: return None - def _send_message_to_client(self, message: str | bytes | dict) -> None: - """ - Send a message to the connected client (if any). - - Args: - message: Message to send to the client - - Raises: - RuntimeError: If the event loop is not running or closed - ConnectionError: If no client is connected - Exception: For other communication errors - """ - if not self._loop or self._loop.is_closed(): - raise RuntimeError("WebSocket server event loop is not running") - + async def _send_to_client(self, message: str | bytes | dict) -> None: + """Send a message to the connected client.""" if self._client is None: raise ConnectionError("No client connected to send message to") - # Schedule message sending in the server's event loop - future = asyncio.run_coroutine_threadsafe(self._send_to_client(message), self._loop) - - try: - future.result(timeout=5.0) - except Exception as e: - logger.error(f"Error sending message to client: {e}") - raise - - async def _send_to_client(self, message: str | bytes | dict) -> None: - """Send message to a single client.""" if isinstance(message, dict): message = json.dumps(message) From 8205c90a397805a664fccf3c18efb4ba288e629d Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 11 Nov 2025 00:51:34 +0100 Subject: [PATCH 40/86] fix --- src/arduino/app_peripherals/camera/websocket_camera.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 54f408d3..8c49b617 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -294,7 +294,8 @@ async def _stop_and_disconnect_client(self): logger.warning(f"Error closing client in stop event: {e}") finally: await self._client.close() - self._stop_event.set() + + self._stop_event.set() def _read_frame(self) -> np.ndarray | None: """Read a frame from the queue.""" From 13e2c8db4ac3601a47449b0d76e168ab738ff4f9 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 13 Nov 2025 09:15:55 +0100 Subject: [PATCH 41/86] refactors --- src/arduino/app_peripherals/camera/__init__.py | 2 ++ src/arduino/app_peripherals/camera/base_camera.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/arduino/app_peripherals/camera/__init__.py b/src/arduino/app_peripherals/camera/__init__.py index ada0b326..d1bb87de 100644 --- a/src/arduino/app_peripherals/camera/__init__.py +++ b/src/arduino/app_peripherals/camera/__init__.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MPL-2.0 from .camera import Camera +from .base_camera import BaseCamera from .v4l_camera import V4LCamera from .ip_camera import IPCamera from .websocket_camera import WebSocketCamera @@ -10,6 +11,7 @@ __all__ = [ "Camera", + "BaseCamera", "V4LCamera", "IPCamera", "WebSocketCamera", diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index 43655efe..e699e4cd 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -71,7 +71,7 @@ def stop(self) -> None: try: self._close_camera() self._is_started = False - self.logger.info(f"Stopped {self.__class__.__name__}") + self.logger.info(f"Successfully stopped {self.__class__.__name__}") except Exception as e: self.logger.warning(f"Error stopping camera: {e}") From 54f44cbb7a2297a1726f7566026b09379df602a5 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 13 Nov 2025 09:16:27 +0100 Subject: [PATCH 42/86] add tests dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 477f8080..060c2be3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dev = [ "setuptools", "build", "pytest", + "pytest-asyncio", "websocket-client", "ruff", "docstring_parser>=0.16", From 8cc5b954e655362ba86c014e2d38977b0544f3c1 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 13 Nov 2025 09:17:02 +0100 Subject: [PATCH 43/86] test: add tests for camera module --- .../camera/test_base_camera.py | 372 ++++++++++++++++++ .../app_peripherals/camera/test_camera.py | 145 +++++++ .../app_peripherals/camera/test_ip_camera.py | 289 ++++++++++++++ .../app_peripherals/camera/test_v4l_camera.py | 280 +++++++++++++ .../camera/test_websocket_camera.py | 337 ++++++++++++++++ 5 files changed, 1423 insertions(+) create mode 100644 tests/arduino/app_peripherals/camera/test_base_camera.py create mode 100644 tests/arduino/app_peripherals/camera/test_camera.py create mode 100644 tests/arduino/app_peripherals/camera/test_ip_camera.py create mode 100644 tests/arduino/app_peripherals/camera/test_v4l_camera.py create mode 100644 tests/arduino/app_peripherals/camera/test_websocket_camera.py diff --git a/tests/arduino/app_peripherals/camera/test_base_camera.py b/tests/arduino/app_peripherals/camera/test_base_camera.py new file mode 100644 index 00000000..02b00f14 --- /dev/null +++ b/tests/arduino/app_peripherals/camera/test_base_camera.py @@ -0,0 +1,372 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import pytest +import time +import numpy as np + +from arduino.app_peripherals.camera import BaseCamera, CameraOpenError, CameraTransformError +from arduino.app_utils.image.pipeable import PipeableFunction + + +class ConcreteCamera(BaseCamera): + """Concrete implementation of BaseCamera for testing.""" + + def __init__(self, *args, **kwargs): + # Extract test configuration + self.should_fail_open = kwargs.pop("should_fail_open", False) + self.should_fail_close = kwargs.pop("should_fail_close", False) + self.should_fail_read = kwargs.pop("should_fail_read", False) + self.open_error_message = kwargs.pop("open_error_message", "Camera open failed") + self.close_error_message = kwargs.pop("close_error_message", "Camera close failed") + self.read_error_message = kwargs.pop("read_error_message", "Frame read failed") + + super().__init__(*args, **kwargs) + + # Track method calls for verification + self.open_call_count = 0 + self.close_call_count = 0 + self.read_call_count = 0 + + def _open_camera(self): + """Mock implementation of _open_camera.""" + self.open_call_count += 1 + if self.should_fail_open: + raise RuntimeError(self.open_error_message) + + def _close_camera(self): + """Mock implementation of _close_camera.""" + self.close_call_count += 1 + if self.should_fail_close: + raise RuntimeError(self.close_error_message) + + def _read_frame(self): + """Mock implementation that returns a dummy frame.""" + self.read_call_count += 1 + if self.should_fail_read: + raise RuntimeError(self.read_error_message) + if not self._is_started: + return None + return np.zeros((480, 640, 3), dtype=np.uint8) + + +def test_base_camera_init_default(): + """Test BaseCamera initialization with default parameters.""" + camera = ConcreteCamera() + assert camera.resolution == (640, 480) + assert camera.fps == 10 + assert camera.adjustments is None + assert not camera.is_started() + + +def test_base_camera_init_custom(): + """Test BaseCamera initialization with custom parameters.""" + adj_func = lambda x: x + camera = ConcreteCamera(resolution=(1920, 1080), fps=30, adjustments=adj_func) + assert camera.resolution == (1920, 1080) + assert camera.fps == 30 + assert camera.adjustments == adj_func + + +def test_is_started_state_transitions(): + """Test is_started return value through different state transitions.""" + camera = ConcreteCamera() + + assert not camera.is_started() + camera.start() + assert camera.is_started() + camera.stop() + assert not camera.is_started() + + camera.start() + assert camera.is_started() + camera.stop() + assert not camera.is_started() + + +def test_start_success(): + """Test that start() calls _open_camera and updates state correctly.""" + camera = ConcreteCamera() + + assert not camera.is_started() + assert camera.open_call_count == 0 + + camera.start() + + assert camera.is_started() + assert camera.open_call_count == 1 + + +def test_start_already_started(): + """Test that start() doesn't call _open_camera again when already started.""" + camera = ConcreteCamera() + + camera.start() + assert camera.open_call_count == 1 + assert camera.is_started() + + camera.start() + # Should not call _open_camera again + assert camera.open_call_count == 1 + assert camera.is_started() + + +def test_start_error_reporting(): + """Test that errors from _open_camera are reported clearly.""" + camera = ConcreteCamera(should_fail_open=True, open_error_message="Mock camera failure") + + # Verify error is properly wrapped and reported + with pytest.raises(CameraOpenError, match="Failed to start camera: Mock camera failure"): + camera.start() + + # Verify camera state remains stopped on error + assert not camera.is_started() + assert camera.open_call_count == 1 + + +def test_stop_success(): + """Test that stop() calls _close_camera and updates state correctly.""" + camera = ConcreteCamera() + camera.start() + assert camera.is_started() + + camera.stop() + + # Verify _close_camera was called and state updated + assert not camera.is_started() + assert camera.close_call_count == 1 + + +def test_stop_not_started(): + """Test that stop() doesn't call _close_camera when not started.""" + camera = ConcreteCamera() + assert not camera.is_started() + + camera.stop() + + # Should not call _close_camera + assert camera.close_call_count == 0 + assert not camera.is_started() + + +def test_stop_error_reporting(): + """Test that errors from _close_camera are handled gracefully.""" + camera = ConcreteCamera(should_fail_close=True, close_error_message="Mock close failure") + + camera.start() + assert camera.is_started() + + # stop() does not raise exceptions + camera.stop() + + assert camera.close_call_count == 1 + assert camera.is_started() # Should still be started due to close error + + +def test_capture_when_started(): + """Test that capture() calls _read_frame when started.""" + camera = ConcreteCamera() + camera.start() + + initial_read_count = camera.read_call_count + frame = camera.capture() + + assert camera.read_call_count == initial_read_count + 1 + assert frame is not None + assert isinstance(frame, np.ndarray) + assert frame.shape == (480, 640, 3) + + +def test_capture_when_stopped(): + """Test that capture() returns None when camera is not started.""" + camera = ConcreteCamera() + + frame = camera.capture() + + assert frame is None + assert camera.read_call_count == 0 + + +def test_capture_read_frame_error_reporting(): + """Test that errors from _read_frame are not caught by capture().""" + camera = ConcreteCamera(should_fail_read=True, read_error_message="Mock read failure") + camera.start() + + # capture() should propagate the exception from _read_frame + with pytest.raises(RuntimeError, match="Mock read failure"): + camera.capture() + + assert camera.read_call_count == 1 + + +def test_capture_with_adjustments(): + """Test that adjustments are applied correctly to captured frames.""" + + def adjustment(frame): + return frame + 10 + + camera = ConcreteCamera(adjustments=adjustment) + camera.start() + + frame = camera.capture() + + assert camera.read_call_count == 1 + assert frame is not None + assert np.all(frame == 10) + + +def test_capture_adjustment_error_reporting(): + """Test that adjustment errors are reported clearly.""" + + def bad_adjustment(frame): + raise ValueError("Adjustment failed") + + camera = ConcreteCamera(adjustments=bad_adjustment) + camera.start() + + with pytest.raises(CameraTransformError, match="Frame transformation failed"): + camera.capture() + + assert camera.read_call_count == 1 + + +def test_capture_rate_limiting(): + """Test that FPS throttling/rate limiting is applied correctly.""" + camera = ConcreteCamera(fps=10) # 0.1 seconds between frames + camera.start() + + start_time = time.monotonic() + frame1 = camera.capture() + frame2 = camera.capture() + elapsed = time.monotonic() - start_time + + # Should take ~0.1 seconds due to throttling + assert elapsed >= 0.09 + assert frame1 is not None + assert frame2 is not None + assert camera.read_call_count == 2 + + +def test_stream_can_be_stopped_by_user_code(): + """Test that stream() can be stopped by user code breaking out of the loop.""" + camera = ConcreteCamera() + camera.start() + + n_frames = 0 + for i, frame in enumerate(camera.stream()): + n_frames += 1 + assert isinstance(frame, np.ndarray) + assert frame.shape == (480, 640, 3) + if i >= 4: # Get 5 frames then break + break + + assert n_frames == 5 + assert camera.is_started() # Camera should still be running + + +def test_stream_stops_when_camera_stopped(): + """Test that stream() stops automatically when camera is stopped.""" + camera = ConcreteCamera() + camera.start() + + frames = [] + for i, frame in enumerate(camera.stream()): + frames.append(frame) + if i >= 4: # Get 5 frames then call stop() + camera.stop() + + assert len(frames) == 5 + assert not camera.is_started() + + +def test_stream_exception_propagation(): + """Test that exceptions from capture() are correctly propagated outside the consuming loop.""" + + def bad_adjustment(frame): + raise ValueError("Stream adjustment failed") + + camera = ConcreteCamera(adjustments=bad_adjustment) + camera.start() + + # Exception should propagate out of the stream loop + with pytest.raises(CameraTransformError, match="Frame transformation failed"): + for _ in camera.stream(): + pass + + +def test_context_manager_calls_start_and_stop(): + """Test that context manager calls start() and stop() when entering and exiting.""" + camera = ConcreteCamera() + + assert not camera.is_started() + assert camera.open_call_count == 0 + assert camera.close_call_count == 0 + + with camera as ctx_camera: + assert camera.is_started() + assert camera.open_call_count == 1 + assert camera.close_call_count == 0 + assert ctx_camera is camera # Should return self + + frame = camera.capture() + assert frame is not None + + assert not camera.is_started() + assert camera.open_call_count == 1 + assert camera.close_call_count == 1 + + +def test_context_manager_with_exception(): + """Test that context manager calls stop() even when exception occurs.""" + camera = ConcreteCamera() + + try: + with camera: + assert camera.is_started() + assert camera.open_call_count == 1 + + raise RuntimeError("Test exception") + except RuntimeError: + pass + + # Verify stop() was called despite exception + assert not camera.is_started() + assert camera.close_call_count == 1 + + +def test_capture_no_throttling(): + """Test capture behavior with fps=0 (no throttling).""" + camera = ConcreteCamera(fps=0) + camera.start() + + start_time = time.monotonic() + frame = camera.capture() + elapsed = time.monotonic() - start_time + + assert elapsed < 0.01 + assert frame is not None + assert camera.read_call_count == 1 + + +def test_capture_multiple_adjustments(): + """Test that adjustment pipelines are applied correctly.""" + + def adj1(frame): + return frame + 5 + + adjustment1 = PipeableFunction(adj1) + + def adj2(frame): + return frame * 2 + + adjustment2 = PipeableFunction(adj2) + + camera = ConcreteCamera(adjustments=adjustment1 | adjustment2) + camera.start() + + frame = camera.capture() + + # Verify _read_frame was called and adjustments applied: (0 + 5) * 2 = 10 + assert camera.read_call_count == 1 + assert np.all(frame == 10) diff --git a/tests/arduino/app_peripherals/camera/test_camera.py b/tests/arduino/app_peripherals/camera/test_camera.py new file mode 100644 index 00000000..e286aed7 --- /dev/null +++ b/tests/arduino/app_peripherals/camera/test_camera.py @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import pytest + +from arduino.app_peripherals.camera import Camera, V4LCamera, IPCamera, WebSocketCamera, CameraConfigError + + +def test_camera_factory_with_device_path(): + """Test Camera factory with device path (V4L).""" + camera = Camera("/dev/video0") + assert isinstance(camera, V4LCamera) + assert camera.device == 0 + + +def test_camera_factory_with_int_source(): + """Test Camera factory with integer source (V4L).""" + camera = Camera(0) + assert isinstance(camera, V4LCamera) + assert camera.device == 0 + + +def test_camera_factory_with_string_digit_source(): + """Test Camera factory with string digit source (V4L).""" + camera = Camera("1") + assert isinstance(camera, V4LCamera) + assert camera.device == 1 + + +def test_camera_factory_with_rtsp_url(): + """Test Camera factory with RTSP URL (IP Camera).""" + camera = Camera("rtsp://192.168.1.100/stream") + assert isinstance(camera, IPCamera) + assert camera.url == "rtsp://192.168.1.100/stream" + + +def test_camera_factory_with_http_url(): + """Test Camera factory with HTTP URL (IP Camera).""" + camera = Camera("http://192.168.1.100:8080/video") + assert isinstance(camera, IPCamera) + assert camera.url == "http://192.168.1.100:8080/video" + + +def test_camera_factory_with_https_url(): + """Test Camera factory with HTTPS URL (IP Camera).""" + camera = Camera("https://192.168.1.100:8080/video") + assert isinstance(camera, IPCamera) + assert camera.url == "https://192.168.1.100:8080/video" + + +def test_camera_factory_with_ws_url(): + """Test Camera factory with WebSocket URL.""" + camera = Camera("ws://0.0.0.0:8080") + assert isinstance(camera, WebSocketCamera) + assert camera.host == "0.0.0.0" + assert camera.port == 8080 + + +def test_camera_factory_with_wss_url(): + """Test Camera factory with secure WebSocket URL.""" + camera = Camera("wss://192.168.1.100:9090") + assert isinstance(camera, WebSocketCamera) + assert camera.host == "192.168.1.100" + assert camera.port == 9090 + + +def test_camera_factory_with_ws_url_default_port(): + """Test Camera factory with WebSocket URL without port.""" + camera = Camera("ws://localhost") + assert isinstance(camera, WebSocketCamera) + assert camera.host == "localhost" + assert camera.port == 8080 # Default port + + +def test_camera_factory_with_ip_camera_kwargs(): + """Test Camera factory with IP camera specific kwargs.""" + camera = Camera("rtsp://192.168.1.100/stream", username="admin", password="secret", timeout=30) + assert isinstance(camera, IPCamera) + assert camera.username == "admin" + assert camera.password == "secret" + assert camera.timeout == 30 + + +def test_camera_factory_with_websocket_camera_kwargs(): + """Test Camera factory with WebSocket camera specific kwargs.""" + camera = Camera("ws://0.0.0.0:8080", frame_format="json", timeout=20) + assert isinstance(camera, WebSocketCamera) + assert camera.frame_format == "json" + assert camera.timeout == 20 + + +def test_camera_factory_invalid_source_type(): + """Test Camera factory with invalid source type.""" + with pytest.raises(CameraConfigError, match="Invalid source type"): + Camera({"invalid": "type"}) + + +def test_camera_factory_unsupported_source(): + """Test Camera factory with unsupported source string.""" + with pytest.raises(CameraConfigError, match="Unsupported camera source"): + Camera("invalid-source") + + +def test_camera_factory_all_parameters(): + """Test Camera factory with all common parameters.""" + adjustment = lambda x: x * 2 + + camera = Camera(source=0, resolution=(1280, 720), fps=60, adjustments=adjustment) + assert isinstance(camera, V4LCamera) + assert camera.resolution == (1280, 720) + assert camera.fps == 60 + assert camera.adjustments == adjustment + + +def test_camera_factory_returns_v4l_instance(): + """Test that Camera factory returns V4LCamera instance for V4L sources.""" + camera = Camera(0) + assert isinstance(camera, V4LCamera) + + +def test_camera_factory_returns_ip_instance(): + """Test that Camera factory returns IPCamera instance for IP sources.""" + camera = Camera("rtsp://192.168.1.100/stream") + assert isinstance(camera, IPCamera) + + +def test_camera_factory_returns_websocket_instance(): + """Test that Camera factory returns WebSocketCamera instance for WS sources.""" + camera = Camera("ws://0.0.0.0:8080") + assert isinstance(camera, WebSocketCamera) + + +def test_camera_factory_rtsp_with_port(): + """Test RTSP URL with custom port.""" + camera = Camera("rtsp://192.168.1.100:554/stream1") + assert isinstance(camera, IPCamera) + assert camera.url == "rtsp://192.168.1.100:554/stream1" + + +def test_camera_factory_http_with_path(): + """Test HTTP URL with path.""" + camera = Camera("http://example.com/cameras/cam1/stream.mjpg") + assert isinstance(camera, IPCamera) + assert camera.url == "http://example.com/cameras/cam1/stream.mjpg" diff --git a/tests/arduino/app_peripherals/camera/test_ip_camera.py b/tests/arduino/app_peripherals/camera/test_ip_camera.py new file mode 100644 index 00000000..f23724fd --- /dev/null +++ b/tests/arduino/app_peripherals/camera/test_ip_camera.py @@ -0,0 +1,289 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import pytest +import numpy as np +from unittest.mock import MagicMock, patch + +from arduino.app_peripherals.camera import IPCamera, CameraConfigError, CameraOpenError + + +@pytest.fixture +def mock_videocapture(): + """Fixture for mocking cv2.VideoCapture.""" + with patch("arduino.app_peripherals.camera.ip_camera.cv2.VideoCapture") as mock_vc: + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (True, np.zeros((480, 640, 3), dtype=np.uint8)) + mock_vc.return_value = mock_cap + yield mock_vc, mock_cap + + +@pytest.fixture +def mock_requests(): + """Fixture for mocking requests.""" + with patch("arduino.app_peripherals.camera.ip_camera.requests") as mock_req: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_req.head.return_value = mock_response + yield mock_req + + +def test_ip_camera_init_default(): + """Test IPCamera initialization with default parameters.""" + camera = IPCamera(url="rtsp://192.168.1.100/stream") + assert camera.url == "rtsp://192.168.1.100/stream" + assert camera.username is None + assert camera.password is None + assert camera.timeout == 10 + assert camera.resolution == (640, 480) + assert camera.fps == 10 + + +def test_ip_camera_init_with_auth(): + """Test IPCamera initialization with authentication.""" + camera = IPCamera(url="rtsp://192.168.1.100/stream", username="admin", password="secret") + assert camera.username == "admin" + assert camera.password == "secret" + + +def test_ip_camera_init_custom_params(): + """Test IPCamera initialization with custom parameters.""" + camera = IPCamera(url="http://192.168.1.100:8080/video", timeout=30, resolution=(1920, 1080), fps=30) + assert camera.timeout == 30 + assert camera.resolution == (1920, 1080) + assert camera.fps == 30 + + +def test_ip_camera_validate_url_rtsp(): + """Test URL validation for RTSP.""" + camera = IPCamera(url="rtsp://192.168.1.100/stream") + assert camera.url == "rtsp://192.168.1.100/stream" + + +def test_ip_camera_validate_url_http(): + """Test URL validation for HTTP.""" + camera = IPCamera(url="http://192.168.1.100:8080/video") + assert camera.url == "http://192.168.1.100:8080/video" + + +def test_ip_camera_validate_url_https(): + """Test URL validation for HTTPS.""" + camera = IPCamera(url="https://192.168.1.100:8080/video") + assert camera.url == "https://192.168.1.100:8080/video" + + +def test_ip_camera_validate_url_invalid_scheme(): + """Test URL validation with invalid scheme.""" + with pytest.raises(CameraConfigError, match="Unsupported URL scheme"): + IPCamera(url="ftp://192.168.1.100/stream") + + +def test_ip_camera_validate_url_malformed(): + """Test URL validation with malformed URL.""" + with pytest.raises(CameraConfigError, match="Invalid URL format"): + IPCamera(url="not a valid url") + + +def test_ip_camera_build_url_no_auth(): + """Test building URL without authentication.""" + camera = IPCamera(url="rtsp://192.168.1.100/stream") + url = camera._build_url() + assert url == "rtsp://192.168.1.100/stream" + + +def test_ip_camera_build_url_with_auth(): + """Test building URL with authentication.""" + camera = IPCamera(url="rtsp://192.168.1.100/stream", username="admin", password="secret") + url = camera._build_url() + assert url == "rtsp://admin:secret@192.168.1.100/stream" + + +def test_ip_camera_build_url_with_auth_and_port(): + """Test building URL with authentication and port.""" + camera = IPCamera(url="rtsp://192.168.1.100:554/stream", username="admin", password="secret") + url = camera._build_url() + assert url == "rtsp://admin:secret@192.168.1.100:554/stream" + + +def test_ip_camera_build_url_override_existing_auth(): + """Test that provided credentials override URL credentials.""" + camera = IPCamera(url="rtsp://olduser:oldpass@192.168.1.100/stream", username="newuser", password="newpass") + url = camera._build_url() + assert url == "rtsp://newuser:newpass@192.168.1.100/stream" + + +def test_ip_camera_start_rtsp(mock_videocapture, mock_requests): + """Test starting RTSP camera.""" + mock_vc, _ = mock_videocapture + + camera = IPCamera(url="rtsp://192.168.1.100/stream") + camera.start() + + assert camera.is_started() + mock_vc.assert_called_once_with("rtsp://192.168.1.100/stream") + # RTSP should not test HTTP connectivity + mock_requests.head.assert_not_called() + + +def test_ip_camera_start_http(mock_videocapture, mock_requests): + """Test starting HTTP camera.""" + camera = IPCamera(url="http://192.168.1.100:8080/video") + camera.start() + + assert camera.is_started() + # HTTP should test HTTP connectivity + mock_requests.head.assert_called_once() + + +def test_ip_camera_start_http_with_auth(mock_videocapture, mock_requests): + """Test starting HTTP camera with authentication.""" + camera = IPCamera(url="http://192.168.1.100:8080/video", username="admin", password="secret") + camera.start() + + # Should pass auth to requests + mock_requests.head.assert_called_once() + call_kwargs = mock_requests.head.call_args[1] + assert call_kwargs["auth"] == ("admin", "secret") + + +def test_ip_camera_start_fails_to_open(mock_videocapture, mock_requests): + """Test error when camera fails to open.""" + _, mock_cap = mock_videocapture + mock_cap.isOpened.return_value = False + + camera = IPCamera(url="rtsp://192.168.1.100/stream") + + with pytest.raises(CameraOpenError, match="Failed to open IP camera"): + camera.start() + + +def test_ip_camera_start_cannot_read_frame(mock_videocapture, mock_requests): + """Test error when cannot read initial frame.""" + _, mock_cap = mock_videocapture + mock_cap.read.return_value = (False, None) + + camera = IPCamera(url="rtsp://192.168.1.100/stream") + + with pytest.raises(CameraOpenError, match="Cannot read from IP camera"): + camera.start() + + +def test_ip_camera_http_connectivity_test_failed(mock_videocapture, mock_requests): + """Test error when HTTP connectivity test fails.""" + mock_requests.head.return_value.status_code = 404 + mock_requests.RequestException = Exception # Mock the exception class + + camera = IPCamera(url="http://192.168.1.100:8080/video") + + with pytest.raises(CameraOpenError, match="HTTP camera returned status 404"): + camera.start() + + +def test_ip_camera_http_connectivity_test_206(mock_videocapture, mock_requests): + """Test HTTP connectivity with 206 Partial Content response.""" + mock_requests.head.return_value.status_code = 206 + + camera = IPCamera(url="http://192.168.1.100:8080/video") + camera.start() + + assert camera.is_started() + + +def test_ip_camera_http_connectivity_network_error(mock_videocapture, mock_requests): + """Test error when HTTP request raises exception.""" + + # Create a real exception to raise + class MockRequestException(Exception): + pass + + mock_requests.RequestException = MockRequestException + mock_requests.head.side_effect = MockRequestException("Network error") + + camera = IPCamera(url="http://192.168.1.100:8080/video") + + with pytest.raises(CameraOpenError, match="Cannot connect to HTTP camera"): + camera.start() + + +def test_ip_camera_stop(mock_videocapture, mock_requests): + """Test stopping IP camera.""" + _, mock_cap = mock_videocapture + + camera = IPCamera(url="rtsp://192.168.1.100/stream") + camera.start() + camera.stop() + + assert not camera.is_started() + mock_cap.release.assert_called_once() + + +def test_ip_camera_read_frame(mock_videocapture, mock_requests): + """Test reading a frame from IP camera.""" + _, mock_cap = mock_videocapture + test_frame = np.ones((480, 640, 3), dtype=np.uint8) * 100 + mock_cap.read.return_value = (True, test_frame) + + camera = IPCamera(url="rtsp://192.168.1.100/stream") + camera.start() + + frame = camera.capture() + + assert frame is not None + assert isinstance(frame, np.ndarray) + assert np.array_equal(frame, test_frame) + + +def test_ip_camera_read_frame_auto_reconnect(mock_videocapture, mock_requests): + """Test automatic reconnection when reading frame.""" + camera = IPCamera(url="rtsp://192.168.1.100/stream") + # Don't start the camera, let _read_frame trigger reconnection + camera._is_started = True + + frame = camera._read_frame() + + # Should attempt to reconnect + assert frame is not None + + +def test_ip_camera_read_frame_reconnect_failure(mock_videocapture, mock_requests): + """Test when reconnection fails during frame read.""" + _, mock_cap = mock_videocapture + mock_cap.isOpened.return_value = False + mock_cap.isOpened.assert_called = True + + camera = IPCamera(url="rtsp://192.168.1.100/stream") + camera._is_started = True + + frame = camera._read_frame() + + # Should return None when reconnection fails + assert frame is None + + +def test_ip_camera_read_frame_connection_dropped(mock_videocapture, mock_requests): + """Test when connection drops during frame read.""" + _, mock_cap = mock_videocapture + + camera = IPCamera(url="rtsp://192.168.1.100/stream") + camera.start() + + # Simulate connection drop + mock_cap.read.return_value = (False, None) + mock_cap.isOpened.return_value = False + + frame = camera._read_frame() + + # Should return None and close connection + assert frame is None + assert camera._cap is None + + +def test_ip_camera_timeout_custom(mock_videocapture, mock_requests): + """Test IP camera with custom timeout.""" + camera = IPCamera(url="http://192.168.1.100:8080/video", timeout=30) + camera.start() + + call_kwargs = mock_requests.head.call_args[1] + assert call_kwargs["timeout"] == 30 diff --git a/tests/arduino/app_peripherals/camera/test_v4l_camera.py b/tests/arduino/app_peripherals/camera/test_v4l_camera.py new file mode 100644 index 00000000..0c98b0b6 --- /dev/null +++ b/tests/arduino/app_peripherals/camera/test_v4l_camera.py @@ -0,0 +1,280 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import pytest +import cv2 +from unittest.mock import MagicMock, patch + +from arduino.app_peripherals.camera import V4LCamera, CameraOpenError, CameraReadError + + +def test_initialization_with_all_parameters(): + """Test that V4LCamera properly initializes with all V4L-specific parameters.""" + + def dummy_adjustment(frame): + return frame + + # Test initialization without triggering camera operations + camera = V4LCamera(device="/dev/video1", resolution=(1280, 720), fps=25, adjustments=dummy_adjustment) + + # Verify V4L-specific device resolution worked + assert camera.device == 1 # Should extract 1 from "/dev/video1" + + # Verify BaseCamera parameters are preserved + assert camera.resolution == (1280, 720) + assert camera.fps == 25 + assert camera.adjustments == dummy_adjustment + + +def test_device_resolution_integer(): + """Test that V4LCamera correctly resolves integer device identifiers.""" + camera = V4LCamera(device=0) + assert camera.device == 0 + + camera = V4LCamera(device=1) + assert camera.device == 1 + + +def test_device_resolution_string_numeric(): + """Test that V4LCamera correctly resolves numeric string device identifiers.""" + # Test with device mapping available + with patch.object(V4LCamera, "_get_video_devices_by_index", return_value={1: "2"}): + camera = V4LCamera(device="1") + assert camera.device == 2 + + # Test with no device mapping (fallback) + with patch.object(V4LCamera, "_get_video_devices_by_index", return_value={}): + camera = V4LCamera(device="3") + assert camera.device == 3 + + +def test_device_resolution_path(): + """Test that V4LCamera correctly resolves device path identifiers.""" + camera = V4LCamera(device="/dev/video0") + assert camera.device == 0 + + camera = V4LCamera(device="/dev/video2") + assert camera.device == 2 + + +def test_device_resolution_invalid(): + """Test that V4LCamera raises appropriate error for invalid device identifiers.""" + with pytest.raises(CameraOpenError, match="Cannot resolve camera identifier: invalid"): + V4LCamera(device="invalid") + + with pytest.raises(CameraOpenError, match="Cannot resolve camera identifier: not_a_device"): + V4LCamera(device="not_a_device") + + +def test_device_mapping(): + """Test that V4LCamera uses device mapping when available for string indices.""" + device_mapping = {0: "1", 1: "3", 2: "0"} + + with patch.object(V4LCamera, "_get_video_devices_by_index", return_value=device_mapping): + camera = V4LCamera(device="1") + assert camera.device == 3 + + camera = V4LCamera(device="0") + assert camera.device == 1 + + +def test_device_mapping_fallback(): + """Test that V4LCamera falls back to direct conversion when no mapping available.""" + with patch.object(V4LCamera, "_get_video_devices_by_index", return_value={}): + # When no mapping is available, should convert string directly to int + camera = V4LCamera(device="5") + assert camera.device == 5 + + +def test_hardware_adaptation_resolution_mismatch(): + """Test that V4LCamera adapts when hardware doesn't support requested resolution.""" + with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + + def get_caps(prop): + if prop == cv2.CAP_PROP_FRAME_WIDTH: + return 320 + elif prop == cv2.CAP_PROP_FRAME_HEIGHT: + return 240 + elif prop == cv2.CAP_PROP_FPS: + return 10 + return 0 + + mock_cap.get.side_effect = get_caps + mock_vc.return_value = mock_cap + + # Request 640x480 but hardware only supports 320x240 + camera = V4LCamera(device=0, resolution=(640, 480), fps=10) + camera.start() + + # Should adapt to actual hardware capabilities + assert camera.resolution == (320, 240) + assert camera.is_started() + + +def test_hardware_adaptation_fps_mismatch(): + """Test that V4LCamera adapts when hardware doesn't support requested FPS.""" + with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + + def get_caps(prop): + if prop == cv2.CAP_PROP_FRAME_WIDTH: + return 640 + elif prop == cv2.CAP_PROP_FRAME_HEIGHT: + return 480 + elif prop == cv2.CAP_PROP_FPS: + return 15 + return 0 + + mock_cap.get.side_effect = get_caps + mock_vc.return_value = mock_cap + + # Request 30fps but hardware only supports 15fps + camera = V4LCamera(device=0, resolution=(640, 480), fps=30) + camera.start() + + assert camera.fps == 15 + assert camera.is_started() + + +def test_read_frame_error_message(): + """Test that V4LCamera provides specific error messages for read failures.""" + with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (False, None) + mock_vc.return_value = mock_cap + + camera = V4LCamera(device=3) + camera.start() + + with pytest.raises(CameraReadError, match="Failed to read from V4L camera 3"): + camera.capture() + + +def test_start_success(): + """Test that V4LCamera start() calls V4L-specific _open_camera and sets up hardware correctly.""" + with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + + def get_caps(prop): + if prop == cv2.CAP_PROP_FRAME_WIDTH: + return 640 + elif prop == cv2.CAP_PROP_FRAME_HEIGHT: + return 480 + elif prop == cv2.CAP_PROP_FPS: + return 10 + return 0 + + mock_cap.get.side_effect = get_caps + mock_vc.return_value = mock_cap + + camera = V4LCamera(device=2, resolution=(640, 480), fps=10) + + assert not camera.is_started() + + camera.start() + + assert camera.is_started() + mock_vc.assert_called_once_with(2) # Should open correct device + + # Verify V4L camera setup calls + set_call_args = [call.args for call in mock_cap.set.call_args_list] + + # Check that buffer size was set to 1 + assert (cv2.CAP_PROP_BUFFERSIZE, 1) in set_call_args + + # Check that resolution was set to 640x480 + assert (cv2.CAP_PROP_FRAME_WIDTH, 640) in set_call_args + assert (cv2.CAP_PROP_FRAME_HEIGHT, 480) in set_call_args + + # Check that FPS was set to 10 + assert (cv2.CAP_PROP_FPS, 10) in set_call_args + + +def test_start_already_started(): + """Test that V4LCamera doesn't reinitialize when already started.""" + with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.get.return_value = 640 + mock_vc.return_value = mock_cap + + camera = V4LCamera(device=0) + + # Start camera first time + camera.start() + assert camera.is_started() + assert mock_vc.call_count == 1 + + # Start camera second time + camera.start() + + # Should still be started but no additional VideoCapture creation + assert camera.is_started() + assert mock_vc.call_count == 1 # No additional calls + + +def test_start_camera_fails_to_open(): + """Test V4LCamera start() error handling when cv2.VideoCapture fails to open.""" + with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: + mock_cap = MagicMock() + mock_cap.isOpened.return_value = False # Camera fails to open + mock_vc.return_value = mock_cap + + camera = V4LCamera(device=5) + + with pytest.raises(CameraOpenError, match="Failed to open V4L camera 5"): + camera.start() + + assert not camera.is_started() + + +def test_stop_success(): + """Test that V4LCamera stop() properly releases V4L resources.""" + with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.get.return_value = 640 + mock_vc.return_value = mock_cap + + camera = V4LCamera(device=1) + camera.start() + assert camera.is_started() + + camera.stop() + + assert not camera.is_started() + mock_cap.release.assert_called_once() # Should release cv2.VideoCapture + + +def test_stop_not_started(): + """Test that V4LCamera stop() is safe when not started.""" + camera = V4LCamera(device=0) + assert not camera.is_started() + + camera.stop() # Should not raise any exception + assert not camera.is_started() + + +def test_is_started(): + """Test V4LCamera is_started() reflects actual V4L camera state.""" + with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.get.return_value = 640 + mock_vc.return_value = mock_cap + + camera = V4LCamera(device=0) + + assert not camera.is_started() + + camera.start() + assert camera.is_started() + + camera.stop() + assert not camera.is_started() diff --git a/tests/arduino/app_peripherals/camera/test_websocket_camera.py b/tests/arduino/app_peripherals/camera/test_websocket_camera.py new file mode 100644 index 00000000..345487e0 --- /dev/null +++ b/tests/arduino/app_peripherals/camera/test_websocket_camera.py @@ -0,0 +1,337 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import pytest +import asyncio +import base64 +import json +import numpy as np +import cv2 + +from arduino.app_peripherals.camera import WebSocketCamera + + +@pytest.fixture +def sample_frame(): + """Create a sample frame for testing.""" + frame = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8) + return frame + + +@pytest.fixture +def encoded_frame_binary(sample_frame): + """Encode frame as binary.""" + _, buffer = cv2.imencode(".jpg", sample_frame) + return buffer.tobytes() + + +@pytest.fixture +def encoded_frame_base64(encoded_frame_binary): + """Encode frame as base64.""" + return base64.b64encode(encoded_frame_binary).decode() + + +@pytest.fixture +def encoded_frame_json(encoded_frame_binary): + """Encode frame as JSON.""" + b64_data = base64.b64encode(encoded_frame_binary).decode() + return json.dumps({"image": b64_data}) + + +def test_websocket_camera_init_default(): + """Test WebSocketCamera initialization with default parameters.""" + camera = WebSocketCamera() + assert camera.host == "0.0.0.0" + assert camera.port == 8080 + assert camera.timeout == 10 + assert camera.frame_format == "binary" + assert camera.resolution == (640, 480) + assert camera.fps == 10 + + +def test_websocket_camera_init_custom(): + """Test WebSocketCamera initialization with custom parameters.""" + camera = WebSocketCamera(host="127.0.0.1", port=9090, timeout=30, frame_format="json", resolution=(1920, 1080), fps=30) + assert camera.host == "127.0.0.1" + assert camera.port == 9090 + assert camera.timeout == 30 + assert camera.frame_format == "json" + assert camera.resolution == (1920, 1080) + assert camera.fps == 30 + + +def test_websocket_camera_start_stop(): + """Test start/stop WebSocket camera server.""" + camera = WebSocketCamera(port=0) + assert not camera.is_started() + + try: + camera.start() + except Exception: + pytest.fail("Camera start failed") + + assert camera.is_started() + + try: + camera.stop() + except Exception: + pytest.fail("Camera stop failed") + + assert not camera.is_started() + + +def test_websocket_camera_parse_message_binary(sample_frame, encoded_frame_binary): + """Test parsing binary frame message.""" + camera = WebSocketCamera(frame_format="binary") + + frame = camera._parse_message(encoded_frame_binary) + + assert frame is not None + assert isinstance(frame, np.ndarray) + assert frame.shape == sample_frame.shape + + +def test_websocket_camera_parse_message_base64(encoded_frame_base64): + """Test parsing base64 encoded frame message.""" + camera = WebSocketCamera(frame_format="base64") + + frame = camera._parse_message(encoded_frame_base64) + + assert frame is not None + assert isinstance(frame, np.ndarray) + + +def test_websocket_camera_parse_message_json_image(encoded_frame_json): + """Test parsing JSON frame message with 'image' field.""" + camera = WebSocketCamera(frame_format="json") + + frame = camera._parse_message(encoded_frame_json) + + assert frame is not None + assert isinstance(frame, np.ndarray) + + +def test_websocket_camera_parse_message_json_frame(): + """Test parsing JSON frame message with 'frame' field.""" + test_frame = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8) + _, buffer = cv2.imencode(".jpg", test_frame) + b64_data = base64.b64encode(buffer.tobytes()).decode() + message = json.dumps({"frame": b64_data}) + + camera = WebSocketCamera(frame_format="json") + + frame = camera._parse_message(message) + + assert frame is not None + assert isinstance(frame, np.ndarray) + + +def test_websocket_camera_parse_message_invalid(): + """Test parsing invalid message.""" + camera = WebSocketCamera(frame_format="json") + + frame = camera._parse_message("invalid json") + + assert frame is None + + +def test_websocket_camera_parse_message_binary_as_string(sample_frame, encoded_frame_binary): + """Test parsing binary message received as string using latin-1 encoding.""" + camera = WebSocketCamera(frame_format="binary") + + # Decode binary data using latin-1 which preserves all bytes + message = encoded_frame_binary.decode("latin-1") + frame = camera._parse_message(message) + + assert frame is not None + assert isinstance(frame, np.ndarray) + assert frame.shape == sample_frame.shape + + +def test_websocket_camera_read_frame_empty_queue(): + """Test reading frame when queue is empty.""" + with WebSocketCamera(port=0) as camera: + frame = camera.capture() + assert frame is None + + +@pytest.mark.asyncio +async def test_websocket_camera_capture_frame(encoded_frame_binary): + """Test capturing frame from WebSocket camera.""" + import websockets + + with WebSocketCamera(port=0, frame_format="binary") as camera: + async with websockets.connect(f"ws://{camera.host}:{camera.port}") as ws: + # Skip welcome message + await ws.recv() + + await ws.send(encoded_frame_binary) + + await asyncio.sleep(0.1) + + frame = camera.capture() + + assert frame is not None + assert isinstance(frame, np.ndarray) + + +@pytest.mark.asyncio +async def test_websocket_camera_single_client(): + """Test WebSocket server accepts only one client at a time.""" + import websockets + + camera = WebSocketCamera(port=0) # Use port 0 for auto-assignment + camera.start() + + try: + # Connect first client + async with websockets.connect(f"ws://{camera.host}:{camera.port}") as ws1: + # First client should receive welcome message + welcome = await ws1.recv() + message = json.loads(welcome) + assert message["status"] == "connected" + + # Try to connect second client while first is connected + try: + async with websockets.connect(f"ws://{camera.host}:{camera.port}") as ws2: + # Second client should receive rejection message + rejection = await asyncio.wait_for(ws2.recv(), timeout=1.0) + message = json.loads(rejection) + assert message.get("error") == "Server busy" + except websockets.exceptions.ConnectionClosed: + # Connection closed immediately - also acceptable + pass + finally: + camera.stop() + + +@pytest.mark.asyncio +async def test_websocket_camera_welcome_message(): + """Test that welcome message is sent to connected client.""" + import websockets + + with WebSocketCamera(port=0) as camera: + async with websockets.connect(f"ws://{camera.host}:{camera.port}") as ws: + # Should receive welcome message + welcome = await asyncio.wait_for(ws.recv(), timeout=1.0) + message = json.loads(welcome) + assert message["status"] == "connected" + assert message["frame_format"] == camera.frame_format + assert tuple(message["resolution"]) == camera.resolution + + +@pytest.mark.asyncio +async def test_websocket_camera_receives_frames(encoded_frame_binary): + """Test that server receives and queues frames from client.""" + import websockets + + with WebSocketCamera(port=0, frame_format="binary") as camera: + async with websockets.connect(f"ws://{camera.host}:{camera.port}") as ws: + # Skip welcome message + await ws.recv() + + # Send a frame + await ws.send(encoded_frame_binary) + + # Give time for frame to be processed + await asyncio.sleep(0.2) + + # Frame should be in queue + assert camera.capture() is not None + + +@pytest.mark.asyncio +async def test_websocket_camera_disconnects_client_on_stop(): + """Test that connected client is disconnected when camera stops.""" + import websockets + + camera = WebSocketCamera(port=0) + camera.start() + + try: + async with websockets.connect(f"ws://{camera.host}:{camera.port}") as ws: + # Client connected, receive welcome message + welcome = await ws.recv() + message = json.loads(welcome) + assert message["status"] == "connected" + + # Stop the camera (runs in background thread via to_thread) + await asyncio.to_thread(camera.stop) + + with pytest.raises(websockets.exceptions.ConnectionClosed): + # Keep receiving until connection is closed + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=1.0) + data = json.loads(msg) + if data.get("status") == "disconnecting": + # Got goodbye message, connection should close soon + continue + except websockets.exceptions.ConnectionClosed: + # Connection was closed, which is expected + pass + + assert not camera.is_started() + + +def test_websocket_camera_stop_without_client(): + """Test stopping server when no client is connected.""" + camera = WebSocketCamera(port=0) + camera.start() + + # Stopping without any connected client should not raise an exception + camera.stop() + + assert not camera.is_started() + + +@pytest.mark.asyncio +async def test_websocket_camera_backpressure(sample_frame): + """Test that old frames are dropped when new frames arrive faster than they're consumed.""" + import websockets + + with WebSocketCamera(port=0, frame_format="binary") as camera: + async with websockets.connect(f"ws://{camera.host}:{camera.port}") as ws: + # Skip welcome message + await ws.recv() + + _, buffer1 = cv2.imencode(".jpg", np.ones((480, 640, 3), dtype=np.uint8) * 1) + _, buffer2 = cv2.imencode(".jpg", np.ones((480, 640, 3), dtype=np.uint8) * 2) + _, buffer3 = cv2.imencode(".jpg", np.ones((480, 640, 3), dtype=np.uint8) * 3) + + await ws.send(buffer1.tobytes()) + await ws.send(buffer2.tobytes()) + await ws.send(buffer3.tobytes()) + + await asyncio.sleep(0.1) + + frame = camera.capture() + assert frame is not None + + mean_value = np.mean(frame) + assert mean_value == 3 + + +def test_websocket_camera_with_adjustments(sample_frame): + """Test WebSocket camera with frame adjustments.""" + + def adjustment(frame): + return frame + 50 + + camera = WebSocketCamera(adjustments=adjustment) + camera._frame_queue.put(sample_frame) + camera._is_started = True + + # Capture uses adjustments + frame = camera.capture() + + # The adjustment is applied in capture() + expected = sample_frame + 50 + assert np.array_equal(frame, expected) + + +@pytest.mark.parametrize("fmt", ["binary", "base64", "json"]) +def test_websocket_camera_multiple_formats(fmt): + """Test WebSocket camera can be initialized with different formats.""" + camera = WebSocketCamera(frame_format=fmt) + assert camera.frame_format == fmt From 24750419afbdc254b34e13e4cd7697f03858e307 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 13 Nov 2025 09:17:06 +0100 Subject: [PATCH 44/86] fixes --- .../camera/websocket_camera.py | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 8c49b617..96bb6258 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -125,6 +125,11 @@ async def _start_server(self) -> None: ping_interval=20, ) + # Get the actual port if OS assigned one (e.g., when port=0) + if self.port == 0: + server_socket = list(self._server.sockets)[0] + self.port = server_socket.getsockname()[1] + logger.info(f"WebSocket camera server started on {self.host}:{self.port}") await self._stop_event.wait() @@ -171,7 +176,7 @@ async def _ws_handler(self, conn: websockets.ServerConnection) -> None: logger.warning(f"Could not send welcome message to {client_addr}: {e}") async for message in conn: - frame = await self._parse_message(message) + frame = self._parse_message(message) if frame is not None: # Drop old frames until there's room for the new one while True: @@ -195,28 +200,29 @@ async def _ws_handler(self, conn: websockets.ServerConnection) -> None: self._client = None logger.info(f"Client removed: {client_addr}") - async def _parse_message(self, message) -> np.ndarray | None: + def _parse_message(self, message: str | bytes) -> np.ndarray | None: """Parse WebSocket message to extract frame.""" try: - if self.frame_format == "base64": - # Expect base64 encoded image + if self.frame_format == "binary": + # Expect raw binary image data if isinstance(message, str): - image_data = base64.b64decode(message) + # Use latin-1 encoding to preserve binary data + image_data = message.encode("latin-1") else: - image_data = base64.b64decode(message.decode()) + image_data = message - # Decode image nparr = np.frombuffer(image_data, np.uint8) frame = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED) return frame - elif self.frame_format == "binary": - # Expect raw binary image data + elif self.frame_format == "base64": + # Expect base64 encoded image if isinstance(message, str): - image_data = message.encode() + image_data = base64.b64decode(message) else: - image_data = message + image_data = base64.b64decode(message.decode()) + # Decode image nparr = np.frombuffer(image_data, np.uint8) frame = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED) return frame @@ -251,10 +257,11 @@ async def _parse_message(self, message) -> np.ndarray | None: def _close_camera(self): """Stop the WebSocket server.""" - if self._loop and not self._loop.is_closed(): + # Only attempt async cleanup if the event loop is running + if self._loop and not self._loop.is_closed() and self._loop.is_running(): try: future = asyncio.run_coroutine_threadsafe(self._stop_and_disconnect_client(), self._loop) - future.result(timeout=1.0) + future.result(1.0) except CancelledError: logger.debug(f"Error stopping WebSocket server: CancelledError") except TimeoutError: @@ -293,7 +300,8 @@ async def _stop_and_disconnect_client(self): except Exception as e: logger.warning(f"Error closing client in stop event: {e}") finally: - await self._client.close() + if self._client: + await self._client.close() self._stop_event.set() @@ -308,14 +316,15 @@ def _read_frame(self) -> np.ndarray | None: async def _send_to_client(self, message: str | bytes | dict) -> None: """Send a message to the connected client.""" - if self._client is None: - raise ConnectionError("No client connected to send message to") - if isinstance(message, dict): message = json.dumps(message) - try: - await self._client.send(message) - except Exception as e: - logger.warning(f"Error sending to client: {e}") - raise + async with self._client_lock: + if self._client is None: + raise ConnectionError("No client connected to send message to") + + try: + await self._client.send(message) + except Exception as e: + logger.warning(f"Error sending to client: {e}") + raise From 2caf0d4e45370c1a3fb81f0b69dab11eb2f15901 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 13 Nov 2025 11:26:44 +0100 Subject: [PATCH 45/86] fix: API --- .../app_bricks/camera_code_detection/detection.py | 10 +++++----- .../app_bricks/video_imageclassification/__init__.py | 6 +++--- .../app_bricks/video_objectdetection/__init__.py | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/arduino/app_bricks/camera_code_detection/detection.py b/src/arduino/app_bricks/camera_code_detection/detection.py index 746c9d6b..d8cd2d60 100644 --- a/src/arduino/app_bricks/camera_code_detection/detection.py +++ b/src/arduino/app_bricks/camera_code_detection/detection.py @@ -10,7 +10,7 @@ import numpy as np from PIL.Image import Image, fromarray -from arduino.app_peripherals.camera import Camera +from arduino.app_peripherals.camera import Camera, BaseCamera from arduino.app_utils.image import greyscale from arduino.app_utils import brick, Logger @@ -44,7 +44,7 @@ class CameraCodeDetection: """Scans a camera video feed for QR codes and/or barcodes. Args: - camera (Camera): The camera instance to use for capturing video. If None, a default camera will be initialized. + camera (BaseCamera): The camera instance to use for capturing video. If None, a default camera will be initialized. detect_qr (bool): Whether to detect QR codes. Defaults to True. detect_barcode (bool): Whether to detect barcodes. Defaults to True. @@ -55,13 +55,15 @@ class CameraCodeDetection: def __init__( self, - camera: Camera = None, + camera: BaseCamera = None, detect_qr: bool = True, detect_barcode: bool = True, ): """Initialize the CameraCodeDetection brick.""" if detect_qr is False and detect_barcode is False: raise ValueError("At least one of 'detect_qr' or 'detect_barcode' must be True.") + + self._camera = camera if camera else Camera() self._detect_qr = detect_qr self._detect_barcode = detect_barcode @@ -76,8 +78,6 @@ def __init__( self.already_seen_codes = set() - self._camera = camera if camera else Camera() - def start(self): """Start the detector and begin scanning for codes.""" self._camera.start() diff --git a/src/arduino/app_bricks/video_imageclassification/__init__.py b/src/arduino/app_bricks/video_imageclassification/__init__.py index fe4ce75b..39e5c94c 100644 --- a/src/arduino/app_bricks/video_imageclassification/__init__.py +++ b/src/arduino/app_bricks/video_imageclassification/__init__.py @@ -12,7 +12,7 @@ from websockets.sync.client import connect, ClientConnection from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError -from arduino.app_peripherals.camera import Camera +from arduino.app_peripherals.camera import Camera, BaseCamera from arduino.app_internal.core import load_brick_compose_file, resolve_address from arduino.app_internal.core import EdgeImpulseRunnerFacade from arduino.app_utils.image import compress_to_jpeg @@ -30,11 +30,11 @@ class VideoImageClassification: ALL_HANDLERS_KEY = "__ALL" - def __init__(self, camera: Camera = None, confidence: float = 0.3, debounce_sec: float = 0.0): + def __init__(self, camera: BaseCamera = None, confidence: float = 0.3, debounce_sec: float = 0.0): """Initialize the VideoImageClassification class. Args: - camera (Camera): The camera instance to use for capturing video. If None, a default camera will be initialized. + camera (BaseCamera): The camera instance to use for capturing video. If None, a default camera will be initialized. confidence (float): The minimum confidence level for a classification to be considered valid. Default is 0.3. debounce_sec (float): The minimum time in seconds between consecutive detections of the same object to avoid multiple triggers. Default is 0 seconds. diff --git a/src/arduino/app_bricks/video_objectdetection/__init__.py b/src/arduino/app_bricks/video_objectdetection/__init__.py index 0a5c3837..1b91d92b 100644 --- a/src/arduino/app_bricks/video_objectdetection/__init__.py +++ b/src/arduino/app_bricks/video_objectdetection/__init__.py @@ -12,7 +12,7 @@ from websockets.sync.client import connect, ClientConnection from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError -from arduino.app_peripherals.camera import Camera +from arduino.app_peripherals.camera import Camera, BaseCamera from arduino.app_internal.core import load_brick_compose_file, resolve_address from arduino.app_internal.core import EdgeImpulseRunnerFacade from arduino.app_utils.image.adjustments import compress_to_jpeg @@ -35,11 +35,11 @@ class VideoObjectDetection: ALL_HANDLERS_KEY = "__ALL" - def __init__(self, camera: Camera = None, confidence: float = 0.3, debounce_sec: float = 0.0): + def __init__(self, camera: BaseCamera = None, confidence: float = 0.3, debounce_sec: float = 0.0): """Initialize the VideoObjectDetection class. Args: - camera (Camera): The camera instance to use for capturing video. If None, a default camera will be initialized. + camera (BaseCamera): The camera instance to use for capturing video. If None, a default camera will be initialized. confidence (float): Confidence level for detection. Default is 0.3 (30%). debounce_sec (float): Minimum seconds between repeated detections of the same object. Default is 0 seconds. From 075fdfbb32a69deaffdd1e16072f0a01abbc2c02 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 13 Nov 2025 11:30:25 +0100 Subject: [PATCH 46/86] fix: linting --- src/arduino/app_bricks/camera_code_detection/detection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/arduino/app_bricks/camera_code_detection/detection.py b/src/arduino/app_bricks/camera_code_detection/detection.py index d8cd2d60..9b8cb340 100644 --- a/src/arduino/app_bricks/camera_code_detection/detection.py +++ b/src/arduino/app_bricks/camera_code_detection/detection.py @@ -62,7 +62,7 @@ def __init__( """Initialize the CameraCodeDetection brick.""" if detect_qr is False and detect_barcode is False: raise ValueError("At least one of 'detect_qr' or 'detect_barcode' must be True.") - + self._camera = camera if camera else Camera() self._detect_qr = detect_qr From d8f7ed04311ad637f296f2712e3051b37c92b3d5 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 13 Nov 2025 12:20:39 +0100 Subject: [PATCH 47/86] chore: bump EI base image version --- containers/ei-models-runner/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/ei-models-runner/Dockerfile b/containers/ei-models-runner/Dockerfile index 4607a020..1e243040 100644 --- a/containers/ei-models-runner/Dockerfile +++ b/containers/ei-models-runner/Dockerfile @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MPL-2.0 -FROM public.ecr.aws/z9b3d4t5/inference-container-qc-adreno-702:39bcebb78de783cb602e1b361b71d6dafbc959b4 +FROM public.ecr.aws/z9b3d4t5/inference-container-qc-adreno-702:f751b08f7270c84b428b6c3d1028e28a24fbc23a # Create the user and group needed to run the container as non-root RUN set -ex; \ From 0f6d472835db1dfa8e2397e1e8ebc7a2b2bd7a21 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 13 Nov 2025 13:36:38 +0100 Subject: [PATCH 48/86] fix: missing job permissions --- .github/workflows/calculate-size-delta.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/calculate-size-delta.yml b/.github/workflows/calculate-size-delta.yml index e199789e..33179b48 100644 --- a/.github/workflows/calculate-size-delta.yml +++ b/.github/workflows/calculate-size-delta.yml @@ -5,6 +5,7 @@ on: permissions: contents: read + pull-requests: read jobs: build: From 6315cb66c3b965188c2cb74d36e44a985423643a Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 13 Nov 2025 15:29:56 +0100 Subject: [PATCH 49/86] fix: port must be an int --- src/arduino/app_bricks/video_imageclassification/__init__.py | 2 +- src/arduino/app_bricks/video_objectdetection/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/arduino/app_bricks/video_imageclassification/__init__.py b/src/arduino/app_bricks/video_imageclassification/__init__.py index 39e5c94c..6b53768c 100644 --- a/src/arduino/app_bricks/video_imageclassification/__init__.py +++ b/src/arduino/app_bricks/video_imageclassification/__init__.py @@ -176,7 +176,7 @@ def camera_loop(self): while self._is_running.is_set(): try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as tcp_socket: - tcp_socket.connect((self._host, "5050")) + tcp_socket.connect((self._host, 5050)) logger.info(f"TCP connection established to {self._host}:5050") while self._is_running.is_set(): diff --git a/src/arduino/app_bricks/video_objectdetection/__init__.py b/src/arduino/app_bricks/video_objectdetection/__init__.py index 1b91d92b..3d9143a1 100644 --- a/src/arduino/app_bricks/video_objectdetection/__init__.py +++ b/src/arduino/app_bricks/video_objectdetection/__init__.py @@ -168,7 +168,7 @@ def camera_loop(self): while self._is_running.is_set(): try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as tcp_socket: - tcp_socket.connect((self._host, "5050")) + tcp_socket.connect((self._host, 5050)) logger.info(f"TCP connection established to {self._host}:5050") while self._is_running.is_set(): From 6712ed24d88add445eb0a985fe7b9a5bd1804120 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Fri, 14 Nov 2025 16:37:25 +0100 Subject: [PATCH 50/86] feat: add automatic reconnection --- .../app_peripherals/camera/base_camera.py | 2 +- .../app_peripherals/camera/v4l_camera.py | 162 ++++-- .../camera/websocket_camera.py | 4 +- .../app_peripherals/camera/test_v4l_camera.py | 504 ++++++++++++------ 4 files changed, 469 insertions(+), 203 deletions(-) diff --git a/src/arduino/app_peripherals/camera/base_camera.py b/src/arduino/app_peripherals/camera/base_camera.py index e699e4cd..2b688670 100644 --- a/src/arduino/app_peripherals/camera/base_camera.py +++ b/src/arduino/app_peripherals/camera/base_camera.py @@ -27,7 +27,7 @@ def __init__( self, resolution: tuple[int, int] = (640, 480), fps: int = 10, - adjustments: Callable[[np.ndarray], np.ndarray] = None, + adjustments: Callable[[np.ndarray], np.ndarray] | None = None, ): """ Initialize the camera base. diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py index 0256f401..c1204857 100644 --- a/src/arduino/app_peripherals/camera/v4l_camera.py +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -4,6 +4,8 @@ import os import re +import time +from typing import Optional import cv2 import numpy as np from collections.abc import Callable @@ -29,7 +31,8 @@ def __init__( device: str | int = 0, resolution: tuple[int, int] = (640, 480), fps: int = 10, - adjustments: Callable[[np.ndarray], np.ndarray] = None, + adjustments: Optional[Callable[[np.ndarray], np.ndarray]] = None, + auto_reconnect: bool = True, ): """ Initialize V4L camera. @@ -42,6 +45,7 @@ def __init__( fps (int, optional): Frames per second to capture from the camera. Default: 10. adjustments (callable, optional): Function or function pipeline to adjust frames that takes a numpy array and returns a numpy array. Default: None + auto_reconnect (bool, optional): Enable automatic reconnection on failure. Default: True. """ super().__init__(resolution, fps, adjustments) self.device = self._resolve_camera_id(device) @@ -49,6 +53,12 @@ def __init__( self._cap = None + # Auto-reconnection parameters + self.reconnect_delay = 1.0 + self.reconnect_max_retries = 5 + self._auto_reconnect = auto_reconnect + self._last_reconnect_attempt = 0.0 + def _resolve_camera_id(self, device: str | int) -> int: """ Resolve camera identifier to a numeric device ID. @@ -122,37 +132,23 @@ def _get_video_devices_by_index(self) -> dict[int, str]: return devices_by_index def _open_camera(self) -> None: - """Open the V4L camera connection.""" - self._cap = cv2.VideoCapture(self.device) - if not self._cap.isOpened(): - raise CameraOpenError(f"Failed to open V4L camera {self.device}") - - self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to minimize latency - - # Set resolution if specified - if self.resolution and self.resolution[0] and self.resolution[1]: - self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.resolution[0]) - self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.resolution[1]) - - # Verify resolution setting - actual_width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - actual_height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - if actual_width != self.resolution[0] or actual_height != self.resolution[1]: - logger.warning( - f"Camera {self.device} resolution set to {actual_width}x{actual_height} " - f"instead of requested {self.resolution[0]}x{self.resolution[1]}" - ) - self.resolution = (actual_width, actual_height) - - if self.fps: - self._cap.set(cv2.CAP_PROP_FPS, self.fps) - - actual_fps = int(self._cap.get(cv2.CAP_PROP_FPS)) - if actual_fps != self.fps: - logger.warning(f"Camera {self.device} FPS set to {actual_fps} instead of requested {self.fps}") - self.fps = actual_fps - - logger.info(f"Opened V4L camera with index {self.device}") + """ + Open the V4L camera connection with retry logic. + + Retries with exponential backoff until successful or self.max_retries is reached. + """ + attempt = 0 + + while not self._connect(): + if not self._auto_reconnect: + raise CameraOpenError(f"VideoCapture returned unopened state for device {self.device}") + if attempt >= self.reconnect_max_retries: + raise CameraOpenError(f"Unable to open camera {self.device} after {self.reconnect_max_retries} attempts") + + delay = self.reconnect_delay * (2 ** min(attempt, 5)) # Cap exponential backoff at 32s + logger.warning(f"Failed to open camera {self.device} (attempt {attempt + 1}/{self.reconnect_max_retries}). Retrying in {delay:.1f}s...") + time.sleep(delay) + attempt += 1 def _close_camera(self) -> None: """Close the V4L camera connection.""" @@ -160,13 +156,105 @@ def _close_camera(self) -> None: self._cap.release() self._cap = None + def _connect(self) -> bool: + """ + Attempt to connect to the camera. + + Returns: + bool: True if reconnection successful, False otherwise + """ + current_time = time.time() + + # Prevent too frequent connection attempts + if current_time - self._last_reconnect_attempt < self.reconnect_delay: + return False + + self._last_reconnect_attempt = current_time + + self._close_camera() + + try: + self._cap = cv2.VideoCapture(self.device) + if not self._cap.isOpened(): + raise CameraOpenError(f"Failed to open camera {self.device}") + + self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to minimize latency + + if self.resolution and self.resolution[0] and self.resolution[1]: + self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.resolution[0]) + self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.resolution[1]) + + # Verify resolution setting + actual_width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + actual_height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + if actual_width != self.resolution[0] or actual_height != self.resolution[1]: + logger.warning( + f"Camera {self.device} resolution set to {actual_width}x{actual_height} " + f"instead of requested {self.resolution[0]}x{self.resolution[1]}" + ) + self.resolution = (actual_width, actual_height) + + if self.fps: + self._cap.set(cv2.CAP_PROP_FPS, self.fps) + + actual_fps = int(self._cap.get(cv2.CAP_PROP_FPS)) + if actual_fps != self.fps: + logger.warning(f"Camera {self.device} FPS set to {actual_fps} instead of requested {self.fps}") + self.fps = actual_fps + + # Verify connection with a test read + ret, _ = self._cap.read() + if not ret: + raise CameraReadError(f"Read test failed for camera {self.device}") + + return True + + except (CameraOpenError, CameraReadError): + self._close_camera() + return False + except Exception as e: + logger.error(f"Unexpected error connecting to camera {self.device}: {e}") + self._close_camera() + return False + def _read_frame(self) -> np.ndarray | None: - """Read a frame from the V4L camera.""" + """ + Read a frame from the V4L camera with auto-reconnection on failure. + + Returns: + np.ndarray | None: Frame data or None if read fails + """ if self._cap is None: return None - ret, frame = self._cap.read() - if not ret or frame is None: - raise CameraReadError(f"Failed to read from V4L camera {self.device}") + try: + ret, frame = self._cap.read() + if not ret: + logger.warning(f"Failed to read from V4L camera {self.device}") + + # Attempt auto-reconnection if enabled + if self._auto_reconnect: + if self._connect(): + # Try reading again after successful reconnect + ret, frame = self._cap.read() + if ret: + logger.info(f"Successfully reconnected to camera {self.device}") + return frame + + return None + + return frame + + except Exception as e: + logger.error(f"Unexpected error reading from camera {self.device}: {e}") + + # Attempt reconnection on unexpected errors + if self._auto_reconnect: + if self._connect(): + # Try reading again after successful reconnect + ret, frame = self._cap.read() + if ret: + logger.info(f"Successfully reconnected to camera {self.device}") + return frame - return frame + return None diff --git a/src/arduino/app_peripherals/camera/websocket_camera.py b/src/arduino/app_peripherals/camera/websocket_camera.py index 96bb6258..c6f39d86 100644 --- a/src/arduino/app_peripherals/camera/websocket_camera.py +++ b/src/arduino/app_peripherals/camera/websocket_camera.py @@ -51,7 +51,7 @@ def __init__( frame_format: Literal["binary", "base64", "json"] = "binary", resolution: tuple[int, int] = (640, 480), fps: int = 10, - adjustments: Callable[[np.ndarray], np.ndarray] = None, + adjustments: Callable[[np.ndarray], np.ndarray] | None = None, ): """ Initialize WebSocket camera server. @@ -79,7 +79,7 @@ def __init__( self._loop = None self._server_thread = None self._stop_event = asyncio.Event() - self._client: websockets.ServerConnection = None + self._client: websockets.ServerConnection | None = None self._client_lock = asyncio.Lock() def _open_camera(self) -> None: diff --git a/tests/arduino/app_peripherals/camera/test_v4l_camera.py b/tests/arduino/app_peripherals/camera/test_v4l_camera.py index 0c98b0b6..88abffe9 100644 --- a/tests/arduino/app_peripherals/camera/test_v4l_camera.py +++ b/tests/arduino/app_peripherals/camera/test_v4l_camera.py @@ -2,96 +2,174 @@ # # SPDX-License-Identifier: MPL-2.0 +import time +import numpy as np import pytest import cv2 from unittest.mock import MagicMock, patch -from arduino.app_peripherals.camera import V4LCamera, CameraOpenError, CameraReadError +from arduino.app_peripherals.camera import V4LCamera, CameraOpenError -def test_initialization_with_all_parameters(): - """Test that V4LCamera properly initializes with all V4L-specific parameters.""" +@pytest.fixture +def mock_successful_connect() -> MagicMock: + """Mock successful connection for V4LCamera.""" + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.get.return_value = 640 + mock_cap.read.return_value = (True, np.zeros((480, 640, 3), dtype=np.uint8)) + return mock_cap - def dummy_adjustment(frame): - return frame - # Test initialization without triggering camera operations - camera = V4LCamera(device="/dev/video1", resolution=(1280, 720), fps=25, adjustments=dummy_adjustment) +@pytest.fixture +def mock_failed_connect_open() -> MagicMock: + """Mock failed connection due to open error for V4LCamera.""" + mock_cap = MagicMock() + mock_cap.isOpened.return_value = False + mock_cap.get.return_value = 640 + mock_cap.read.return_value = (True, np.zeros((480, 640, 3), dtype=np.uint8)) + return mock_cap - # Verify V4L-specific device resolution worked - assert camera.device == 1 # Should extract 1 from "/dev/video1" - # Verify BaseCamera parameters are preserved - assert camera.resolution == (1280, 720) - assert camera.fps == 25 - assert camera.adjustments == dummy_adjustment +@pytest.fixture +def mock_failed_connect_read() -> MagicMock: + """Mock failed connection due to test read error for V4LCamera.""" + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.get.return_value = 640 + mock_cap.read.return_value = (False, None) + return mock_cap -def test_device_resolution_integer(): - """Test that V4LCamera correctly resolves integer device identifiers.""" - camera = V4LCamera(device=0) - assert camera.device == 0 +class TestV4LCameraInitialization: + def test_initialization_with_all_parameters(self): + """Test that V4LCamera properly initializes with all V4L-specific parameters.""" - camera = V4LCamera(device=1) - assert camera.device == 1 + def dummy_adjustment(frame): + return frame + # Test initialization without triggering camera operations + camera = V4LCamera(device="/dev/video1", resolution=(1280, 720), fps=25, adjustments=dummy_adjustment) -def test_device_resolution_string_numeric(): - """Test that V4LCamera correctly resolves numeric string device identifiers.""" - # Test with device mapping available - with patch.object(V4LCamera, "_get_video_devices_by_index", return_value={1: "2"}): - camera = V4LCamera(device="1") + # Verify V4L-specific device resolution worked + assert camera.device == 1 # Should extract 1 from "/dev/video1" + + # Verify BaseCamera parameters are preserved + assert camera.resolution == (1280, 720) + assert camera.fps == 25 + assert camera.adjustments == dummy_adjustment + + def test_device_resolution_integer(self): + """Test that V4LCamera correctly resolves integer device identifiers.""" + camera = V4LCamera(device=0) + assert camera.device == 0 + + camera = V4LCamera(device=1) + assert camera.device == 1 + + def test_device_resolution_string_numeric(self): + """Test that V4LCamera correctly resolves numeric string device identifiers.""" + # Test with device mapping available + with patch.object(V4LCamera, "_get_video_devices_by_index", return_value={1: "2"}): + camera = V4LCamera(device="1") + assert camera.device == 2 + + # Test with no device mapping (fallback) + with patch.object(V4LCamera, "_get_video_devices_by_index", return_value={}): + camera = V4LCamera(device="3") + assert camera.device == 3 + + def test_device_resolution_path(self): + """Test that V4LCamera correctly resolves device path identifiers.""" + camera = V4LCamera(device="/dev/video0") + assert camera.device == 0 + + camera = V4LCamera(device="/dev/video2") assert camera.device == 2 - # Test with no device mapping (fallback) - with patch.object(V4LCamera, "_get_video_devices_by_index", return_value={}): - camera = V4LCamera(device="3") - assert camera.device == 3 + def test_device_resolution_invalid(self): + """Test that V4LCamera raises appropriate error for invalid device identifiers.""" + with pytest.raises(CameraOpenError, match="Cannot resolve camera identifier: invalid"): + V4LCamera(device="invalid") + + with pytest.raises(CameraOpenError, match="Cannot resolve camera identifier: not_a_device"): + V4LCamera(device="not_a_device") + + def test_device_mapping(self): + """Test that V4LCamera uses device mapping when available for string indices.""" + device_mapping = {0: "1", 1: "3", 2: "0"} + + with patch.object(V4LCamera, "_get_video_devices_by_index", return_value=device_mapping): + camera = V4LCamera(device="1") + assert camera.device == 3 + camera = V4LCamera(device="0") + assert camera.device == 1 -def test_device_resolution_path(): - """Test that V4LCamera correctly resolves device path identifiers.""" - camera = V4LCamera(device="/dev/video0") - assert camera.device == 0 + def test_device_mapping_fallback(self): + """Test that V4LCamera falls back to direct conversion when no mapping available.""" + with patch.object(V4LCamera, "_get_video_devices_by_index", return_value={}): + # When no mapping is available, should convert string directly to int + camera = V4LCamera(device="5") + assert camera.device == 5 - camera = V4LCamera(device="/dev/video2") - assert camera.device == 2 +class TestV4LCameraStartStop: + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_start_success(self, mock_video_capture, mock_successful_connect): + """Test that V4LCamera start() calls V4L-specific _open_camera and sets up hardware correctly.""" -def test_device_resolution_invalid(): - """Test that V4LCamera raises appropriate error for invalid device identifiers.""" - with pytest.raises(CameraOpenError, match="Cannot resolve camera identifier: invalid"): - V4LCamera(device="invalid") + def get_caps(prop): + if prop == cv2.CAP_PROP_FRAME_WIDTH: + return 640 + elif prop == cv2.CAP_PROP_FRAME_HEIGHT: + return 480 + elif prop == cv2.CAP_PROP_FPS: + return 10 + return 0 - with pytest.raises(CameraOpenError, match="Cannot resolve camera identifier: not_a_device"): - V4LCamera(device="not_a_device") + mock_successful_connect.get.side_effect = get_caps + mock_video_capture.return_value = mock_successful_connect + camera = V4LCamera(device=2, resolution=(640, 480), fps=10) -def test_device_mapping(): - """Test that V4LCamera uses device mapping when available for string indices.""" - device_mapping = {0: "1", 1: "3", 2: "0"} + assert not camera.is_started() - with patch.object(V4LCamera, "_get_video_devices_by_index", return_value=device_mapping): - camera = V4LCamera(device="1") - assert camera.device == 3 + camera.start() - camera = V4LCamera(device="0") - assert camera.device == 1 + assert camera.is_started() + mock_video_capture.assert_called_once_with(2) + # Verify V4L camera setup calls + assert mock_successful_connect.set.call_count == 4 + set_call_args = [call.args for call in mock_successful_connect.set.call_args_list] + assert (cv2.CAP_PROP_BUFFERSIZE, 1) in set_call_args + assert (cv2.CAP_PROP_FRAME_WIDTH, 640) in set_call_args + assert (cv2.CAP_PROP_FRAME_HEIGHT, 480) in set_call_args + assert (cv2.CAP_PROP_FPS, 10) in set_call_args + + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_start_already_started(self, mock_video_capture, mock_successful_connect): + """Test that V4LCamera doesn't reinitialize when already started.""" + mock_video_capture.return_value = mock_successful_connect -def test_device_mapping_fallback(): - """Test that V4LCamera falls back to direct conversion when no mapping available.""" - with patch.object(V4LCamera, "_get_video_devices_by_index", return_value={}): - # When no mapping is available, should convert string directly to int - camera = V4LCamera(device="5") - assert camera.device == 5 + camera = V4LCamera(device=0) + + # Start camera first time + camera.start() + assert camera.is_started() + assert mock_video_capture.call_count == 1 + + # Start camera second time + camera.start() + # Should still be started but no additional VideoCapture creation + assert camera.is_started() + assert mock_video_capture.call_count == 1 # No additional calls -def test_hardware_adaptation_resolution_mismatch(): - """Test that V4LCamera adapts when hardware doesn't support requested resolution.""" - with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: - mock_cap = MagicMock() - mock_cap.isOpened.return_value = True + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_hardware_adaptation_resolution_mismatch(self, mock_video_capture, mock_successful_connect): + """Test that V4LCamera adapts when hardware doesn't support requested resolution.""" def get_caps(prop): if prop == cv2.CAP_PROP_FRAME_WIDTH: @@ -102,8 +180,8 @@ def get_caps(prop): return 10 return 0 - mock_cap.get.side_effect = get_caps - mock_vc.return_value = mock_cap + mock_successful_connect.get.side_effect = get_caps + mock_video_capture.return_value = mock_successful_connect # Request 640x480 but hardware only supports 320x240 camera = V4LCamera(device=0, resolution=(640, 480), fps=10) @@ -113,12 +191,9 @@ def get_caps(prop): assert camera.resolution == (320, 240) assert camera.is_started() - -def test_hardware_adaptation_fps_mismatch(): - """Test that V4LCamera adapts when hardware doesn't support requested FPS.""" - with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: - mock_cap = MagicMock() - mock_cap.isOpened.return_value = True + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_hardware_adaptation_fps_mismatch(self, mock_video_capture, mock_successful_connect): + """Test that V4LCamera adapts when hardware doesn't support requested FPS.""" def get_caps(prop): if prop == cv2.CAP_PROP_FRAME_WIDTH: @@ -129,8 +204,8 @@ def get_caps(prop): return 15 return 0 - mock_cap.get.side_effect = get_caps - mock_vc.return_value = mock_cap + mock_successful_connect.get.side_effect = get_caps + mock_video_capture.return_value = mock_successful_connect # Request 30fps but hardware only supports 15fps camera = V4LCamera(device=0, resolution=(640, 480), fps=30) @@ -139,142 +214,245 @@ def get_caps(prop): assert camera.fps == 15 assert camera.is_started() + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_stop_success(self, mock_video_capture, mock_successful_connect): + """Test that V4LCamera stop() properly releases V4L resources.""" + mock_video_capture.return_value = mock_successful_connect -def test_read_frame_error_message(): - """Test that V4LCamera provides specific error messages for read failures.""" - with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: - mock_cap = MagicMock() - mock_cap.isOpened.return_value = True - mock_cap.read.return_value = (False, None) - mock_vc.return_value = mock_cap - - camera = V4LCamera(device=3) + camera = V4LCamera(device=1) camera.start() + assert camera.is_started() - with pytest.raises(CameraReadError, match="Failed to read from V4L camera 3"): - camera.capture() + camera.stop() + assert not camera.is_started() + mock_successful_connect.release.assert_called_once() # Should release cv2.VideoCapture -def test_start_success(): - """Test that V4LCamera start() calls V4L-specific _open_camera and sets up hardware correctly.""" - with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: - mock_cap = MagicMock() - mock_cap.isOpened.return_value = True + def test_stop_not_started(self): + """Test that V4LCamera stop() is safe when not started.""" + camera = V4LCamera(device=0) + assert not camera.is_started() - def get_caps(prop): - if prop == cv2.CAP_PROP_FRAME_WIDTH: - return 640 - elif prop == cv2.CAP_PROP_FRAME_HEIGHT: - return 480 - elif prop == cv2.CAP_PROP_FPS: - return 10 - return 0 + camera.stop() # Should not raise any exception - mock_cap.get.side_effect = get_caps - mock_vc.return_value = mock_cap + assert not camera.is_started() - camera = V4LCamera(device=2, resolution=(640, 480), fps=10) + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_is_started(self, mock_video_capture, mock_successful_connect): + """Test V4LCamera is_started() reflects actual V4L camera state.""" + mock_video_capture.return_value = mock_successful_connect + + camera = V4LCamera(device=0) assert not camera.is_started() camera.start() - assert camera.is_started() - mock_vc.assert_called_once_with(2) # Should open correct device - - # Verify V4L camera setup calls - set_call_args = [call.args for call in mock_cap.set.call_args_list] - # Check that buffer size was set to 1 - assert (cv2.CAP_PROP_BUFFERSIZE, 1) in set_call_args + camera.stop() + assert not camera.is_started() - # Check that resolution was set to 640x480 - assert (cv2.CAP_PROP_FRAME_WIDTH, 640) in set_call_args - assert (cv2.CAP_PROP_FRAME_HEIGHT, 480) in set_call_args - # Check that FPS was set to 10 - assert (cv2.CAP_PROP_FPS, 10) in set_call_args +class TestV4LCameraRecovery: + """Test suite for camera disconnection and recovery mechanisms.""" + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_initial_connection_with_retry(self, mock_video_capture): + """Test that initial connection retries on failure.""" + # First two attempts fail, third succeeds + mock_cap_fail = MagicMock() + mock_cap_fail.isOpened.return_value = False -def test_start_already_started(): - """Test that V4LCamera doesn't reinitialize when already started.""" - with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: - mock_cap = MagicMock() - mock_cap.isOpened.return_value = True - mock_cap.get.return_value = 640 - mock_vc.return_value = mock_cap + mock_cap_success = MagicMock() + mock_cap_success.isOpened.return_value = True + mock_cap_success.get.return_value = 640 + mock_cap_success.read.return_value = (True, np.zeros((480, 640, 3), dtype=np.uint8)) - camera = V4LCamera(device=0) - - # Start camera first time - camera.start() - assert camera.is_started() - assert mock_vc.call_count == 1 + mock_video_capture.side_effect = [mock_cap_fail, mock_cap_fail, mock_cap_success] - # Start camera second time + camera = V4LCamera() + camera.reconnect_delay = 0 camera.start() - # Should still be started but no additional VideoCapture creation assert camera.is_started() - assert mock_vc.call_count == 1 # No additional calls + assert mock_video_capture.call_count == 3 + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_start_open_error(self, mock_video_capture, mock_failed_connect_open): + """Test that V4LCamera raises an exception for open failures.""" + mock_video_capture.return_value = mock_failed_connect_open -def test_start_camera_fails_to_open(): - """Test V4LCamera start() error handling when cv2.VideoCapture fails to open.""" - with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: - mock_cap = MagicMock() - mock_cap.isOpened.return_value = False # Camera fails to open - mock_vc.return_value = mock_cap + camera = V4LCamera(device=3) + camera.reconnect_delay = 0 + with pytest.raises(CameraOpenError): + camera.start() - camera = V4LCamera(device=5) + assert not camera.is_started() + assert mock_video_capture.call_count == 6 # 1 initial attempt + 5 failed retries - with pytest.raises(CameraOpenError, match="Failed to open V4L camera 5"): + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_start_read_error(self, mock_video_capture, mock_failed_connect_read): + """Test that V4LCamera raises an exception for read failures.""" + mock_video_capture.return_value = mock_failed_connect_read + + camera = V4LCamera(device=3) + camera.reconnect_delay = 0 + with pytest.raises(CameraOpenError): camera.start() assert not camera.is_started() + assert mock_video_capture.call_count == 6 # 1 initial attempt + 5 failed retries + + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_auto_reconnect_on_read_failure(self, mock_video_capture, mock_successful_connect): + """Test automatic reconnection when frame read fails.""" + # Setup connection, first actual capture() and simulate disconnect on second capture() + mock_successful_connect.read.side_effect = [ + (True, np.zeros((480, 640, 3), dtype=np.uint8)), # Read during start() succeeds + (True, np.zeros((480, 640, 3), dtype=np.uint8)), # First capture succeeds + (False, None), # Second capture fails + ] + + # Setup reconnection success + mock_cap_reconnect = MagicMock() + mock_cap_reconnect.isOpened.return_value = True + mock_cap_reconnect.read.return_value = (True, np.zeros((480, 640, 3), dtype=np.uint8)) + + mock_video_capture.side_effect = [ + mock_successful_connect, # Used for initial connection + mock_cap_reconnect, # Used for reconnection + ] + + camera = V4LCamera() + camera.reconnect_delay = 0 + camera.start() + # First capture works + frame1 = camera.capture() + assert frame1 is not None + + # Second capture fails but auto-reconnects + frame2 = camera.capture() + assert frame2 is not None + + # Verify reconnection happened + assert mock_video_capture.call_count == 2 + + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_auto_reconnect_on_read_failure_by_exception(self, mock_video_capture, mock_successful_connect): + """Test automatic reconnection when frame read fails.""" + # Setup connection, first actual capture() and simulate disconnect on second capture() + mock_successful_connect.read.side_effect = [ + (True, np.zeros((480, 640, 3), dtype=np.uint8)), # Read during start() succeeds + (True, np.zeros((480, 640, 3), dtype=np.uint8)), # First capture succeeds + Exception("Simulated read exception"), # Second capture fails by exception + ] + + # Setup reconnection success + mock_cap_reconnect = MagicMock() + mock_cap_reconnect.isOpened.return_value = True + mock_cap_reconnect.read.return_value = (True, np.zeros((480, 640, 3), dtype=np.uint8)) + + mock_video_capture.side_effect = [ + mock_successful_connect, # Used for initial connection + mock_cap_reconnect, # Used for reconnection + ] + + camera = V4LCamera() + camera.reconnect_delay = 0 + camera.start() -def test_stop_success(): - """Test that V4LCamera stop() properly releases V4L resources.""" - with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: - mock_cap = MagicMock() - mock_cap.isOpened.return_value = True - mock_cap.get.return_value = 640 - mock_vc.return_value = mock_cap + # First capture works + frame1 = camera.capture() + assert frame1 is not None - camera = V4LCamera(device=1) + # Second capture fails but auto-reconnects + frame2 = camera.capture() + assert frame2 is not None + + # Verify reconnection happened + assert mock_video_capture.call_count == 2 + + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_no_auto_reconnect_when_disabled(self, mock_video_capture, mock_successful_connect): + """Test that auto-reconnect doesn't happen when disabled.""" + mock_successful_connect.read.side_effect = [ + (True, np.zeros((480, 640, 3), dtype=np.uint8)), # Read during start() succeeds + (True, np.zeros((480, 640, 3), dtype=np.uint8)), # First capture succeeds + (False, None), # Second capture fails + ] + mock_video_capture.return_value = mock_successful_connect + + camera = V4LCamera(auto_reconnect=False) camera.start() - assert camera.is_started() - camera.stop() + # First read succeeds + frame1 = camera.capture() + assert frame1 is not None - assert not camera.is_started() - mock_cap.release.assert_called_once() # Should release cv2.VideoCapture + # Second read fails and returns None (no reconnect) + frame2 = camera.capture() + assert frame2 is None + # Verify only initial connection, no reconnection + assert mock_video_capture.call_count == 1 -def test_stop_not_started(): - """Test that V4LCamera stop() is safe when not started.""" - camera = V4LCamera(device=0) - assert not camera.is_started() + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_reconnect_rate_limiting(self, mock_video_capture, mock_failed_connect_open): + """Test that reconnection attempts are rate-limited.""" + import time - camera.stop() # Should not raise any exception - assert not camera.is_started() + mock_video_capture.return_value = mock_failed_connect_open + camera = V4LCamera() + camera.reconnect_delay = 0.1 -def test_is_started(): - """Test V4LCamera is_started() reflects actual V4L camera state.""" - with patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") as mock_vc: - mock_cap = MagicMock() - mock_cap.isOpened.return_value = True - mock_cap.get.return_value = 640 - mock_vc.return_value = mock_cap + result = camera._connect() + assert result is False + initial_time = camera._last_reconnect_attempt + assert initial_time > 0 - camera = V4LCamera(device=0) + # After waiting, should allow reconnect attempt + time.sleep(0.15) - assert not camera.is_started() + result = camera._connect() + assert camera._last_reconnect_attempt > initial_time - camera.start() - assert camera.is_started() + @patch("arduino.app_peripherals.camera.v4l_camera.cv2.VideoCapture") + def test_exponential_backoff_on_open(self, mock_video_capture, mock_successful_connect, mock_failed_connect_open, mock_failed_connect_read): + """Test that exponential backoff is used during camera opening.""" + real_sleep = time.sleep # Save the real sleep before patching - camera.stop() - assert not camera.is_started() + sleep_calls = [] + + def spy_sleep(seconds): + sleep_calls.append(seconds) + return real_sleep(seconds) + + # Patch time.sleep in the v4l_camera module only for this test + with patch("arduino.app_peripherals.camera.v4l_camera.time.sleep", side_effect=spy_sleep): + # Fail, attempt 5 times, succeed at last one + mock_video_capture.side_effect = [ + mock_failed_connect_open, + mock_failed_connect_read, + mock_failed_connect_open, + mock_failed_connect_read, + mock_failed_connect_open, + mock_successful_connect, + ] + + camera = V4LCamera() + camera.reconnect_delay = 0.1 + camera.start() + + # Check that sleep was called with exponentially increasing delays + # Attempt 0 fails -> sleep(0.1 * 2^0) = 0.1 + # Attempt 1 fails -> sleep(0.1 * 2^1) = 0.2 + # ... + # Attempt 5 succeeds + assert len(sleep_calls) == 5 + assert sleep_calls[0] == 0.1 + assert sleep_calls[1] == 0.2 + assert sleep_calls[2] == 0.4 + assert sleep_calls[3] == 0.8 + assert sleep_calls[4] == 1.6 From b32dea69594d783591b7d94e5464783f84101a2a Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Sat, 15 Nov 2025 05:23:52 +0100 Subject: [PATCH 51/86] feat: resolve device to stable links for more reliable reconnections --- src/arduino/app_peripherals/camera/camera.py | 6 +- .../app_peripherals/camera/v4l_camera.py | 123 +++++++++++++----- .../app_peripherals/camera/conftest.py | 50 +++++++ .../app_peripherals/camera/test_camera.py | 31 ++--- .../app_peripherals/camera/test_v4l_camera.py | 88 ++++--------- 5 files changed, 180 insertions(+), 118 deletions(-) create mode 100644 tests/arduino/app_peripherals/camera/conftest.py diff --git a/src/arduino/app_peripherals/camera/camera.py b/src/arduino/app_peripherals/camera/camera.py index 733062e4..7596a5fe 100644 --- a/src/arduino/app_peripherals/camera/camera.py +++ b/src/arduino/app_peripherals/camera/camera.py @@ -40,7 +40,7 @@ def __new__( Args: source (Union[str, int]): Camera source identifier. Supports: - int: V4L camera index (e.g., 0, 1) - - str: V4L camera index (e.g., "0", "1") or device path (e.g., "/dev/video0") + - str: V4L camera index (e.g., "0", "1") or device path (i.e., "/dev/video0", "/dev/v4l/by-id/...", "/dev/v4l/by-path/...") - str: URL for IP cameras (e.g., "rtsp://...", "http://...") - str: WebSocket URL for input streams (e.g., "ws://0.0.0.0:8080") resolution (tuple, optional): Frame resolution as (width, height). @@ -111,8 +111,8 @@ def __new__( host = parsed.hostname or "localhost" port = parsed.port or 8080 return WebSocketCamera(host=host, port=port, resolution=resolution, fps=fps, adjustments=adjustments, **kwargs) - elif source.startswith("/dev/video") or source.isdigit(): - # V4L device path or index as string + elif source.startswith("/dev/video") or source.startswith("/dev/v4l/by-id/") or source.startswith("/dev/v4l/by-path/"): + # V4L device path, by-id, or by-path from .v4l_camera import V4LCamera return V4LCamera(source, resolution=resolution, fps=fps, adjustments=adjustments, **kwargs) diff --git a/src/arduino/app_peripherals/camera/v4l_camera.py b/src/arduino/app_peripherals/camera/v4l_camera.py index c1204857..a5099e05 100644 --- a/src/arduino/app_peripherals/camera/v4l_camera.py +++ b/src/arduino/app_peripherals/camera/v4l_camera.py @@ -48,7 +48,8 @@ def __init__( auto_reconnect (bool, optional): Enable automatic reconnection on failure. Default: True. """ super().__init__(resolution, fps, adjustments) - self.device = self._resolve_camera_id(device) + self.device_path = self._resolve_stable_path(device) + self.device_name = self._resolve_name(self.device_path) self.logger = logger self._cap = None @@ -59,39 +60,93 @@ def __init__( self._auto_reconnect = auto_reconnect self._last_reconnect_attempt = 0.0 - def _resolve_camera_id(self, device: str | int) -> int: + def _resolve_stable_path(self, device: str | int) -> str: """ - Resolve camera identifier to a numeric device ID. + Resolve a camera identifier to a link stable across reconnections. Args: device: Camera identifier Returns: - Numeric camera device ID + str: stable path to the camera device Raises: CameraOpenError: If camera cannot be resolved """ - if isinstance(device, int): + if isinstance(device, str) and device.startswith("/dev/v4l/by-id"): + # Already a stable link return device + elif isinstance(device, str) and device.startswith("/dev/v4l/by-path"): + # A stable link, but not the one we want, resolve to by-id. + if not os.path.exists(device): + raise CameraOpenError(f"Device path {device} does not exist") + resolved_path = os.path.realpath(device) + video_path = resolved_path + elif isinstance(device, int) or (isinstance(device, str) and device.isdigit()): + # Treat as /dev/video + dev_num = int(device) + video_path = f"/dev/video{dev_num}" + elif isinstance(device, str) and device.startswith("/dev/video"): + # A device node path + video_path = device + else: + raise CameraOpenError(f"Unrecognized device identifier: {device}") + + # Now, map /dev/videoX to a stable link in /dev/v4l/by-id + by_id_dir = "/dev/v4l/by-id/" + if not os.path.exists(by_id_dir): + raise CameraOpenError(f"Directory '{by_id_dir}' not found.") - if isinstance(device, str): - # If it's a numeric string, convert directly - if device.isdigit(): - device_idx = int(device) - # Validate using device index mapping - video_devices = self._get_video_devices_by_index() - if device_idx in video_devices: - return int(video_devices[device_idx]) - else: - # Fallback to direct device ID if mapping not available - return device_idx + try: + for entry in os.listdir(by_id_dir): + full_path = os.path.join(by_id_dir, entry) + if os.path.islink(full_path): + target = os.path.realpath(full_path) + if target == video_path: + return full_path + except Exception as e: + raise CameraOpenError(f"Error resolving stable link: {e}") + + raise CameraOpenError(f"No stable link found for device {device} (resolved as {video_path})") - # If it's a device path like "/dev/video0" - if device.startswith("/dev/video"): - return int(device.replace("/dev/video", "")) + def _resolve_name(self, stable_path: str) -> str: + """ + Resolve a human-readable name for the camera whose stable path is provided + by looking at /sys/class/video4linux/