Add annotation UI for building a custom training dataset

New page /event/{id}/annotate:
- Frame selector grid (all frames from the event)
- Canvas with click-and-drag rectangle drawing to mark the plate bbox
- Touch support for tablets
- Plate text input (pre-filled with auto-detected plate)
- Saves image + YOLO label (.txt) + plates.csv to /data/annotations/
- Stores annotation metadata in DB (annotations table)

/dataset/export serves a ZIP with images/, labels/, plates.csv and
a data.yaml ready for YOLOv8/v9 fine-tuning on Google Colab.

"Annoter" button added to event detail header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-03 10:34:09 +02:00
co-authored by Claude Sonnet 4.6
parent 9b3720c424
commit fd52942bd1
4 changed files with 453 additions and 0 deletions
+51
View File
@@ -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]:
+81
View File
@@ -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()
+320
View File
@@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CamWatch — Annotation</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { background: #0f172a; color: #e2e8f0; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
.plate { font-family: monospace; letter-spacing: 0.12em; }
.btn-ghost { background: #1e293b; border: 1px solid #475569; color: #94a3b8; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; text-decoration: none; display: inline-block; }
.btn-ghost:hover { background: #334155; color: #e2e8f0; }
label { color: #64748b; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
#canvas-wrap { position: relative; width: 100%; background: #000; border-radius: 8px; overflow: hidden; }
#ann-canvas { display: block; width: 100%; cursor: crosshair; }
.frame-thumb { cursor: pointer; border: 2px solid #334155; border-radius: 4px; overflow: hidden; transition: border-color 0.1s; }
.frame-thumb:hover { border-color: #60a5fa; }
.frame-thumb.active { border-color: #3b82f6; }
input[type=text] { background: #0f172a; border: 1px solid #475569; color: #e2e8f0; border-radius: 6px; padding: 8px 12px; width: 100%; font-size: 1rem; }
input[type=text]:focus { outline: none; border-color: #60a5fa; }
.tuto-step { display: flex; gap: 14px; align-items: flex-start; padding: 12px 0; border-bottom: 1px solid #1e293b; }
.tuto-step:last-child { border-bottom: none; }
.tuto-num { width: 28px; height: 28px; border-radius: 50%; background: #1e3a5f; color: #60a5fa; border: 1px solid #2563eb;
display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 0.85rem; flex-shrink: 0; }
</style>
</head>
<body class="min-h-screen">
<header class="sticky top-0 z-10 px-4 py-3 flex items-center gap-3" style="background:#0f172a;border-bottom:1px solid #1e293b;">
<a href="/event/{{ ev.id }}" class="btn-ghost">← Retour</a>
<span class="text-xl"></span>
<span class="font-bold flex-1 truncate">Annotation — {{ ev.time_str }}</span>
<span class="text-slate-500 text-sm hidden sm:inline">{{ ann_count }} annotation{{ 's' if ann_count != 1 else '' }} au total</span>
<a href="/dataset/export" class="btn-ghost text-green-400 border-green-900 hover:border-green-700">⬇ Export dataset</a>
</header>
<main class="max-w-6xl mx-auto px-3 py-4 space-y-4">
<!-- Main annotation area -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
<!-- Frame selector (left column) -->
<div class="card p-3 lg:col-span-1">
<label class="block mb-2">Frames — clique pour choisir</label>
{% if frames %}
<div class="grid grid-cols-3 lg:grid-cols-2 gap-1.5 max-h-96 overflow-y-auto pr-1">
{% for fp in frames %}
<div class="frame-thumb {% if loop.first %}active{% endif %}"
onclick="selectFrame(this, '/{{ fp }}')">
<img src="/{{ fp }}" class="w-full object-cover" style="aspect-ratio:16/9;" loading="lazy">
<div class="text-center text-xs text-slate-500 py-0.5 bg-slate-900 leading-none">#{{ loop.index }}</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-slate-500 text-sm">Aucune frame disponible.</p>
{% endif %}
</div>
<!-- Canvas + controls (right column) -->
<div class="lg:col-span-3 space-y-3">
<!-- Canvas -->
<div class="card overflow-hidden">
<div id="canvas-wrap">
<canvas id="ann-canvas"></canvas>
</div>
<div class="px-3 py-2 flex items-center justify-between flex-wrap gap-2" style="background:#111827;">
<span id="bbox-status" class="text-sm text-slate-500">Clique et glisse sur l'image pour dessiner le rectangle autour de la plaque</span>
<button onclick="clearRect()" class="btn-ghost text-xs py-1 px-3">✕ Effacer le rectangle</button>
</div>
</div>
<!-- Form -->
<form id="ann-form" action="/event/{{ ev.id }}/annotate" method="post">
<input type="hidden" name="frame_path" id="input-frame" value="{{ frames[0] if frames else '' }}">
<input type="hidden" name="bbox" id="input-bbox" value="">
<div class="card p-4 space-y-4">
<div>
<label class="block mb-1">Plaque d'immatriculation (texte correct)</label>
<input type="text" name="plate" id="input-plate"
value="{{ ev.plate or '' }}"
placeholder="Ex: AB-123-CD"
class="font-mono uppercase text-lg"
oninput="this.value = this.value.toUpperCase()">
</div>
<div id="save-area" class="flex items-center gap-3 flex-wrap">
<button type="submit" id="btn-save"
class="px-6 py-2 rounded font-medium"
style="background:#166534;color:#4ade80;border:1px solid #16a34a;">
✓ Sauvegarder l'annotation
</button>
<span id="save-warning" class="text-yellow-400 text-sm hidden">⚠ Dessine d'abord le rectangle autour de la plaque</span>
<span id="save-ok" class="text-green-400 text-sm hidden">✓ Rectangle défini — prêt à sauvegarder</span>
</div>
</div>
</form>
</div>
</div>
<!-- Tutorial -->
<div class="card p-5">
<div class="flex items-center gap-2 mb-4">
<span class="text-lg">📖</span>
<span class="font-bold text-slate-200">Comment annoter — Guide</span>
<span class="ml-auto text-slate-500 text-sm">{{ ann_count }} annotation{{ 's' if ann_count != 1 else '' }} collectée{{ 's' if ann_count != 1 else '' }}</span>
</div>
<div class="tuto-step">
<div class="tuto-num">1</div>
<div>
<p class="text-slate-200 font-medium text-sm">Choisis la meilleure frame</p>
<p class="text-slate-400 text-sm mt-0.5">Dans la grille à gauche, clique sur la frame où la plaque est <strong class="text-white">la plus visible et la plus nette</strong>. Les frames sont déjà triées : la #1 est celle que l'IA considère la meilleure.</p>
</div>
</div>
<div class="tuto-step">
<div class="tuto-num">2</div>
<div>
<p class="text-slate-200 font-medium text-sm">Dessine le rectangle autour de la plaque</p>
<p class="text-slate-400 text-sm mt-0.5"><strong class="text-white">Clique et glisse</strong> 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.</p>
</div>
</div>
<div class="tuto-step">
<div class="tuto-num">3</div>
<div>
<p class="text-slate-200 font-medium text-sm">Saisis la plaque correcte</p>
<p class="text-slate-400 text-sm mt-0.5">Tape la plaque que tu <strong class="text-white">vois réellement</strong> sur l'image (pas ce que l'IA a lu). Format : <code class="text-blue-400">AB-123-CD</code>. C'est cette valeur qui servira d'étiquette pour entraîner le modèle.</p>
</div>
</div>
<div class="tuto-step">
<div class="tuto-num">4</div>
<div>
<p class="text-slate-200 font-medium text-sm">Sauvegarde</p>
<p class="text-slate-400 text-sm mt-0.5">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.</p>
</div>
</div>
<div class="tuto-step">
<div class="tuto-num">5</div>
<div>
<p class="text-slate-200 font-medium text-sm">Exporter et entraîner</p>
<p class="text-slate-400 text-sm mt-0.5">Une fois ~100-200 images annotées, clique <strong class="text-white">"Export dataset"</strong> en haut à droite. Tu obtiens un fichier <code class="text-blue-400">.zip</code> avec :</p>
<ul class="text-slate-400 text-sm mt-1 ml-4 list-disc space-y-0.5">
<li><code>images/</code> — les frames annotées</li>
<li><code>labels/</code> — les coordonnées YOLO (<code>classe cx cy w h</code>, normalisées 0→1)</li>
<li><code>plates.csv</code> — le texte de chaque plaque pour l'OCR</li>
<li><code>data.yaml</code> — config prête pour l'entraînement YOLOv8/v9</li>
</ul>
<p class="text-slate-400 text-sm mt-1">Ce dataset peut être utilisé sur <strong class="text-white">Google Colab</strong> (GPU gratuit) pour fine-tuner le détecteur en ~30 minutes. Le nouveau <code>.onnx</code> remplace ensuite celui dans <code>/models/</code>.</p>
</div>
</div>
<div class="mt-4 p-3 rounded-lg text-sm" style="background:#1c2942;border:1px solid #1e3a5f;color:#93c5fd;">
<strong>💡 Conseil :</strong> 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.
</div>
</div>
</main>
<script>
const canvas = document.getElementById('ann-canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
let currentFrameSrc = '';
let startX = 0, startY = 0, endX = 0, endY = 0;
let isDrawing = false;
let rect = null; // finalized {x, y, w, h} in canvas pixels
// ── Image loading ──────────────────────────────────────────────────────
function loadImage(src) {
img.src = src;
}
img.onload = function() {
const wrap = document.getElementById('canvas-wrap');
canvas.width = wrap.clientWidth;
canvas.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth);
redraw();
};
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const r = isDrawing
? { x: Math.min(startX, endX), y: Math.min(startY, endY),
w: Math.abs(endX - startX), h: Math.abs(endY - startY) }
: rect;
if (r && r.w > 4 && r.h > 4) {
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 3;
ctx.strokeRect(r.x, r.y, r.w, r.h);
ctx.fillStyle = 'rgba(59,130,246,0.12)';
ctx.fillRect(r.x, r.y, r.w, r.h);
// Corner handles
const hs = 8;
ctx.fillStyle = '#3b82f6';
[[r.x,r.y],[r.x+r.w,r.y],[r.x,r.y+r.h],[r.x+r.w,r.y+r.h]].forEach(([hx,hy]) => {
ctx.fillRect(hx-hs/2, hy-hs/2, hs, hs);
});
}
}
// ── Mouse events ───────────────────────────────────────────────────────
function evPos(e) {
const r = canvas.getBoundingClientRect();
const scaleX = canvas.width / r.width;
const scaleY = canvas.height / r.height;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
return [(clientX - r.left) * scaleX, (clientY - r.top) * scaleY];
}
canvas.addEventListener('mousedown', e => {
[startX, startY] = evPos(e);
isDrawing = true;
rect = null;
updateStatus(false);
});
canvas.addEventListener('mousemove', e => {
if (!isDrawing) return;
[endX, endY] = evPos(e);
redraw();
});
canvas.addEventListener('mouseup', e => {
if (!isDrawing) return;
[endX, endY] = evPos(e);
isDrawing = false;
const x = Math.min(startX, endX), y = Math.min(startY, endY);
const w = Math.abs(endX - startX), h = Math.abs(endY - startY);
if (w > 8 && h > 4) {
rect = { x, y, w, h };
updateBbox();
updateStatus(true);
} else {
rect = null;
updateStatus(false);
}
redraw();
});
// Touch support
canvas.addEventListener('touchstart', e => { e.preventDefault(); canvas.dispatchEvent(new MouseEvent('mousedown', {clientX: e.touches[0].clientX, clientY: e.touches[0].clientY})); });
canvas.addEventListener('touchmove', e => { e.preventDefault(); canvas.dispatchEvent(new MouseEvent('mousemove', {clientX: e.touches[0].clientX, clientY: e.touches[0].clientY})); });
canvas.addEventListener('touchend', e => { e.preventDefault(); canvas.dispatchEvent(new MouseEvent('mouseup', {clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY})); });
// ── Helpers ────────────────────────────────────────────────────────────
function updateBbox() {
if (!rect) { document.getElementById('input-bbox').value = ''; return; }
const cx = (rect.x + rect.w / 2) / canvas.width;
const cy = (rect.y + rect.h / 2) / canvas.height;
const bw = rect.w / canvas.width;
const bh = rect.h / canvas.height;
document.getElementById('input-bbox').value = JSON.stringify(
{ cx: +cx.toFixed(6), cy: +cy.toFixed(6), w: +bw.toFixed(6), h: +bh.toFixed(6) }
);
}
function updateStatus(ok) {
document.getElementById('bbox-status').textContent = ok
? `Rectangle défini — ${Math.round(rect.w)}×${Math.round(rect.h)}px`
: 'Clique et glisse sur l\'image pour dessiner le rectangle autour de la plaque';
document.getElementById('save-warning').classList.toggle('hidden', ok);
document.getElementById('save-ok').classList.toggle('hidden', !ok);
}
function clearRect() {
rect = null;
isDrawing = false;
document.getElementById('input-bbox').value = '';
updateStatus(false);
redraw();
}
function selectFrame(el, src) {
document.querySelectorAll('.frame-thumb').forEach(t => t.classList.remove('active'));
el.classList.add('active');
currentFrameSrc = src;
document.getElementById('input-frame').value = src.startsWith('/') ? src.slice(1) : src;
clearRect();
loadImage(src);
}
// ── Form validation ────────────────────────────────────────────────────
document.getElementById('ann-form').addEventListener('submit', function(e) {
if (!document.getElementById('input-bbox').value) {
e.preventDefault();
document.getElementById('save-warning').classList.remove('hidden');
document.getElementById('save-ok').classList.add('hidden');
canvas.scrollIntoView({ behavior: 'smooth' });
}
});
// ── Init ───────────────────────────────────────────────────────────────
{% if frames %}
loadImage('/{{ frames[0] }}');
currentFrameSrc = '/{{ frames[0] }}';
{% endif %}
// Handle window resize
window.addEventListener('resize', () => {
if (!img.src) return;
const wrap = document.getElementById('canvas-wrap');
canvas.width = wrap.clientWidth;
canvas.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth);
redraw();
});
</script>
</body>
</html>
+1
View File
@@ -35,6 +35,7 @@
<a href="/" class="btn-ghost">← Retour</a>
<span class="text-xl">🚗</span>
<span class="font-bold flex-1">{{ ev.time_str }}</span>
<a href="/event/{{ ev.id }}/annotate" class="btn-ghost">✏ Annoter</a>
{% if ev.plate %}
{% if is_whitelisted %}
<form action="/whitelist/remove/{{ ev.plate }}" method="post">