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 }}
+
+ {{ ev.camera }}
+
+
+
+
+
+
+
+
+
+
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 (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 %}
+
+

+
+ #{{ 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 %}
-
-
+
{% 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