Files
camwatch/app/templates/annotate.html
T
percoandClaude Sonnet 4.6 c43652d187 Add LPR test button in annotation UI
New POST /event/{id}/test-lpr endpoint runs PlateRecognizer on the full
frame and local OCR on the drawn bbox region (with perspective correction
when possible). Annotation UI shows results inline with a one-click
"Utiliser" button to copy the best plate into the input field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:20:14 +02:00

583 lines
28 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>
<button type="button" id="btn-test-lpr" onclick="testLPR()"
class="px-4 py-2 rounded font-medium"
style="background:#1e293b;color:#60a5fa;border:1px solid #2563eb;">
🔍 Tester LPR
</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>
<!-- LPR test results -->
<div id="lpr-result" class="card p-4 hidden"></div>
</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();
};
// ── LPR test ───────────────────────────────────────────────────────────
async function testLPR() {
const framePathEl = document.getElementById('input-frame');
const bboxEl = document.getElementById('input-bbox');
const resultEl = document.getElementById('lpr-result');
const btn = document.getElementById('btn-test-lpr');
if (!framePathEl.value) {
resultEl.innerHTML = '<p class="text-yellow-400 text-sm">Aucune frame sélectionnée.</p>';
resultEl.classList.remove('hidden');
return;
}
btn.disabled = true;
btn.textContent = '⏳ En cours…';
resultEl.innerHTML = '<p class="text-slate-500 text-sm">Requête en cours…</p>';
resultEl.classList.remove('hidden');
const fd = new FormData();
fd.append('frame_path', framePathEl.value);
fd.append('bbox', bboxEl.value);
try {
const resp = await fetch('/event/{{ ev.id }}/test-lpr', { method: 'POST', body: fd });
const data = await resp.json();
renderLPRResult(data);
} catch(e) {
resultEl.innerHTML = `<p class="text-red-400 text-sm">Erreur: ${e.message}</p>`;
} finally {
btn.disabled = false;
btn.textContent = '🔍 Tester LPR';
}
}
function usePlate(plate) {
document.getElementById('input-plate').value = plate;
}
function renderLPRResult(data) {
const resultEl = document.getElementById('lpr-result');
if (data.error) {
resultEl.innerHTML = `<p class="text-red-400 text-sm">${data.error}</p>`;
return;
}
let html = '<div class="space-y-4">';
html += '<label class="block">Résultats LPR</label>';
// PlateRecognizer
if (data.platerecognizer) {
const pr = data.platerecognizer;
const pct = Math.round(pr.conf * 100);
const col = pr.conf >= 0.7 ? '#4ade80' : pr.conf >= 0.4 ? '#fbbf24' : '#f87171';
html += `<div>
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide font-semibold">PlateRecognizer API</div>
<div class="flex items-center gap-3 flex-wrap">
<span class="font-mono text-lg font-bold px-4 py-1 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">${pr.plate || '—'}</span>
<span class="text-sm font-mono" style="color:${col};">${pct}%</span>
${pr.plate ? `<button type="button" onclick="usePlate('${pr.plate}')" class="btn-ghost text-xs py-1 px-2 text-blue-400">↑ Utiliser</button>` : ''}
</div>
${pr.raw && pr.raw.replace(/-/g,'') !== pr.plate.replace(/-/g,'') ? `<p class="text-slate-500 text-xs mt-1">Brut API: ${pr.raw}</p>` : ''}
</div>`;
} else if (data.has_pr) {
html += `<div>
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide font-semibold">PlateRecognizer API</div>
<p class="text-slate-500 text-sm">Aucune plaque détectée</p>
</div>`;
}
// Local OCR
if (data.local) {
const loc = data.local;
const pct = Math.round(loc.conf * 100);
const col = loc.conf >= 0.7 ? '#4ade80' : loc.conf >= 0.4 ? '#fbbf24' : '#f87171';
const src = loc.source === 'perspective_corrected' ? 'correction perspective ✓' : 'recadrage direct';
html += `<div>
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide font-semibold">OCR local — ${src}</div>
<div class="flex items-center gap-3 flex-wrap mb-2">
<span class="font-mono text-lg font-bold px-4 py-1 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">${loc.plate || '—'}</span>
<span class="text-sm font-mono" style="color:${col};">${pct}%</span>
${loc.plate ? `<button type="button" onclick="usePlate('${loc.plate}')" class="btn-ghost text-xs py-1 px-2 text-blue-400">↑ Utiliser</button>` : ''}
</div>
${loc.raw && loc.raw !== loc.plate ? `<p class="text-slate-500 text-xs mb-2">Brut OCR: "${loc.raw}"</p>` : ''}
${loc.img_b64 ? `<img src="data:image/jpeg;base64,${loc.img_b64}" class="rounded border border-slate-600" style="image-rendering:pixelated;max-height:56px;" title="Image envoyée à l'OCR">` : ''}
</div>`;
} else if (data.local_error) {
html += `<div>
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide font-semibold">OCR local</div>
<p class="text-red-400 text-sm">${data.local_error}</p>
</div>`;
} else if (document.getElementById('input-bbox').value) {
html += `<div>
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide font-semibold">OCR local</div>
<p class="text-slate-500 text-sm">Aucun résultat</p>
</div>`;
} else {
html += `<p class="text-slate-500 text-sm italic">Dessine un rectangle autour de la plaque pour tester l'OCR local.</p>`;
}
html += '</div>';
resultEl.innerHTML = html;
}
// ── 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>