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>
This commit is contained in:
160
server/motion.py
160
server/motion.py
@@ -2,8 +2,12 @@
|
||||
"""
|
||||
Motion Detection Module
|
||||
|
||||
Simple frame-differencing motion detection with event reporting.
|
||||
Frame-differencing motion detection with optional object detection.
|
||||
Runs as background thread, POSTs events to collector on Mac mini.
|
||||
|
||||
When object detection is enabled, motion acts as a pre-filter:
|
||||
motion triggers -> object detection confirms -> event reported.
|
||||
If no objects are found, the event can be suppressed (configurable).
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -15,7 +19,7 @@ import httpx
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable
|
||||
from dataclasses import dataclass, asdict
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,23 +27,25 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class MotionEvent:
|
||||
"""Motion detection event"""
|
||||
"""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
|
||||
detections: Optional[list] = None # List of detection dicts when objects found
|
||||
|
||||
|
||||
class MotionDetector:
|
||||
"""
|
||||
Background motion detection with event reporting.
|
||||
|
||||
Uses frame differencing to detect motion and reports
|
||||
events to a collector endpoint.
|
||||
Background motion detection with optional object detection.
|
||||
|
||||
Uses frame differencing to detect motion. When object detection is
|
||||
enabled, runs inference on motion frames to identify objects and
|
||||
suppress false positives.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
camera_id: str,
|
||||
@@ -49,6 +55,12 @@ class MotionDetector:
|
||||
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
|
||||
# Object detection
|
||||
detection_enabled: bool = False,
|
||||
detection_model_path: Optional[str] = None,
|
||||
detection_labels_path: Optional[str] = None,
|
||||
detection_confidence: float = 0.5,
|
||||
detection_suppress_empty: bool = True,
|
||||
):
|
||||
self.camera_id = camera_id
|
||||
self.collector_url = collector_url
|
||||
@@ -57,42 +69,58 @@ class MotionDetector:
|
||||
self.min_area_percent = min_area_percent
|
||||
self.cooldown_seconds = cooldown_seconds
|
||||
self.check_interval = check_interval
|
||||
|
||||
self.detection_suppress_empty = detection_suppress_empty
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Object detector (lazy import to avoid requiring tflite when disabled)
|
||||
self._detector = None
|
||||
if detection_enabled and detection_model_path:
|
||||
try:
|
||||
from detector import ObjectDetector
|
||||
self._detector = ObjectDetector(
|
||||
model_path=detection_model_path,
|
||||
labels_path=detection_labels_path or "",
|
||||
confidence_threshold=detection_confidence,
|
||||
)
|
||||
logger.info(f"Object detection enabled (model: {detection_model_path})")
|
||||
except ImportError as e:
|
||||
logger.error(f"Object detection unavailable: {e}")
|
||||
|
||||
# Stats
|
||||
self.events_detected = 0
|
||||
self.events_reported = 0
|
||||
self.events_suppressed = 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:
|
||||
@@ -100,91 +128,137 @@ class MotionDetector:
|
||||
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"""
|
||||
"""Handle detected motion, optionally running object detection"""
|
||||
now = time.time()
|
||||
|
||||
|
||||
# Check cooldown
|
||||
if now - self._last_event_time < self.cooldown_seconds:
|
||||
return
|
||||
|
||||
|
||||
self._last_event_time = now
|
||||
self.events_detected += 1
|
||||
|
||||
|
||||
# Run object detection if enabled
|
||||
detections_list = []
|
||||
detections_dicts = None
|
||||
snapshot_frame = frame
|
||||
|
||||
if self._detector:
|
||||
try:
|
||||
detections_list = self._detector.detect(frame)
|
||||
except Exception as e:
|
||||
logger.error(f"Object detection error: {e}")
|
||||
|
||||
if detections_list:
|
||||
detections_dicts = [{
|
||||
"label": d.label,
|
||||
"confidence": round(d.confidence, 3),
|
||||
"bbox": [round(x, 4) for x in d.bbox],
|
||||
} for d in detections_list]
|
||||
|
||||
# Draw bounding boxes on snapshot
|
||||
try:
|
||||
from detector import annotate_frame
|
||||
snapshot_frame = annotate_frame(frame, detections_list)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to annotate frame: {e}")
|
||||
|
||||
elif self.detection_suppress_empty:
|
||||
self.events_suppressed += 1
|
||||
logger.debug(
|
||||
f"Motion ({area_percent:.1f}%) but no objects detected - suppressed "
|
||||
f"({self.events_suppressed} total)"
|
||||
)
|
||||
return
|
||||
|
||||
# Create event
|
||||
if detections_list:
|
||||
top_confidence = max(d.confidence for d in detections_list)
|
||||
event_type = "object"
|
||||
else:
|
||||
top_confidence = min(area_percent / 10.0, 1.0)
|
||||
event_type = "motion"
|
||||
|
||||
event = MotionEvent(
|
||||
timestamp=datetime.utcnow().isoformat() + "Z",
|
||||
camera_id=self.camera_id,
|
||||
confidence=min(area_percent / 10.0, 1.0), # Normalize to 0-1
|
||||
event_type=event_type,
|
||||
confidence=round(top_confidence, 3),
|
||||
area_percent=round(area_percent, 2),
|
||||
detections=detections_dicts,
|
||||
)
|
||||
self.last_event = event
|
||||
|
||||
logger.info(f"Motion detected: {area_percent:.1f}% of frame (confidence: {event.confidence:.2f})")
|
||||
|
||||
|
||||
if detections_list:
|
||||
labels = ", ".join(f"{d.label}({d.confidence:.0%})" for d in detections_list)
|
||||
logger.info(f"Objects detected: {labels} (motion: {area_percent:.1f}%)")
|
||||
else:
|
||||
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)
|
||||
|
||||
self._report_event(event, snapshot_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(
|
||||
@@ -192,22 +266,24 @@ class MotionDetector:
|
||||
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,
|
||||
"events_suppressed": self.events_suppressed,
|
||||
"detection_enabled": self._detector is not None,
|
||||
"last_event": asdict(self.last_event) if self.last_event else None,
|
||||
"config": {
|
||||
"threshold": self.threshold,
|
||||
|
||||
Reference in New Issue
Block a user