Add motion detection to camera server
🔍 New features: - motion.py: Frame differencing motion detector - Background thread compares frames continuously - Configurable threshold, cooldown, sensitivity - POSTs events + snapshot to collector 📡 New endpoints: - GET /motion/stats - Detection statistics - POST /motion/enable - Start detection - POST /motion/disable - Stop detection ⚙️ Configuration (in .env): - MOTION_ENABLED: true/false - MOTION_THRESHOLD: Pixel diff threshold - MOTION_COOLDOWN: Seconds between events - COLLECTOR_URL: Where to POST events Next: Event collector in vixy-mcp 🦊
This commit is contained in:
250
server/main.py
250
server/main.py
@@ -1,24 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Camera Snapshot Server
|
||||
vixy-vision Camera Server
|
||||
|
||||
Simple FastAPI server that serves snapshots from a USB camera.
|
||||
FastAPI server that serves snapshots from a USB camera with motion detection.
|
||||
Features:
|
||||
- API key authentication
|
||||
- HTTPS support
|
||||
- Thread-safe camera access
|
||||
- Auto-reconnect on camera failure
|
||||
- Motion detection with event reporting
|
||||
"""
|
||||
|
||||
import os
|
||||
import cv2
|
||||
import threading
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Security, HTTPException, Response
|
||||
from fastapi.security import APIKeyHeader
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from motion import MotionDetector
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
@@ -30,17 +31,26 @@ CAMERA_WIDTH = int(os.getenv("CAMERA_WIDTH", "1920"))
|
||||
CAMERA_HEIGHT = int(os.getenv("CAMERA_HEIGHT", "1080"))
|
||||
JPEG_QUALITY = int(os.getenv("JPEG_QUALITY", "85"))
|
||||
|
||||
# Motion detection config
|
||||
MOTION_ENABLED = os.getenv("MOTION_ENABLED", "false").lower() == "true"
|
||||
MOTION_THRESHOLD = int(os.getenv("MOTION_THRESHOLD", "25"))
|
||||
MOTION_MIN_AREA = float(os.getenv("MOTION_MIN_AREA", "0.5"))
|
||||
MOTION_COOLDOWN = float(os.getenv("MOTION_COOLDOWN", "5.0"))
|
||||
MOTION_INTERVAL = float(os.getenv("MOTION_INTERVAL", "0.5"))
|
||||
COLLECTOR_URL = os.getenv("COLLECTOR_URL", "")
|
||||
COLLECTOR_API_KEY = os.getenv("COLLECTOR_API_KEY", "")
|
||||
CAMERA_ID = os.getenv("CAMERA_ID", "camera")
|
||||
|
||||
if not API_KEY:
|
||||
raise ValueError("API_KEY not set in .env file. Generate one with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'")
|
||||
raise ValueError("API_KEY not set in .env file")
|
||||
|
||||
# FastAPI app
|
||||
app = FastAPI(
|
||||
title="Camera Snapshot Server",
|
||||
description="Serves snapshots from USB camera with API key authentication",
|
||||
version="1.0.0"
|
||||
title="vixy-vision Camera Server",
|
||||
description="Camera snapshots + motion detection for the fox 🦊",
|
||||
version="2.0.0"
|
||||
)
|
||||
|
||||
# API Key authentication
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
@@ -53,6 +63,7 @@ class CameraManager:
|
||||
self.height = height
|
||||
self.camera: Optional[cv2.VideoCapture] = None
|
||||
self.lock = threading.Lock()
|
||||
self._last_frame = None
|
||||
|
||||
def _open_camera(self) -> bool:
|
||||
"""Open camera connection"""
|
||||
@@ -61,156 +72,173 @@ class CameraManager:
|
||||
if not self.camera.isOpened():
|
||||
return False
|
||||
|
||||
# Set camera resolution
|
||||
self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
|
||||
self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
|
||||
self.camera.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
|
||||
# Set camera properties for better performance
|
||||
self.camera.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Reduce buffer to get latest frame
|
||||
|
||||
# Log actual resolution (camera may not support requested resolution)
|
||||
actual_width = int(self.camera.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
actual_height = int(self.camera.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
print(f"Camera resolution: {actual_width}x{actual_height} (requested: {self.width}x{self.height})")
|
||||
|
||||
print(f"Camera: {actual_width}x{actual_height}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error opening camera: {e}")
|
||||
return False
|
||||
|
||||
def get_snapshot(self) -> Optional[bytes]:
|
||||
"""
|
||||
Capture a snapshot from the camera.
|
||||
|
||||
Returns:
|
||||
JPEG-encoded image bytes, or None if failed
|
||||
"""
|
||||
def get_raw_frame(self):
|
||||
"""Get raw frame as numpy array (for motion detection)"""
|
||||
with self.lock:
|
||||
# Open camera if not initialized or closed
|
||||
if self.camera is None or not self.camera.isOpened():
|
||||
if not self._open_camera():
|
||||
return None
|
||||
|
||||
# Flush buffer to get latest frame
|
||||
# Read and discard several frames to clear old buffered frames
|
||||
for _ in range(5):
|
||||
for _ in range(3):
|
||||
self.camera.grab()
|
||||
|
||||
# Capture the latest frame
|
||||
ret, frame = self.camera.read()
|
||||
if ret:
|
||||
self._last_frame = frame
|
||||
return frame
|
||||
return None
|
||||
|
||||
# Retry on failure
|
||||
if not ret:
|
||||
print("Failed to capture frame, attempting reconnect...")
|
||||
self.release()
|
||||
if not self._open_camera():
|
||||
return None
|
||||
# Flush buffer again after reconnect
|
||||
for _ in range(5):
|
||||
self.camera.grab()
|
||||
ret, frame = self.camera.read()
|
||||
def get_snapshot(self) -> Optional[bytes]:
|
||||
"""Capture snapshot as JPEG bytes"""
|
||||
frame = self.get_raw_frame()
|
||||
if frame is None:
|
||||
return None
|
||||
|
||||
if not ret:
|
||||
return None
|
||||
|
||||
# Encode as JPEG
|
||||
try:
|
||||
ret, buffer = cv2.imencode(
|
||||
'.jpg',
|
||||
frame,
|
||||
[cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]
|
||||
)
|
||||
if not ret:
|
||||
return None
|
||||
|
||||
return buffer.tobytes()
|
||||
except Exception as e:
|
||||
print(f"Error encoding image: {e}")
|
||||
return None
|
||||
try:
|
||||
ret, buffer = cv2.imencode(
|
||||
'.jpg', frame,
|
||||
[cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]
|
||||
)
|
||||
return buffer.tobytes() if ret else None
|
||||
except Exception as e:
|
||||
print(f"Error encoding: {e}")
|
||||
return None
|
||||
|
||||
def release(self):
|
||||
"""Release camera resources"""
|
||||
"""Release camera"""
|
||||
if self.camera is not None:
|
||||
self.camera.release()
|
||||
self.camera = None
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on deletion"""
|
||||
self.release()
|
||||
|
||||
|
||||
# Global camera manager
|
||||
# Initialize camera
|
||||
camera_manager = CameraManager(CAMERA_INDEX, CAMERA_WIDTH, CAMERA_HEIGHT)
|
||||
|
||||
# Initialize motion detector (if enabled)
|
||||
motion_detector: Optional[MotionDetector] = None
|
||||
|
||||
if MOTION_ENABLED:
|
||||
motion_detector = MotionDetector(
|
||||
camera_id=CAMERA_ID,
|
||||
collector_url=COLLECTOR_URL if COLLECTOR_URL else None,
|
||||
collector_api_key=COLLECTOR_API_KEY if COLLECTOR_API_KEY else None,
|
||||
threshold=MOTION_THRESHOLD,
|
||||
min_area_percent=MOTION_MIN_AREA,
|
||||
cooldown_seconds=MOTION_COOLDOWN,
|
||||
check_interval=MOTION_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
def verify_api_key(api_key: str = Security(api_key_header)) -> str:
|
||||
"""Verify API key from header"""
|
||||
if api_key is None or api_key != API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid or missing API key"
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||
return api_key
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
"""Root endpoint with API info"""
|
||||
return {
|
||||
"service": "Camera Snapshot Server",
|
||||
"version": "1.0.0",
|
||||
"endpoints": {
|
||||
"/snapshot": "GET - Returns JPEG snapshot (requires X-API-Key header)",
|
||||
"/health": "GET - Health check (no auth required)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/snapshot")
|
||||
def get_snapshot(api_key: str = Security(verify_api_key)):
|
||||
"""
|
||||
Get a snapshot from the USB camera.
|
||||
|
||||
Requires X-API-Key header for authentication.
|
||||
|
||||
Returns:
|
||||
JPEG image
|
||||
"""
|
||||
snapshot = camera_manager.get_snapshot()
|
||||
|
||||
if snapshot is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Failed to capture snapshot. Check camera connection."
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=snapshot,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0"
|
||||
}
|
||||
)
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
"""Start motion detection on server startup"""
|
||||
if motion_detector:
|
||||
motion_detector.start(camera_manager.get_raw_frame)
|
||||
print(f"🦊 Motion detection enabled (camera: {CAMERA_ID})")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown_event():
|
||||
"""Cleanup on shutdown"""
|
||||
if motion_detector:
|
||||
motion_detector.stop()
|
||||
camera_manager.release()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"service": "vixy-vision Camera Server",
|
||||
"version": "2.0.0",
|
||||
"camera_id": CAMERA_ID,
|
||||
"motion_enabled": MOTION_ENABLED,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "camera_id": CAMERA_ID}
|
||||
|
||||
|
||||
@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"}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/motion/stats")
|
||||
def get_motion_stats(api_key: str = Security(verify_api_key)):
|
||||
"""Get motion detection statistics"""
|
||||
if not motion_detector:
|
||||
return {"enabled": False, "message": "Motion detection not enabled"}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
**motion_detector.get_stats()
|
||||
}
|
||||
|
||||
|
||||
@app.post("/motion/enable")
|
||||
def enable_motion(api_key: str = Security(verify_api_key)):
|
||||
"""Enable motion detection"""
|
||||
global motion_detector
|
||||
|
||||
if motion_detector and motion_detector._running:
|
||||
return {"status": "already running"}
|
||||
|
||||
if not motion_detector:
|
||||
motion_detector = MotionDetector(
|
||||
camera_id=CAMERA_ID,
|
||||
collector_url=COLLECTOR_URL if COLLECTOR_URL else None,
|
||||
threshold=MOTION_THRESHOLD,
|
||||
min_area_percent=MOTION_MIN_AREA,
|
||||
cooldown_seconds=MOTION_COOLDOWN,
|
||||
)
|
||||
|
||||
motion_detector.start(camera_manager.get_raw_frame)
|
||||
return {"status": "started"}
|
||||
|
||||
|
||||
@app.post("/motion/disable")
|
||||
def disable_motion(api_key: str = Security(verify_api_key)):
|
||||
"""Disable motion detection"""
|
||||
if motion_detector:
|
||||
motion_detector.stop()
|
||||
return {"status": "stopped"}
|
||||
return {"status": "not running"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
# For development only - use uvicorn command for production
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
|
||||
Reference in New Issue
Block a user