Ajoute vue détail par passage (clip + galerie frames)

- Sauvegarde le clip MP4 et les top 10 frames par événement dans /data/events/{id}/
- Nouvelle route GET /event/{id} avec page détail : info, vignette interactive, lecteur vidéo, galerie frames
- Les cartes de l'index sont maintenant cliquables
- Migration DB : ajout colonne clip_path sur les BDD existantes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-02 15:52:12 +02:00
co-authored by Claude Sonnet 4.6
parent 8507987ea9
commit 528a158c42
6 changed files with 261 additions and 83 deletions
+17 -4
View File
@@ -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()
+32 -5
View File
@@ -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),
+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 — Passage {{ ev.time_str }}</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; }
.plate { font-family: monospace; letter-spacing: 0.15em; }
.btn { background: #2563eb; color: #fff; border-radius: 6px; padding: 7px 16px; font-size: 0.85rem; cursor: pointer; text-decoration: none; display: inline-block; }
.btn:hover { background: #1d4ed8; }
.btn-ghost { background: #1e293b; border: 1px solid #475569; color: #94a3b8; }
.btn-ghost:hover { background: #334155; color: #e2e8f0; }
.frame-thumb { cursor: pointer; transition: transform 0.1s; }
.frame-thumb:hover { transform: scale(1.03); border-color: #3b82f6 !important; }
.frame-thumb.active { border-color: #3b82f6 !important; }
</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-3">
<a href="/" class="btn btn-ghost text-sm px-3 py-1.5">← Retour</a>
<span class="text-xl">🚗</span>
<span class="font-bold">Passage — {{ ev.time_str }}</span>
</div>
<span class="text-slate-500 text-sm hidden sm:inline">{{ ev.camera }}</span>
</header>
<main class="max-w-5xl mx-auto px-3 py-5 space-y-5">
<!-- Top row: info + main image -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Info card -->
<div class="card rounded-xl p-4 space-y-3">
<h2 class="text-slate-400 text-xs uppercase font-semibold tracking-wide">Informations</h2>
<div>
<div class="text-xs text-slate-500 mb-0.5">Date / Heure</div>
<div class="font-medium">{{ ev.time_str }}</div>
</div>
<div>
<div class="text-xs text-slate-500 mb-0.5">Caméra</div>
<div class="font-medium">{{ ev.camera }}</div>
</div>
<div>
<div class="text-xs text-slate-500 mb-1">Plaque détectée</div>
{% if ev.plate %}
<span class="plate text-lg font-bold px-4 py-1.5 rounded"
style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">
{{ ev.plate }}
</span>
{% else %}
<span class="text-slate-500 italic text-sm">Non lue</span>
{% endif %}
</div>
<div>
<div class="text-xs text-slate-500 mb-1">Couleur véhicule</div>
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full border border-slate-600"
style="background:{{ ev.color_hex or '#808080' }};"></div>
<span class="font-medium">{{ ev.color_name or '—' }}</span>
<span class="text-slate-600 text-xs">{{ ev.color_hex or '' }}</span>
</div>
</div>
</div>
<!-- Main image viewer -->
<div class="md:col-span-2 card rounded-xl overflow-hidden">
<div class="relative bg-black" style="aspect-ratio:16/9;">
<img id="main-img"
src="{% if ev.snapshot_path %}/{{ ev.snapshot_path }}{% endif %}"
alt="Meilleure frame"
class="w-full h-full object-contain">
</div>
<div class="px-3 py-2 text-xs text-slate-500 text-center" id="img-label">Meilleure frame (score LPR)</div>
</div>
</div>
<!-- Video clip -->
{% if has_clip %}
<div class="card rounded-xl p-4">
<h2 class="text-slate-400 text-xs uppercase font-semibold tracking-wide mb-3">Clip vidéo ({{ ev.clip_path.split('/')[-1] }})</h2>
<video controls class="w-full rounded-lg" style="max-height:400px;background:#000;"
preload="metadata">
<source src="/{{ ev.clip_path }}" type="video/mp4">
Votre navigateur ne supporte pas la vidéo HTML5.
</video>
</div>
{% endif %}
<!-- Frames gallery -->
{% if frames %}
<div class="card rounded-xl p-4">
<h2 class="text-slate-400 text-xs uppercase font-semibold tracking-wide mb-3">
Frames extraites ({{ frames|length }} meilleures sur {{ (15 * 5)|int }} capturées)
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{% for frame_path in frames %}
<div class="frame-thumb rounded overflow-hidden border border-slate-700 {% if loop.first %}active{% endif %}"
onclick="selectFrame(this, '/{{ frame_path }}', 'Frame {{ loop.index }} / {{ frames|length }}')">
<img src="/{{ frame_path }}" alt="Frame {{ loop.index }}"
class="w-full object-cover" style="aspect-ratio:16/9;">
<div class="text-center text-xs text-slate-500 py-0.5">
#{{ loop.index }}{% if loop.first %} ★{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</main>
<script>
function selectFrame(el, src, label) {
document.getElementById('main-img').src = src;
document.getElementById('img-label').textContent = label;
document.querySelectorAll('.frame-thumb').forEach(t => t.classList.remove('active'));
el.classList.add('active');
}
</script>
</body>
</html>
+2 -2
View File
@@ -61,7 +61,7 @@
<!-- 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">
<a href="/event/{{ ev.id }}" class="card rounded-xl overflow-hidden block hover:border-blue-500 transition-colors" style="text-decoration:none;color:inherit;">
<!-- Snapshot -->
<div class="relative bg-black" style="aspect-ratio:16/9;">
{% if ev.snapshot_path %}
@@ -103,7 +103,7 @@
{% endif %}
</div>
</div>
</div>
</a>
{% endfor %}
</div>
+77 -72
View File
@@ -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)