Toutes les frames, clip web-compatible, lightbox plein écran
- Garde toutes les frames (plus de limite TOP_FRAMES), triées score LPR desc - Clip remuxé avec -movflags +faststart pour lecture directe navigateur + lien téléchargement - Lightbox : double-clic sur frame → plein écran, navigation ←/→, Échap pour fermer - Grille 8 colonnes pour voir toutes les frames Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
8c2af7dfab
commit
6aae4ef40c
+1
-2
@@ -80,7 +80,6 @@ async def index(
|
|||||||
|
|
||||||
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"))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/event/{event_id}", response_class=HTMLResponse)
|
@app.get("/event/{event_id}", response_class=HTMLResponse)
|
||||||
@@ -109,8 +108,8 @@ async def event_detail(request: Request, event_id: str):
|
|||||||
"has_clip": has_clip,
|
"has_clip": has_clip,
|
||||||
"clip_size": clip_size,
|
"clip_size": clip_size,
|
||||||
"capture_duration": CAPTURE_DURATION,
|
"capture_duration": CAPTURE_DURATION,
|
||||||
|
"capture_fps": CAPTURE_FPS,
|
||||||
"capture_total": CAPTURE_DURATION * CAPTURE_FPS,
|
"capture_total": CAPTURE_DURATION * CAPTURE_FPS,
|
||||||
"top_frames": TOP_FRAMES,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,21 @@
|
|||||||
.plate { font-family: monospace; letter-spacing: 0.15em; }
|
.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 { 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-ghost:hover { background: #334155; color: #e2e8f0; }
|
||||||
.frame-thumb { cursor: pointer; border: 2px solid #334155; border-radius: 6px; overflow: hidden; transition: border-color 0.15s; }
|
.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; }
|
||||||
.frame-thumb:hover { border-color: #3b82f6; }
|
.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; }
|
.frame-thumb.active { border-color: #3b82f6; }
|
||||||
label { color: #64748b; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen">
|
||||||
@@ -28,10 +39,9 @@
|
|||||||
|
|
||||||
<main class="max-w-5xl mx-auto px-3 py-5 space-y-4">
|
<main class="max-w-5xl mx-auto px-3 py-5 space-y-4">
|
||||||
|
|
||||||
<!-- Main image + info -->
|
<!-- Info + main image -->
|
||||||
<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 -->
|
|
||||||
<div class="card p-4 space-y-4">
|
<div class="card p-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label>Plaque</label>
|
<label>Plaque</label>
|
||||||
@@ -45,7 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label>Couleur véhicule</label>
|
<label>Couleur</label>
|
||||||
<div class="flex items-center gap-2 mt-1">
|
<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>
|
<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="font-medium">{{ ev.color_name or '—' }}</span>
|
||||||
@@ -53,35 +63,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug section -->
|
|
||||||
<div class="pt-2 border-t border-slate-700">
|
<div class="pt-2 border-t border-slate-700">
|
||||||
<label>Debug</label>
|
<label>Debug</label>
|
||||||
<div class="mt-1 space-y-1 text-xs font-mono text-slate-400">
|
<div class="mt-1 space-y-0.5 text-xs font-mono text-slate-400">
|
||||||
<div>id: {{ ev.id[:8] }}…</div>
|
<div>id: {{ ev.id[:8] }}…</div>
|
||||||
<div>frames: {{ frames|length }} / {{ capture_total }}</div>
|
<div>frames: {{ frames|length }} (~{{ capture_total }} capturées)</div>
|
||||||
<div>clip: {% if ev.clip_path %}✓ {{ clip_size }}{% else %}—{% endif %}</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>
|
||||||
|
|
||||||
<!-- Image viewer -->
|
{% 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="md:col-span-2 card overflow-hidden">
|
||||||
<div class="bg-black relative" style="aspect-ratio:16/9;">
|
<div class="bg-black relative cursor-zoom-in" style="aspect-ratio:16/9;"
|
||||||
|
onclick="openLightbox('/{{ ev.snapshot_path }}', 'Meilleure frame ★')">
|
||||||
<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 %}"
|
||||||
class="w-full h-full object-contain">
|
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>
|
||||||
<div class="px-3 py-2 text-xs text-slate-500 text-center" id="img-label">Meilleure frame (★)</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 p-4">
|
<div class="card p-4">
|
||||||
<label class="block mb-3">Clip vidéo — {{ capture_duration }}s</label>
|
<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;">
|
<video controls preload="metadata" class="w-full rounded-lg" style="max-height:380px;background:#000;">
|
||||||
<source src="/{{ ev.clip_path }}" type="video/mp4">
|
<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>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -91,38 +109,91 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Frames gallery -->
|
<!-- All frames -->
|
||||||
{% if frames %}
|
{% if frames %}
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<label class="block mb-3">Frames analysées — {{ frames|length }} meilleures (sur ~{{ capture_total }}), classées par score LPR</label>
|
<label class="block mb-3">
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
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 %}
|
{% for frame_path in frames %}
|
||||||
<div class="frame-thumb {% if loop.first %}active{% endif %}"
|
<div class="frame-thumb {% if loop.first %}active{% endif %}"
|
||||||
onclick="selectFrame(this, '/{{ frame_path }}', 'Frame #{{ loop.index }}{% if loop.first %} ★{% 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">
|
<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">
|
<div class="text-center text-xs text-slate-500 py-0.5 bg-slate-900 leading-none">
|
||||||
#{{ loop.index }}{% if loop.first %} ★{% endif %}
|
#{{ loop.index }}{% if loop.first %} ★{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xs text-slate-600 mt-2">Clic simple → aperçu | Double-clic → plein écran</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card p-4 text-slate-500 text-sm">
|
<div class="card p-4 text-slate-500 text-sm">
|
||||||
<label class="block mb-1">Frames</label>
|
<label class="block mb-1">Frames</label>
|
||||||
Événement antérieur à la fonctionnalité frames (pas de dossier /data/events/{{ ev.id[:8] }}…)
|
Événement antérieur à la fonctionnalité frames
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</main>
|
</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>
|
<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) {
|
function selectFrame(el, src, label) {
|
||||||
document.getElementById('main-img').src = src;
|
document.getElementById('main-img').src = src;
|
||||||
|
document.getElementById('main-img').parentElement.onclick = () => openLightbox(src, label);
|
||||||
document.getElementById('img-label').textContent = label;
|
document.getElementById('img-label').textContent = label;
|
||||||
document.querySelectorAll('.frame-thumb').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.frame-thumb').forEach(t => t.classList.remove('active'));
|
||||||
el.classList.add('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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+17
-14
@@ -26,7 +26,6 @@ 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
|
||||||
@@ -133,12 +132,21 @@ def _process_event():
|
|||||||
shutil.rmtree(event_dir, ignore_errors=True)
|
shutil.rmtree(event_dir, ignore_errors=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Step 2: extract frames
|
# Step 2: extract all frames
|
||||||
subprocess.run([
|
subprocess.run([
|
||||||
"ffmpeg", "-y", "-i", clip_path,
|
"ffmpeg", "-y", "-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)
|
||||||
|
|
||||||
|
# Step 2b: remux clip for browser compatibility (moov atom at start)
|
||||||
|
web_clip = os.path.join(event_dir, "clip_web.mp4")
|
||||||
|
subprocess.run([
|
||||||
|
"ffmpeg", "-y", "-i", clip_path,
|
||||||
|
"-c", "copy", "-movflags", "+faststart", web_clip,
|
||||||
|
], capture_output=True, timeout=20)
|
||||||
|
if os.path.exists(web_clip):
|
||||||
|
os.replace(web_clip, clip_path)
|
||||||
|
|
||||||
frame_files = sorted(glob.glob(os.path.join(event_dir, "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")
|
||||||
|
|
||||||
@@ -146,7 +154,7 @@ def _process_event():
|
|||||||
shutil.rmtree(event_dir, ignore_errors=True)
|
shutil.rmtree(event_dir, ignore_errors=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Step 3: score and select top frames
|
# Step 3: score ALL frames, rename sorted (best = frame_0001)
|
||||||
scored: list[tuple[float, np.ndarray, str]] = []
|
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)
|
||||||
@@ -158,18 +166,13 @@ def _process_event():
|
|||||||
|
|
||||||
scored.sort(key=lambda x: -x[0])
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
|
||||||
# Delete frames below TOP_FRAMES
|
# Rename to sorted order (temp names to avoid collisions)
|
||||||
for _, _, path in scored[TOP_FRAMES:]:
|
for i, (_, _, old_path) in enumerate(scored):
|
||||||
os.remove(path)
|
os.rename(old_path, old_path + ".tmp")
|
||||||
|
for i, (_, _, old_path) in enumerate(scored):
|
||||||
|
os.rename(old_path + ".tmp", os.path.join(event_dir, f"frame_{i+1:04d}.jpg"))
|
||||||
|
|
||||||
# Rename kept frames to sorted order (best first = frame_0001)
|
best_frame = scored[0][1] if scored else None
|
||||||
kept = scored[:TOP_FRAMES]
|
|
||||||
for i, (_, _, old_path) in enumerate(kept):
|
|
||||||
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:
|
if best_frame is None:
|
||||||
shutil.rmtree(event_dir, ignore_errors=True)
|
shutil.rmtree(event_dir, ignore_errors=True)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user