diff --git a/app/database.py b/app/database.py index 7e989f3..bb2f10c 100644 --- a/app/database.py +++ b/app/database.py @@ -30,6 +30,19 @@ def init_db(): added_at INTEGER ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS annotations ( + id TEXT PRIMARY KEY, + event_id TEXT, + frame_path TEXT, + plate_text TEXT, + bbox_cx REAL, + bbox_cy REAL, + bbox_w REAL, + bbox_h REAL, + created_at INTEGER + ) + """) # Migrate existing DBs that lack clip_path try: conn.execute("ALTER TABLE events ADD COLUMN clip_path TEXT") @@ -171,6 +184,44 @@ def remove_from_whitelist(plate: str): conn.close() +# ── Annotations ─────────────────────────────────────────────────────────────── + +def save_annotation(ann_id: str, event_id: str, frame_path: str, plate_text: str, + cx: float, cy: float, w: float, h: float): + import time + conn = get_db() + conn.execute(""" + INSERT OR REPLACE INTO annotations + (id, event_id, frame_path, plate_text, bbox_cx, bbox_cy, bbox_w, bbox_h, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (ann_id, event_id, frame_path, plate_text, cx, cy, w, h, int(time.time()))) + conn.commit() + conn.close() + + +def count_annotations() -> int: + conn = get_db() + count = conn.execute("SELECT COUNT(*) FROM annotations").fetchone()[0] + conn.close() + return count + + +def get_annotations() -> list[dict]: + conn = get_db() + rows = conn.execute( + "SELECT * FROM annotations ORDER BY created_at DESC" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_annotated_event_ids() -> set: + conn = get_db() + rows = conn.execute("SELECT DISTINCT event_id FROM annotations").fetchall() + conn.close() + return {r[0] for r in rows} + + # ── Stats ───────────────────────────────────────────────────────────────────── def get_plate_stats() -> list[dict]: diff --git a/app/main.py b/app/main.py index e2b1e5c..b38d4cf 100644 --- a/app/main.py +++ b/app/main.py @@ -19,8 +19,11 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname SNAPSHOTS_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots") EVENTS_DIR = os.environ.get("EVENTS_DIR", "/data/events") +ANNOTATIONS_DIR = os.environ.get("ANNOTATIONS_DIR", "/data/annotations") os.makedirs(SNAPSHOTS_DIR, exist_ok=True) os.makedirs(EVENTS_DIR, exist_ok=True) +os.makedirs(os.path.join(ANNOTATIONS_DIR, "images"), exist_ok=True) +os.makedirs(os.path.join(ANNOTATIONS_DIR, "labels"), exist_ok=True) @asynccontextmanager @@ -34,6 +37,7 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) app.mount("/snapshots", StaticFiles(directory=SNAPSHOTS_DIR), name="snapshots") app.mount("/events", StaticFiles(directory=EVENTS_DIR), name="events") +app.mount("/annotations", StaticFiles(directory=ANNOTATIONS_DIR), name="annotations") templates = Jinja2Templates(directory="/app/templates") @@ -188,6 +192,83 @@ async def upload_clip(file: UploadFile = File(...)): return RedirectResponse(f"/event/{event_id}", status_code=303) +@app.get("/event/{event_id}/annotate", response_class=HTMLResponse) +async def annotate_page(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] + ann_count = database.count_annotations() + return templates.TemplateResponse("annotate.html", { + "request": request, + "ev": ev, + "frames": frames, + "ann_count": ann_count, + }) + + +@app.post("/event/{event_id}/annotate") +async def save_annotation( + event_id: str, + frame_path: str = Form(""), + plate: str = Form(""), + bbox: str = Form(""), +): + import json, uuid + ev = database.get_event(event_id) + if not ev: + raise HTTPException(status_code=404, detail="Événement introuvable") + plate = plate.strip().upper() + if not plate or not frame_path or not bbox: + raise HTTPException(status_code=400, detail="Données manquantes") + try: + box = json.loads(bbox) + cx, cy, bw, bh = float(box["cx"]), float(box["cy"]), float(box["w"]), float(box["h"]) + except Exception: + raise HTTPException(status_code=400, detail="bbox invalide") + + ann_id = str(uuid.uuid4()) + src = os.path.join("/data", frame_path) + dst_img = os.path.join(ANNOTATIONS_DIR, "images", f"{ann_id}.jpg") + shutil.copy2(src, dst_img) + with open(os.path.join(ANNOTATIONS_DIR, "labels", f"{ann_id}.txt"), "w") as f: + f.write(f"0 {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}\n") + csv_path = os.path.join(ANNOTATIONS_DIR, "plates.csv") + with open(csv_path, "a") as f: + f.write(f"{ann_id}.jpg,{plate}\n") + database.save_annotation(ann_id, event_id, frame_path, plate, cx, cy, bw, bh) + return RedirectResponse(f"/event/{event_id}?annotated=1", status_code=303) + + +@app.get("/dataset/export") +async def export_annotations(): + import zipfile, io + from fastapi.responses import StreamingResponse + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + img_dir = os.path.join(ANNOTATIONS_DIR, "images") + lbl_dir = os.path.join(ANNOTATIONS_DIR, "labels") + csv_path = os.path.join(ANNOTATIONS_DIR, "plates.csv") + for f in glob.glob(os.path.join(img_dir, "*.jpg")): + zf.write(f, f"images/{os.path.basename(f)}") + for f in glob.glob(os.path.join(lbl_dir, "*.txt")): + zf.write(f, f"labels/{os.path.basename(f)}") + if os.path.exists(csv_path): + zf.write(csv_path, "plates.csv") + # data.yaml for YOLO training + yaml_content = "path: .\ntrain: images\nval: images\nnc: 1\nnames: ['license_plate']\n" + zf.writestr("data.yaml", yaml_content) + buf.seek(0) + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": "attachment; filename=camwatch_dataset.zip"}, + ) + + @app.get("/stats", response_class=HTMLResponse) async def stats_page(request: Request): rows = database.get_plate_stats() diff --git a/app/templates/annotate.html b/app/templates/annotate.html new file mode 100644 index 0000000..572b679 --- /dev/null +++ b/app/templates/annotate.html @@ -0,0 +1,320 @@ + + + + + + CamWatch — Annotation + + + + + +
+ ← Retour + + Annotation — {{ ev.time_str }} + + ⬇ Export dataset +
+ +
+ + +
+ + +
+ + {% if frames %} +
+ {% for fp in frames %} +
+ +
#{{ loop.index }}
+
+ {% endfor %} +
+ {% else %} +

Aucune frame disponible.

+ {% endif %} +
+ + +
+ + +
+
+ +
+
+ Clique et glisse sur l'image pour dessiner le rectangle autour de la plaque + +
+
+ + +
+ + + +
+
+ + +
+ +
+ + + +
+
+
+ +
+
+ + +
+
+ 📖 + Comment annoter — Guide + {{ ann_count }} annotation{{ 's' if ann_count != 1 else '' }} collectée{{ 's' if ann_count != 1 else '' }} +
+ +
+
1
+
+

Choisis la meilleure frame

+

Dans la grille à gauche, clique sur la frame où la plaque est la plus visible et la plus nette. Les frames sont déjà triées : la #1 est celle que l'IA considère la meilleure.

+
+
+ +
+
2
+
+

Dessine le rectangle autour de la plaque

+

Clique et glisse sur l'image pour tracer un rectangle qui entoure la plaque d'immatriculation. Inclus un petit peu de marge autour. Si tu te trompes, clique "Effacer" et recommence.

+
+
+ +
+
3
+
+

Saisis la plaque correcte

+

Tape la plaque que tu vois réellement sur l'image (pas ce que l'IA a lu). Format : AB-123-CD. C'est cette valeur qui servira d'étiquette pour entraîner le modèle.

