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

@@ -518,7 +518,7 @@ def vision_get_events(
events = []
for row in rows:
events.append({
event_dict = {
"id": row["id"],
"event_id": row["event_id"],
"timestamp": row["timestamp"],
@@ -529,7 +529,14 @@ def vision_get_events(
"snapshot_path": row["snapshot_path"],
"annotation": row["annotation"],
"tags": row["tags"],
})
}
# Include detections if present
try:
det_raw = row["detections"]
event_dict["detections"] = json.loads(det_raw) if det_raw else None
except (KeyError, json.JSONDecodeError, TypeError):
event_dict["detections"] = None
events.append(event_dict)
logger.info(f"Retrieved {len(events)} events")
return events
@@ -541,6 +548,99 @@ def vision_get_events(
conn.close()
@mcp.tool()
def vision_get_detections(
label: str = None,
camera_id: str = None,
since: str = None,
min_confidence: float = 0.0,
limit: int = 20
) -> List[Dict[str, Any]]:
"""
Query events that contain specific object detections.
Filters events to only those where the AI detected objects
(person, cat, dog, car, etc.). More targeted than raw motion events.
Args:
label: Filter by detected object type (e.g., "person", "cat", "dog")
camera_id: Filter by camera
since: ISO timestamp - only events after this time
min_confidence: Minimum detection confidence (0.0-1.0)
limit: Maximum events to return (default 20)
Returns:
List of events with their detections
Examples:
vision_get_detections(label="cat")
vision_get_detections(label="person", camera_id="basement")
vision_get_detections(min_confidence=0.8)
"""
conn = get_events_db()
if not conn:
return [{"error": f"Events database not found: {EVENTS_DB}"}]
try:
query = "SELECT * FROM events WHERE detections IS NOT NULL"
params = []
if since:
query += " AND timestamp >= ?"
params.append(since)
if camera_id:
query += " AND camera_id = ?"
params.append(camera_id)
# Fetch more than limit to allow for client-side filtering
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit * 5)
rows = conn.execute(query, params).fetchall()
events = []
for row in rows:
try:
dets = json.loads(row["detections"])
except (json.JSONDecodeError, TypeError):
continue
# Filter by label and confidence
if label or min_confidence > 0:
matching = [
d for d in dets
if (not label or d.get("label") == label)
and d.get("confidence", 0) >= min_confidence
]
if not matching:
continue
else:
matching = dets
events.append({
"event_id": row["event_id"],
"timestamp": row["timestamp"],
"camera_id": row["camera_id"],
"confidence": row["confidence"],
"annotation": row["annotation"],
"tags": row["tags"],
"detections": matching,
})
if len(events) >= limit:
break
logger.info(f"Retrieved {len(events)} detection events")
return events
except Exception as e:
logger.error(f"Error querying detections: {e}")
return [{"error": str(e)}]
finally:
conn.close()
@mcp.tool()
def vision_get_event_snapshot(event_id: str) -> Union[MCPImage, str]:
"""
@@ -702,9 +802,36 @@ def vision_event_stats() -> Dict[str, Any]:
WHERE timestamp >= datetime('now', '-1 day')
""").fetchone()[0]
# Detection stats
try:
with_detections = conn.execute(
"SELECT COUNT(*) FROM events WHERE detections IS NOT NULL"
).fetchone()[0]
stats["with_detections"] = with_detections
if with_detections > 0:
det_rows = conn.execute(
"SELECT detections FROM events WHERE detections IS NOT NULL"
).fetchall()
label_counts = {}
for det_row in det_rows:
try:
dets = json.loads(det_row[0])
for d in dets:
lbl = d.get("label", "unknown")
label_counts[lbl] = label_counts.get(lbl, 0) + 1
except (json.JSONDecodeError, TypeError):
pass
if label_counts:
stats["detected_objects"] = dict(
sorted(label_counts.items(), key=lambda x: -x[1])
)
except Exception:
pass # Column may not exist on older databases
# Most recent event
row = conn.execute("""
SELECT event_id, timestamp, camera_id
SELECT event_id, timestamp, camera_id
FROM events ORDER BY timestamp DESC LIMIT 1
""").fetchone()
if row: