Fix couleur véhicule, supprime filtre caméra, améliore debug
- Couleur : crop autour de la plaque (zone carrosserie au-dessus) au lieu du frame entier ; fallback center 50% si pas de plaque - lpr.read_plate() retourne maintenant (text, conf, bbox) pour exposer la position de la plaque - Index : supprime filtre caméra et badge caméra (une seule caméra) - Carte index : badge ▶ clip si clip disponible - Page détail : section debug (nb frames, taille clip, id), gestion propre des anciens événements sans clip/frames Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
528a158c42
commit
8c2af7dfab
+6
-5
@@ -111,11 +111,12 @@ class PlateAnalyzer:
|
|||||||
avg_conf = float(np.mean(confs)) if confs else 0.0
|
avg_conf = float(np.mean(confs)) if confs else 0.0
|
||||||
return text, avg_conf
|
return text, avg_conf
|
||||||
|
|
||||||
def read_plate(self, frame: np.ndarray) -> tuple[str, float]:
|
def read_plate(self, frame: np.ndarray) -> tuple[str, float, tuple | None]:
|
||||||
"""Detect plate in frame, read text. Returns (plate_text, confidence)."""
|
"""Detect plate, read text. Returns (plate_text, confidence, bbox_or_None).
|
||||||
|
bbox = (x1, y1, x2, y2) in pixel coords."""
|
||||||
plate_boxes = self.detect_plates(frame)
|
plate_boxes = self.detect_plates(frame)
|
||||||
if not plate_boxes:
|
if not plate_boxes:
|
||||||
return "", 0.0
|
return "", 0.0, None
|
||||||
|
|
||||||
x1, y1, x2, y2, plate_conf = plate_boxes[0]
|
x1, y1, x2, y2, plate_conf = plate_boxes[0]
|
||||||
pad = 8
|
pad = 8
|
||||||
@@ -130,6 +131,6 @@ class PlateAnalyzer:
|
|||||||
# Filter noise: plate must have at least 4 alphanumeric chars
|
# Filter noise: plate must have at least 4 alphanumeric chars
|
||||||
alnum = "".join(c for c in text if c.isalnum())
|
alnum = "".join(c for c in text if c.isalnum())
|
||||||
if len(alnum) < 4:
|
if len(alnum) < 4:
|
||||||
return "", 0.0
|
return "", 0.0, (int(x1), int(y1), int(x2), int(y2))
|
||||||
|
|
||||||
return text, (plate_conf + ocr_conf) / 2.0
|
return text, (plate_conf + ocr_conf) / 2.0, (int(x1), int(y1), int(x2), int(y2))
|
||||||
|
|||||||
+16
-3
@@ -78,6 +78,11 @@ async def index(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/event/{event_id}", response_class=HTMLResponse)
|
@app.get("/event/{event_id}", response_class=HTMLResponse)
|
||||||
async def event_detail(request: Request, event_id: str):
|
async def event_detail(request: Request, event_id: str):
|
||||||
ev = database.get_event(event_id)
|
ev = database.get_event(event_id)
|
||||||
@@ -86,18 +91,26 @@ async def event_detail(request: Request, event_id: str):
|
|||||||
|
|
||||||
ev["time_str"] = ts_to_str(ev["start_time"])
|
ev["time_str"] = ts_to_str(ev["start_time"])
|
||||||
|
|
||||||
# List frames for this event
|
|
||||||
event_dir = os.path.join(EVENTS_DIR, event_id)
|
event_dir = os.path.join(EVENTS_DIR, event_id)
|
||||||
frame_paths = sorted(glob.glob(os.path.join(event_dir, "frame_*.jpg")))
|
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))]
|
frames = [f"events/{event_id}/{os.path.basename(p)}" for p in frame_paths]
|
||||||
|
|
||||||
has_clip = ev.get("clip_path") and os.path.exists(os.path.join("/data", ev["clip_path"]))
|
clip_abs = os.path.join("/data", ev["clip_path"]) if ev.get("clip_path") else None
|
||||||
|
has_clip = bool(clip_abs and os.path.exists(clip_abs))
|
||||||
|
clip_size = ""
|
||||||
|
if has_clip:
|
||||||
|
size = os.path.getsize(clip_abs)
|
||||||
|
clip_size = f"{size // 1024 // 1024}MB" if size > 1024 * 1024 else f"{size // 1024}KB"
|
||||||
|
|
||||||
return templates.TemplateResponse("event_detail.html", {
|
return templates.TemplateResponse("event_detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"ev": ev,
|
"ev": ev,
|
||||||
"frames": frames,
|
"frames": frames,
|
||||||
"has_clip": has_clip,
|
"has_clip": has_clip,
|
||||||
|
"clip_size": clip_size,
|
||||||
|
"capture_duration": CAPTURE_DURATION,
|
||||||
|
"capture_total": CAPTURE_DURATION * CAPTURE_FPS,
|
||||||
|
"top_frames": TOP_FRAMES,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,119 +3,115 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>CamWatch — Passage {{ ev.time_str }}</title>
|
<title>CamWatch — {{ ev.time_str }}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script>tailwind.config = { darkMode: 'class' }</script>
|
<script>tailwind.config = { darkMode: 'class' }</script>
|
||||||
<style>
|
<style>
|
||||||
body { background: #0f172a; color: #e2e8f0; }
|
body { background: #0f172a; color: #e2e8f0; }
|
||||||
.card { background: #1e293b; border: 1px solid #334155; }
|
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||||||
.plate { font-family: monospace; letter-spacing: 0.15em; }
|
.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-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:hover { background: #1d4ed8; }
|
|
||||||
.btn-ghost { background: #1e293b; border: 1px solid #475569; color: #94a3b8; }
|
|
||||||
.btn-ghost:hover { background: #334155; color: #e2e8f0; }
|
.btn-ghost:hover { background: #334155; color: #e2e8f0; }
|
||||||
.frame-thumb { cursor: pointer; transition: transform 0.1s; }
|
.frame-thumb { cursor: pointer; border: 2px solid #334155; border-radius: 6px; overflow: hidden; transition: border-color 0.15s; }
|
||||||
.frame-thumb:hover { transform: scale(1.03); border-color: #3b82f6 !important; }
|
.frame-thumb:hover { border-color: #3b82f6; }
|
||||||
.frame-thumb.active { border-color: #3b82f6 !important; }
|
.frame-thumb.active { border-color: #3b82f6; }
|
||||||
|
label { color: #64748b; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen">
|
||||||
|
|
||||||
<!-- Header -->
|
<header class="sticky top-0 z-10 px-4 py-3 flex items-center gap-3" style="background:#0f172a;border-bottom:1px solid #1e293b;">
|
||||||
<header class="sticky top-0 z-10 px-4 py-3 flex items-center justify-between" style="background:#0f172a;border-bottom:1px solid #1e293b;">
|
<a href="/" class="btn-ghost">← Retour</a>
|
||||||
<div class="flex items-center gap-3">
|
<span class="text-xl">🚗</span>
|
||||||
<a href="/" class="btn btn-ghost text-sm px-3 py-1.5">← Retour</a>
|
<span class="font-bold">{{ ev.time_str }}</span>
|
||||||
<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>
|
</header>
|
||||||
|
|
||||||
<main class="max-w-5xl mx-auto px-3 py-5 space-y-5">
|
<main class="max-w-5xl mx-auto px-3 py-5 space-y-4">
|
||||||
|
|
||||||
<!-- Top row: info + main image -->
|
<!-- Main image + info -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
|
||||||
<!-- Info card -->
|
<!-- Info -->
|
||||||
<div class="card rounded-xl p-4 space-y-3">
|
<div class="card p-4 space-y-4">
|
||||||
<h2 class="text-slate-400 text-xs uppercase font-semibold tracking-wide">Informations</h2>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-slate-500 mb-0.5">Date / Heure</div>
|
<label>Plaque</label>
|
||||||
<div class="font-medium">{{ ev.time_str }}</div>
|
<div class="mt-1">
|
||||||
|
{% if ev.plate %}
|
||||||
|
<span class="plate text-xl font-bold px-4 py-2 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">{{ ev.plate }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-slate-500 italic">Non lue</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-slate-500 mb-0.5">Caméra</div>
|
<label>Couleur véhicule</label>
|
||||||
<div class="font-medium">{{ ev.camera }}</div>
|
<div class="flex items-center gap-2 mt-1">
|
||||||
</div>
|
<div class="w-7 h-7 rounded-full border-2 border-slate-600" style="background:{{ ev.color_hex or '#808080' }};"></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="font-medium">{{ ev.color_name or '—' }}</span>
|
||||||
<span class="text-slate-600 text-xs">{{ ev.color_hex or '' }}</span>
|
<span class="text-slate-600 text-xs font-mono">{{ ev.color_hex or '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug section -->
|
||||||
|
<div class="pt-2 border-t border-slate-700">
|
||||||
|
<label>Debug</label>
|
||||||
|
<div class="mt-1 space-y-1 text-xs font-mono text-slate-400">
|
||||||
|
<div>id: {{ ev.id[:8] }}…</div>
|
||||||
|
<div>frames: {{ frames|length }} / {{ capture_total }}</div>
|
||||||
|
<div>clip: {% if ev.clip_path %}✓ {{ clip_size }}{% else %}—{% endif %}</div>
|
||||||
|
<div>snapshot: {% if ev.snapshot_path %}✓{% else %}—{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main image viewer -->
|
<!-- Image viewer -->
|
||||||
<div class="md:col-span-2 card rounded-xl overflow-hidden">
|
<div class="md:col-span-2 card overflow-hidden">
|
||||||
<div class="relative bg-black" style="aspect-ratio:16/9;">
|
<div class="bg-black relative" style="aspect-ratio:16/9;">
|
||||||
<img id="main-img"
|
<img id="main-img"
|
||||||
src="{% if ev.snapshot_path %}/{{ ev.snapshot_path }}{% endif %}"
|
src="{% if ev.snapshot_path %}/{{ ev.snapshot_path }}{% endif %}"
|
||||||
alt="Meilleure frame"
|
|
||||||
class="w-full h-full object-contain">
|
class="w-full h-full object-contain">
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-2 text-xs text-slate-500 text-center" id="img-label">Meilleure frame (score LPR)</div>
|
<div class="px-3 py-2 text-xs text-slate-500 text-center" id="img-label">Meilleure frame (★)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video clip -->
|
<!-- Video clip -->
|
||||||
{% if has_clip %}
|
{% if has_clip %}
|
||||||
<div class="card rounded-xl p-4">
|
<div class="card 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>
|
<label class="block mb-3">Clip vidéo — {{ capture_duration }}s</label>
|
||||||
<video controls class="w-full rounded-lg" style="max-height:400px;background:#000;"
|
<video controls preload="metadata" class="w-full rounded-lg" style="max-height:380px;background:#000;">
|
||||||
preload="metadata">
|
|
||||||
<source src="/{{ ev.clip_path }}" type="video/mp4">
|
<source src="/{{ ev.clip_path }}" type="video/mp4">
|
||||||
Votre navigateur ne supporte pas la vidéo HTML5.
|
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card p-4 text-slate-500 text-sm">
|
||||||
|
<label class="block mb-1">Clip vidéo</label>
|
||||||
|
{% if ev.clip_path %}Fichier introuvable{% else %}Événement antérieur à la fonctionnalité clip{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Frames gallery -->
|
<!-- Frames gallery -->
|
||||||
{% if frames %}
|
{% if frames %}
|
||||||
<div class="card rounded-xl p-4">
|
<div class="card p-4">
|
||||||
<h2 class="text-slate-400 text-xs uppercase font-semibold tracking-wide mb-3">
|
<label class="block mb-3">Frames analysées — {{ frames|length }} meilleures (sur ~{{ capture_total }}), classées par score LPR</label>
|
||||||
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">
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||||
{% for frame_path in frames %}
|
{% for frame_path in frames %}
|
||||||
<div class="frame-thumb rounded overflow-hidden border border-slate-700 {% if loop.first %}active{% endif %}"
|
<div class="frame-thumb {% if loop.first %}active{% endif %}"
|
||||||
onclick="selectFrame(this, '/{{ frame_path }}', 'Frame {{ loop.index }} / {{ frames|length }}')">
|
onclick="selectFrame(this, '/{{ frame_path }}', 'Frame #{{ loop.index }}{% if loop.first %} ★{% endif %}')">
|
||||||
<img src="/{{ frame_path }}" alt="Frame {{ loop.index }}"
|
<img src="/{{ frame_path }}" class="w-full object-cover" style="aspect-ratio:16/9;" loading="lazy">
|
||||||
class="w-full object-cover" style="aspect-ratio:16/9;">
|
<div class="text-center text-xs text-slate-500 py-0.5 bg-slate-900">
|
||||||
<div class="text-center text-xs text-slate-500 py-0.5">
|
|
||||||
#{{ loop.index }}{% if loop.first %} ★{% endif %}
|
#{{ loop.index }}{% if loop.first %} ★{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card p-4 text-slate-500 text-sm">
|
||||||
|
<label class="block mb-1">Frames</label>
|
||||||
|
Événement antérieur à la fonctionnalité frames (pas de dossier /data/events/{{ ev.id[:8] }}…)
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+21
-50
@@ -9,19 +9,17 @@
|
|||||||
<style>
|
<style>
|
||||||
body { background: #0f172a; color: #e2e8f0; }
|
body { background: #0f172a; color: #e2e8f0; }
|
||||||
.card { background: #1e293b; border: 1px solid #334155; }
|
.card { background: #1e293b; border: 1px solid #334155; }
|
||||||
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:0.7rem; font-weight:600; }
|
.plate { font-family: monospace; letter-spacing: 0.12em; }
|
||||||
.plate { font-family: monospace; letter-spacing:0.1em; }
|
input { background: #0f172a; border: 1px solid #475569; color: #e2e8f0; border-radius: 6px; padding: 6px 10px; }
|
||||||
input, select { background:#0f172a; border:1px solid #475569; color:#e2e8f0; border-radius:6px; padding:6px 10px; }
|
input:focus { outline: none; border-color: #60a5fa; }
|
||||||
input:focus, select:focus { outline:none; border-color:#60a5fa; }
|
.btn { background: #2563eb; color: #fff; border-radius: 6px; padding: 7px 16px; font-size: 0.85rem; cursor: pointer; }
|
||||||
.btn { background:#2563eb; color:#fff; border-radius:6px; padding:7px 16px; font-size:0.85rem; cursor:pointer; }
|
.btn:hover { background: #1d4ed8; }
|
||||||
.btn:hover { background:#1d4ed8; }
|
.btn-ghost { background: #1e293b; border: 1px solid #475569; color: #94a3b8; }
|
||||||
.btn-ghost { background:#1e293b; border:1px solid #475569; color:#94a3b8; }
|
.btn-ghost:hover { background: #334155; color: #e2e8f0; }
|
||||||
.btn-ghost:hover { background:#334155; color:#e2e8f0; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<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;">
|
<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-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xl">🚗</span>
|
<span class="text-xl">🚗</span>
|
||||||
@@ -36,20 +34,13 @@
|
|||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<form method="get" class="flex flex-wrap gap-2 mb-5">
|
<form method="get" class="flex flex-wrap gap-2 mb-5">
|
||||||
<input type="text" name="plate" placeholder="Plaque…" value="{{ filter_plate }}" class="w-32">
|
<input type="text" name="plate" placeholder="Plaque…" value="{{ filter_plate }}" class="w-32">
|
||||||
<select name="camera">
|
|
||||||
<option value="">Toutes caméras</option>
|
|
||||||
{% for cam in cameras %}
|
|
||||||
<option value="{{ cam }}" {% if cam == filter_camera %}selected{% endif %}>{{ cam }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<input type="date" name="date" value="{{ filter_date }}">
|
<input type="date" name="date" value="{{ filter_date }}">
|
||||||
<button type="submit" class="btn">Filtrer</button>
|
<button type="submit" class="btn">Filtrer</button>
|
||||||
{% if filter_plate or filter_camera or filter_date %}
|
{% if filter_plate or filter_date %}
|
||||||
<a href="/" class="btn btn-ghost">✕ Reset</a>
|
<a href="/" class="btn btn-ghost">✕ Reset</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
{% if not events %}
|
{% if not events %}
|
||||||
<div class="text-center py-20 text-slate-500">
|
<div class="text-center py-20 text-slate-500">
|
||||||
<div class="text-5xl mb-4">📷</div>
|
<div class="text-5xl mb-4">📷</div>
|
||||||
@@ -58,46 +49,32 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- 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 %}
|
||||||
<a href="/event/{{ ev.id }}" class="card rounded-xl overflow-hidden block hover:border-blue-500 transition-colors" style="text-decoration:none;color:inherit;">
|
<a href="/event/{{ ev.id }}" class="card rounded-xl overflow-hidden block transition-colors hover:border-blue-500" style="text-decoration:none;color:inherit;">
|
||||||
<!-- 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 %}
|
||||||
<img src="/{{ ev.snapshot_path }}" alt="snapshot"
|
<img src="/{{ ev.snapshot_path }}" alt="snapshot" class="w-full h-full object-cover"
|
||||||
class="w-full h-full object-cover"
|
onerror="this.parentElement.innerHTML='<div class=\'w-full h-full flex items-center justify-center text-slate-600 text-4xl\'>📷</div>'">
|
||||||
onerror="this.parentElement.innerHTML='<div class=\'w-full h-full flex items-center justify-center text-slate-600 text-3xl\'>📷</div>'">
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="w-full h-full flex items-center justify-center text-slate-600 text-3xl">📷</div>
|
<div class="w-full h-full flex items-center justify-center text-slate-600 text-4xl">📷</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if ev.clip_path %}
|
||||||
|
<span class="absolute top-2 right-2 text-xs px-1.5 py-0.5 rounded" style="background:rgba(0,0,0,0.7);color:#4ade80;">▶ clip</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Camera badge -->
|
|
||||||
<span class="absolute top-2 left-2 badge" style="background:rgba(0,0,0,0.6);color:#94a3b8;">
|
|
||||||
{{ ev.camera }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info -->
|
|
||||||
<div class="p-3 space-y-2">
|
<div class="p-3 space-y-2">
|
||||||
<!-- Time -->
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-slate-300 text-sm font-medium">{{ ev.time_str }}</span>
|
<span class="text-slate-300 text-sm font-medium">{{ ev.time_str }}</span>
|
||||||
<!-- Color swatch -->
|
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<div class="w-4 h-4 rounded-full border border-slate-600"
|
<div class="w-4 h-4 rounded-full border border-slate-600" style="background:{{ ev.color_hex or '#808080' }};" title="{{ ev.color_name }}"></div>
|
||||||
style="background:{{ ev.color_hex or '#808080' }};"
|
|
||||||
title="{{ ev.color_hex }}"></div>
|
|
||||||
<span class="text-slate-400 text-xs">{{ ev.color_name or '—' }}</span>
|
<span class="text-slate-400 text-xs">{{ ev.color_name or '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<!-- Plate -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{% if ev.plate %}
|
{% if ev.plate %}
|
||||||
<span class="plate text-sm font-bold px-3 py-1 rounded"
|
<span class="plate text-sm font-bold px-3 py-1 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">{{ ev.plate }}</span>
|
||||||
style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">
|
|
||||||
{{ ev.plate }}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-slate-500 text-sm italic">Plaque non lue</span>
|
<span class="text-slate-500 text-sm italic">Plaque non lue</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -107,26 +84,20 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="flex items-center justify-center gap-2 mt-6">
|
<div class="flex items-center justify-center gap-2 mt-6">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a href="?page={{ page - 1 }}&plate={{ filter_plate }}&camera={{ filter_camera }}&date={{ filter_date }}"
|
<a href="?page={{ page - 1 }}&plate={{ filter_plate }}&date={{ filter_date }}" class="btn btn-ghost text-sm">← Préc.</a>
|
||||||
class="btn btn-ghost text-sm">← Préc.</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="text-slate-400 text-sm">Page {{ page }} / {{ total_pages }}</span>
|
<span class="text-slate-400 text-sm">Page {{ page }} / {{ total_pages }}</span>
|
||||||
{% if page < total_pages %}
|
{% if page < total_pages %}
|
||||||
<a href="?page={{ page + 1 }}&plate={{ filter_plate }}&camera={{ filter_camera }}&date={{ filter_date }}"
|
<a href="?page={{ page + 1 }}&plate={{ filter_plate }}&date={{ filter_date }}" class="btn btn-ghost text-sm">Suiv. →</a>
|
||||||
class="btn btn-ghost text-sm">Suiv. →</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Auto-refresh every 60s -->
|
<script>setTimeout(() => location.reload(), 60000);</script>
|
||||||
<script>
|
|
||||||
setTimeout(() => location.reload(), 60000);
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+28
-4
@@ -80,6 +80,29 @@ def _get_ai_state() -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _vehicle_color(frame: np.ndarray, plate_bbox: tuple | None) -> tuple[str, str]:
|
||||||
|
"""Extract dominant color from the vehicle body (above the plate, or center frame)."""
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
if plate_bbox:
|
||||||
|
px1, py1, px2, py2 = plate_bbox
|
||||||
|
pw = px2 - px1
|
||||||
|
ph = py2 - py1
|
||||||
|
# Vehicle body: above plate, same horizontal span expanded ×3
|
||||||
|
crop_x1 = max(0, px1 - pw * 2)
|
||||||
|
crop_x2 = min(w, px2 + pw * 2)
|
||||||
|
crop_y2 = max(0, py1 - 5)
|
||||||
|
crop_y1 = max(0, py1 - ph * 10) # 10× plate height above plate
|
||||||
|
if crop_y2 > crop_y1 and crop_x2 > crop_x1:
|
||||||
|
crop = frame[int(crop_y1):int(crop_y2), int(crop_x1):int(crop_x2)]
|
||||||
|
return extract_dominant_color_from_frame(crop)
|
||||||
|
# Fallback: center 50% of image (excludes sky at top, road at bottom)
|
||||||
|
cy1 = h // 4
|
||||||
|
cy2 = 3 * h // 4
|
||||||
|
cx1 = w // 4
|
||||||
|
cx2 = 3 * w // 4
|
||||||
|
return extract_dominant_color_from_frame(frame[cy1:cy2, cx1:cx2])
|
||||||
|
|
||||||
|
|
||||||
def _frame_score(frame: np.ndarray, plates: list) -> float:
|
def _frame_score(frame: np.ndarray, plates: list) -> float:
|
||||||
if plates:
|
if plates:
|
||||||
x1, y1, x2, y2, conf = plates[0]
|
x1, y1, x2, y2, conf = plates[0]
|
||||||
@@ -152,17 +175,18 @@ def _process_event():
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Step 4: run LPR on best frame
|
# Step 4: run LPR on best frame
|
||||||
plate, conf = ("", 0.0)
|
plate, conf, plate_bbox = ("", 0.0, None)
|
||||||
if _analyzer:
|
if _analyzer:
|
||||||
plate, conf = _analyzer.read_plate(best_frame)
|
plate, conf, plate_bbox = _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} bbox={plate_bbox}")
|
||||||
|
|
||||||
# Step 5: save thumbnail (best frame)
|
# Step 5: save thumbnail (best frame)
|
||||||
snapshot_file = f"{event_id}.jpg"
|
snapshot_file = f"{event_id}.jpg"
|
||||||
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])
|
||||||
|
|
||||||
hex_color, color_name = extract_dominant_color_from_frame(best_frame)
|
# Color: crop vehicle body above plate (avoids sky/road)
|
||||||
|
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()),
|
||||||
f"snapshots/{snapshot_file}",
|
f"snapshots/{snapshot_file}",
|
||||||
|
|||||||
Reference in New Issue
Block a user