Files
camwatch/app/main.py
T
percoandClaude Sonnet 4.6 accbae41ce Add delete event, perspective correction OCR, and OCR crop display
- Delete event: POST /event/{id}/delete removes DB row + files
- Perspective correction: minAreaRect on white plate region → getPerspectiveTransform
  gives a flat frontal view of the plate (e.g. 'GR B38 WR' instead of trapezoid)
- Tesseract now scales image 3x before OCR and skips EU blue strip (left 11%)
- Saves plate_ocr.jpg (perspective-corrected + enhanced crop actually fed to OCR)
- Event detail shows both raw detection crop and OCR crop side by side
- Manual plate edit form in event detail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 18:05:10 +02:00

192 lines
6.3 KiB
Python

import os
import glob
import shutil
import logging
import threading
import tempfile
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Query, HTTPException, UploadFile, File, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from datetime import datetime
import database
import watcher
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
SNAPSHOTS_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots")
EVENTS_DIR = os.environ.get("EVENTS_DIR", "/data/events")
os.makedirs(SNAPSHOTS_DIR, exist_ok=True)
os.makedirs(EVENTS_DIR, exist_ok=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
database.init_db()
t = threading.Thread(target=watcher.run_watcher, daemon=True)
t.start()
yield
app = FastAPI(lifespan=lifespan)
app.mount("/snapshots", StaticFiles(directory=SNAPSHOTS_DIR), name="snapshots")
app.mount("/events", StaticFiles(directory=EVENTS_DIR), name="events")
templates = Jinja2Templates(directory="/app/templates")
def ts_to_str(ts):
try:
return datetime.fromtimestamp(ts).strftime("%d/%m/%Y %H:%M:%S")
except Exception:
return str(ts)
@app.get("/", response_class=HTMLResponse)
async def index(
request: Request,
page: int = Query(1, ge=1),
plate: str = Query(""),
camera: str = Query(""),
date: str = Query(""),
):
limit = 20
offset = (page - 1) * limit
events = database.get_events(limit=limit, offset=offset,
plate_filter=plate or None,
camera_filter=camera or None,
date_filter=date or None)
total = database.count_events(plate_filter=plate or None,
camera_filter=camera or None,
date_filter=date or None)
cameras = database.get_cameras()
total_pages = max(1, (total + limit - 1) // limit)
for ev in events:
ev["time_str"] = ts_to_str(ev["start_time"])
return templates.TemplateResponse("index.html", {
"request": request,
"events": events,
"total": total,
"page": page,
"total_pages": total_pages,
"cameras": cameras,
"filter_plate": plate,
"filter_camera": camera,
"filter_date": date,
})
CAPTURE_DURATION = int(os.environ.get("CAPTURE_DURATION", "15"))
CAPTURE_FPS = int(os.environ.get("CAPTURE_FPS", "5"))
@app.get("/event/{event_id}", response_class=HTMLResponse)
async def event_detail(request: Request, event_id: str):
ev = database.get_event(event_id)
if not ev:
raise HTTPException(status_code=404, detail="Événement introuvable")
ev["time_str"] = ts_to_str(ev["start_time"])
event_dir = os.path.join(EVENTS_DIR, event_id)
frame_paths = sorted(glob.glob(os.path.join(event_dir, "frame_*.jpg")))
frames = [f"events/{event_id}/{os.path.basename(p)}" for p in frame_paths]
clip_abs = os.path.join("/data", ev["clip_path"]) if ev.get("clip_path") else None
has_clip = bool(clip_abs and os.path.exists(clip_abs))
clip_size = ""
if has_clip:
size = os.path.getsize(clip_abs)
clip_size = f"{size // 1024 // 1024}MB" if size > 1024 * 1024 else f"{size // 1024}KB"
plate_crop_abs = os.path.join(event_dir, "plate_crop.jpg")
plate_crop = f"events/{event_id}/plate_crop.jpg" if os.path.exists(plate_crop_abs) else None
plate_ocr_abs = os.path.join(event_dir, "plate_ocr.jpg")
plate_ocr = f"events/{event_id}/plate_ocr.jpg" if os.path.exists(plate_ocr_abs) else None
return templates.TemplateResponse("event_detail.html", {
"request": request,
"ev": ev,
"frames": frames,
"has_clip": has_clip,
"clip_size": clip_size,
"plate_crop": plate_crop,
"plate_ocr": plate_ocr,
"capture_duration": CAPTURE_DURATION,
"capture_fps": CAPTURE_FPS,
"capture_total": CAPTURE_DURATION * CAPTURE_FPS,
})
@app.get("/api/events")
async def api_events(
page: int = Query(1, ge=1),
plate: str = Query(""),
camera: str = Query(""),
date: str = Query(""),
):
limit = 20
offset = (page - 1) * limit
events = database.get_events(limit=limit, offset=offset,
plate_filter=plate or None,
camera_filter=camera or None,
date_filter=date or None)
total = database.count_events(plate_filter=plate or None,
camera_filter=camera or None,
date_filter=date or None)
for ev in events:
ev["time_str"] = ts_to_str(ev["start_time"])
return {"events": events, "total": total}
@app.post("/event/{event_id}/delete")
async def delete_event(event_id: str):
ev = database.get_event(event_id)
if not ev:
raise HTTPException(status_code=404, detail="Événement introuvable")
database.delete_event(event_id)
event_dir = os.path.join(EVENTS_DIR, event_id)
shutil.rmtree(event_dir, ignore_errors=True)
snapshot = os.path.join(SNAPSHOTS_DIR, f"{event_id}.jpg")
if os.path.exists(snapshot):
os.unlink(snapshot)
return RedirectResponse("/", status_code=303)
@app.post("/event/{event_id}/plate")
async def update_plate(event_id: str, plate: str = Form("")):
ev = database.get_event(event_id)
if not ev:
raise HTTPException(status_code=404, detail="Événement introuvable")
database.update_plate(event_id, plate)
return RedirectResponse(f"/event/{event_id}", status_code=303)
@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"}