feat: initial CamWatch service
Polls Frigate events API, extracts dominant vehicle color (KMeans), reads LPR plate from Frigate sub_label. Responsive dark UI with filter by plate/camera/date and auto-refresh. FastAPI + SQLite. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgl1 libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY app/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app/ .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import colorsys
|
||||||
|
|
||||||
|
|
||||||
|
def extract_dominant_color(image_path: str, bbox: dict | None = None) -> tuple[str, str]:
|
||||||
|
"""Returns (hex_color, color_name) from image, optionally cropped to bbox."""
|
||||||
|
img = cv2.imread(image_path)
|
||||||
|
if img is None:
|
||||||
|
return "#808080", "Inconnu"
|
||||||
|
|
||||||
|
if bbox:
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
x1 = max(0, int(bbox.get("x", 0) * w))
|
||||||
|
y1 = max(0, int(bbox.get("y", 0) * h))
|
||||||
|
x2 = min(w, int((bbox.get("x", 0) + bbox.get("width", 1)) * w))
|
||||||
|
y2 = min(h, int((bbox.get("y", 0) + bbox.get("height", 1)) * h))
|
||||||
|
if x2 > x1 and y2 > y1:
|
||||||
|
img = img[y1:y2, x1:x2]
|
||||||
|
|
||||||
|
small = cv2.resize(img, (60, 60), interpolation=cv2.INTER_AREA)
|
||||||
|
pixels = small.reshape(-1, 3).astype(np.float32)
|
||||||
|
|
||||||
|
k = min(4, len(pixels))
|
||||||
|
_, labels, centers = cv2.kmeans(
|
||||||
|
pixels, k, None,
|
||||||
|
(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 20, 1.0),
|
||||||
|
10, cv2.KMEANS_RANDOM_CENTERS
|
||||||
|
)
|
||||||
|
counts = np.bincount(labels.flatten())
|
||||||
|
dominant = centers[np.argmax(counts)]
|
||||||
|
b, g, r = int(dominant[0]), int(dominant[1]), int(dominant[2])
|
||||||
|
hex_color = f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
return hex_color, _name_color(r, g, b)
|
||||||
|
|
||||||
|
|
||||||
|
def _name_color(r: int, g: int, b: int) -> str:
|
||||||
|
h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
|
||||||
|
if v < 0.18:
|
||||||
|
return "Noir"
|
||||||
|
if v > 0.82 and s < 0.18:
|
||||||
|
return "Blanc"
|
||||||
|
if s < 0.18:
|
||||||
|
return "Gris"
|
||||||
|
hue = h * 360
|
||||||
|
if hue < 15 or hue >= 345:
|
||||||
|
return "Rouge"
|
||||||
|
if hue < 45:
|
||||||
|
return "Orange"
|
||||||
|
if hue < 75:
|
||||||
|
return "Jaune"
|
||||||
|
if hue < 150:
|
||||||
|
return "Vert"
|
||||||
|
if hue < 195:
|
||||||
|
return "Cyan"
|
||||||
|
if hue < 255:
|
||||||
|
return "Bleu"
|
||||||
|
if hue < 290:
|
||||||
|
return "Violet"
|
||||||
|
return "Rose"
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/data/camwatch.db")
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
camera TEXT,
|
||||||
|
start_time INTEGER,
|
||||||
|
snapshot_path TEXT,
|
||||||
|
plate TEXT,
|
||||||
|
color_hex TEXT,
|
||||||
|
color_name TEXT,
|
||||||
|
processed_at INTEGER
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def event_exists(event_id: str) -> bool:
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT id FROM events WHERE id = ?", (event_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def insert_event(event_id, camera, start_time, snapshot_path, plate, color_hex, color_name):
|
||||||
|
import time
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR IGNORE INTO events
|
||||||
|
(id, camera, start_time, snapshot_path, plate, color_hex, color_name, processed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (event_id, camera, start_time, snapshot_path, plate, color_hex, color_name, int(time.time())))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_events(limit=100, offset=0, plate_filter=None, camera_filter=None, date_filter=None):
|
||||||
|
conn = get_db()
|
||||||
|
query = "SELECT * FROM events WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
if plate_filter:
|
||||||
|
query += " AND plate LIKE ?"
|
||||||
|
params.append(f"%{plate_filter}%")
|
||||||
|
if camera_filter:
|
||||||
|
query += " AND camera = ?"
|
||||||
|
params.append(camera_filter)
|
||||||
|
if date_filter:
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(date_filter, "%Y-%m-%d")
|
||||||
|
ts_start = int(dt.timestamp())
|
||||||
|
ts_end = ts_start + 86400
|
||||||
|
query += " AND start_time >= ? AND start_time < ?"
|
||||||
|
params.extend([ts_start, ts_end])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
query += " ORDER BY start_time DESC LIMIT ? OFFSET ?"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def count_events(plate_filter=None, camera_filter=None, date_filter=None):
|
||||||
|
conn = get_db()
|
||||||
|
query = "SELECT COUNT(*) FROM events WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
if plate_filter:
|
||||||
|
query += " AND plate LIKE ?"
|
||||||
|
params.append(f"%{plate_filter}%")
|
||||||
|
if camera_filter:
|
||||||
|
query += " AND camera = ?"
|
||||||
|
params.append(camera_filter)
|
||||||
|
if date_filter:
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(date_filter, "%Y-%m-%d")
|
||||||
|
ts_start = int(dt.timestamp())
|
||||||
|
ts_end = ts_start + 86400
|
||||||
|
query += " AND start_time >= ? AND start_time < ?"
|
||||||
|
params.extend([ts_start, ts_end])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
count = conn.execute(query, params).fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_cameras():
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute("SELECT DISTINCT camera FROM events ORDER BY camera").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [r[0] for r in rows]
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI, Request, Query
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import database
|
||||||
|
import watcher
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
DATA_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots")
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
database.init_db()
|
||||||
|
t = threading.Thread(target=watcher.run_watcher, daemon=True)
|
||||||
|
t.start()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
app.mount("/snapshots", StaticFiles(directory="/data/snapshots"), name="snapshots")
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="/app/templates")
|
||||||
|
|
||||||
|
|
||||||
|
def ts_to_str(ts):
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(ts).strftime("%d/%m/%Y %H:%M:%S")
|
||||||
|
except Exception:
|
||||||
|
return str(ts)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(
|
||||||
|
request: Request,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
plate: str = Query(""),
|
||||||
|
camera: str = Query(""),
|
||||||
|
date: str = Query(""),
|
||||||
|
):
|
||||||
|
limit = 20
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
events = database.get_events(limit=limit, offset=offset,
|
||||||
|
plate_filter=plate or None,
|
||||||
|
camera_filter=camera or None,
|
||||||
|
date_filter=date or None)
|
||||||
|
total = database.count_events(plate_filter=plate or None,
|
||||||
|
camera_filter=camera or None,
|
||||||
|
date_filter=date or None)
|
||||||
|
cameras = database.get_cameras()
|
||||||
|
total_pages = max(1, (total + limit - 1) // limit)
|
||||||
|
|
||||||
|
for ev in events:
|
||||||
|
ev["time_str"] = ts_to_str(ev["start_time"])
|
||||||
|
|
||||||
|
return templates.TemplateResponse("index.html", {
|
||||||
|
"request": request,
|
||||||
|
"events": events,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"cameras": cameras,
|
||||||
|
"filter_plate": plate,
|
||||||
|
"filter_camera": camera,
|
||||||
|
"filter_date": date,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/events")
|
||||||
|
async def api_events(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
plate: str = Query(""),
|
||||||
|
camera: str = Query(""),
|
||||||
|
date: str = Query(""),
|
||||||
|
):
|
||||||
|
limit = 20
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
events = database.get_events(limit=limit, offset=offset,
|
||||||
|
plate_filter=plate or None,
|
||||||
|
camera_filter=camera or None,
|
||||||
|
date_filter=date or None)
|
||||||
|
total = database.count_events(plate_filter=plate or None,
|
||||||
|
camera_filter=camera or None,
|
||||||
|
date_filter=date or None)
|
||||||
|
for ev in events:
|
||||||
|
ev["time_str"] = ts_to_str(ev["start_time"])
|
||||||
|
return {"events": events, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.6
|
||||||
|
jinja2==3.1.4
|
||||||
|
python-multipart==0.0.9
|
||||||
|
requests==2.32.3
|
||||||
|
opencv-python-headless==4.10.0.84
|
||||||
|
numpy==1.26.4
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CamWatch — Passages véhicules</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>tailwind.config = { darkMode: 'class' }</script>
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; color: #e2e8f0; }
|
||||||
|
.card { background: #1e293b; border: 1px solid #334155; }
|
||||||
|
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:0.7rem; font-weight:600; }
|
||||||
|
.plate { font-family: monospace; letter-spacing:0.1em; }
|
||||||
|
input, select { background:#0f172a; border:1px solid #475569; color:#e2e8f0; border-radius:6px; padding:6px 10px; }
|
||||||
|
input:focus, select:focus { outline:none; border-color:#60a5fa; }
|
||||||
|
.btn { background:#2563eb; color:#fff; border-radius:6px; padding:7px 16px; font-size:0.85rem; cursor:pointer; }
|
||||||
|
.btn:hover { background:#1d4ed8; }
|
||||||
|
.btn-ghost { background:#1e293b; border:1px solid #475569; color:#94a3b8; }
|
||||||
|
.btn-ghost:hover { background:#334155; color:#e2e8f0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="sticky top-0 z-10 px-4 py-3 flex items-center justify-between" style="background:#0f172a;border-bottom:1px solid #1e293b;">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xl">🚗</span>
|
||||||
|
<span class="font-bold text-lg">CamWatch</span>
|
||||||
|
<span class="text-slate-500 text-sm hidden sm:inline">— Passages véhicules</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-slate-400 text-sm">{{ total }} passage{{ 's' if total != 1 else '' }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-5xl mx-auto px-3 py-4">
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="get" class="flex flex-wrap gap-2 mb-5">
|
||||||
|
<input type="text" name="plate" placeholder="Plaque…" value="{{ filter_plate }}" class="w-32">
|
||||||
|
<select name="camera">
|
||||||
|
<option value="">Toutes caméras</option>
|
||||||
|
{% for cam in cameras %}
|
||||||
|
<option value="{{ cam }}" {% if cam == filter_camera %}selected{% endif %}>{{ cam }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="date" name="date" value="{{ filter_date }}">
|
||||||
|
<button type="submit" class="btn">Filtrer</button>
|
||||||
|
{% if filter_plate or filter_camera or filter_date %}
|
||||||
|
<a href="/" class="btn btn-ghost">✕ Reset</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
{% if not events %}
|
||||||
|
<div class="text-center py-20 text-slate-500">
|
||||||
|
<div class="text-5xl mb-4">📷</div>
|
||||||
|
<p class="text-lg">Aucun passage enregistré</p>
|
||||||
|
<p class="text-sm mt-1">En attente de détections Frigate…</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Events grid -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{% for ev in events %}
|
||||||
|
<div class="card rounded-xl overflow-hidden">
|
||||||
|
<!-- Snapshot -->
|
||||||
|
<div class="relative bg-black" style="aspect-ratio:16/9;">
|
||||||
|
{% if ev.snapshot_path %}
|
||||||
|
<img src="/{{ ev.snapshot_path }}" alt="snapshot"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
onerror="this.parentElement.innerHTML='<div class=\'w-full h-full flex items-center justify-center text-slate-600 text-3xl\'>📷</div>'">
|
||||||
|
{% else %}
|
||||||
|
<div class="w-full h-full flex items-center justify-center text-slate-600 text-3xl">📷</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Camera badge -->
|
||||||
|
<span class="absolute top-2 left-2 badge" style="background:rgba(0,0,0,0.6);color:#94a3b8;">
|
||||||
|
{{ ev.camera }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
|
<!-- Time -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-300 text-sm font-medium">{{ ev.time_str }}</span>
|
||||||
|
<!-- Color swatch -->
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-4 h-4 rounded-full border border-slate-600"
|
||||||
|
style="background:{{ ev.color_hex or '#808080' }};"
|
||||||
|
title="{{ ev.color_hex }}"></div>
|
||||||
|
<span class="text-slate-400 text-xs">{{ ev.color_name or '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plate -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if ev.plate %}
|
||||||
|
<span class="plate text-sm font-bold px-3 py-1 rounded"
|
||||||
|
style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">
|
||||||
|
{{ ev.plate }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-slate-500 text-sm italic">Plaque non lue</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="flex items-center justify-center gap-2 mt-6">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="?page={{ page - 1 }}&plate={{ filter_plate }}&camera={{ filter_camera }}&date={{ filter_date }}"
|
||||||
|
class="btn btn-ghost text-sm">← Préc.</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-slate-400 text-sm">Page {{ page }} / {{ total_pages }}</span>
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="?page={{ page + 1 }}&plate={{ filter_plate }}&camera={{ filter_camera }}&date={{ filter_date }}"
|
||||||
|
class="btn btn-ghost text-sm">Suiv. →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Auto-refresh every 60s -->
|
||||||
|
<script>
|
||||||
|
setTimeout(() => location.reload(), 60000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import shutil
|
||||||
|
from database import event_exists, insert_event
|
||||||
|
from analyzer import extract_dominant_color
|
||||||
|
|
||||||
|
log = logging.getLogger("watcher")
|
||||||
|
|
||||||
|
FRIGATE_URL = os.environ.get("FRIGATE_URL", "http://frigate:5000")
|
||||||
|
SNAPSHOTS_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots")
|
||||||
|
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_new_events() -> list[dict]:
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{FRIGATE_URL}/api/events",
|
||||||
|
params={"labels": "car", "has_snapshot": "1", "limit": "50"},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Frigate fetch error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def download_snapshot(event_id: str, dest_path: str) -> bool:
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg",
|
||||||
|
params={"bbox": "1", "crop": "1", "quality": "95"},
|
||||||
|
timeout=15, stream=True
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(dest_path, "wb") as f:
|
||||||
|
shutil.copyfileobj(resp.raw, f)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Snapshot download error for {event_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def process_event(ev: dict):
|
||||||
|
event_id = ev.get("id", "")
|
||||||
|
if not event_id or event_exists(event_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
camera = ev.get("camera", "unknown")
|
||||||
|
start_time = int(ev.get("start_time", time.time()))
|
||||||
|
|
||||||
|
# Plate from Frigate LPR
|
||||||
|
plate = None
|
||||||
|
data = ev.get("data", {})
|
||||||
|
if data.get("sub_label"):
|
||||||
|
plate = data["sub_label"]
|
||||||
|
if isinstance(plate, list):
|
||||||
|
plate = plate[0] if plate else None
|
||||||
|
|
||||||
|
# Download snapshot
|
||||||
|
snapshot_file = f"{event_id}.jpg"
|
||||||
|
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)
|
||||||
|
if not download_snapshot(event_id, snapshot_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract color from vehicle bounding box
|
||||||
|
bbox = None
|
||||||
|
if ev.get("box"):
|
||||||
|
b = ev["box"]
|
||||||
|
bbox = {"x": b[0], "y": b[1], "width": b[2] - b[0], "height": b[3] - b[1]}
|
||||||
|
elif data.get("box"):
|
||||||
|
b = data["box"]
|
||||||
|
if len(b) == 4:
|
||||||
|
bbox = {"x": b[0], "y": b[1], "width": b[2] - b[0], "height": b[3] - b[1]}
|
||||||
|
|
||||||
|
hex_color, color_name = extract_dominant_color(snapshot_path, bbox)
|
||||||
|
|
||||||
|
insert_event(event_id, camera, start_time, f"snapshots/{snapshot_file}",
|
||||||
|
plate, hex_color, color_name)
|
||||||
|
log.info(f"Processed event {event_id} | plate={plate} | color={color_name} | camera={camera}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_watcher():
|
||||||
|
log.info(f"Watcher started — polling Frigate every {POLL_INTERVAL}s")
|
||||||
|
while True:
|
||||||
|
events = fetch_new_events()
|
||||||
|
for ev in events:
|
||||||
|
try:
|
||||||
|
process_event(ev)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error processing event: {e}")
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
camwatch:
|
||||||
|
build: .
|
||||||
|
container_name: camwatch
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- FRIGATE_URL=http://frigate:5000
|
||||||
|
- DB_PATH=/data/camwatch.db
|
||||||
|
- SNAPSHOTS_DIR=/data/snapshots
|
||||||
|
- POLL_INTERVAL=30
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.camwatch.rule=Host(`camwatch.nas.percolouco.com`)
|
||||||
|
- traefik.http.routers.camwatch.entrypoints=websecure
|
||||||
|
- traefik.http.routers.camwatch.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.camwatch.loadbalancer.server.port=8000
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
Reference in New Issue
Block a user