// Labeler frontend — fetches one image at a time, shows green-tinted dominant // leg, three draggable landmark dots. Save -> send back -> auto-load next. const KP_NAMES = ["UPPER", "PT", "LOWER"]; const KP_COLOR = { UPPER: "#3399ff", PT: "#22dd22", LOWER: "#ff8800" }; const HIT_RADIUS = 18; // pixels let current = null; // { anon_id, image_w, image_h, keypoints, dominant_side, ... } let scale = 1; // canvas-px per image-px let canvasOffset = { x: 0, y: 0 }; let dragging = null; // KP name being dragged const $ = sel => document.querySelector(sel); const canvas = $("#canvas"); const ctx = canvas.getContext("2d"); let imgEl = new Image(); let maskEl = new Image(); let imgLoaded = false, maskLoaded = false; // ----- fetch + load next ----- function showLoading(msg) { $("#loading-msg").innerText = msg || "Loading next image…"; $("#loading-sub").innerText = "First load can take a few seconds while predictions run."; $("#loading-overlay").classList.remove("hidden"); } function hideLoading() { $("#loading-overlay").classList.add("hidden"); } async function loadNext() { showLoading("Fetching next image…"); let data; try { const resp = await fetch("/api/next"); data = await resp.json(); } catch (e) { $("#loading-msg").innerText = "Network error: " + e.message; $("#loading-sub").innerText = ""; return; } if (data.done) { $("#loading-msg").innerHTML = '
🎉
' + (data.msg || "Queue empty — all images labeled!"); $("#loading-sub").innerText = ""; return; } if (data.error) { $("#loading-msg").innerText = "Error: " + data.error; $("#loading-sub").innerText = ""; return; } current = data; imgLoaded = false; maskLoaded = false; $("#loading-msg").innerText = "Downloading image…"; const imgPromise = new Promise((resolve, reject) => { imgEl = new Image(); imgEl.onload = () => { imgLoaded = true; resolve(); }; imgEl.onerror = () => reject(new Error("image load failed")); imgEl.src = data.image_url + "?t=" + Date.now(); }); const maskPromise = new Promise((resolve, reject) => { maskEl = new Image(); maskEl.onload = () => { maskLoaded = true; resolve(); }; maskEl.onerror = () => reject(new Error("mask load failed")); maskEl.src = data.mask_url + "?t=" + Date.now(); }); try { await Promise.all([imgPromise, maskPromise]); } catch (e) { $("#loading-msg").innerHTML = `Failed to load: ${e.message}
`; $("#loading-sub").innerText = ""; return; } $("#dom-side").innerText = (data.dominant_side === "L" ? "Subject's LEFT" : "Subject's RIGHT"); updateStats(data); $("#retrain-banner").hidden = !data.retraining; hideLoading(); render(); } function updateStats(data) { // Top-of-page header stats const v = data.model_version || "v?"; const left = data.labels_until_retrain; let leftStr; if (data.retraining) { leftStr = `🔄 retraining now`; } else if (left === 0) { leftStr = `retrain due!`; } else { leftStr = `${left} until next retrain`; } $("#stats").innerHTML = ` labeled ${data.labeled_count} ${data.queue_remaining} remaining ${leftStr} model ${v} `; } function maybeRender() { if (imgLoaded && maskLoaded) render(); } // Mini overlay spinner during save→fetch transition function showMiniSpinner(msg) { let m = document.getElementById("mini-sp"); if (!m) { m = document.createElement("div"); m.id = "mini-sp"; m.className = "mini-spinner"; m.innerHTML = '
Saving…
'; document.body.appendChild(m); } document.getElementById("mini-msg").innerText = msg || "Saving…"; m.style.display = "block"; } function hideMiniSpinner() { const m = document.getElementById("mini-sp"); if (m) m.style.display = "none"; } // ----- rendering ----- function fitCanvasToImage() { const wrap = canvas.parentElement; const maxW = Math.min(wrap.clientWidth - 20, 1200); const maxH = window.innerHeight - 200; const w = current.image_w, h = current.image_h; scale = Math.min(maxW / w, maxH / h); canvas.width = Math.floor(w * scale); canvas.height = Math.floor(h * scale); } function render() { fitCanvasToImage(); const W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); ctx.drawImage(imgEl, 0, 0, W, H); // Apply green tint where the dominant-leg mask is // Render mask to an offscreen canvas, walk pixels const off = document.createElement("canvas"); off.width = W; off.height = H; const octx = off.getContext("2d"); octx.drawImage(maskEl, 0, 0, W, H); const m = octx.getImageData(0, 0, W, H); // Tint the live canvas const live = ctx.getImageData(0, 0, W, H); for (let i = 0; i < m.data.length; i += 4) { const v = m.data[i]; // mask is grayscale, R == G == B if (v > 127) { // dominant leg pixel — push toward green live.data[i] = live.data[i] * 0.55 + 60 * 0.45; live.data[i + 1] = live.data[i + 1] * 0.55 + 220 * 0.45; live.data[i + 2] = live.data[i + 2] * 0.55 + 100 * 0.45; } else { // non-dominant area — desaturate slightly const gray = (live.data[i] + live.data[i+1] + live.data[i+2]) / 3; live.data[i] = (live.data[i] * 0.5 + gray * 0.5); live.data[i + 1] = (live.data[i + 1] * 0.5 + gray * 0.5); live.data[i + 2] = (live.data[i + 2] * 0.5 + gray * 0.5); } } ctx.putImageData(live, 0, 0); // Skeleton: dashed line UPPER → PT → LOWER (drawn under the dots) const segments = [["UPPER", "PT"], ["PT", "LOWER"]]; ctx.save(); ctx.setLineDash([8, 6]); ctx.lineWidth = 3; for (const [a, b] of segments) { if (current.keypoints[a] && current.keypoints[b]) { const [ax, ay] = current.keypoints[a]; const [bx, by] = current.keypoints[b]; // White outline for contrast ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(ax * scale, ay * scale); ctx.lineTo(bx * scale, by * scale); ctx.stroke(); // Dark dashed line on top ctx.strokeStyle = "rgba(30,30,30,0.85)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(ax * scale, ay * scale); ctx.lineTo(bx * scale, by * scale); ctx.stroke(); } } ctx.restore(); // Draw the 3 keypoints for (const name of KP_NAMES) { if (!current.keypoints[name]) continue; const [xo, yo] = current.keypoints[name]; const x = xo * scale, y = yo * scale; const c = KP_COLOR[name]; // halo ctx.fillStyle = "white"; ctx.beginPath(); ctx.arc(x, y, 13, 0, Math.PI * 2); ctx.fill(); // dot ctx.fillStyle = c; ctx.beginPath(); ctx.arc(x, y, 10, 0, Math.PI * 2); ctx.fill(); // label ctx.fillStyle = "white"; ctx.fillRect(x + 12, y - 24, 60, 18); ctx.strokeStyle = c; ctx.strokeRect(x + 12, y - 24, 60, 18); ctx.fillStyle = c; ctx.font = "bold 13px system-ui"; ctx.fillText(name, x + 17, y - 11); } } // ----- drag handlers ----- function canvasPos(ev) { const r = canvas.getBoundingClientRect(); return { x: ev.clientX - r.left, y: ev.clientY - r.top }; } function hitKeypoint(x, y) { for (const name of KP_NAMES) { if (!current.keypoints[name]) continue; const [xo, yo] = current.keypoints[name]; const kx = xo * scale, ky = yo * scale; if ((x - kx) ** 2 + (y - ky) ** 2 < HIT_RADIUS * HIT_RADIUS) return name; } return null; } canvas.addEventListener("mousedown", ev => { const { x, y } = canvasPos(ev); dragging = hitKeypoint(x, y); if (!dragging) { // Empty click: pop a small menu by setting the closest-named missing keypoint here // Actually simpler: ignore. They use existing dots to drag. } }); canvas.addEventListener("mousemove", ev => { if (!dragging) return; const { x, y } = canvasPos(ev); current.keypoints[dragging] = [x / scale, y / scale]; render(); }); window.addEventListener("mouseup", () => { dragging = null; }); // Right-click opens a context menu with explicit Move/Add options for all 3 landmarks canvas.addEventListener("contextmenu", ev => { ev.preventDefault(); const { x, y } = canvasPos(ev); showContextMenu(ev.clientX, ev.clientY, x / scale, y / scale); }); function showContextMenu(screenX, screenY, imgX, imgY) { // Remove any existing menu const old = document.getElementById("kp-menu"); if (old) old.remove(); const menu = document.createElement("div"); menu.id = "kp-menu"; menu.className = "kp-menu"; menu.style.left = screenX + "px"; menu.style.top = screenY + "px"; for (const name of KP_NAMES) { const has = !!current.keypoints[name]; const verb = has ? "Move" : "Add"; const item = document.createElement("button"); item.className = "kp-menu-item"; item.innerHTML = ` ${verb} ${name} here`; item.addEventListener("click", () => { current.keypoints[name] = [imgX, imgY]; render(); menu.remove(); }); menu.appendChild(item); } // Add a "Delete" submenu only for landmarks that exist const existing = KP_NAMES.filter(n => current.keypoints[n]); if (existing.length > 0) { const sep = document.createElement("div"); sep.className = "kp-menu-sep"; menu.appendChild(sep); for (const name of existing) { const item = document.createElement("button"); item.className = "kp-menu-item kp-menu-delete"; item.innerHTML = `🗑 Delete ${name}`; item.addEventListener("click", () => { delete current.keypoints[name]; render(); menu.remove(); }); menu.appendChild(item); } } document.body.appendChild(menu); // Click anywhere to dismiss setTimeout(() => { document.addEventListener("click", function dismiss() { menu.remove(); document.removeEventListener("click", dismiss); }); }, 0); } // ----- buttons ----- $("#save-btn").addEventListener("click", async () => { // Validate: all 3 keypoints present for (const n of KP_NAMES) { if (!current.keypoints[n]) { alert(`Missing keypoint: ${n}. Right-click on the image to add it.`); return; } } $("#save-btn").disabled = true; $("#save-btn").innerText = "Saving…"; showMiniSpinner("Saving…"); let r; try { const resp = await fetch("/api/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ anon_id: current.anon_id, side: current.dominant_side, keypoints: current.keypoints, labeler: "anonymous", }), }); r = await resp.json(); } catch (e) { hideMiniSpinner(); $("#save-btn").disabled = false; $("#save-btn").innerText = "✓ Save & Next"; alert("Save failed (network): " + e.message); return; } $("#save-btn").disabled = false; $("#save-btn").innerText = "✓ Save & Next"; if (r.error) { hideMiniSpinner(); alert("Save failed: " + r.error); return; } if (r.retrain_kicked) { const ban = $("#retrain-banner"); ban.hidden = false; setTimeout(() => { ban.hidden = true; }, 4000); } // Update header counters from save response too (stays in sync between images) if (typeof r.labeled_count !== "undefined") { updateStats({ labeled_count: r.labeled_count, queue_remaining: (current ? current.queue_remaining : 0) - 1, model_version: r.model_version, labels_until_retrain: r.labels_until_retrain, retraining: r.retraining, }); } hideMiniSpinner(); await loadNext(); }); $("#swap-btn").addEventListener("click", async () => { if (!current) return; $("#swap-btn").disabled = true; const resp = await fetch("/api/swap_side", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ anon_id: current.anon_id }), }); const r = await resp.json(); $("#swap-btn").disabled = false; if (r.error) { alert("Swap failed: " + r.error); return; } current.dominant_side = r.dominant_side; current.keypoints = r.keypoints; current.image_w = r.image_w; current.image_h = r.image_h; $("#dom-side").innerText = (r.dominant_side === "L" ? "Subject's LEFT" : "Subject's RIGHT"); // Reload mask maskLoaded = false; maskEl = new Image(); maskEl.onload = () => { maskLoaded = true; render(); }; maskEl.src = r.mask_url + "?t=" + Date.now(); }); $("#skip-btn").addEventListener("click", async () => { if (!current) return; await fetch("/api/skip", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ anon_id: current.anon_id }), }); await loadNext(); }); // ----- Reject modal ----- $("#reject-btn").addEventListener("click", () => { // Clear previous selection document.querySelectorAll('input[name="reject-reason"]').forEach(r => r.checked = false); $("#reject-note").value = ""; $("#reject-modal").hidden = false; }); $("#reject-cancel").addEventListener("click", () => { $("#reject-modal").hidden = true; }); $("#reject-modal").addEventListener("click", ev => { if (ev.target.id === "reject-modal") $("#reject-modal").hidden = true; // click outside card }); $("#reject-submit").addEventListener("click", async () => { const reason = (document.querySelector('input[name="reject-reason"]:checked') || {}).value; if (!reason) { alert("Pick a reason first."); return; } const note = $("#reject-note").value.trim(); $("#reject-submit").disabled = true; await fetch("/api/reject", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ anon_id: current.anon_id, reason, note }), }); $("#reject-submit").disabled = false; $("#reject-modal").hidden = true; await loadNext(); }); // ----- Live training console (poll while retraining) ----- let trainPollTimer = null; let trainConsoleManuallyHidden = false; let lastSeenModelVersion = null; async function pollTrainingLog() { try { const r = await fetch("/api/training_log"); const d = await r.json(); const consoleEl = $("#train-console"); const logEl = $("#train-console-log"); if (d.retraining) { if (!trainConsoleManuallyHidden) consoleEl.hidden = false; logEl.textContent = (d.lines || []).join("\n"); logEl.scrollTop = logEl.scrollHeight; } else { // Training finished consoleEl.hidden = true; trainConsoleManuallyHidden = false; if (lastSeenModelVersion && d.model_version !== lastSeenModelVersion) { // Show a quick "new model live" toast showToast(`✓ New model live: ${d.model_version}`); } lastSeenModelVersion = d.model_version; stopTrainingPoll(); } } catch (e) { console.warn("training_log poll failed:", e); } } function startTrainingPoll() { if (trainPollTimer) return; pollTrainingLog(); trainPollTimer = setInterval(pollTrainingLog, 1500); } function stopTrainingPoll() { if (trainPollTimer) clearInterval(trainPollTimer); trainPollTimer = null; } $("#train-console-toggle").addEventListener("click", () => { $("#train-console").hidden = true; trainConsoleManuallyHidden = true; }); function showToast(msg) { let t = document.getElementById("toast"); if (!t) { t = document.createElement("div"); t.id = "toast"; t.style.cssText = "position:fixed;top:70px;left:50%;transform:translateX(-50%);background:#28a745;color:white;padding:12px 24px;border-radius:6px;font-weight:600;z-index:200;box-shadow:0 4px 16px rgba(0,0,0,0.3);"; document.body.appendChild(t); } t.textContent = msg; t.style.display = "block"; setTimeout(() => { t.style.display = "none"; }, 5000); } // Hook into the existing retraining detection — start polling whenever we see retraining=true const _origUpdateStats = updateStats; updateStats = function(data) { _origUpdateStats(data); lastSeenModelVersion = lastSeenModelVersion || data.model_version; if (data.retraining) startTrainingPoll(); }; window.addEventListener("resize", () => { if (current) render(); }); // ----- About panel: remember collapsed state across sessions ----- const aboutPanel = document.getElementById("about-panel"); const dismissBtn = document.getElementById("dismiss-about"); if (aboutPanel) { // If user has seen+dismissed before, start collapsed if (localStorage.getItem("about_dismissed") === "1") { aboutPanel.open = false; } if (dismissBtn) { dismissBtn.addEventListener("click", () => { aboutPanel.open = false; localStorage.setItem("about_dismissed", "1"); window.scrollTo({ top: 0, behavior: "smooth" }); }); } aboutPanel.addEventListener("toggle", () => { if (!aboutPanel.open) { localStorage.setItem("about_dismissed", "1"); } }); } // ----- start ----- loadNext();