🔍 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 🦊
219 lines
7.2 KiB
Python
219 lines
7.2 KiB
Python
#!/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,
|
|
}
|
|
}
|