From f6422b0e7e52f6e24bea2e16bbbda62976919a54 Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 2 Jun 2026 14:10:05 +0200 Subject: [PATCH] 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 --- .gitignore | 3 + Dockerfile | 13 ++++ app/analyzer.py | 60 ++++++++++++++++++ app/database.py | 101 ++++++++++++++++++++++++++++++ app/main.py | 100 +++++++++++++++++++++++++++++ app/requirements.txt | 7 +++ app/templates/index.html | 132 +++++++++++++++++++++++++++++++++++++++ app/watcher.py | 94 ++++++++++++++++++++++++++++ docker-compose.yml | 24 +++++++ 9 files changed, 534 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/analyzer.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/requirements.txt create mode 100644 app/templates/index.html create mode 100644 app/watcher.py create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac481ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +data/ +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4cab83e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/analyzer.py b/app/analyzer.py new file mode 100644 index 0000000..4883cfd --- /dev/null +++ b/app/analyzer.py @@ -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" diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..4f87a89 --- /dev/null +++ b/app/database.py @@ -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] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f8bd85b --- /dev/null +++ b/app/main.py @@ -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"} diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..bf37b7a --- /dev/null +++ b/app/requirements.txt @@ -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 diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..d0e57e2 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,132 @@ + + + + + + CamWatch — Passages véhicules + + + + + + + +
+
+ 🚗 + CamWatch + +
+ {{ total }} passage{{ 's' if total != 1 else '' }} +
+ +
+ + +
+ + + + + {% if filter_plate or filter_camera or filter_date %} + ✕ Reset + {% endif %} +
+ + + {% if not events %} +
+
📷
+

Aucun passage enregistré

+

En attente de détections Frigate…

+
+ {% endif %} + + +
+ {% for ev in events %} +
+ +
+ {% if ev.snapshot_path %} + snapshot + {% else %} +
📷
+ {% endif %} + + + {{ ev.camera }} + +
+ + +
+ +
+ {{ ev.time_str }} + +
+
+ {{ ev.color_name or '—' }} +
+
+ + +
+ {% if ev.plate %} + + {{ ev.plate }} + + {% else %} + Plaque non lue + {% endif %} +
+
+
+ {% endfor %} +
+ + + {% if total_pages > 1 %} +
+ {% if page > 1 %} + ← Préc. + {% endif %} + Page {{ page }} / {{ total_pages }} + {% if page < total_pages %} + Suiv. → + {% endif %} +
+ {% endif %} + +
+ + + + + diff --git a/app/watcher.py b/app/watcher.py new file mode 100644 index 0000000..c5a741d --- /dev/null +++ b/app/watcher.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4a6eb1a --- /dev/null +++ b/docker-compose.yml @@ -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