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:
co-authored by
Claude Sonnet 4.6
parent
5594010d78
commit
ec214ed7b2
+23
-2
@@ -2,9 +2,11 @@ import os
|
|||||||
import glob
|
import glob
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import tempfile
|
||||||
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request, Query, HTTPException
|
from fastapi import FastAPI, Request, Query, HTTPException, UploadFile, File
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -134,6 +136,25 @@ async def api_events(
|
|||||||
return {"events": events, "total": total}
|
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")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
.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; }
|
||||||
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen">
|
||||||
@@ -26,11 +31,30 @@
|
|||||||
<span class="font-bold text-lg">CamWatch</span>
|
<span class="font-bold text-lg">CamWatch</span>
|
||||||
<span class="text-slate-500 text-sm hidden sm:inline">— Passages véhicules</span>
|
<span class="text-slate-500 text-sm hidden sm:inline">— Passages véhicules</span>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<main class="max-w-5xl mx-auto px-3 py-4">
|
<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 -->
|
<!-- 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">
|
||||||
@@ -98,6 +122,13 @@
|
|||||||
|
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+60
-41
@@ -86,15 +86,13 @@ def _vehicle_color(frame: np.ndarray, plate_bbox: tuple | None) -> tuple[str, st
|
|||||||
px1, py1, px2, py2 = plate_bbox
|
px1, py1, px2, py2 = plate_bbox
|
||||||
pw = px2 - px1
|
pw = px2 - px1
|
||||||
ph = py2 - py1
|
ph = py2 - py1
|
||||||
# Vehicle body: above plate, same horizontal span expanded ×3
|
|
||||||
crop_x1 = max(0, px1 - pw * 2)
|
crop_x1 = max(0, px1 - pw * 2)
|
||||||
crop_x2 = min(w, px2 + pw * 2)
|
crop_x2 = min(w, px2 + pw * 2)
|
||||||
crop_y2 = max(0, py1 - 5)
|
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:
|
if crop_y2 > crop_y1 and crop_x2 > crop_x1:
|
||||||
crop = frame[int(crop_y1):int(crop_y2), int(crop_x1):int(crop_x2)]
|
crop = frame[int(crop_y1):int(crop_y2), int(crop_x1):int(crop_x2)]
|
||||||
return extract_dominant_color_from_frame(crop)
|
return extract_dominant_color_from_frame(crop)
|
||||||
# Fallback: center 50% of image (excludes sky at top, road at bottom)
|
|
||||||
cy1 = h // 4
|
cy1 = h // 4
|
||||||
cy2 = 3 * h // 4
|
cy2 = 3 * h // 4
|
||||||
cx1 = w // 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
|
return cv2.Laplacian(gray, cv2.CV_64F).var() * 0.001
|
||||||
|
|
||||||
|
|
||||||
def _process_event():
|
def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: str = None):
|
||||||
event_id = str(uuid.uuid4())
|
"""Process an existing clip: transcode, extract frames, LPR, store in DB."""
|
||||||
event_dir = os.path.join(EVENTS_DIR, event_id)
|
if camera_name is None:
|
||||||
os.makedirs(event_dir, exist_ok=True)
|
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")
|
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]}")
|
# Transcode to H.264 baseline for browser compatibility
|
||||||
|
|
||||||
# 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
|
|
||||||
web_clip = os.path.join(event_dir, "clip_web.mp4")
|
web_clip = os.path.join(event_dir, "clip_web.mp4")
|
||||||
subprocess.run([
|
subprocess.run([
|
||||||
"ffmpeg", "-y", "-i", clip_path,
|
"ffmpeg", "-y", "-i", clip_path,
|
||||||
"-c:v", "libx264", "-profile:v", "baseline", "-level", "3.1",
|
"-c:v", "libx264", "-profile:v", "baseline", "-level", "3.1",
|
||||||
"-preset", "fast", "-crf", "28",
|
"-preset", "fast", "-crf", "28",
|
||||||
"-vf", "scale=-2:720", # downsample to 720p — enough for review
|
"-vf", "scale=-2:720",
|
||||||
"-an", # no audio needed for surveillance
|
"-an",
|
||||||
"-movflags", "+faststart", # moov atom at front for streaming
|
"-movflags", "+faststart",
|
||||||
web_clip,
|
web_clip,
|
||||||
], capture_output=True, timeout=60)
|
], capture_output=True, timeout=120)
|
||||||
if os.path.exists(web_clip):
|
if os.path.exists(web_clip):
|
||||||
os.replace(web_clip, clip_path)
|
os.replace(web_clip, clip_path)
|
||||||
else:
|
else:
|
||||||
log.warning("Transcode failed, keeping raw clip")
|
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")))
|
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")
|
||||||
|
|
||||||
if not frame_files:
|
if not frame_files:
|
||||||
shutil.rmtree(event_dir, ignore_errors=True)
|
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]] = []
|
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)
|
||||||
@@ -173,7 +156,6 @@ def _process_event():
|
|||||||
|
|
||||||
scored.sort(key=lambda x: -x[0])
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
|
||||||
# Rename to sorted order (temp names to avoid collisions)
|
|
||||||
for i, (_, _, old_path) in enumerate(scored):
|
for i, (_, _, old_path) in enumerate(scored):
|
||||||
os.rename(old_path, old_path + ".tmp")
|
os.rename(old_path, old_path + ".tmp")
|
||||||
for i, (_, _, old_path) in enumerate(scored):
|
for i, (_, _, old_path) in enumerate(scored):
|
||||||
@@ -182,28 +164,65 @@ def _process_event():
|
|||||||
best_frame = scored[0][1] if scored else None
|
best_frame = scored[0][1] if scored 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 None
|
||||||
|
|
||||||
# Step 4: run LPR on best frame
|
# LPR on best frame
|
||||||
plate, conf, plate_bbox = ("", 0.0, None)
|
plate, conf, plate_bbox = ("", 0.0, None)
|
||||||
if _analyzer:
|
if _analyzer:
|
||||||
plate, conf, plate_bbox = _analyzer.read_plate(best_frame)
|
plate, conf, plate_bbox = _analyzer.read_plate(best_frame)
|
||||||
log.info(f"LPR: plate={plate!r} conf={conf:.2f} bbox={plate_bbox}")
|
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_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])
|
||||||
|
|
||||||
# Color: crop vehicle body above plate (avoids sky/road)
|
|
||||||
hex_color, color_name = _vehicle_color(best_frame, plate_bbox)
|
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}",
|
||||||
f"events/{event_id}/clip.mp4",
|
f"events/{event_id}/clip.mp4",
|
||||||
plate or None, hex_color, color_name,
|
plate or None, hex_color, color_name,
|
||||||
)
|
)
|
||||||
log.info(f"Stored: {event_id[:8]} | plate={plate} | color={color_name} | frames={len(frame_files)}")
|
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():
|
def run_watcher():
|
||||||
|
|||||||
Reference in New Issue
Block a user