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:
218
server/motion.py
Normal file
218
server/motion.py
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Motion Detection Module
|
||||
|
||||
Simple frame-differencing motion detection with event reporting.
|
||||
Runs as background thread, POSTs events to collector on Mac mini.
|
||||
"""
|
||||
|
||||
import os
|
||||
import cv2
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
import httpx
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotionEvent:
|
||||
"""Motion detection event"""
|
||||
timestamp: str
|
||||
camera_id: str
|
||||
event_type: str = "motion"
|
||||
confidence: float = 0.0
|
||||
region: str = "full" # Could be "left", "right", "center" etc.
|
||||
area_percent: float = 0.0 # % of frame with motion
|
||||
|
||||
|
||||
class MotionDetector:
|
||||
"""
|
||||
Background motion detection with event reporting.
|
||||
|
||||
Uses frame differencing to detect motion and reports
|
||||
events to a collector endpoint.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
camera_id: str,
|
||||
collector_url: Optional[str] = None,
|
||||
collector_api_key: Optional[str] = None,
|
||||
threshold: int = 25, # Pixel difference threshold
|
||||
min_area_percent: float = 0.5, # Minimum % of frame to trigger
|
||||
cooldown_seconds: float = 5.0, # Seconds between events
|
||||
check_interval: float = 0.5, # Seconds between frame checks
|
||||
):
|
||||
self.camera_id = camera_id
|
||||
self.collector_url = collector_url
|
||||
self.collector_api_key = collector_api_key
|
||||
self.threshold = threshold
|
||||
self.min_area_percent = min_area_percent
|
||||
self.cooldown_seconds = cooldown_seconds
|
||||
self.check_interval = check_interval
|
||||
|
||||
self._previous_frame: Optional[any] = None
|
||||
self._last_event_time: float = 0
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._get_frame: Optional[Callable] = None
|
||||
|
||||
# Stats
|
||||
self.events_detected = 0
|
||||
self.events_reported = 0
|
||||
self.last_event: Optional[MotionEvent] = None
|
||||
|
||||
def start(self, get_frame_func: Callable):
|
||||
"""
|
||||
Start motion detection in background thread.
|
||||
|
||||
Args:
|
||||
get_frame_func: Function that returns current frame as numpy array
|
||||
"""
|
||||
if self._running:
|
||||
logger.warning("Motion detector already running")
|
||||
return
|
||||
|
||||
self._get_frame = get_frame_func
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._detection_loop, daemon=True)
|
||||
self._thread.start()
|
||||
logger.info(f"Motion detection started (threshold={self.threshold}, cooldown={self.cooldown_seconds}s)")
|
||||
|
||||
def stop(self):
|
||||
"""Stop motion detection"""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
logger.info("Motion detection stopped")
|
||||
|
||||
def _detection_loop(self):
|
||||
"""Main detection loop - runs in background thread"""
|
||||
while self._running:
|
||||
try:
|
||||
self._check_for_motion()
|
||||
except Exception as e:
|
||||
logger.error(f"Motion detection error: {e}")
|
||||
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def _check_for_motion(self):
|
||||
"""Check current frame for motion"""
|
||||
if not self._get_frame:
|
||||
return
|
||||
|
||||
# Get current frame
|
||||
frame = self._get_frame()
|
||||
if frame is None:
|
||||
return
|
||||
|
||||
# Convert to grayscale for comparison
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
gray = cv2.GaussianBlur(gray, (21, 21), 0)
|
||||
|
||||
# Need previous frame to compare
|
||||
if self._previous_frame is None:
|
||||
self._previous_frame = gray
|
||||
return
|
||||
|
||||
# Compute difference
|
||||
frame_delta = cv2.absdiff(self._previous_frame, gray)
|
||||
thresh = cv2.threshold(frame_delta, self.threshold, 255, cv2.THRESH_BINARY)[1]
|
||||
|
||||
# Dilate to fill gaps
|
||||
thresh = cv2.dilate(thresh, None, iterations=2)
|
||||
|
||||
# Calculate motion area percentage
|
||||
motion_pixels = cv2.countNonZero(thresh)
|
||||
total_pixels = thresh.shape[0] * thresh.shape[1]
|
||||
area_percent = (motion_pixels / total_pixels) * 100
|
||||
|
||||
# Update previous frame
|
||||
self._previous_frame = gray
|
||||
|
||||
# Check if motion exceeds threshold
|
||||
if area_percent >= self.min_area_percent:
|
||||
self._handle_motion(frame, area_percent)
|
||||
|
||||
def _handle_motion(self, frame, area_percent: float):
|
||||
"""Handle detected motion"""
|
||||
now = time.time()
|
||||
|
||||
# Check cooldown
|
||||
if now - self._last_event_time < self.cooldown_seconds:
|
||||
return
|
||||
|
||||
self._last_event_time = now
|
||||
self.events_detected += 1
|
||||
|
||||
# Create event
|
||||
event = MotionEvent(
|
||||
timestamp=datetime.utcnow().isoformat() + "Z",
|
||||
camera_id=self.camera_id,
|
||||
confidence=min(area_percent / 10.0, 1.0), # Normalize to 0-1
|
||||
area_percent=round(area_percent, 2),
|
||||
)
|
||||
self.last_event = event
|
||||
|
||||
logger.info(f"Motion detected: {area_percent:.1f}% of frame (confidence: {event.confidence:.2f})")
|
||||
|
||||
# Report to collector
|
||||
if self.collector_url:
|
||||
self._report_event(event, frame)
|
||||
|
||||
def _report_event(self, event: MotionEvent, frame):
|
||||
"""POST event to collector endpoint"""
|
||||
try:
|
||||
# Encode frame as JPEG
|
||||
_, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
snapshot_b64 = base64.b64encode(buffer.tobytes()).decode('utf-8')
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"event": asdict(event),
|
||||
"snapshot": snapshot_b64,
|
||||
}
|
||||
|
||||
# POST to collector
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.collector_api_key:
|
||||
headers["X-API-Key"] = self.collector_api_key
|
||||
|
||||
# Use sync client (we're in a thread)
|
||||
with httpx.Client(timeout=5.0, verify=False) as client:
|
||||
response = client.post(
|
||||
self.collector_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
self.events_reported += 1
|
||||
logger.info(f"Event reported to collector ({self.events_reported} total)")
|
||||
else:
|
||||
logger.warning(f"Collector returned {response.status_code}: {response.text[:100]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to report event: {e}")
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Get detection statistics"""
|
||||
return {
|
||||
"running": self._running,
|
||||
"events_detected": self.events_detected,
|
||||
"events_reported": self.events_reported,
|
||||
"last_event": asdict(self.last_event) if self.last_event else None,
|
||||
"config": {
|
||||
"threshold": self.threshold,
|
||||
"min_area_percent": self.min_area_percent,
|
||||
"cooldown_seconds": self.cooldown_seconds,
|
||||
"collector_url": self.collector_url,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user