- Remove main_cycling.py, main_multi.py, main_release.py (single main.py is canonical) - Update setup.sh to read SERVICE_NAME and PORT from .env - Update env.example with SERVICE_NAME and PORT for multi-instance support - Fix server-csi to try rpicam-still before libcamera-still (Debian Trixie) Deploy pattern: clone repo twice, configure each .env, run setup.sh Each instance gets its own systemd service and install directory.
269 lines
8.1 KiB
Python
269 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
vixy-vision CSI Camera Server
|
|
|
|
FastAPI server for Raspberry Pi CSI ribbon cameras (including IR cameras).
|
|
Supports both picamera2 (new) and picamera (legacy) libraries.
|
|
|
|
For Pi 3 with older Raspbian: uses picamera
|
|
For Pi 4/5 with newer Pi OS: uses picamera2
|
|
"""
|
|
|
|
import os
|
|
import io
|
|
import threading
|
|
import subprocess
|
|
from typing import Optional
|
|
from dotenv import load_dotenv
|
|
from fastapi import FastAPI, Security, HTTPException, Response
|
|
from fastapi.security import APIKeyHeader
|
|
|
|
# Load environment variables
|
|
load_dotenv()
|
|
|
|
# Configuration
|
|
API_KEY = os.getenv("API_KEY")
|
|
CAMERA_WIDTH = int(os.getenv("CAMERA_WIDTH", "1920"))
|
|
CAMERA_HEIGHT = int(os.getenv("CAMERA_HEIGHT", "1080"))
|
|
JPEG_QUALITY = int(os.getenv("JPEG_QUALITY", "85"))
|
|
CAMERA_ID = os.getenv("CAMERA_ID", "csi-camera")
|
|
|
|
# IR camera settings
|
|
IR_MODE = os.getenv("IR_MODE", "false").lower() == "true"
|
|
ROTATION = int(os.getenv("ROTATION", "0")) # 0, 90, 180, 270
|
|
|
|
if not API_KEY:
|
|
raise ValueError("API_KEY not set in .env file")
|
|
|
|
# FastAPI app
|
|
app = FastAPI(
|
|
title="vixy-vision CSI Camera Server",
|
|
description="CSI ribbon camera server for the fox 🦊 (supports IR)",
|
|
version="1.0.0"
|
|
)
|
|
|
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
|
|
# Detect which camera library is available
|
|
CAMERA_LIB = None
|
|
picamera2 = None
|
|
picamera = None
|
|
|
|
try:
|
|
from picamera2 import Picamera2
|
|
CAMERA_LIB = "picamera2"
|
|
print("📷 Using picamera2 (modern)")
|
|
except ImportError:
|
|
try:
|
|
import picamera as picamera_legacy
|
|
picamera = picamera_legacy
|
|
CAMERA_LIB = "picamera"
|
|
print("📷 Using picamera (legacy)")
|
|
except ImportError:
|
|
print("⚠️ No camera library found, using libcamera-still fallback")
|
|
CAMERA_LIB = "libcamera"
|
|
|
|
|
|
class CSICameraManager:
|
|
"""Manages CSI camera access with multiple backend support"""
|
|
|
|
def __init__(self, width: int, height: int, rotation: int = 0):
|
|
self.width = width
|
|
self.height = height
|
|
self.rotation = rotation
|
|
self.lock = threading.Lock()
|
|
self.camera = None
|
|
self._initialized = False
|
|
|
|
def _init_picamera2(self):
|
|
"""Initialize picamera2"""
|
|
from picamera2 import Picamera2
|
|
self.camera = Picamera2()
|
|
config = self.camera.create_still_configuration(
|
|
main={"size": (self.width, self.height)},
|
|
transform=self._get_transform()
|
|
)
|
|
self.camera.configure(config)
|
|
self.camera.start()
|
|
self._initialized = True
|
|
|
|
def _init_picamera(self):
|
|
"""Initialize legacy picamera"""
|
|
self.camera = picamera.PiCamera()
|
|
self.camera.resolution = (self.width, self.height)
|
|
self.camera.rotation = self.rotation
|
|
# Warm up
|
|
import time
|
|
time.sleep(2)
|
|
self._initialized = True
|
|
|
|
def _get_transform(self):
|
|
"""Get libcamera Transform for rotation"""
|
|
try:
|
|
from libcamera import Transform
|
|
if self.rotation == 180:
|
|
return Transform(hflip=True, vflip=True)
|
|
elif self.rotation == 90:
|
|
return Transform(transpose=True, vflip=True)
|
|
elif self.rotation == 270:
|
|
return Transform(transpose=True, hflip=True)
|
|
except ImportError:
|
|
pass
|
|
return None
|
|
|
|
def get_snapshot(self) -> Optional[bytes]:
|
|
"""Capture snapshot as JPEG bytes"""
|
|
with self.lock:
|
|
try:
|
|
if CAMERA_LIB == "picamera2":
|
|
if not self._initialized:
|
|
self._init_picamera2()
|
|
|
|
# Capture to bytes
|
|
stream = io.BytesIO()
|
|
self.camera.capture_file(stream, format='jpeg')
|
|
return stream.getvalue()
|
|
|
|
elif CAMERA_LIB == "picamera":
|
|
if not self._initialized:
|
|
self._init_picamera()
|
|
|
|
stream = io.BytesIO()
|
|
self.camera.capture(stream, format='jpeg', quality=JPEG_QUALITY)
|
|
stream.seek(0)
|
|
return stream.read()
|
|
|
|
else:
|
|
# Fallback to libcamera-still command
|
|
return self._capture_libcamera()
|
|
|
|
except Exception as e:
|
|
print(f"Error capturing: {e}")
|
|
return None
|
|
|
|
def _capture_libcamera(self) -> Optional[bytes]:
|
|
"""Fallback: use rpicam-still/libcamera-still command"""
|
|
# Try rpicam-still first (Debian Trixie / newer Pi OS)
|
|
for cmd_name in ["rpicam-still", "libcamera-still"]:
|
|
try:
|
|
cmd = [
|
|
cmd_name,
|
|
"-n", # No preview
|
|
"-o", "-", # Output to stdout
|
|
"--width", str(self.width),
|
|
"--height", str(self.height),
|
|
"-q", str(JPEG_QUALITY),
|
|
"-t", "1", # 1ms timeout (immediate capture)
|
|
]
|
|
|
|
if self.rotation:
|
|
cmd.extend(["--rotation", str(self.rotation)])
|
|
|
|
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
|
if result.returncode == 0:
|
|
return result.stdout
|
|
else:
|
|
print(f"{cmd_name} error: {result.stderr.decode()}")
|
|
continue
|
|
except subprocess.TimeoutExpired:
|
|
print(f"{cmd_name} timed out")
|
|
continue
|
|
except FileNotFoundError:
|
|
continue
|
|
|
|
# Fall back to raspistill (older Pi OS)
|
|
return self._capture_raspistill()
|
|
|
|
def _capture_raspistill(self) -> Optional[bytes]:
|
|
"""Legacy fallback: use raspistill command"""
|
|
try:
|
|
cmd = [
|
|
"raspistill",
|
|
"-n", # No preview
|
|
"-o", "-", # Output to stdout
|
|
"-w", str(self.width),
|
|
"-h", str(self.height),
|
|
"-q", str(JPEG_QUALITY),
|
|
"-t", "1",
|
|
]
|
|
|
|
if self.rotation:
|
|
cmd.extend(["-rot", str(self.rotation)])
|
|
|
|
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
|
if result.returncode == 0:
|
|
return result.stdout
|
|
return None
|
|
except Exception as e:
|
|
print(f"raspistill error: {e}")
|
|
return None
|
|
|
|
def release(self):
|
|
"""Release camera resources"""
|
|
if self.camera:
|
|
try:
|
|
if CAMERA_LIB == "picamera2":
|
|
self.camera.stop()
|
|
elif CAMERA_LIB == "picamera":
|
|
self.camera.close()
|
|
except:
|
|
pass
|
|
self.camera = None
|
|
self._initialized = False
|
|
|
|
|
|
# Initialize camera
|
|
camera_manager = CSICameraManager(CAMERA_WIDTH, CAMERA_HEIGHT, ROTATION)
|
|
|
|
|
|
def verify_api_key(api_key: str = Security(api_key_header)) -> str:
|
|
if api_key is None or api_key != API_KEY:
|
|
raise HTTPException(status_code=403, detail="Invalid API key")
|
|
return api_key
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
def shutdown_event():
|
|
camera_manager.release()
|
|
|
|
|
|
@app.get("/")
|
|
def root():
|
|
return {
|
|
"service": "vixy-vision CSI Camera Server",
|
|
"version": "1.0.0",
|
|
"camera_id": CAMERA_ID,
|
|
"camera_lib": CAMERA_LIB,
|
|
"ir_mode": IR_MODE,
|
|
}
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok", "camera_id": CAMERA_ID, "library": CAMERA_LIB}
|
|
|
|
|
|
@app.get("/snapshot")
|
|
def get_snapshot(api_key: str = Security(verify_api_key)):
|
|
"""Get JPEG snapshot"""
|
|
snapshot = camera_manager.get_snapshot()
|
|
if snapshot is None:
|
|
raise HTTPException(status_code=503, detail="Camera unavailable")
|
|
|
|
return Response(
|
|
content=snapshot,
|
|
media_type="image/jpeg",
|
|
headers={"Cache-Control": "no-cache", "X-Camera-ID": CAMERA_ID}
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(
|
|
"main:app",
|
|
host="0.0.0.0",
|
|
port=8443,
|
|
ssl_keyfile="ssl/key.pem",
|
|
ssl_certfile="ssl/cert.pem"
|
|
)
|