Add MP4 upload endpoint for testing pipeline without live camera

POST /upload saves the file, runs full processing (transcode, frame extraction,
LPR, color detection) and redirects to the resulting event page.
UI: "Tester un clip" button in header reveals a file picker form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-02 17:15:49 +02:00
co-authored by Claude Sonnet 4.6
parent 5594010d78
commit ec214ed7b2
3 changed files with 116 additions and 45 deletions
+23 -2
View File
@@ -2,9 +2,11 @@ import os
import glob
import logging
import threading
import tempfile
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Query, HTTPException
from fastapi.responses import HTMLResponse
from fastapi import FastAPI, Request, Query, HTTPException, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from datetime import datetime
@@ -134,6 +136,25 @@ async def api_events(
return {"events": events, "total": total}
@app.post("/upload")
async def upload_clip(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".mp4"):
raise HTTPException(status_code=400, detail="Seuls les fichiers .mp4 sont acceptés")
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
try:
loop = asyncio.get_event_loop()
event_id = await loop.run_in_executor(None, watcher.process_uploaded_clip, tmp_path)
finally:
os.unlink(tmp_path)
return RedirectResponse(f"/event/{event_id}", status_code=303)
@app.get("/health")
async def health():
return {"status": "ok"}
+33 -2
View File
@@ -16,6 +16,11 @@
.btn:hover { background: #1d4ed8; }
.btn-ghost { background: #1e293b; border: 1px solid #475569; color: #94a3b8; }
.btn-ghost:hover { background: #334155; color: #e2e8f0; }
.btn-upload { background: #1e293b; border: 1px solid #475569; color: #94a3b8; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; cursor: pointer; }
.btn-upload:hover { background: #334155; color: #e2e8f0; }
#upload-panel { display:none; background:#1e293b; border:1px solid #334155; border-radius:10px; padding:16px; margin-bottom:16px; }
#upload-panel.open { display:block; }
#upload-progress { display:none; }
</style>
</head>
<body class="min-h-screen">
@@ -26,11 +31,30 @@
<span class="font-bold text-lg">CamWatch</span>
<span class="text-slate-500 text-sm hidden sm:inline">— Passages véhicules</span>
</div>
<span class="text-slate-400 text-sm">{{ total }} passage{{ 's' if total != 1 else '' }}</span>
<div class="flex items-center gap-2">
<span class="text-slate-400 text-sm">{{ total }} passage{{ 's' if total != 1 else '' }}</span>
<button class="btn-upload" onclick="document.getElementById('upload-panel').classList.toggle('open')">⬆ Tester un clip</button>
</div>
</header>
<main class="max-w-5xl mx-auto px-3 py-4">
<!-- Upload panel -->
<div id="upload-panel">
<p class="text-slate-300 text-sm font-medium mb-3">Tester un clip .mp4 — il sera traité comme un vrai passage (LPR + couleur + frames)</p>
<form id="upload-form" action="/upload" method="post" enctype="multipart/form-data"
onsubmit="startUpload(event)">
<div class="flex items-center gap-3 flex-wrap">
<input type="file" name="file" accept=".mp4,video/mp4" required
class="text-sm text-slate-300 file:mr-3 file:py-1.5 file:px-4 file:rounded file:border-0 file:text-sm file:bg-slate-700 file:text-slate-200 hover:file:bg-slate-600 cursor-pointer">
<button type="submit" class="btn text-sm">Analyser</button>
</div>
</form>
<div id="upload-progress" class="mt-3 text-sm text-slate-400">
⏳ Traitement en cours (extraction frames + LPR)… merci de patienter ~60s
</div>
</div>
<!-- Filters -->
<form method="get" class="flex flex-wrap gap-2 mb-5">
<input type="text" name="plate" placeholder="Plaque…" value="{{ filter_plate }}" class="w-32">
@@ -98,6 +122,13 @@
</main>
<script>setTimeout(() => location.reload(), 60000);</script>
<script>
setTimeout(() => location.reload(), 60000);
function startUpload(e) {
document.getElementById('upload-form').querySelector('button[type=submit]').disabled = true;
document.getElementById('upload-progress').style.display = 'block';
}
</script>
</body>
</html>
+60 -41
View File
@@ -86,15 +86,13 @@ def _vehicle_color(frame: np.ndarray, plate_bbox: tuple | None) -> tuple[str, st
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
crop_y1 = max(0, py1 - ph * 10)
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
@@ -110,58 +108,43 @@ def _frame_score(frame: np.ndarray, plates: list) -> float:
return cv2.Laplacian(gray, cv2.CV_64F).var() * 0.001
def _process_event():
event_id = str(uuid.uuid4())
event_dir = os.path.join(EVENTS_DIR, event_id)
os.makedirs(event_dir, exist_ok=True)
def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: str = None):
"""Process an existing clip: transcode, extract frames, LPR, store in DB."""
if camera_name is None:
camera_name = CAMERA_NAME
url = _rtsp_url()
clip_path = os.path.join(event_dir, "clip.mp4")
frames_pattern = os.path.join(event_dir, "frame_%04d.jpg")
log.info(f"Capturing {CAPTURE_DURATION}s at {CAPTURE_FPS}fps — event {event_id[:8]}")
# Step 1: capture clip
ret = subprocess.run([
"ffmpeg", "-y", "-rtsp_transport", "tcp",
"-i", url, "-t", str(CAPTURE_DURATION), "-c", "copy", clip_path,
], capture_output=True, timeout=CAPTURE_DURATION + 10)
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')}")
shutil.rmtree(event_dir, ignore_errors=True)
return
# Step 2: extract all frames
subprocess.run([
"ffmpeg", "-y", "-i", clip_path,
"-vf", f"fps={CAPTURE_FPS}", "-q:v", "2", frames_pattern,
], capture_output=True, timeout=30)
# Step 2b: transcode to H.264 baseline for browser compatibility
# Transcode to H.264 baseline for browser compatibility
web_clip = os.path.join(event_dir, "clip_web.mp4")
subprocess.run([
"ffmpeg", "-y", "-i", clip_path,
"-c:v", "libx264", "-profile:v", "baseline", "-level", "3.1",
"-preset", "fast", "-crf", "28",
"-vf", "scale=-2:720", # downsample to 720p — enough for review
"-an", # no audio needed for surveillance
"-movflags", "+faststart", # moov atom at front for streaming
"-vf", "scale=-2:720",
"-an",
"-movflags", "+faststart",
web_clip,
], capture_output=True, timeout=60)
], capture_output=True, timeout=120)
if os.path.exists(web_clip):
os.replace(web_clip, clip_path)
else:
log.warning("Transcode failed, keeping raw clip")
# Extract frames
subprocess.run([
"ffmpeg", "-y", "-i", clip_path,
"-vf", f"fps={CAPTURE_FPS}", "-q:v", "2", frames_pattern,
], capture_output=True, timeout=60)
frame_files = sorted(glob.glob(os.path.join(event_dir, "frame_*.jpg")))
log.info(f"Extracted {len(frame_files)} frames")
if not frame_files:
shutil.rmtree(event_dir, ignore_errors=True)
return
return None
# Step 3: score ALL frames, rename sorted (best = frame_0001)
# Score ALL frames, rename sorted (best = frame_0001)
scored: list[tuple[float, np.ndarray, str]] = []
for path in frame_files:
frame = cv2.imread(path)
@@ -173,7 +156,6 @@ def _process_event():
scored.sort(key=lambda x: -x[0])
# Rename to sorted order (temp names to avoid collisions)
for i, (_, _, old_path) in enumerate(scored):
os.rename(old_path, old_path + ".tmp")
for i, (_, _, old_path) in enumerate(scored):
@@ -182,28 +164,65 @@ def _process_event():
best_frame = scored[0][1] if scored else None
if best_frame is None:
shutil.rmtree(event_dir, ignore_errors=True)
return
return None
# Step 4: run LPR on best frame
# LPR on best frame
plate, conf, plate_bbox = ("", 0.0, None)
if _analyzer:
plate, conf, plate_bbox = _analyzer.read_plate(best_frame)
log.info(f"LPR: plate={plate!r} conf={conf:.2f} bbox={plate_bbox}")
# Step 5: save thumbnail (best frame)
# Save thumbnail
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])
# Color: crop vehicle body above plate (avoids sky/road)
hex_color, color_name = _vehicle_color(best_frame, plate_bbox)
insert_event(
event_id, CAMERA_NAME, int(time.time()),
event_id, camera_name, int(time.time()),
f"snapshots/{snapshot_file}",
f"events/{event_id}/clip.mp4",
plate or None, hex_color, color_name,
)
log.info(f"Stored: {event_id[:8]} | plate={plate} | color={color_name} | frames={len(frame_files)}")
return event_id
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()
clip_path = os.path.join(event_dir, "clip.mp4")
log.info(f"Capturing {CAPTURE_DURATION}s at {CAPTURE_FPS}fps — event {event_id[:8]}")
ret = subprocess.run([
"ffmpeg", "-y", "-rtsp_transport", "tcp",
"-i", url, "-t", str(CAPTURE_DURATION), "-c", "copy", clip_path,
], capture_output=True, timeout=CAPTURE_DURATION + 10)
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')}")
shutil.rmtree(event_dir, ignore_errors=True)
return
_process_clip(event_id, event_dir, clip_path)
def process_uploaded_clip(src_path: str) -> str:
"""Create a new event from an uploaded MP4. Returns event_id."""
event_id = str(uuid.uuid4())
event_dir = os.path.join(EVENTS_DIR, event_id)
os.makedirs(event_dir, exist_ok=True)
clip_path = os.path.join(event_dir, "clip.mp4")
shutil.copy2(src_path, clip_path)
log.info(f"Processing uploaded clip — event {event_id[:8]}")
result = _process_clip(event_id, event_dir, clip_path, camera_name="upload")
if result is None:
raise RuntimeError("Processing failed: no frames extracted")
return event_id
def run_watcher():