Files
camwatch/app/templates/event_detail.html
T
percoandClaude Sonnet 4.6 31bb5d86c6 Crop thumbnail to vehicle area instead of full frame
When a plate bbox is detected, the snapshot thumbnail is now cropped
around the vehicle (plate bbox + generous margins) and resized to
≤1280px wide. This makes the grid on the index page much more useful.

The full best frame is saved separately as best_frame.jpg and used
as the main image in the event detail view. Without a plate bbox,
fallback crops the center 80% of the frame.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:14:25 +02:00

288 lines
13 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 flex-1">{{ ev.time_str }}</span>
<a href="/event/{{ ev.id }}/annotate" class="btn-ghost">✏ Annoter</a>
{% 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"
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>
</form>
</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>Détection brute (4×)</label>
<img src="/{{ plate_crop }}" class="mt-1 rounded border border-slate-600 w-full" style="image-rendering:pixelated;">
</div>
{% endif %}
{% if plate_ocr %}
<div class="mb-2">
<label>Image utilisée pour l'OCR</label>
<img src="/{{ plate_ocr }}" class="mt-1 rounded border border-blue-700 w-full" style="image-rendering:pixelated;">
</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('/{{ best_frame_path }}', 'Meilleure frame ★')">
<img id="main-img"
src="{% if best_frame_path %}/{{ best_frame_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>
<div id="lb-zoom-hint" style="position:absolute;top:12px;left:50%;transform:translateX(-50%);color:#64748b;font-size:0.75rem;pointer-events:none;">
Molette pour zoomer · Glisser pour déplacer · <span id="lb-zoom-pct">100%</span>
&nbsp;·&nbsp;<a id="lb-fullres" href="#" target="_blank" style="color:#60a5fa;text-decoration:underline;">Ouvrir en plein écran</a>
</div>
<img id="lb-img" src="" alt="" style="transform-origin:center center;">
<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;
// Lightbox zoom/pan state
let lbZ = 1, lbOx = 0, lbOy = 0, lbDrag = null;
function lbResetZoom() {
lbZ = 1; lbOx = 0; lbOy = 0;
const im = document.getElementById('lb-img');
im.style.transform = '';
im.style.cursor = '';
document.getElementById('lb-zoom-pct').textContent = '100%';
}
function lbApplyTransform() {
document.getElementById('lb-img').style.transform =
`translate(${lbOx}px,${lbOy}px) scale(${lbZ})`;
document.getElementById('lb-zoom-pct').textContent = Math.round(lbZ*100)+'%';
document.getElementById('lb-img').style.cursor = lbZ > 1 ? 'grab' : '';
}
document.getElementById('lightbox').addEventListener('wheel', e => {
if (!document.getElementById('lightbox').classList.contains('open')) return;
e.preventDefault();
const factor = e.deltaY < 0 ? 1.3 : 1/1.3;
lbZ = Math.max(0.5, Math.min(20, lbZ * factor));
lbApplyTransform();
}, { passive: false });
document.getElementById('lb-img').addEventListener('mousedown', e => {
if (lbZ <= 1) return;
e.stopPropagation();
lbDrag = [e.clientX - lbOx, e.clientY - lbOy];
document.getElementById('lb-img').style.cursor = 'grabbing';
});
document.addEventListener('mousemove', e => {
if (!lbDrag) return;
lbOx = e.clientX - lbDrag[0];
lbOy = e.clientY - lbDrag[1];
lbApplyTransform();
});
document.addEventListener('mouseup', () => {
if (lbDrag) { lbDrag = null; if (lbZ > 1) document.getElementById('lb-img').style.cursor = 'grab'; }
});
function openLightbox(src, label) {
lbResetZoom();
document.getElementById('lb-img').src = src;
document.getElementById('lb-label').textContent = label;
document.getElementById('lb-fullres').href = src;
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');
lbResetZoom();
}
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;
lbResetZoom();
document.getElementById('lb-img').src = '/' + lbFrames[lbIdx];
document.getElementById('lb-fullres').href = '/' + 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>