Add vehicle whitelist and per-plate passage statistics
Whitelist: plates added to /whitelist are silently ignored on detection — no clip, no frames, no DB entry. Manageable via /whitelist page or from the event detail page per-event. Stats: /stats shows all known plates ranked by passage count, with first/last seen dates and one-click whitelist toggle. Navigation links added to the index header. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
bd890f111e
commit
ea58a860b8
@@ -23,6 +23,13 @@ def init_db():
|
|||||||
processed_at INTEGER
|
processed_at INTEGER
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS whitelist (
|
||||||
|
plate TEXT PRIMARY KEY,
|
||||||
|
label TEXT,
|
||||||
|
added_at INTEGER
|
||||||
|
)
|
||||||
|
""")
|
||||||
# Migrate existing DBs that lack clip_path
|
# Migrate existing DBs that lack clip_path
|
||||||
try:
|
try:
|
||||||
conn.execute("ALTER TABLE events ADD COLUMN clip_path TEXT")
|
conn.execute("ALTER TABLE events ADD COLUMN clip_path TEXT")
|
||||||
@@ -126,3 +133,57 @@ def get_cameras():
|
|||||||
rows = conn.execute("SELECT DISTINCT camera FROM events ORDER BY camera").fetchall()
|
rows = conn.execute("SELECT DISTINCT camera FROM events ORDER BY camera").fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return [r[0] for r in rows]
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Whitelist ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_whitelist() -> list[dict]:
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute("SELECT plate, label, added_at FROM whitelist ORDER BY added_at DESC").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def is_whitelisted(plate: str) -> bool:
|
||||||
|
if not plate:
|
||||||
|
return False
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT plate FROM whitelist WHERE plate = ?", (plate.upper().strip(),)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def add_to_whitelist(plate: str, label: str = ""):
|
||||||
|
import time
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO whitelist (plate, label, added_at) VALUES (?, ?, ?)",
|
||||||
|
(plate.upper().strip(), label.strip(), int(time.time())),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_from_whitelist(plate: str):
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("DELETE FROM whitelist WHERE plate = ?", (plate.upper().strip(),))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_plate_stats() -> list[dict]:
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT plate,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
MIN(start_time) AS first_seen,
|
||||||
|
MAX(start_time) AS last_seen
|
||||||
|
FROM events
|
||||||
|
WHERE plate IS NOT NULL AND plate != ''
|
||||||
|
GROUP BY plate
|
||||||
|
ORDER BY count DESC
|
||||||
|
""").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|||||||
+35
@@ -109,6 +109,7 @@ async def event_detail(request: Request, event_id: str):
|
|||||||
plate_ocr_abs = os.path.join(event_dir, "plate_ocr.jpg")
|
plate_ocr_abs = os.path.join(event_dir, "plate_ocr.jpg")
|
||||||
plate_ocr = f"events/{event_id}/plate_ocr.jpg" if os.path.exists(plate_ocr_abs) else None
|
plate_ocr = f"events/{event_id}/plate_ocr.jpg" if os.path.exists(plate_ocr_abs) else None
|
||||||
|
|
||||||
|
is_wl = database.is_whitelisted(ev.get("plate") or "")
|
||||||
return templates.TemplateResponse("event_detail.html", {
|
return templates.TemplateResponse("event_detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"ev": ev,
|
"ev": ev,
|
||||||
@@ -120,6 +121,7 @@ async def event_detail(request: Request, event_id: str):
|
|||||||
"capture_duration": CAPTURE_DURATION,
|
"capture_duration": CAPTURE_DURATION,
|
||||||
"capture_fps": CAPTURE_FPS,
|
"capture_fps": CAPTURE_FPS,
|
||||||
"capture_total": CAPTURE_DURATION * CAPTURE_FPS,
|
"capture_total": CAPTURE_DURATION * CAPTURE_FPS,
|
||||||
|
"is_whitelisted": is_wl,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -186,6 +188,39 @@ async def upload_clip(file: UploadFile = File(...)):
|
|||||||
return RedirectResponse(f"/event/{event_id}", status_code=303)
|
return RedirectResponse(f"/event/{event_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stats", response_class=HTMLResponse)
|
||||||
|
async def stats_page(request: Request):
|
||||||
|
rows = database.get_plate_stats()
|
||||||
|
whitelist_plates = {w["plate"] for w in database.get_whitelist()}
|
||||||
|
for r in rows:
|
||||||
|
r["first_str"] = ts_to_str(r["first_seen"])
|
||||||
|
r["last_str"] = ts_to_str(r["last_seen"])
|
||||||
|
r["whitelisted"] = r["plate"] in whitelist_plates
|
||||||
|
return templates.TemplateResponse("stats.html", {"request": request, "stats": rows})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/whitelist", response_class=HTMLResponse)
|
||||||
|
async def whitelist_page(request: Request):
|
||||||
|
entries = database.get_whitelist()
|
||||||
|
for e in entries:
|
||||||
|
e["added_str"] = ts_to_str(e["added_at"])
|
||||||
|
return templates.TemplateResponse("whitelist.html", {"request": request, "entries": entries})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/whitelist/add")
|
||||||
|
async def whitelist_add(plate: str = Form(""), label: str = Form(""), back: str = Form("")):
|
||||||
|
plate = plate.strip().upper()
|
||||||
|
if plate:
|
||||||
|
database.add_to_whitelist(plate, label)
|
||||||
|
return RedirectResponse(back or "/whitelist", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/whitelist/remove/{plate}")
|
||||||
|
async def whitelist_remove(plate: str, back: str = Form("")):
|
||||||
|
database.remove_from_whitelist(plate)
|
||||||
|
return RedirectResponse(back or "/whitelist", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -35,6 +35,20 @@
|
|||||||
<a href="/" class="btn-ghost">← Retour</a>
|
<a href="/" class="btn-ghost">← Retour</a>
|
||||||
<span class="text-xl">🚗</span>
|
<span class="text-xl">🚗</span>
|
||||||
<span class="font-bold flex-1">{{ ev.time_str }}</span>
|
<span class="font-bold flex-1">{{ ev.time_str }}</span>
|
||||||
|
{% if ev.plate %}
|
||||||
|
{% if is_whitelisted %}
|
||||||
|
<form action="/whitelist/remove/{{ ev.plate }}" method="post">
|
||||||
|
<input type="hidden" name="back" value="/event/{{ ev.id }}">
|
||||||
|
<button type="submit" class="btn-ghost text-green-400 hover:text-green-300 hover:border-green-600">🛡 Connu ✓</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form action="/whitelist/add" method="post">
|
||||||
|
<input type="hidden" name="plate" value="{{ ev.plate }}">
|
||||||
|
<input type="hidden" name="back" value="/event/{{ ev.id }}">
|
||||||
|
<button type="submit" class="btn-ghost hover:text-green-400 hover:border-green-700">🛡 Whitelister</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<form action="/event/{{ ev.id }}/delete" method="post"
|
<form action="/event/{{ ev.id }}/delete" method="post"
|
||||||
onsubmit="return confirm('Supprimer cet événement ?')">
|
onsubmit="return confirm('Supprimer cet événement ?')">
|
||||||
<button type="submit" class="btn-ghost text-red-400 hover:text-red-300 hover:border-red-500">✕ Supprimer</button>
|
<button type="submit" class="btn-ghost text-red-400 hover:text-red-300 hover:border-red-500">✕ Supprimer</button>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-slate-400 text-sm">{{ total }} passage{{ 's' if total != 1 else '' }}</span>
|
<span class="text-slate-400 text-sm">{{ total }} passage{{ 's' if total != 1 else '' }}</span>
|
||||||
|
<a href="/stats" class="btn-upload" style="text-decoration:none;">📊 Stats</a>
|
||||||
|
<a href="/whitelist" class="btn-upload" style="text-decoration:none;">🛡 Whitelist</a>
|
||||||
<button class="btn-upload" onclick="document.getElementById('upload-panel').classList.toggle('open')">⬆ Tester un clip</button>
|
<button class="btn-upload" onclick="document.getElementById('upload-panel').classList.toggle('open')">⬆ Tester un clip</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CamWatch — Statistiques</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; color: #e2e8f0; }
|
||||||
|
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||||||
|
.plate { font-family: monospace; letter-spacing: 0.12em; }
|
||||||
|
.btn-ghost { background: #1e293b; border: 1px solid #475569; color: #94a3b8; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; text-decoration: none; display: inline-block; }
|
||||||
|
.btn-ghost:hover { background: #334155; color: #e2e8f0; }
|
||||||
|
label { color: #64748b; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
|
||||||
|
<header class="sticky top-0 z-10 px-4 py-3 flex items-center gap-3" style="background:#0f172a;border-bottom:1px solid #1e293b;">
|
||||||
|
<a href="/" class="btn-ghost">← Retour</a>
|
||||||
|
<span class="text-xl">📊</span>
|
||||||
|
<span class="font-bold flex-1">Statistiques de passages</span>
|
||||||
|
<a href="/whitelist" class="btn-ghost">🛡 Liste blanche</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-3xl mx-auto px-3 py-5">
|
||||||
|
|
||||||
|
{% if not stats %}
|
||||||
|
<div class="text-center py-20 text-slate-500">
|
||||||
|
<div class="text-5xl mb-4">📊</div>
|
||||||
|
<p>Aucune plaque enregistrée pour le moment.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-700">
|
||||||
|
<th class="text-left px-4 py-3 text-slate-400 font-semibold">#</th>
|
||||||
|
<th class="text-left px-4 py-3 text-slate-400 font-semibold">Plaque</th>
|
||||||
|
<th class="text-right px-4 py-3 text-slate-400 font-semibold">Passages</th>
|
||||||
|
<th class="text-left px-4 py-3 text-slate-400 font-semibold hidden md:table-cell">1er passage</th>
|
||||||
|
<th class="text-left px-4 py-3 text-slate-400 font-semibold hidden md:table-cell">Dernier passage</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in stats %}
|
||||||
|
<tr class="border-b border-slate-800 hover:bg-slate-800/40 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-slate-500">{{ loop.index }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/?plate={{ row.plate }}"
|
||||||
|
class="plate font-bold px-3 py-1 rounded text-sm"
|
||||||
|
style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;text-decoration:none;">{{ row.plate }}</a>
|
||||||
|
{% if row.whitelisted %}
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded" style="background:#14532d;color:#4ade80;border:1px solid #16a34a;">✓ connu</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="font-bold text-white text-base">{{ row.count }}</span>
|
||||||
|
<span class="text-slate-500 text-xs ml-1">fois</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-slate-400 hidden md:table-cell text-xs">{{ row.first_str }}</td>
|
||||||
|
<td class="px-4 py-3 text-slate-400 hidden md:table-cell text-xs">{{ row.last_str }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
{% if row.whitelisted %}
|
||||||
|
<form action="/whitelist/remove/{{ row.plate }}" method="post" class="inline">
|
||||||
|
<input type="hidden" name="back" value="/stats">
|
||||||
|
<button type="submit" class="text-xs text-red-400 hover:text-red-300 px-2 py-1 rounded border border-red-900 hover:border-red-700">✕ Retirer</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form action="/whitelist/add" method="post" class="inline">
|
||||||
|
<input type="hidden" name="plate" value="{{ row.plate }}">
|
||||||
|
<input type="hidden" name="back" value="/stats">
|
||||||
|
<button type="submit" class="text-xs text-slate-400 hover:text-green-400 px-2 py-1 rounded border border-slate-700 hover:border-green-700">🛡 Whitelister</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CamWatch — Liste blanche</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; color: #e2e8f0; }
|
||||||
|
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||||||
|
.plate { font-family: monospace; letter-spacing: 0.12em; }
|
||||||
|
.btn-ghost { background: #1e293b; border: 1px solid #475569; color: #94a3b8; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; text-decoration: none; display: inline-block; }
|
||||||
|
.btn-ghost:hover { background: #334155; color: #e2e8f0; }
|
||||||
|
label { color: #64748b; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||||||
|
input[type=text] { background: #0f172a; border: 1px solid #475569; color: #e2e8f0; border-radius: 6px; padding: 6px 10px; width: 100%; }
|
||||||
|
input[type=text]:focus { outline: none; border-color: #60a5fa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
|
||||||
|
<header class="sticky top-0 z-10 px-4 py-3 flex items-center gap-3" style="background:#0f172a;border-bottom:1px solid #1e293b;">
|
||||||
|
<a href="/" class="btn-ghost">← Retour</a>
|
||||||
|
<span class="text-xl">🛡</span>
|
||||||
|
<span class="font-bold flex-1">Liste blanche</span>
|
||||||
|
<a href="/stats" class="btn-ghost">📊 Stats</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-2xl mx-auto px-3 py-5 space-y-5">
|
||||||
|
|
||||||
|
<!-- Add form -->
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-slate-300 text-sm mb-3">Les plaques listées ici sont <strong class="text-white">ignorées automatiquement</strong> — aucun événement ni fichier ne sera créé lors de leur passage.</p>
|
||||||
|
<form action="/whitelist/add" method="post" class="flex gap-2 flex-wrap">
|
||||||
|
<input type="hidden" name="back" value="/whitelist">
|
||||||
|
<input type="text" name="plate" placeholder="Plaque (ex: AB-123-CD)" class="flex-1 uppercase font-mono" style="min-width:140px;">
|
||||||
|
<input type="text" name="label" placeholder="Libellé (ex: Ma voiture)" class="flex-1" style="min-width:160px;">
|
||||||
|
<button type="submit" class="px-4 py-2 rounded text-sm font-medium" style="background:#166534;color:#4ade80;border:1px solid #16a34a;">+ Ajouter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
{% if not entries %}
|
||||||
|
<div class="text-center py-12 text-slate-500">
|
||||||
|
<div class="text-4xl mb-3">🛡</div>
|
||||||
|
<p>Aucune plaque en liste blanche.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-700">
|
||||||
|
<th class="text-left px-4 py-3 text-slate-400 font-semibold">Plaque</th>
|
||||||
|
<th class="text-left px-4 py-3 text-slate-400 font-semibold">Libellé</th>
|
||||||
|
<th class="text-left px-4 py-3 text-slate-400 font-semibold hidden sm:table-cell">Ajoutée le</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in entries %}
|
||||||
|
<tr class="border-b border-slate-800 hover:bg-slate-800/40">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="plate font-bold px-3 py-1 rounded text-sm" style="background:#14532d;color:#4ade80;border:1px solid #16a34a;">{{ e.plate }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-slate-300">{{ e.label or '—' }}</td>
|
||||||
|
<td class="px-4 py-3 text-slate-500 text-xs hidden sm:table-cell">{{ e.added_str }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<form action="/whitelist/remove/{{ e.plate }}" method="post" class="inline"
|
||||||
|
onsubmit="return confirm('Retirer {{ e.plate }} de la liste blanche ?')">
|
||||||
|
<input type="hidden" name="back" value="/whitelist">
|
||||||
|
<button type="submit" class="text-xs text-red-400 hover:text-red-300 px-2 py-1 rounded border border-red-900 hover:border-red-700">✕ Retirer</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -255,6 +255,12 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
|
|||||||
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)
|
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)
|
||||||
cv2.imwrite(snapshot_path, best_frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
cv2.imwrite(snapshot_path, best_frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||||
|
|
||||||
|
from database import is_whitelisted
|
||||||
|
if plate and is_whitelisted(plate):
|
||||||
|
log.info(f"Skipped (whitelist): {plate} — event {event_id[:8]}")
|
||||||
|
shutil.rmtree(event_dir, ignore_errors=True)
|
||||||
|
return None
|
||||||
|
|
||||||
hex_color, color_name = _vehicle_color(best_frame, plate_bbox)
|
hex_color, color_name = _vehicle_color(best_frame, plate_bbox)
|
||||||
insert_event(
|
insert_event(
|
||||||
event_id, camera_name, int(time.time()),
|
event_id, camera_name, int(time.time()),
|
||||||
|
|||||||
Reference in New Issue
Block a user