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:
perco
2026-06-02 14:10:05 +02:00
co-authored by Claude Sonnet 4.6
commit f6422b0e7e
9 changed files with 534 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
data/
__pycache__/
*.pyc
+13
View File
@@ -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"]
+60
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+7
View File
@@ -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
+132
View File
@@ -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>
+94
View File
@@ -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)
+24
View File
@@ -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