Files
camwatch/app/templates/annotate.html
T
percoandClaude Sonnet 4.6 00368fba0d Fix annotation canvas resolution and fullres button
Canvas now uses img.naturalWidth/Height as its coordinate space
so zooming in shows actual 4K pixels instead of upscaled 800px.
CSS width/height handle display scaling separately.

Replace window.open() fullres button (blocked by popup blockers)
with a direct <a target="_blank"> link updated on frame select.

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

472 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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" style="position:relative;">
<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 pour dessiner le rectangle · Molette pour zoomer · Clic droit ou Espace+glisser pour déplacer</span>
<div class="flex items-center gap-2">
<span id="zoom-label" class="text-xs text-slate-500 font-mono">100%</span>
<button onclick="resetZoom()" class="btn-ghost text-xs py-1 px-2">⊡ 1:1</button>
<a id="btn-fullres" href="#" target="_blank" class="btn-ghost text-xs py-1 px-2" style="text-decoration:none;">🔍 Plein écran</a>
<button onclick="clearRect()" class="btn-ghost text-xs py-1 px-3">✕ Effacer</button>
</div>
</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 = '';
// ── Viewport (zoom + pan) ──────────────────────────────────────────────
// All coordinates are in "canvas logical space" (0..canvas.width, 0..canvas.height).
// The viewport transform maps logical coords → screen pixels on the canvas element.
let vp = { scale: 1, tx: 0, ty: 0 };
function clampVP() {
const minTx = canvas.width * (1 - vp.scale);
const minTy = canvas.height * (1 - vp.scale);
vp.tx = Math.min(0, Math.max(minTx, vp.tx));
vp.ty = Math.min(0, Math.max(minTy, vp.ty));
}
// Convert a client-space (screen) position to logical canvas coords
function clientToLogical(cx, cy) {
const r = canvas.getBoundingClientRect();
const px = (cx - r.left) * (canvas.width / r.width);
const py = (cy - r.top) * (canvas.height / r.height);
return [(px - vp.tx) / vp.scale, (py - vp.ty) / vp.scale];
}
function resetZoom() {
vp = { scale: 1, tx: 0, ty: 0 };
document.getElementById('zoom-label').textContent = '100%';
redraw();
}
// ── Rect state (in logical canvas coords) ─────────────────────────────
let rect = null; // finalized: {x1,y1,x2,y2}
let drawStart = null;
let drawEnd = null;
let isDrawing = false;
// ── Pan state ─────────────────────────────────────────────────────────
let isPanning = false;
let panLast = null;
let spaceDown = false;
// ── Render ────────────────────────────────────────────────────────────
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(vp.tx, vp.ty);
ctx.scale(vp.scale, vp.scale);
if (img.src) ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Current bbox in logical coords
let r = null;
if (isDrawing && drawStart && drawEnd) {
r = { x: Math.min(drawStart[0], drawEnd[0]), y: Math.min(drawStart[1], drawEnd[1]),
w: Math.abs(drawEnd[0]-drawStart[0]), h: Math.abs(drawEnd[1]-drawStart[1]) };
} else if (rect) {
r = { x: rect.x1, y: rect.y1, w: rect.x2-rect.x1, h: rect.y2-rect.y1 };
}
if (r && r.w > 4 && r.h > 4) {
const lw = 3 / vp.scale;
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = lw;
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);
const hs = 7 / vp.scale;
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);
});
}
ctx.restore();
}
// ── Mouse wheel → zoom ─────────────────────────────────────────────────
canvas.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.25 : 1/1.25;
const r = canvas.getBoundingClientRect();
const mx = (e.clientX - r.left) * (canvas.width / r.width);
const my = (e.clientY - r.top) * (canvas.height / r.height);
const newScale = Math.max(1, Math.min(20, vp.scale * factor));
const f = newScale / vp.scale;
vp.tx = mx - (mx - vp.tx) * f;
vp.ty = my - (my - vp.ty) * f;
vp.scale = newScale;
clampVP();
document.getElementById('zoom-label').textContent = Math.round(vp.scale * 100) + '%';
redraw();
}, { passive: false });
// ── Space key → pan mode ───────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.code === 'Space' && !e.target.matches('input,textarea')) {
spaceDown = true;
canvas.style.cursor = 'grab';
e.preventDefault();
}
});
document.addEventListener('keyup', e => {
if (e.code === 'Space') { spaceDown = false; canvas.style.cursor = 'crosshair'; }
});
// ── Mouse down ────────────────────────────────────────────────────────
canvas.addEventListener('mousedown', e => {
if (e.button === 1 || e.button === 2 || spaceDown) {
isPanning = true;
panLast = [e.clientX, e.clientY];
canvas.style.cursor = 'grabbing';
e.preventDefault();
return;
}
if (e.button !== 0) return;
drawStart = clientToLogical(e.clientX, e.clientY);
drawEnd = [...drawStart];
isDrawing = true;
rect = null;
updateStatus(false);
});
canvas.addEventListener('mousemove', e => {
if (isPanning) {
const r = canvas.getBoundingClientRect();
vp.tx += (e.clientX - panLast[0]) * (canvas.width / r.width);
vp.ty += (e.clientY - panLast[1]) * (canvas.height / r.height);
panLast = [e.clientX, e.clientY];
clampVP();
redraw();
return;
}
if (!isDrawing) return;
drawEnd = clientToLogical(e.clientX, e.clientY);
redraw();
});
canvas.addEventListener('mouseup', e => {
if (isPanning) {
isPanning = false;
canvas.style.cursor = spaceDown ? 'grab' : 'crosshair';
return;
}
if (!isDrawing) return;
isDrawing = false;
drawEnd = clientToLogical(e.clientX, e.clientY);
const w = Math.abs(drawEnd[0]-drawStart[0]), h = Math.abs(drawEnd[1]-drawStart[1]);
if (w > 8 && h > 4) {
rect = { x1: Math.min(drawStart[0],drawEnd[0]), y1: Math.min(drawStart[1],drawEnd[1]),
x2: Math.max(drawStart[0],drawEnd[0]), y2: Math.max(drawStart[1],drawEnd[1]) };
updateBbox();
updateStatus(true);
} else {
rect = null;
updateStatus(false);
}
redraw();
});
canvas.addEventListener('contextmenu', e => e.preventDefault());
// ── Touch (two-finger pinch + single-finger draw) ─────────────────────
let touchLast = null, lastDist = null;
canvas.addEventListener('touchstart', e => {
e.preventDefault();
if (e.touches.length === 2) {
lastDist = Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY);
touchLast = [(e.touches[0].clientX+e.touches[1].clientX)/2, (e.touches[0].clientY+e.touches[1].clientY)/2];
} else {
drawStart = clientToLogical(e.touches[0].clientX, e.touches[0].clientY);
drawEnd = [...drawStart];
isDrawing = true; rect = null; updateStatus(false);
}
}, {passive:false});
canvas.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 2) {
const d = Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY);
const mx = (e.touches[0].clientX+e.touches[1].clientX)/2;
const my = (e.touches[0].clientY+e.touches[1].clientY)/2;
if (lastDist) {
const factor = d / lastDist;
const r = canvas.getBoundingClientRect();
const px = (mx - r.left) * (canvas.width/r.width), py = (my - r.top) * (canvas.height/r.height);
const newScale = Math.max(1, Math.min(20, vp.scale * factor));
const f = newScale / vp.scale;
vp.tx = px - (px - vp.tx)*f; vp.ty = py - (py - vp.ty)*f; vp.scale = newScale;
clampVP();
document.getElementById('zoom-label').textContent = Math.round(vp.scale*100)+'%';
}
if (touchLast) { const r=canvas.getBoundingClientRect(); vp.tx+=(mx-touchLast[0])*(canvas.width/r.width); vp.ty+=(my-touchLast[1])*(canvas.height/r.height); clampVP(); }
lastDist = d; touchLast = [mx, my];
redraw();
} else if (isDrawing) {
drawEnd = clientToLogical(e.touches[0].clientX, e.touches[0].clientY);
redraw();
}
}, {passive:false});
canvas.addEventListener('touchend', e => {
e.preventDefault();
lastDist = null; touchLast = null;
if (isDrawing) {
isDrawing = false;
const w = Math.abs(drawEnd[0]-drawStart[0]), h = Math.abs(drawEnd[1]-drawStart[1]);
if (w > 8 && h > 4) {
rect = { x1:Math.min(drawStart[0],drawEnd[0]), y1:Math.min(drawStart[1],drawEnd[1]),
x2:Math.max(drawStart[0],drawEnd[0]), y2:Math.max(drawStart[1],drawEnd[1]) };
updateBbox(); updateStatus(true);
} else { rect = null; updateStatus(false); }
redraw();
}
}, {passive:false});
// ── Helpers ────────────────────────────────────────────────────────────
function updateBbox() {
if (!rect) { document.getElementById('input-bbox').value = ''; return; }
const cx = (rect.x1+rect.x2)/2 / canvas.width;
const cy = (rect.y1+rect.y2)/2 / canvas.height;
const bw = (rect.x2-rect.x1) / canvas.width;
const bh = (rect.y2-rect.y1) / 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) {
const px = ok ? Math.round(rect.x2-rect.x1) : 0;
const py = ok ? Math.round(rect.y2-rect.y1) : 0;
document.getElementById('bbox-status').textContent = ok
? `✓ Rectangle défini — ${px}×${py}px logiques`
: 'Clique et glisse pour dessiner le rectangle · Molette pour zoomer · Clic droit ou Espace+glisser pour déplacer';
document.getElementById('save-warning').classList.toggle('hidden', ok);
document.getElementById('save-ok').classList.toggle('hidden', !ok);
}
function clearRect() {
rect = null; isDrawing = false; drawStart = null; drawEnd = null;
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;
document.getElementById('btn-fullres').href = src;
clearRect();
resetZoom();
img.src = src;
}
// ── Image loading ──────────────────────────────────────────────────────
img.onload = function() {
// Use native resolution as canvas coordinate space so zoom shows real pixels
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// Scale CSS display to fit container width
const wrap = document.getElementById('canvas-wrap');
canvas.style.width = '100%';
canvas.style.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth) + 'px';
vp = { scale: 1, tx: 0, ty: 0 };
document.getElementById('zoom-label').textContent = '100%';
redraw();
};
// ── 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 %}
currentFrameSrc = '/{{ frames[0] }}';
document.getElementById('btn-fullres').href = currentFrameSrc;
img.src = currentFrameSrc;
{% endif %}
window.addEventListener('resize', () => {
if (!img.complete || !img.naturalWidth) return;
// Canvas internal resolution stays at 4K; only update CSS display height
const wrap = document.getElementById('canvas-wrap');
canvas.style.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth) + 'px';
redraw();
});
</script>
</body>
</html>