Files
vixy-vision/server/main.py
Alex e1171e8ff8 Add TFLite object detection to reduce false positives
Motion detection now optionally runs MobileNet V2 SSD (COCO, quantized)
on frames that trigger motion, identifying objects like people, cats, and
cars. Events without detected objects are suppressed by default. Snapshots
include bounding box annotations. New MCP tool vision_get_detections()
enables label-based queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:04:10 -06:00

268 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""
vixy-vision Camera Server
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
from typing import Optional
from dotenv import load_dotenv
from fastapi import FastAPI, Security, HTTPException, Response
from fastapi.security import APIKeyHeader
from motion import MotionDetector
# Load environment variables
load_dotenv()
# Configuration
API_KEY = os.getenv("API_KEY")
CAMERA_INDEX = int(os.getenv("CAMERA_INDEX", "0"))
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")
# Object detection config
DETECTION_ENABLED = os.getenv("DETECTION_ENABLED", "false").lower() == "true"
DETECTION_MODEL_PATH = os.getenv("DETECTION_MODEL_PATH", "models/ssd_mobilenet_v2_coco_quant_postprocess.tflite")
DETECTION_LABELS_PATH = os.getenv("DETECTION_LABELS_PATH", "models/coco_labels.txt")
DETECTION_CONFIDENCE = float(os.getenv("DETECTION_CONFIDENCE", "0.5"))
DETECTION_SUPPRESS_EMPTY = os.getenv("DETECTION_SUPPRESS_EMPTY", "true").lower() == "true"
if not API_KEY:
raise ValueError("API_KEY not set in .env file")
# FastAPI app
app = FastAPI(
title="vixy-vision Camera Server",
description="Camera snapshots + motion detection for the fox 🦊",
version="2.0.0"
)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
class CameraManager:
"""Thread-safe camera manager with auto-reconnect"""
def __init__(self, camera_index: int = 0, width: int = 1920, height: int = 1080):
self.camera_index = camera_index
self.width = width
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"""
try:
self.camera = cv2.VideoCapture(self.camera_index)
if not self.camera.isOpened():
return False
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)
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: {actual_width}x{actual_height}")
return True
except Exception as e:
print(f"Error opening camera: {e}")
return False
def get_raw_frame(self):
"""Get raw frame as numpy array (for motion detection)"""
with self.lock:
if self.camera is None or not self.camera.isOpened():
if not self._open_camera():
return None
for _ in range(3):
self.camera.grab()
ret, frame = self.camera.read()
if ret:
self._last_frame = frame
return frame
return None
def get_snapshot(self) -> Optional[bytes]:
"""Capture snapshot as JPEG bytes"""
frame = self.get_raw_frame()
if frame is None:
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"""
if self.camera is not None:
self.camera.release()
self.camera = None
def __del__(self):
self.release()
# 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,
detection_enabled=DETECTION_ENABLED,
detection_model_path=DETECTION_MODEL_PATH,
detection_labels_path=DETECTION_LABELS_PATH,
detection_confidence=DETECTION_CONFIDENCE,
detection_suppress_empty=DETECTION_SUPPRESS_EMPTY,
)
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("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,
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,
detection_enabled=DETECTION_ENABLED,
detection_model_path=DETECTION_MODEL_PATH,
detection_labels_path=DETECTION_LABELS_PATH,
detection_confidence=DETECTION_CONFIDENCE,
detection_suppress_empty=DETECTION_SUPPRESS_EMPTY,
)
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
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8443,
ssl_keyfile="ssl/key.pem",
ssl_certfile="ssl/cert.pem"
)