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:
Alex
2026-02-08 17:04:10 -06:00
parent 68c7e9772f
commit e1171e8ff8
11 changed files with 687 additions and 50 deletions

View File

@@ -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,