+
+
+ +
+
4
+
+

Sauvegarde

+

Clique "Sauvegarder". L'image et les coordonnées sont copiées dans le dataset local. Tu peux annoter plusieurs frames du même passage si la plaque est visible sous différents angles.

+
+
+ +
+
5
+
+

Exporter et entraîner

+

Une fois ~100-200 images annotées, clique "Export dataset" en haut à droite. Tu obtiens un fichier .zip avec :

+
    +
  • images/ — les frames annotées
  • +
  • labels/ — les coordonnées YOLO (classe cx cy w h, normalisées 0→1)
  • +
  • plates.csv — le texte de chaque plaque pour l'OCR
  • +
  • data.yaml — config prête pour l'entraînement YOLOv8/v9
  • +
+

Ce dataset peut être utilisé sur Google Colab (GPU gratuit) pour fine-tuner le détecteur en ~30 minutes. Le nouveau .onnx remplace ensuite celui dans /models/.

+
+
+ +
+ 💡 Conseil : annote 2-3 passages par semaine. Après ~150 annotations, le modèle fine-tuné reconnaîtra mieux ton angle de caméra spécifique et les conditions lumineuses de ton portail. +
+
+ +
+ + + + diff --git a/app/templates/event_detail.html b/app/templates/event_detail.html index 4ccd593..539a263 100644 --- a/app/templates/event_detail.html +++ b/app/templates/event_detail.html @@ -35,6 +35,7 @@ ← Retour 🚗 {{ ev.time_str }} + ✏ Annoter {% if ev.plate %} {% if is_whitelisted %}