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:
co-authored by
Claude Sonnet 4.6
parent
8507987ea9
commit
528a158c42
+17
-4
@@ -16,12 +16,18 @@ def init_db():
|
|||||||
camera TEXT,
|
camera TEXT,
|
||||||
start_time INTEGER,
|
start_time INTEGER,
|
||||||
snapshot_path TEXT,
|
snapshot_path TEXT,
|
||||||
|
clip_path TEXT,
|
||||||
plate TEXT,
|
plate TEXT,
|
||||||
color_hex TEXT,
|
color_hex TEXT,
|
||||||
color_name TEXT,
|
color_name TEXT,
|
||||||
processed_at INTEGER
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -31,14 +37,14 @@ def event_exists(event_id: str) -> bool:
|
|||||||
conn.close()
|
conn.close()
|
||||||
return row is not None
|
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
|
import time
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR IGNORE INTO events
|
INSERT OR IGNORE INTO events
|
||||||
(id, camera, start_time, snapshot_path, plate, color_hex, color_name, processed_at)
|
(id, camera, start_time, snapshot_path, clip_path, plate, color_hex, color_name, processed_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (event_id, camera, start_time, snapshot_path, plate, color_hex, color_name, int(time.time())))
|
""", (event_id, camera, start_time, snapshot_path, clip_path, plate, color_hex, color_name, int(time.time())))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -94,6 +100,13 @@ def count_events(plate_filter=None, camera_filter=None, date_filter=None):
|
|||||||
conn.close()
|
conn.close()
|
||||||
return count
|
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():
|
def get_cameras():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
rows = conn.execute("SELECT DISTINCT camera FROM events ORDER BY camera").fetchall()
|
rows = conn.execute("SELECT DISTINCT camera FROM events ORDER BY camera").fetchall()
|
||||||
|
|||||||
+32
-5
@@ -1,9 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
import glob
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request, Query
|
from fastapi import FastAPI, Request, Query, HTTPException
|
||||||
from fastapi.responses import HTMLResponse, FileResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -13,8 +14,10 @@ import watcher
|
|||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots")
|
SNAPSHOTS_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots")
|
||||||
os.makedirs(DATA_DIR, exist_ok=True)
|
EVENTS_DIR = os.environ.get("EVENTS_DIR", "/data/events")
|
||||||
|
os.makedirs(SNAPSHOTS_DIR, exist_ok=True)
|
||||||
|
os.makedirs(EVENTS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -26,7 +29,8 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
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")
|
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")
|
@app.get("/api/events")
|
||||||
async def api_events(
|
async def api_events(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<!-- Events grid -->
|
<!-- Events grid -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{% for ev in events %}
|
{% 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 -->
|
<!-- Snapshot -->
|
||||||
<div class="relative bg-black" style="aspect-ratio:16/9;">
|
<div class="relative bg-black" style="aspect-ratio:16/9;">
|
||||||
{% if ev.snapshot_path %}
|
{% if ev.snapshot_path %}
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+54
-49
@@ -1,9 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import shutil
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
|
||||||
import glob
|
import glob
|
||||||
import requests
|
import requests
|
||||||
import cv2
|
import cv2
|
||||||
@@ -22,9 +22,11 @@ CAMERA_PASS = os.environ.get("CAMERA_PASS", "")
|
|||||||
CAMERA_RTSP = os.environ.get("CAMERA_RTSP", "")
|
CAMERA_RTSP = os.environ.get("CAMERA_RTSP", "")
|
||||||
CAMERA_NAME = os.environ.get("CAMERA_NAME", "portail")
|
CAMERA_NAME = os.environ.get("CAMERA_NAME", "portail")
|
||||||
SNAPSHOTS_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots")
|
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"))
|
POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "2"))
|
||||||
CAPTURE_DURATION = int(os.environ.get("CAPTURE_DURATION", "15"))
|
CAPTURE_DURATION = int(os.environ.get("CAPTURE_DURATION", "15"))
|
||||||
CAPTURE_FPS = int(os.environ.get("CAPTURE_FPS", "5"))
|
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"))
|
COOLDOWN = int(os.environ.get("COOLDOWN", "60"))
|
||||||
|
|
||||||
_token: str | None = None
|
_token: str | None = None
|
||||||
@@ -54,7 +56,6 @@ def _login() -> str | None:
|
|||||||
if data[0]["code"] == 0:
|
if data[0]["code"] == 0:
|
||||||
_token = data[0]["value"]["Token"]["name"]
|
_token = data[0]["value"]["Token"]["name"]
|
||||||
_token_time = time.time()
|
_token_time = time.time()
|
||||||
log.debug("Camera login OK")
|
|
||||||
return _token
|
return _token
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"Login error: {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:
|
def _frame_score(frame: np.ndarray, plates: list) -> float:
|
||||||
"""Score a frame: prefer large, high-confidence plates. Fallback to sharpness."""
|
|
||||||
if plates:
|
if plates:
|
||||||
x1, y1, x2, y2, conf = plates[0]
|
x1, y1, x2, y2, conf = plates[0]
|
||||||
return (x2 - x1) * (y2 - y1) * conf
|
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
|
return cv2.Laplacian(gray, cv2.CV_64F).var() * 0.001
|
||||||
|
|
||||||
|
|
||||||
def _capture_best_frame() -> np.ndarray | None:
|
def _process_event():
|
||||||
|
event_id = str(uuid.uuid4())
|
||||||
|
event_dir = os.path.join(EVENTS_DIR, event_id)
|
||||||
|
os.makedirs(event_dir, exist_ok=True)
|
||||||
|
|
||||||
url = _rtsp_url()
|
url = _rtsp_url()
|
||||||
log.info(f"Capturing {CAPTURE_DURATION}s at {CAPTURE_FPS}fps via ffmpeg...")
|
clip_path = os.path.join(event_dir, "clip.mp4")
|
||||||
|
frames_pattern = os.path.join(event_dir, "frame_%04d.jpg")
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
log.info(f"Capturing {CAPTURE_DURATION}s at {CAPTURE_FPS}fps — event {event_id[:8]}")
|
||||||
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)
|
# Step 1: capture clip
|
||||||
ret = subprocess.run([
|
ret = subprocess.run([
|
||||||
"ffmpeg", "-y",
|
"ffmpeg", "-y", "-rtsp_transport", "tcp",
|
||||||
"-rtsp_transport", "tcp",
|
"-i", url, "-t", str(CAPTURE_DURATION), "-c", "copy", clip_path,
|
||||||
"-i", url,
|
|
||||||
"-t", str(CAPTURE_DURATION),
|
|
||||||
"-c", "copy",
|
|
||||||
clip_path,
|
|
||||||
], capture_output=True, timeout=CAPTURE_DURATION + 10)
|
], capture_output=True, timeout=CAPTURE_DURATION + 10)
|
||||||
|
|
||||||
if not os.path.exists(clip_path) or os.path.getsize(clip_path) < 1000:
|
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')}")
|
log.error(f"ffmpeg capture failed: {ret.stderr[-200:].decode(errors='ignore')}")
|
||||||
return None
|
shutil.rmtree(event_dir, ignore_errors=True)
|
||||||
|
return
|
||||||
|
|
||||||
# Step 2: extract frames at CAPTURE_FPS
|
# Step 2: extract frames
|
||||||
subprocess.run([
|
subprocess.run([
|
||||||
"ffmpeg", "-y",
|
"ffmpeg", "-y", "-i", clip_path,
|
||||||
"-i", clip_path,
|
"-vf", f"fps={CAPTURE_FPS}", "-q:v", "2", frames_pattern,
|
||||||
"-vf", f"fps={CAPTURE_FPS}",
|
|
||||||
"-q:v", "2",
|
|
||||||
frames_pattern,
|
|
||||||
], capture_output=True, timeout=30)
|
], capture_output=True, timeout=30)
|
||||||
|
|
||||||
frame_files = sorted(glob.glob(os.path.join(tmpdir, "frame_*.jpg")))
|
frame_files = sorted(glob.glob(os.path.join(event_dir, "frame_*.jpg")))
|
||||||
log.info(f"Extracted {len(frame_files)} frames")
|
log.info(f"Extracted {len(frame_files)} frames")
|
||||||
|
|
||||||
if not frame_files:
|
if not frame_files:
|
||||||
return None
|
shutil.rmtree(event_dir, ignore_errors=True)
|
||||||
|
return
|
||||||
# Step 3: score each frame, keep the best
|
|
||||||
best_frame: np.ndarray | None = None
|
|
||||||
best_score = -1.0
|
|
||||||
|
|
||||||
|
# Step 3: score and select top frames
|
||||||
|
scored: list[tuple[float, np.ndarray, str]] = []
|
||||||
for path in frame_files:
|
for path in frame_files:
|
||||||
frame = cv2.imread(path)
|
frame = cv2.imread(path)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
continue
|
continue
|
||||||
plates = _analyzer.detect_plates(frame) if _analyzer else []
|
plates = _analyzer.detect_plates(frame) if _analyzer else []
|
||||||
score = _frame_score(frame, plates)
|
score = _frame_score(frame, plates)
|
||||||
if score > best_score:
|
scored.append((score, frame, path))
|
||||||
best_score = score
|
|
||||||
best_frame = frame.copy()
|
|
||||||
|
|
||||||
log.info(f"Best frame score: {best_score:.2f}")
|
scored.sort(key=lambda x: -x[0])
|
||||||
return best_frame
|
|
||||||
|
|
||||||
|
# Delete frames below TOP_FRAMES
|
||||||
|
for _, _, path in scored[TOP_FRAMES:]:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
def _process_event():
|
# Rename kept frames to sorted order (best first = frame_0001)
|
||||||
frame = _capture_best_frame()
|
kept = scored[:TOP_FRAMES]
|
||||||
if frame is None:
|
for i, (_, _, old_path) in enumerate(kept):
|
||||||
log.warning("No frame captured")
|
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
|
return
|
||||||
|
|
||||||
event_id = str(uuid.uuid4())
|
# Step 4: run LPR on best frame
|
||||||
snapshot_file = f"{event_id}.jpg"
|
plate, conf = ("", 0.0)
|
||||||
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)
|
|
||||||
cv2.imwrite(snapshot_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
|
||||||
|
|
||||||
plate, conf = "", 0.0
|
|
||||||
if _analyzer:
|
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}")
|
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(
|
insert_event(
|
||||||
event_id, CAMERA_NAME, int(time.time()),
|
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():
|
def run_watcher():
|
||||||
global _last_event_time, _analyzer
|
global _last_event_time, _analyzer
|
||||||
|
|
||||||
|
os.makedirs(EVENTS_DIR, exist_ok=True)
|
||||||
log.info("Loading plate analyzer...")
|
log.info("Loading plate analyzer...")
|
||||||
_analyzer = PlateAnalyzer()
|
_analyzer = PlateAnalyzer()
|
||||||
|
|
||||||
@@ -184,7 +189,7 @@ def run_watcher():
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
if now - _last_event_time > COOLDOWN:
|
if now - _last_event_time > COOLDOWN:
|
||||||
_last_event_time = now
|
_last_event_time = now
|
||||||
log.info("Vehicle detected! Triggering capture...")
|
log.info("Vehicle detected!")
|
||||||
_process_event()
|
_process_event()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Watcher loop error: {e}", exc_info=True)
|
log.error(f"Watcher loop error: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ services:
|
|||||||
- MODEL_CACHE=/models
|
- MODEL_CACHE=/models
|
||||||
- DB_PATH=/data/camwatch.db
|
- DB_PATH=/data/camwatch.db
|
||||||
- SNAPSHOTS_DIR=/data/snapshots
|
- SNAPSHOTS_DIR=/data/snapshots
|
||||||
|
- EVENTS_DIR=/data/events
|
||||||
- POLL_INTERVAL=2
|
- POLL_INTERVAL=2
|
||||||
- CAPTURE_DURATION=15
|
- CAPTURE_DURATION=15
|
||||||
- CAPTURE_FPS=5
|
- CAPTURE_FPS=5
|
||||||
|
|||||||
Reference in New Issue
Block a user