Files
camwatch/app/templates/event_detail.html
T
percoandClaude Sonnet 4.6 13e6038069 Improve LPR accuracy and add manual plate edit
- Tiled YOLO (3x2, 20% overlap): plate confidence 0.307 -> 0.838 on test frame
- Aspect ratio filter (2.0-6.5:1): rejects non-plate shapes
- Confidence threshold 0.03 -> 0.04: removes fence/noise false positives
- Exclude top 15% (sky) and bottom 8% (timestamp overlay) from detection zone
- Add Tesseract as primary OCR (better for Latin plates), PaddleOCR as fallback
- Enhance plate crop before OCR: CLAHE + sharpening + min 80px upscale
- Save plate_crop.jpg (4x upscaled) to event dir for manual review
- Show plate crop in event detail page
- Add manual plate edit form in event detail (POST /event/{id}/plate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:45:50 +02:00

213 lines
9.6 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CamWatch — {{ 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; border-radius: 12px; }
.plate { font-family: monospace; letter-spacing: 0.15em; }
.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; }
.btn-dl { background: #166534; border: 1px solid #16a34a; color: #4ade80; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; text-decoration: none; display: inline-block; }
.btn-dl:hover { background: #14532d; }
.frame-thumb { cursor: zoom-in; border: 2px solid #334155; border-radius: 6px; overflow: hidden; transition: border-color 0.12s; }
.frame-thumb:hover { border-color: #60a5fa; }
.frame-thumb.active { border-color: #3b82f6; }
label { color: #64748b; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
/* Lightbox */
#lightbox { display:none; position:fixed; inset:0; z-index:50; background:rgba(0,0,0,0.92); align-items:center; justify-content:center; }
#lightbox.open { display:flex; }
#lightbox img { max-width:95vw; max-height:92vh; object-fit:contain; border-radius:4px; }
#lb-nav { position:absolute; bottom:16px; left:50%; transform:translateX(-50%); display:flex; gap:12px; align-items:center; }
#lb-close { position:absolute; top:12px; right:16px; font-size:1.8rem; color:#94a3b8; cursor:pointer; line-height:1; }
#lb-close:hover { color:#fff; }
#lb-label { color:#94a3b8; font-size:0.85rem; }
</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">{{ ev.time_str }}</span>
</header>
<main class="max-w-5xl mx-auto px-3 py-5 space-y-4">
<!-- Info + main image -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="card p-4 space-y-4">
<div>
<label>Plaque</label>
<div class="mt-1 mb-2">
{% 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 text-sm">Non lue automatiquement</span>
{% endif %}
</div>
{% if plate_crop %}
<div class="mb-2">
<label>Crop détecté</label>
<img src="/{{ plate_crop }}" class="mt-1 rounded border border-slate-600 w-full" style="image-rendering:pixelated;" title="Zoom plate crop">
</div>
{% endif %}
<form action="/event/{{ ev.id }}/plate" method="post" class="flex gap-2 mt-1">
<input type="text" name="plate" value="{{ ev.plate or '' }}"
placeholder="Saisir plaque…"
class="flex-1 text-sm font-mono uppercase"
style="background:#0f172a;border:1px solid #475569;color:#e2e8f0;border-radius:6px;padding:5px 8px;">
<button type="submit" class="text-xs px-3 py-1 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;"></button>
</form>
</div>
<div>
<label>Couleur</label>
<div class="flex items-center gap-2 mt-1">
<div class="w-7 h-7 rounded-full border-2 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 font-mono">{{ ev.color_hex or '' }}</span>
</div>
</div>
<div class="pt-2 border-t border-slate-700">
<label>Debug</label>
<div class="mt-1 space-y-0.5 text-xs font-mono text-slate-400">
<div>id: {{ ev.id[:8] }}…</div>
<div>frames: {{ frames|length }} (~{{ capture_total }} capturées)</div>
<div>clip: {% if ev.clip_path %}✓ {{ clip_size }}{% else %}—{% endif %}</div>
</div>
</div>
{% if has_clip %}
<a href="/{{ ev.clip_path }}" download class="btn-dl w-full text-center">⬇ Télécharger le clip</a>
{% endif %}
</div>
<div class="md:col-span-2 card overflow-hidden">
<div class="bg-black relative cursor-zoom-in" style="aspect-ratio:16/9;"
onclick="openLightbox('/{{ ev.snapshot_path }}', 'Meilleure frame ★')">
<img id="main-img"
src="{% if ev.snapshot_path %}/{{ ev.snapshot_path }}{% endif %}"
class="w-full h-full object-contain">
<span class="absolute bottom-2 right-2 text-xs text-slate-500">🔍 cliquer pour agrandir</span>
</div>
<div class="px-3 py-2 text-xs text-slate-500 text-center" id="img-label">Meilleure frame ★</div>
</div>
</div>
<!-- Video clip -->
{% if has_clip %}
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<label>Clip vidéo — {{ capture_duration }}s</label>
<a href="/{{ ev.clip_path }}" download class="btn-dl text-xs py-1 px-3">⬇ Télécharger</a>
</div>
<video controls preload="metadata" class="w-full rounded-lg" style="max-height:380px;background:#000;">
<source src="/{{ ev.clip_path }}" type="video/mp4">
<p class="text-slate-400 text-sm p-4">Lecture impossible dans le navigateur —
<a href="/{{ ev.clip_path }}" download class="text-blue-400 underline">télécharger le clip</a></p>
</video>
</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 %}
<!-- All frames -->
{% if frames %}
<div class="card p-4">
<label class="block mb-3">
Toutes les frames — {{ frames|length }} extraites à {{ capture_fps }}fps, triées meilleure plaque en premier
</label>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-1.5">
{% for frame_path in frames %}
<div class="frame-thumb {% if loop.first %}active{% endif %}"
onclick="selectFrame(this, '/{{ frame_path }}', 'Frame #{{ loop.index }}{% if loop.first %} ★{% endif %}')"
ondblclick="openLightbox('/{{ frame_path }}', 'Frame #{{ loop.index }}')">
<img src="/{{ frame_path }}" class="w-full object-cover" style="aspect-ratio:16/9;" loading="lazy">
<div class="text-center text-xs text-slate-500 py-0.5 bg-slate-900 leading-none">
#{{ loop.index }}{% if loop.first %} ★{% endif %}
</div>
</div>
{% endfor %}
</div>
<p class="text-xs text-slate-600 mt-2">Clic simple → aperçu | Double-clic → plein écran</p>
</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
</div>
{% endif %}
</main>
<!-- Lightbox -->
<div id="lightbox" onclick="closeLightboxIfOutside(event)">
<span id="lb-close" onclick="closeLightbox()">×</span>
<img id="lb-img" src="" alt="">
<div id="lb-nav">
<button onclick="lbPrev()" class="btn-ghost px-4"></button>
<span id="lb-label" class="text-slate-400 text-sm"></span>
<button onclick="lbNext()" class="btn-ghost px-4"></button>
</div>
</div>
<script>
let lbFrames = {{ frames | tojson }};
let lbIdx = 0;
function openLightbox(src, label) {
document.getElementById('lb-img').src = src;
document.getElementById('lb-label').textContent = label;
// Find index in frames array
const rel = src.startsWith('/') ? src.slice(1) : src;
lbIdx = lbFrames.indexOf(rel);
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
}
function closeLightboxIfOutside(e) {
if (e.target.id === 'lightbox') closeLightbox();
}
function lbGo(idx) {
if (lbFrames.length === 0) return;
lbIdx = ((idx % lbFrames.length) + lbFrames.length) % lbFrames.length;
document.getElementById('lb-img').src = '/' + lbFrames[lbIdx];
document.getElementById('lb-label').textContent = 'Frame #' + (lbIdx + 1) + (lbIdx === 0 ? ' ★' : '');
}
function lbPrev() { lbGo(lbIdx - 1); }
function lbNext() { lbGo(lbIdx + 1); }
function selectFrame(el, src, label) {
document.getElementById('main-img').src = src;
document.getElementById('main-img').parentElement.onclick = () => openLightbox(src, label);
document.getElementById('img-label').textContent = label;
document.querySelectorAll('.frame-thumb').forEach(t => t.classList.remove('active'));
el.classList.add('active');
}
document.addEventListener('keydown', e => {
if (!document.getElementById('lightbox').classList.contains('open')) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') lbPrev();
if (e.key === 'ArrowRight') lbNext();
});
</script>
</body>
</html>