From 53a459f12ad12111eb295620494a837c845ed954 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 16 Oct 2025 16:54:05 +0200 Subject: [PATCH 01/38] 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 a2596167cee15e8c5a3be89c2d92c81a3051dec5 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 20 Oct 2025 13:05:12 +0200 Subject: [PATCH 02/38] 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 f5d35b70a348451e290b0ee853ba7fd85175f219 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 20 Oct 2025 13:37:35 +0200 Subject: [PATCH 03/38] 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 c1c9cb988497ca9871e5ffe29b4c3e8e9117b3e8 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 20 Oct 2025 18:15:52 +0200 Subject: [PATCH 04/38] 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 bb020364..e9da9125 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 c57861417285856fbef552c3fa7863a20cca787d Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 21 Oct 2025 11:29:43 +0200 Subject: [PATCH 05/38] 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 b3bf6c1993f67a29e21e6e412c51e9faad2239fe Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 21 Oct 2025 11:30:13 +0200 Subject: [PATCH 06/38] 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 58e2cbc4..027e4eb0 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 20e21a313861ba33e2bb2d1062587845ddfe9a94 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Fri, 24 Oct 2025 10:05:20 +0200 Subject: [PATCH 07/38] refactor: image manipulation --- .../camera_code_detection/__init__.py | 5 ++-- .../camera_code_detection/detection.py | 8 ++--- .../app_bricks/object_detection/README.md | 19 ++++++------ .../app_bricks/object_detection/__init__.py | 16 +--------- .../examples/object_detection_example.py | 21 +++++++------- .../brick_compose.yaml | 2 +- .../video_objectdetection/brick_compose.yaml | 2 +- .../examples/object_detection_example.py | 25 ---------------- .../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 | 29 +++++-------------- .../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 +- 19 files changed, 92 insertions(+), 144 deletions(-) delete mode 100644 src/arduino/app_bricks/visual_anomaly_detection/examples/object_detection_example.py 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 (87%) 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 e2c166fc..084984f1 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 e9da9125..9b8f7488 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/__init__.py b/src/arduino/app_bricks/object_detection/__init__.py index 93f2e290..3640fa52 100644 --- a/src/arduino/app_bricks/object_detection/__init__.py +++ b/src/arduino/app_bricks/object_detection/__init__.py @@ -2,8 +2,7 @@ # # SPDX-License-Identifier: MPL-2.0 -from PIL import Image -from arduino.app_utils import brick, Logger, draw_bounding_boxes +from arduino.app_utils import brick, Logger from arduino.app_internal.core import EdgeImpulseRunnerFacade logger = Logger("ObjectDetection") @@ -54,19 +53,6 @@ def detect(self, image_bytes, image_type: str = "jpg", confidence: float = None) ret = super().infer_from_image(image_bytes, image_type) return self._extract_detection(ret, confidence) - def draw_bounding_boxes(self, image: Image.Image | bytes, detections: dict) -> Image.Image | None: - """Draw bounding boxes on an image enclosing detected objects using PIL. - - Args: - image: The input image to annotate. Can be a PIL Image object or raw image bytes. - detections: Detection results containing object labels and bounding boxes. - - Returns: - Image with bounding boxes and key points drawn. - None if no detection or invalid image. - """ - return draw_bounding_boxes(image, detections) - def _extract_detection(self, item, confidence: float = None): if not item: return None 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 f2ca3b9f..eccc68eb 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 dbca6363..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", "--force-target", "--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/object_detection_example.py b/src/arduino/app_bricks/visual_anomaly_detection/examples/object_detection_example.py deleted file mode 100644 index 5dc0d2cc..00000000 --- a/src/arduino/app_bricks/visual_anomaly_detection/examples/object_detection_example.py +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA -# -# SPDX-License-Identifier: MPL-2.0 - -# EXAMPLE_NAME = "Object Detection" -import os -from arduino.app_bricks.object_detection import ObjectDetection - -object_detection = ObjectDetection() - -# Image frame can be as bytes or PIL image -frame = 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) -if out and "detection" in out: - for i, obj_det in enumerate(out["detection"]): - # For every object detected, get 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) - -# draw the bounding box and key points on the image -out_image = object_detection.draw_bounding_boxes(frame, 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 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 df9e4353..87d90aae 100644 --- a/src/arduino/app_utils/__init__.py +++ b/src/arduino/app_utils/__init__.py @@ -8,11 +8,9 @@ from .bridge import * from .folderwatch import * from .httprequest import * -from .image import * from .jsonparser import * from .logger import * from .slidingwindowbuffer import * -from .userinput import * __all__ = [ "App", @@ -23,12 +21,8 @@ "provide", "FolderWatcher", "HttpClient", - "draw_bounding_boxes", - "get_image_bytes", - "get_image_type", "JSONParser", "Logger", "SineGenerator", "SlidingWindowBuffer", - "UserTextInput", ] 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 87% rename from src/arduino/app_utils/image.py rename to src/arduino/app_utils/image/image.py index 8870f9b1..c07bc887 100644 --- a/src/arduino/app_utils/image.py +++ b/src/arduino/app_utils/image/image.py @@ -35,7 +35,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 @@ -78,23 +78,11 @@ def get_image_bytes(image: str | Image.Image | bytes) -> bytes: return None -def draw_colored_dot(draw, x, y, color, size): - """Draws a large colored dot on a PIL Image at the specified coordinate. - - Args: - draw: An ImageDraw object from PIL. - x: The x-coordinate of the center of the dot. - y: The y-coordinate of the center of the dot. - color: A color value that PIL understands (e.g., "red", (255, 0, 0), "#FF0000"). - size: The radius of the dot (in pixels). - """ - # Calculate the bounding box for the circle - bounding_box = (x - size, y - size, x + size, y + size) - # Draw a filled ellipse (which looks like a circle if the bounding box is a square) - draw.ellipse(bounding_box, fill=color) - - -def draw_bounding_boxes(image: Image.Image | bytes, detection: dict, draw: ImageDraw.ImageDraw = None) -> Image.Image | None: +def draw_bounding_boxes( + 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. @@ -181,7 +169,7 @@ def draw_bounding_boxes(image: Image.Image | bytes, detection: dict, draw: Image 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. @@ -192,9 +180,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 530b978e..00000000 --- a/src/arduino/app_utils/userinput.py +++ /dev/null @@ -1,14 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA -# -# 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 6f748561..19b6fa0a 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 f98f779f..26fcf5a0 100644 --- a/tests/arduino/app_bricks/objectdetection/test_objectdetection.py +++ b/tests/arduino/app_bricks/objectdetection/test_objectdetection.py @@ -113,27 +113,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 bf3d8985..0e53f110 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 964ce99757fd406a7cf92790a727d3411fad22be Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 27 Oct 2025 09:49:39 +0100 Subject: [PATCH 08/38] 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 50d781a140abdf827abc661dea14cd188e5e21d6 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 27 Oct 2025 18:31:03 +0100 Subject: [PATCH 09/38] 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 5b2d1544e5b2e183ff2e4634d1794892c206aeab Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 28 Oct 2025 18:23:02 +0100 Subject: [PATCH 10/38] 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 3c2961bb1fa2528ebbb9fb5997190be7e4533282 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 12:19:17 +0100 Subject: [PATCH 11/38] 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 e71a9fc3..39979a6f 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 ba82d879502eefdecf997eaabb3afa23c89a59e4 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 12:23:35 +0100 Subject: [PATCH 12/38] 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 da354c8b9f005192c94fbab212cf530d6370ce5e Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 18:32:26 +0100 Subject: [PATCH 13/38] 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 f4a9a5397a4a04c35e0f99eb8c29fc6f35309a99 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 18:33:21 +0100 Subject: [PATCH 14/38] 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 91f421944e3c9842c359dcc2496072cbbfb65fd9 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 18:33:54 +0100 Subject: [PATCH 15/38] 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 68052080ace30b40b74c1dbf45a983c6f53752c1 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 18:34:20 +0100 Subject: [PATCH 16/38] 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 39979a6f..926c981a 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 902a6440d3bf05fb87818fab7ba421a91784c0bc Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 29 Oct 2025 18:36:51 +0100 Subject: [PATCH 17/38] 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 17e6878aa10247767d4e7aa609d3cefba51613ca Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 00:15:51 +0100 Subject: [PATCH 18/38] 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 0c0fdeca27076efe4220bead67d137739e322375 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 00:16:07 +0100 Subject: [PATCH 19/38] 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 df27fbf21aede83adf498c9ad0a3278e5427ac5b Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 00:45:13 +0100 Subject: [PATCH 20/38] 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 926c981a..8a6dbabd 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 4fff7cdf54db14cf8ae434d254cfe2072dfcda20 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 00:52:34 +0100 Subject: [PATCH 21/38] 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 a18fd8904f2f309cd6e0a1254539cdaee831745b Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 01:05:38 +0100 Subject: [PATCH 22/38] 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 | 12 +-- 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, 279 insertions(+), 275 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 eccc68eb..80b92b20 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 c07bc887..e24aa397 100644 --- a/src/arduino/app_utils/image/image.py +++ b/src/arduino/app_utils/image/image.py @@ -78,11 +78,7 @@ def get_image_bytes(image: str | Image.Image | bytes) -> bytes: return None -def draw_bounding_boxes( - image: Image.Image | bytes, - detection: dict, - draw: ImageDraw.ImageDraw = None -) -> Image.Image | None: +def draw_bounding_boxes(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. @@ -166,11 +162,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 bb4e814eef77f7855a650094e709fbfcfad3fac8 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 01:19:59 +0100 Subject: [PATCH 23/38] 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 8a6dbabd..421cce2b 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 26fcf5a0..8d6b714a 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 14a229a6996a50002465d77db07ff0903ba38d0f Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 01:28:34 +0100 Subject: [PATCH 24/38] 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 825b8435..8753a317 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 596c019bf0934db0a7adddbe47f88de66d52b454 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 01:45:06 +0100 Subject: [PATCH 25/38] 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 8979656ba7c77cb9ad5b058b076d66ebd38b7fa7 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 02:08:56 +0100 Subject: [PATCH 26/38] 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 f6eead859af7d42f285f8af549fc4c16a04c4d3f Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 14:55:22 +0100 Subject: [PATCH 27/38] 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 28e3530e05b19427e1c3248fd356b1959881dacb Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Thu, 30 Oct 2025 14:56:08 +0100 Subject: [PATCH 28/38] 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 f163f235c502b40145c9ed852ca5188e7518914e Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Fri, 31 Oct 2025 08:42:30 +0100 Subject: [PATCH 29/38] 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 f20eaf1d5b242d88b33d2d6d0f2db9ab360cbc9f Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Fri, 31 Oct 2025 08:52:33 +0100 Subject: [PATCH 30/38] 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 4b61da786b6b56fdb5405c090ff191fc814aa63f Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Fri, 31 Oct 2025 09:20:40 +0100 Subject: [PATCH 31/38] 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 f49076fd960c0c17a06fab5aebaca69c1f3272ea Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Sat, 1 Nov 2025 00:32:04 +0100 Subject: [PATCH 32/38] 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 f4f92490b78ffc870d94ff61c49fbe97f755f264 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Mon, 3 Nov 2025 13:32:26 +0100 Subject: [PATCH 33/38] 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 e0eb5386a732b2c2537c8ae975c591e93649df7e Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 4 Nov 2025 16:27:54 +0100 Subject: [PATCH 34/38] 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 dcccd8e982336ad667d2d53d57a180388062eac5 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 4 Nov 2025 17:23:00 +0100 Subject: [PATCH 35/38] 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 1d134b12..3efdc747 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 af523078cef517d7a937418aec2279ca73b53d41 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 4 Nov 2025 17:50:59 +0100 Subject: [PATCH 36/38] 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 9b8f7488..964b5870 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 6288d571..e3021eb9 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 8a672470..fcd8ba3c 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 a4f07a51a2141866b1de812b08b3f246ead9f055 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Tue, 4 Nov 2025 23:48:00 +0100 Subject: [PATCH 37/38] 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 87abab5e..437bc561 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 2752a577a40f2e1d7278765ab26a74fba6a499f0 Mon Sep 17 00:00:00 2001 From: Roberto Gazia Date: Wed, 5 Nov 2025 00:03:44 +0100 Subject: [PATCH 38/38] 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 b9372e04..7c41df73 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)