// 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();