Source code for AFL.automation.vision.PiCameraDriver

"""A server-facing driver for Raspberry Pi cameras using Picamera2."""

from __future__ import annotations

from datetime import datetime, timezone
import getpass
from pathlib import Path
import socket
import threading
import time
import uuid

import lazy_loader as lazy

from AFL.automation.APIServer.Driver import Driver
from AFL.automation.vision.ImageProcessing import ImageProcessing


[docs] class PiCameraDriver(ImageProcessing, Driver): """Capture still images from a Raspberry Pi camera. ``picamera2`` is loaded only when a real camera is constructed, so this module can be imported on development machines and in test environments without Raspberry Pi camera support. A camera-like object may be supplied to the constructor for testing; it must provide ``capture_array`` and/or ``capture_file``. """ defaults = { "resolution": [1920, 1080], "warmup_seconds": 1.0, "output_dir": "camera_images", "image_format": "jpg", "stream_bitrate": 10_000_000, "stream_address": "127.0.0.1:8000", }
[docs] def __init__( self, camera=None, overrides=None, name="PiCameraDriver", initialize_driver=True, ): """Create the driver and initialize a real Picamera2 when needed.""" if initialize_driver: Driver.__init__( self, name=name, defaults=self.gather_defaults(), overrides=overrides, ) self.camera = camera self._owns_camera = camera is None self._last_image = None self._background_image = None self._last_saved_path = None self._stream_server = None self._stream_client = None self._stream_thread = None self._stream_stop_event = None self._stream_address = None self._stream_lock = threading.Lock() if self.camera is None: self.camera = self._create_camera()
def _create_camera(self): """Configure and start a Picamera2 still-camera stream.""" picamera2 = lazy.load("picamera2", require="AFL-automation[picamera]") camera = picamera2.Picamera2() resolution = self._resolution() configuration = camera.create_still_configuration(main={"size": resolution}) camera.configure(configuration) camera.start() warmup_seconds = float(self.config["warmup_seconds"]) if warmup_seconds > 0: time.sleep(warmup_seconds) return camera def _resolution(self): resolution = self.config["resolution"] if not isinstance(resolution, (list, tuple)) or len(resolution) != 2: raise ValueError("resolution must be a two-item [width, height] sequence") width, height = (int(value) for value in resolution) if width <= 0 or height <= 0: raise ValueError("resolution values must be positive") return width, height def _output_path(self, filename=None): output_dir = Path(self.config["output_dir"]).expanduser() if not output_dir.is_absolute(): output_dir = self.path / output_dir output_dir.mkdir(parents=True, exist_ok=True) image_format = str(self.config["image_format"]).lstrip(".") if not image_format: raise ValueError("image_format must not be empty") if filename is None: timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S_%fZ") filename = f"image_{timestamp}.{image_format}" else: filename = Path(filename) if filename.name != str(filename): raise ValueError("filename must be a basename, not a path") if not filename.suffix: filename = filename.with_suffix(f".{image_format}") return output_dir / filename
[docs] @Driver.queued() def capture(self, save=False, filename=None): """Take one image, optionally saving it to the configured output directory. When ``save`` is false, the captured image is placed in the driver's dropbox and the returned ``image_uid`` can be retrieved through the API server. When ``save`` is true, Picamera2 writes the image directly and the returned ``path`` identifies the saved file. """ if save: output_path = self._output_path(filename) self.camera.capture_file(str(output_path)) self._last_saved_path = output_path return {"saved": True, "path": str(output_path)} image = self.camera.capture_array("main") self._last_image = image if self.dropbox is None: self.dropbox = {} image_uid = "DB-" + str(uuid.uuid4()) self.dropbox[image_uid] = image return { "saved": False, "image_uid": image_uid, "shape": [int(dimension) for dimension in image.shape], "dtype": str(image.dtype), }
@staticmethod def _parse_stream_address(address): """Parse a TCP listener address supplied as ``host:port``.""" if not isinstance(address, str): raise ValueError("address must be a host:port string") address = address.removeprefix("tcp://") if address.startswith("["): host, separator, port = address[1:].partition("]:") else: host, separator, port = address.rpartition(":") if not separator or not host: raise ValueError(f"address must have the form host:port, e.g. 0.0.0.0:8000 not {address}") try: port = int(port) except ValueError as exc: raise ValueError("stream port must be an integer") from exc if not 0 <= port <= 65535: raise ValueError("stream port must be between 0 and 65535") return host, port @staticmethod def _viewer_command(port): """Return the SSH-tunnel command to run on a viewer computer.""" return ( f"ssh -fN -L {port}:127.0.0.1:{port} " f"{getpass.getuser()}@{socket.gethostname()} && " "ffplay -fflags nobuffer -flags low_delay -f h264 " f"tcp://127.0.0.1:{port}" ) def _serve_stream_client(self): """Accept one TCP client and feed it an H.264 stream until stopped.""" try: self._stream_server.settimeout(0.25) while not self._stream_stop_event.is_set(): try: client, _ = self._stream_server.accept() break except TimeoutError: continue except OSError: return else: return with self._stream_lock: self._stream_client = client picamera2_encoders = lazy.load( "picamera2.encoders", require="AFL-automation[picamera]" ) picamera2_outputs = lazy.load( "picamera2.outputs", require="AFL-automation[picamera]" ) encoder = picamera2_encoders.H264Encoder( bitrate=int(self.config["stream_bitrate"]) ) output = picamera2_outputs.FileOutput(client.makefile("wb")) self.camera.start_encoder(encoder, output, name="main") self._stream_stop_event.wait() self.camera.stop_encoder(encoder) except Exception as exc: self.log_error(f"Camera stream failed: {exc}") finally: with self._stream_lock: if self._stream_client is not None: self._stream_client.close() self._stream_client = None
[docs] @Driver.queued() def start_streaming(self, address=None): """Start an H.264 TCP stream listener at ``address``. When omitted, ``address`` uses the configured ``stream_address`` (``127.0.0.1:8000`` by default). It must be ``host:port`` (for example ``0.0.0.0:8000``). The driver listens immediately, and begins streaming only after one client connects. It does not start automatically during driver setup. """ if address is None: address = self.config["stream_address"] host, port = self._parse_stream_address(address) with self._stream_lock: if self._stream_thread is not None and self._stream_thread.is_alive(): raise RuntimeError("A video stream is already running") server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: server.bind((host, port)) server.listen(1) except Exception: server.close() raise self._stream_server = server self._stream_address = f"{server.getsockname()[0]}:{server.getsockname()[1]}" bound_port = server.getsockname()[1] self._stream_stop_event = threading.Event() self._stream_thread = threading.Thread( target=self._serve_stream_client, name="PiCameraDriver-stream", daemon=True, ) self._stream_thread.start() viewer_command = self._viewer_command(bound_port) print("To view this stream from another computer, run:") print(viewer_command) return { "streaming": True, "address": self._stream_address, "viewer_command": viewer_command, }
[docs] @Driver.queued() def stop_streaming(self): """Stop the active video stream listener and disconnect its client.""" with self._stream_lock: if self._stream_stop_event is None: return {"streaming": False} self._stream_stop_event.set() if self._stream_client is not None: try: self._stream_client.shutdown(socket.SHUT_RDWR) except OSError: pass self._stream_client.close() if self._stream_server is not None: self._stream_server.close() stream_thread = self._stream_thread if stream_thread is not None: stream_thread.join(timeout=2) with self._stream_lock: self._stream_server = None self._stream_client = None self._stream_thread = None self._stream_stop_event = None self._stream_address = None return {"streaming": False}
[docs] @Driver.queued() def close(self): """Stop and close a camera created by this driver.""" self.stop_streaming() if self.camera is not None and self._owns_camera: if hasattr(self.camera, "stop"): self.camera.stop() if hasattr(self.camera, "close"): self.camera.close() self.camera = None return {"closed": True}
[docs] def status(self): """Return cheap, serializable camera state.""" return { "connected": self.camera is not None, "resolution": list(self._resolution()), "last_saved_path": ( str(self._last_saved_path) if self._last_saved_path is not None else None ), "last_image_available": self._last_image is not None, "streaming": ( self._stream_thread is not None and self._stream_thread.is_alive() ), "stream_address": self._stream_address, }
_DEFAULT_CUSTOM_CONFIG = { "_classname": "AFL.automation.vision.PiCameraDriver.PiCameraDriver", "overrides": {}, } _DEFAULT_PORT = 5096 if __name__ == "__main__": from AFL.automation.shared.launcher import *