#!/usr/bin/env python3 """ vixy-vision Camera Server v3.2 - Multi-Camera with Cycling Motion Detection For Pi with V4L2 limitations: - Releases camera after each snapshot - Single motion detection thread cycles between cameras """ import os import cv2 import json import time import threading import requests from datetime import datetime from typing import Optional, Dict from dotenv import load_dotenv from fastapi import FastAPI, Security, HTTPException, Response from fastapi.security import APIKeyHeader load_dotenv() # Configuration API_KEY = os.getenv("API_KEY") JPEG_QUALITY = int(os.getenv("JPEG_QUALITY", "85")) CAMERAS_CONFIG = os.getenv("CAMERAS", '{"camera": 0}') DEFAULT_WIDTH = int(os.getenv("CAMERA_WIDTH", "1920")) DEFAULT_HEIGHT = int(os.getenv("CAMERA_HEIGHT", "1080")) 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", "60.0")) COLLECTOR_URL = os.getenv("COLLECTOR_URL", "") CYCLE_INTERVAL = float(os.getenv("CYCLE_INTERVAL", "1.0")) # seconds between camera switches if not API_KEY: raise ValueError("API_KEY not set") CAMERAS: Dict[str, int] = json.loads(CAMERAS_CONFIG) app = FastAPI( title="vixy-vision Camera Server", description="Multi-camera with cycling motion detection 🦊", version="3.2.0" ) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) camera_lock = threading.Lock() # Motion detection state per camera motion_state: Dict[str, dict] = {} for cam_id in CAMERAS: motion_state[cam_id] = { "prev_frame": None, "last_event": 0, } def capture_frame(device_index: int, width: int, height: int) -> Optional[any]: """Capture a single frame, release camera immediately.""" cap = None try: cap = cv2.VideoCapture(device_index) if not cap.isOpened(): return None cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) for _ in range(3): cap.grab() ret, frame = cap.read() return frame if ret else None except Exception as e: print(f"Error capturing from /dev/video{device_index}: {e}") return None finally: if cap is not None: cap.release() def capture_snapshot(device_index: int, width: int, height: int) -> Optional[bytes]: """Capture snapshot as JPEG bytes.""" with camera_lock: frame = capture_frame(device_index, width, height) if frame is None: return None ret, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]) return buffer.tobytes() if ret else None def detect_motion(cam_id: str, frame) -> Optional[dict]: """Check for motion against previous frame.""" state = motion_state[cam_id] # Convert to grayscale and blur gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (21, 21), 0) if state["prev_frame"] is None: state["prev_frame"] = gray return None # Calculate difference delta = cv2.absdiff(state["prev_frame"], gray) state["prev_frame"] = gray thresh = cv2.threshold(delta, MOTION_THRESHOLD, 255, cv2.THRESH_BINARY)[1] thresh = cv2.dilate(thresh, None, iterations=2) contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None # Find largest contour largest = max(contours, key=cv2.contourArea) area = cv2.contourArea(largest) frame_area = frame.shape[0] * frame.shape[1] area_percent = (area / frame_area) * 100 if area_percent < MOTION_MIN_AREA: return None # Check cooldown now = time.time() if now - state["last_event"] < MOTION_COOLDOWN: return None state["last_event"] = now return { "camera_id": cam_id, "area_percent": round(area_percent, 2), "timestamp": datetime.now().isoformat(), } def send_event(event: dict, snapshot: bytes): """Send motion event to collector.""" if not COLLECTOR_URL: print(f"🔔 Motion on {event['camera_id']}: {event['area_percent']}% (no collector)") return try: files = {"snapshot": ("snapshot.jpg", snapshot, "image/jpeg")} data = { "camera_id": event["camera_id"], "event_type": "motion", "confidence": min(event["area_percent"] * 10, 100), "area_percent": event["area_percent"], } response = requests.post(COLLECTOR_URL, data=data, files=files, timeout=5) if response.ok: print(f"🔔 Motion event sent: {event['camera_id']}") else: print(f"⚠️ Collector error: {response.status_code}") except Exception as e: print(f"⚠️ Failed to send event: {e}") def motion_detection_loop(): """Single thread that cycles through all cameras for motion detection.""" print(f"🎯 Motion detection started (cycling {len(CAMERAS)} cameras, {CYCLE_INTERVAL}s interval)") camera_list = list(CAMERAS.items()) while True: for cam_id, device_index in camera_list: try: with camera_lock: frame = capture_frame(device_index, DEFAULT_WIDTH, DEFAULT_HEIGHT) if frame is None: continue event = detect_motion(cam_id, frame) if event: # Capture fresh snapshot for the event with camera_lock: snap_frame = capture_frame(device_index, DEFAULT_WIDTH, DEFAULT_HEIGHT) if snap_frame is not None: ret, buffer = cv2.imencode('.jpg', snap_frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]) if ret: send_event(event, buffer.tobytes()) except Exception as e: print(f"Motion error on {cam_id}: {e}") # Wait before checking next camera time.sleep(CYCLE_INTERVAL) def verify_api_key(api_key: str = Security(api_key_header)) -> str: if api_key != API_KEY: raise HTTPException(status_code=403, detail="Invalid API key") return api_key # Startup for cam_id, dev_idx in CAMERAS.items(): print(f"🦊 Registered: {cam_id} -> /dev/video{dev_idx}") if MOTION_ENABLED: motion_thread = threading.Thread(target=motion_detection_loop, daemon=True) motion_thread.start() else: print("📷 Motion detection disabled") DEFAULT_CAM = list(CAMERAS.keys())[0] if CAMERAS else None @app.get("/") def root(): return { "service": "vixy-vision Camera Server", "version": "3.2.0", "mode": "cycling-motion", "cameras": list(CAMERAS.keys()), "motion_enabled": MOTION_ENABLED, "cycle_interval": CYCLE_INTERVAL, } @app.get("/health") def health(): return {"status": "ok", "cameras": list(CAMERAS.keys()), "motion": MOTION_ENABLED} @app.get("/snapshot") def snapshot_default(api_key: str = Security(verify_api_key)): if not DEFAULT_CAM: raise HTTPException(status_code=503, detail="No cameras configured") return snapshot_by_id(DEFAULT_CAM, api_key) @app.get("/snapshot/{cam_id}") def snapshot_by_id(cam_id: str, api_key: str = Security(verify_api_key)): if cam_id not in CAMERAS: raise HTTPException(status_code=404, detail=f"Camera '{cam_id}' not found") device_index = CAMERAS[cam_id] snapshot = capture_snapshot(device_index, DEFAULT_WIDTH, DEFAULT_HEIGHT) if snapshot is None: raise HTTPException(status_code=503, detail=f"Camera '{cam_id}' unavailable") return Response( content=snapshot, media_type="image/jpeg", headers={"Cache-Control": "no-cache", "X-Camera-ID": cam_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")