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 }}
+ {{ ann_count }} annotation{{ 's' if ann_count != 1 else '' }} au total
+ ⬇ Export dataset
+
+
+
+
+
+
+
+
+
+
Frames — clique pour choisir
+ {% 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
+ ✕ Effacer le rectangle
+
+
+
+
+
+
+
+
+
+
+
+
+ 📖
+ 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 %}