Annotation canvas: - Mouse wheel: zoom centered on cursor (1×→20×) - Right-click drag or Space+drag: pan - Two-finger pinch-to-zoom on touch devices - Bbox coordinates computed in logical image space (zoom-invariant) - "Plein écran" button opens current frame in new tab at full res - Zoom % indicator + reset button Event detail lightbox: - Mouse wheel: zoom in/out (0.5×→20×) - Drag to pan when zoomed in - "Ouvrir en plein écran" link opens raw frame at native 4K in new tab - Zoom resets on image change or lightbox close Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
472 lines
22 KiB
HTML
472 lines
22 KiB
HTML
<!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>
|
||
<button id="btn-fullres" onclick="openFullRes()" class="btn-ghost text-xs py-1 px-2">🔍 Plein écran</button>
|
||
<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 openFullRes() {
|
||
if (currentFrameSrc) window.open(currentFrameSrc, '_blank');
|
||
}
|
||
|
||
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();
|
||
resetZoom();
|
||
img.src = src;
|
||
}
|
||
|
||
// ── Image loading ──────────────────────────────────────────────────────
|
||
img.onload = function() {
|
||
const wrap = document.getElementById('canvas-wrap');
|
||
canvas.width = wrap.clientWidth;
|
||
canvas.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth);
|
||
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] }}';
|
||
img.src = currentFrameSrc;
|
||
{% endif %}
|
||
|
||
window.addEventListener('resize', () => {
|
||
if (!img.complete || !img.naturalWidth) return;
|
||
const wrap = document.getElementById('canvas-wrap');
|
||
canvas.width = wrap.clientWidth;
|
||
canvas.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth);
|
||
vp = { scale: 1, tx: 0, ty: 0 };
|
||
document.getElementById('zoom-label').textContent = '100%';
|
||
redraw();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|