diff --git a/app/database.py b/app/database.py index 4f87a89..b3390eb 100644 --- a/app/database.py +++ b/app/database.py @@ -16,12 +16,18 @@ def init_db(): camera TEXT, start_time INTEGER, snapshot_path TEXT, + clip_path TEXT, plate TEXT, color_hex TEXT, color_name TEXT, processed_at INTEGER ) """) + # Migrate existing DBs that lack clip_path + try: + conn.execute("ALTER TABLE events ADD COLUMN clip_path TEXT") + except Exception: + pass conn.commit() conn.close() @@ -31,14 +37,14 @@ def event_exists(event_id: str) -> bool: conn.close() return row is not None -def insert_event(event_id, camera, start_time, snapshot_path, plate, color_hex, color_name): +def insert_event(event_id, camera, start_time, snapshot_path, clip_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()))) + (id, camera, start_time, snapshot_path, clip_path, plate, color_hex, color_name, processed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (event_id, camera, start_time, snapshot_path, clip_path, plate, color_hex, color_name, int(time.time()))) conn.commit() conn.close() @@ -94,6 +100,13 @@ def count_events(plate_filter=None, camera_filter=None, date_filter=None): conn.close() return count +def get_event(event_id: str) -> dict | None: + conn = get_db() + row = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone() + conn.close() + return dict(row) if row else None + + def get_cameras(): conn = get_db() rows = conn.execute("SELECT DISTINCT camera FROM events ORDER BY camera").fetchall() diff --git a/app/main.py b/app/main.py index f8bd85b..938b00f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,10 @@ import os +import glob import logging import threading from contextlib import asynccontextmanager -from fastapi import FastAPI, Request, Query -from fastapi.responses import HTMLResponse, FileResponse +from fastapi import FastAPI, Request, Query, HTTPException +from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from datetime import datetime @@ -13,8 +14,10 @@ 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) +SNAPSHOTS_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots") +EVENTS_DIR = os.environ.get("EVENTS_DIR", "/data/events") +os.makedirs(SNAPSHOTS_DIR, exist_ok=True) +os.makedirs(EVENTS_DIR, exist_ok=True) @asynccontextmanager @@ -26,7 +29,8 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) -app.mount("/snapshots", StaticFiles(directory="/data/snapshots"), name="snapshots") +app.mount("/snapshots", StaticFiles(directory=SNAPSHOTS_DIR), name="snapshots") +app.mount("/events", StaticFiles(directory=EVENTS_DIR), name="events") templates = Jinja2Templates(directory="/app/templates") @@ -74,6 +78,29 @@ async def index( }) +@app.get("/event/{event_id}", response_class=HTMLResponse) +async def event_detail(request: Request, event_id: str): + ev = database.get_event(event_id) + if not ev: + raise HTTPException(status_code=404, detail="Événement introuvable") + + ev["time_str"] = ts_to_str(ev["start_time"]) + + # List frames for this event + event_dir = os.path.join(EVENTS_DIR, event_id) + frame_paths = sorted(glob.glob(os.path.join(event_dir, "frame_*.jpg"))) + frames = [f"events/{event_id}/frame_{i+1:04d}.jpg" for i in range(len(frame_paths))] + + has_clip = ev.get("clip_path") and os.path.exists(os.path.join("/data", ev["clip_path"])) + + return templates.TemplateResponse("event_detail.html", { + "request": request, + "ev": ev, + "frames": frames, + "has_clip": has_clip, + }) + + @app.get("/api/events") async def api_events( page: int = Query(1, ge=1), diff --git a/app/templates/event_detail.html b/app/templates/event_detail.html new file mode 100644 index 0000000..669aac6 --- /dev/null +++ b/app/templates/event_detail.html @@ -0,0 +1,132 @@ + + + + + + CamWatch — Passage {{ ev.time_str }} + + + + + + + +
+
+ ← Retour + 🚗 + Passage — {{ ev.time_str }} +
+ +
+ +
+ + +
+ + +
+

Informations

+ +
+
Date / Heure
+
{{ ev.time_str }}
+
+ +
+
Caméra
+
{{ ev.camera }}
+
+ +
+
Plaque détectée
+ {% if ev.plate %} + + {{ ev.plate }} + + {% else %} + Non lue + {% endif %} +
+ +
+
Couleur véhicule
+
+
+ {{ ev.color_name or '—' }} + {{ ev.color_hex or '' }} +
+
+
+ + +
+
+ Meilleure frame +
+
Meilleure frame (score LPR)
+
+
+ + + {% if has_clip %} +
+

Clip vidéo ({{ ev.clip_path.split('/')[-1] }})

+ +
+ {% endif %} + + + {% if frames %} +
+

+ Frames extraites ({{ frames|length }} meilleures sur {{ (15 * 5)|int }} capturées) +

+
+ {% for frame_path in frames %} +
+ Frame {{ loop.index }} +
+ #{{ loop.index }}{% if loop.first %} ★{% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + +
+ + + + diff --git a/app/templates/index.html b/app/templates/index.html index 79907cc..96bd8aa 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -61,7 +61,7 @@
{% for ev in events %} -
+
{% if ev.snapshot_path %} @@ -103,7 +103,7 @@ {% endif %}
-
+ {% endfor %} diff --git a/app/watcher.py b/app/watcher.py index a1f1226..07cb921 100644 --- a/app/watcher.py +++ b/app/watcher.py @@ -1,9 +1,9 @@ import os import time import uuid +import shutil import logging import subprocess -import tempfile import glob import requests import cv2 @@ -22,9 +22,11 @@ CAMERA_PASS = os.environ.get("CAMERA_PASS", "") CAMERA_RTSP = os.environ.get("CAMERA_RTSP", "") CAMERA_NAME = os.environ.get("CAMERA_NAME", "portail") SNAPSHOTS_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots") +EVENTS_DIR = os.environ.get("EVENTS_DIR", "/data/events") POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "2")) CAPTURE_DURATION = int(os.environ.get("CAPTURE_DURATION", "15")) CAPTURE_FPS = int(os.environ.get("CAPTURE_FPS", "5")) +TOP_FRAMES = int(os.environ.get("TOP_FRAMES", "10")) COOLDOWN = int(os.environ.get("COOLDOWN", "60")) _token: str | None = None @@ -54,7 +56,6 @@ def _login() -> str | None: if data[0]["code"] == 0: _token = data[0]["value"]["Token"]["name"] _token_time = time.time() - log.debug("Camera login OK") return _token except Exception as e: log.warning(f"Login error: {e}") @@ -80,7 +81,6 @@ def _get_ai_state() -> dict | None: def _frame_score(frame: np.ndarray, plates: list) -> float: - """Score a frame: prefer large, high-confidence plates. Fallback to sharpness.""" if plates: x1, y1, x2, y2, conf = plates[0] return (x2 - x1) * (y2 - y1) * conf @@ -88,89 +88,94 @@ def _frame_score(frame: np.ndarray, plates: list) -> float: return cv2.Laplacian(gray, cv2.CV_64F).var() * 0.001 -def _capture_best_frame() -> np.ndarray | None: - url = _rtsp_url() - log.info(f"Capturing {CAPTURE_DURATION}s at {CAPTURE_FPS}fps via ffmpeg...") - - with tempfile.TemporaryDirectory() as tmpdir: - clip_path = os.path.join(tmpdir, "clip.mp4") - frames_pattern = os.path.join(tmpdir, "frame_%04d.jpg") - - # Step 1: capture clip with ffmpeg (TCP for reliability) - ret = subprocess.run([ - "ffmpeg", "-y", - "-rtsp_transport", "tcp", - "-i", url, - "-t", str(CAPTURE_DURATION), - "-c", "copy", - clip_path, - ], capture_output=True, timeout=CAPTURE_DURATION + 10) - - if not os.path.exists(clip_path) or os.path.getsize(clip_path) < 1000: - log.error(f"ffmpeg capture failed: {ret.stderr[-200:].decode(errors='ignore')}") - return None - - # Step 2: extract frames at CAPTURE_FPS - subprocess.run([ - "ffmpeg", "-y", - "-i", clip_path, - "-vf", f"fps={CAPTURE_FPS}", - "-q:v", "2", - frames_pattern, - ], capture_output=True, timeout=30) - - frame_files = sorted(glob.glob(os.path.join(tmpdir, "frame_*.jpg"))) - log.info(f"Extracted {len(frame_files)} frames") - - if not frame_files: - return None - - # Step 3: score each frame, keep the best - best_frame: np.ndarray | None = None - best_score = -1.0 - - for path in frame_files: - frame = cv2.imread(path) - if frame is None: - continue - plates = _analyzer.detect_plates(frame) if _analyzer else [] - score = _frame_score(frame, plates) - if score > best_score: - best_score = score - best_frame = frame.copy() - - log.info(f"Best frame score: {best_score:.2f}") - return best_frame - - def _process_event(): - frame = _capture_best_frame() - if frame is None: - log.warning("No frame captured") + event_id = str(uuid.uuid4()) + event_dir = os.path.join(EVENTS_DIR, event_id) + os.makedirs(event_dir, exist_ok=True) + + url = _rtsp_url() + clip_path = os.path.join(event_dir, "clip.mp4") + frames_pattern = os.path.join(event_dir, "frame_%04d.jpg") + + log.info(f"Capturing {CAPTURE_DURATION}s at {CAPTURE_FPS}fps — event {event_id[:8]}") + + # Step 1: capture clip + ret = subprocess.run([ + "ffmpeg", "-y", "-rtsp_transport", "tcp", + "-i", url, "-t", str(CAPTURE_DURATION), "-c", "copy", clip_path, + ], capture_output=True, timeout=CAPTURE_DURATION + 10) + + if not os.path.exists(clip_path) or os.path.getsize(clip_path) < 1000: + log.error(f"ffmpeg capture failed: {ret.stderr[-200:].decode(errors='ignore')}") + shutil.rmtree(event_dir, ignore_errors=True) return - event_id = str(uuid.uuid4()) - snapshot_file = f"{event_id}.jpg" - snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file) - cv2.imwrite(snapshot_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 90]) + # Step 2: extract frames + subprocess.run([ + "ffmpeg", "-y", "-i", clip_path, + "-vf", f"fps={CAPTURE_FPS}", "-q:v", "2", frames_pattern, + ], capture_output=True, timeout=30) - plate, conf = "", 0.0 + frame_files = sorted(glob.glob(os.path.join(event_dir, "frame_*.jpg"))) + log.info(f"Extracted {len(frame_files)} frames") + + if not frame_files: + shutil.rmtree(event_dir, ignore_errors=True) + return + + # Step 3: score and select top frames + scored: list[tuple[float, np.ndarray, str]] = [] + for path in frame_files: + frame = cv2.imread(path) + if frame is None: + continue + plates = _analyzer.detect_plates(frame) if _analyzer else [] + score = _frame_score(frame, plates) + scored.append((score, frame, path)) + + scored.sort(key=lambda x: -x[0]) + + # Delete frames below TOP_FRAMES + for _, _, path in scored[TOP_FRAMES:]: + os.remove(path) + + # Rename kept frames to sorted order (best first = frame_0001) + kept = scored[:TOP_FRAMES] + for i, (_, _, old_path) in enumerate(kept): + new_path = os.path.join(event_dir, f"frame_{i+1:04d}.jpg") + if old_path != new_path: + os.rename(old_path, new_path) + + best_frame = kept[0][1] if kept else None + if best_frame is None: + shutil.rmtree(event_dir, ignore_errors=True) + return + + # Step 4: run LPR on best frame + plate, conf = ("", 0.0) if _analyzer: - plate, conf = _analyzer.read_plate(frame) - + plate, conf = _analyzer.read_plate(best_frame) log.info(f"LPR: plate={plate!r} conf={conf:.2f}") - hex_color, color_name = extract_dominant_color_from_frame(frame) + # Step 5: save thumbnail (best frame) + snapshot_file = f"{event_id}.jpg" + snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file) + cv2.imwrite(snapshot_path, best_frame, [cv2.IMWRITE_JPEG_QUALITY, 90]) + + hex_color, color_name = extract_dominant_color_from_frame(best_frame) insert_event( event_id, CAMERA_NAME, int(time.time()), - f"snapshots/{snapshot_file}", plate or None, hex_color, color_name, + f"snapshots/{snapshot_file}", + f"events/{event_id}/clip.mp4", + plate or None, hex_color, color_name, ) - log.info(f"Stored: {event_id} | plate={plate} | color={color_name}") + log.info(f"Stored: {event_id[:8]} | plate={plate} | color={color_name} | frames={len(kept)}") def run_watcher(): global _last_event_time, _analyzer + os.makedirs(EVENTS_DIR, exist_ok=True) log.info("Loading plate analyzer...") _analyzer = PlateAnalyzer() @@ -184,7 +189,7 @@ def run_watcher(): now = time.time() if now - _last_event_time > COOLDOWN: _last_event_time = now - log.info("Vehicle detected! Triggering capture...") + log.info("Vehicle detected!") _process_event() except Exception as e: log.error(f"Watcher loop error: {e}", exc_info=True) diff --git a/docker-compose.yml b/docker-compose.yml index 055abaf..ce63a9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - MODEL_CACHE=/models - DB_PATH=/data/camwatch.db - SNAPSHOTS_DIR=/data/snapshots + - EVENTS_DIR=/data/events - POLL_INTERVAL=2 - CAPTURE_DURATION=15 - CAPTURE_FPS=5