Add release-after-use and cycling motion detection modes

- main_release.py (v3.1.0): Release camera after each snapshot for V4L2 compatibility
- main_cycling.py (v3.2.0): Single motion thread cycles between cameras (1s interval)
- mcp/vision_mcp.py: Support custom snapshot_path for multi-camera servers

Fixes Pi 3 dual-camera V4L2 conflicts by not holding cameras open.
This commit is contained in:
Alex Kazaiev
2025-12-29 16:18:45 -06:00
parent 37a2f2dcd6
commit 844502b4a1
3 changed files with 401 additions and 1 deletions

259
server/main_cycling.py Normal file
View File

@@ -0,0 +1,259 @@
#!/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")