cupido/scripts/barrier_picker_app/static/index.html
Giorgio Gilestro d0f0e2d443 Picker: render existing annotation as a clickable mark below the video
When a video that already has a barrier_opening row is loaded, a thin
bar now appears below the player with a red vertical line at the
marked opening_s. A tooltip shows the timestamp, the analyst's
initials, and the bad_rois flag. Clicking the line snaps the player
to the exact opening; clicking elsewhere on the bar seeks
proportionally. Bar is hidden for un-annotated videos.

API change: each queue item now carries an `existing` field with the
opening_s, bad_rois, analyst, etc. from barrier_opening.csv when
available, so the frontend can paint the mark without extra requests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:27:33 +01:00

516 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Cupido — barrier-opening picker</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
background: #1a1a1a; color: #e8e8e8; }
header { padding: 0.6rem 1rem; background: #111; border-bottom: 1px solid #333;
display: flex; align-items: center; gap: 1.5rem; }
h1 { margin: 0; font-size: 1rem; font-weight: 500; color: #ccc; }
#status { font-family: ui-monospace, "SF Mono", monospace; font-size: 0.85rem;
color: #9aa; }
#info { font-family: ui-monospace, monospace; font-size: 0.85rem; color: #cce; }
#meta { display: flex; gap: 1.5rem; flex-wrap: wrap; margin: 0.6rem 0 0.4rem;
font-size: 0.85rem; color: #aab; max-width: 1400px; width: 100%;
justify-content: center; }
#meta .pair { font-family: ui-monospace, monospace; }
#meta .pair .k { color: #678; }
#meta .pair .v { color: #def; margin-left: 0.25rem; }
#meta .role-training { color: #cd6 !important; }
#meta .role-testing { color: #6cd !important; }
main { display: flex; flex-direction: column; align-items: center; padding: 1rem; }
video { width: 100%; max-width: 1400px; height: auto; background: #000;
border-radius: 4px; }
#controls { margin-top: 1rem; display: flex; gap: 1rem; flex-wrap: wrap;
justify-content: center; }
button { padding: 0.7rem 1.4rem; font-size: 0.95rem; border: 1px solid #444;
background: #2a2a2a; color: #eee; border-radius: 4px; cursor: pointer;
transition: all 80ms; }
button:hover { background: #383838; border-color: #666; }
button:active { transform: translateY(1px); }
button.primary { background: #2d5; color: #053; border-color: #1a4;
font-weight: 600; }
button.primary:hover { background: #3e6; }
button.warn { background: #d84; color: #311; border-color: #b62; }
button.warn:hover { background: #ea5; }
button.muted { background: #2a2a2a; color: #888; }
.mute-divider { width: 1px; background: #333; margin: 0 0.3rem; }
#help { margin-top: 1rem; font-size: 0.8rem; color: #889; text-align: center;
font-family: ui-monospace, monospace; }
kbd { background: #222; border: 1px solid #444; border-radius: 3px;
padding: 0.1rem 0.4rem; font-size: 0.8rem; color: #ddd;
font-family: ui-monospace, monospace; }
#progress { font-size: 0.85rem; color: #889; margin-left: auto; }
#flash { position: fixed; top: 0.5rem; right: 0.5rem; padding: 0.5rem 1rem;
border-radius: 4px; opacity: 0; transition: opacity 200ms;
font-family: ui-monospace, monospace; font-size: 0.85rem; }
#flash.show { opacity: 1; }
#flash.ok { background: #2d5; color: #042; }
#flash.err { background: #d44; color: white; }
/* Welcome modal */
#help-btn { background: transparent; border: 1px solid #444; color: #aab;
font-size: 0.85rem; padding: 0.3rem 0.7rem; }
#help-btn:hover { background: #2a2a2a; }
#modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85);
display: none; align-items: center; justify-content: center;
z-index: 100; padding: 1rem; }
#modal-backdrop.show { display: flex; }
#modal { background: #222; border: 1px solid #444; border-radius: 6px;
padding: 1.6rem 2rem; max-width: 640px; width: 100%;
max-height: 90vh; overflow-y: auto; color: #ddd; line-height: 1.55; }
#modal h2 { margin: 0 0 0.6rem; color: #fff; font-size: 1.2rem; }
#modal h3 { margin: 1.2rem 0 0.4rem; color: #cce; font-size: 0.95rem;
font-weight: 600; }
#modal p { margin: 0.5rem 0; }
#modal ol { padding-left: 1.4rem; margin: 0.5rem 0; }
#modal li { margin: 0.6rem 0; }
#modal .button-tag { display: inline-block; padding: 0.05rem 0.4rem;
background: #2d5; color: #053; border-radius: 3px;
font-weight: 600; font-size: 0.85rem; }
#modal-close { margin-top: 1.4rem; width: 100%; padding: 0.7rem;
background: #2d5; color: #053; font-weight: 600;
border-radius: 4px; cursor: pointer; border: 1px solid #1a4;
font-size: 0.95rem; }
#modal-close:hover { background: #3e6; }
/* User badge in header */
#user-badge { background: #2a3; color: #042; font-weight: 700;
padding: 0.2rem 0.6rem; border-radius: 12px;
font-family: ui-monospace, monospace; font-size: 0.85rem;
cursor: pointer; user-select: none; }
#user-badge:hover { background: #3b4; }
#user-badge.empty { background: #d84; color: #311; }
/* Login modal — narrower than the welcome modal */
#login-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85);
display: none; align-items: center; justify-content: center;
z-index: 200; }
#login-backdrop.show { display: flex; }
#login-box { background: #222; border: 1px solid #444; border-radius: 6px;
padding: 1.6rem 2rem; max-width: 380px; width: 90%;
color: #ddd; }
#login-box h2 { margin: 0 0 0.6rem; color: #fff; font-size: 1.1rem; }
#login-box p { margin: 0.4rem 0 1rem; color: #aab; font-size: 0.9rem; }
#login-input { width: 100%; padding: 0.6rem 0.8rem; font-size: 1.1rem;
background: #111; color: #fff; border: 1px solid #444;
border-radius: 4px; text-align: center;
font-family: ui-monospace, monospace; letter-spacing: 0.2em;
text-transform: uppercase; }
#login-input:focus { outline: none; border-color: #6c5; }
#login-submit { width: 100%; margin-top: 1rem; padding: 0.7rem;
background: #2d5; color: #053; font-weight: 600;
border-radius: 4px; cursor: pointer; border: 1px solid #1a4;
font-size: 0.95rem; }
#login-submit:hover { background: #3e6; }
#login-submit:disabled { background: #444; color: #888; cursor: not-allowed; }
/* Annotation bar (existing-mark indicator below the video) */
#annotation-bar { position: relative; width: 100%; max-width: 1400px;
height: 18px; background: #181818; border: 1px solid #333;
border-radius: 3px; margin-top: 0.5rem; cursor: pointer; }
#annotation-bar.empty { background: transparent; border-color: transparent;
cursor: default; height: 0.6rem; }
#annotation-bar .mark-line { position: absolute; top: -2px; bottom: -2px;
width: 3px; background: #f44;
box-shadow: 0 0 4px rgba(255,80,80,0.7); }
#annotation-bar .mark-line:hover { background: #f88; }
#annotation-bar .mark-tooltip { position: absolute; top: 100%;
transform: translateX(-50%);
margin-top: 4px; background: #f44;
color: white; padding: 2px 6px;
font-size: 0.75rem; border-radius: 3px;
font-family: ui-monospace, monospace;
white-space: nowrap; pointer-events: none; }
</style>
</head>
<body>
<header>
<h1>Cupido — barrier picker</h1>
<span id="info">loading…</span>
<span id="progress"></span>
<span id="user-badge" title="click to change initials"></span>
<button id="help-btn" title="show the help modal">?</button>
</header>
<div id="login-backdrop">
<div id="login-box">
<h2>Who are you?</h2>
<p>Enter your initials so we can record who annotated each video.
(Just letters, e.g. <code>GG</code>.)</p>
<input id="login-input" maxlength="4" autocomplete="off" autofocus />
<button id="login-submit" disabled>Continue</button>
</div>
</div>
<div id="modal-backdrop">
<div id="modal">
<h2>Welcome to the Cupido barrier picker</h2>
<p>For each tracked video you'll mark the moment the barrier between
the two halves of the arena is removed and the flies start interacting.
That timestamp is what every downstream "post-opening" analysis needs.</p>
<h3>How to annotate one video</h3>
<ol>
<li><strong>Drag the seekbar to the end of the video</strong> and check
whether <em>all</em> ROIs are open or only the upper or lower half.
This determines which button you'll click later.</li>
<li><strong>Drag back toward the beginning</strong> to roughly find
the moment the barrier(s) lift. The opening can be anywhere from a
few seconds to several minutes in.</li>
<li><strong>Use the arrow keys</strong> to pin down the exact frame:
<kbd></kbd>/<kbd></kbd> jump ±5 s, <kbd>Shift</kbd>+arrow jump ±30 s.
Pause the player on the opening frame.</li>
<li><strong>Click the button</strong> matching what opened — the
playhead time is recorded, ROI inclusion is set accordingly, and
the next video loads automatically.</li>
</ol>
<h3>The three buttons</h3>
<p>
<span class="button-tag">All barriers open</span> — every ROI usable<br>
<span class="button-tag">Upper barrier opens</span> — only ROIs 1, 3, 5 (top row); lower row will be excluded from analysis<br>
<span class="button-tag">Lower barrier opens</span> — only ROIs 2, 4, 6 (bottom row); upper row excluded
</p>
<h3>Other controls</h3>
<p>
<kbd>Space</kbd> play/pause &middot;
<kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> the three buttons &middot;
<kbd>s</kbd> skip &middot; <kbd>u</kbd> mark unusable
</p>
<p style="font-size: 0.85rem; color: #889;">Each click saves to
<code>barrier_opening.csv</code> immediately, so closing the tab
and coming back resumes from where you left off.</p>
<button id="modal-close">Got it — let's start</button>
</div>
</div>
<main>
<div id="meta"></div>
<video id="player" controls preload="auto"></video>
<div id="annotation-bar" class="empty"></div>
<div id="controls">
<button class="primary" data-mode="all">All barriers open <kbd>1</kbd></button>
<button class="primary" data-mode="upper">Upper barrier opens <kbd>2</kbd></button>
<button class="primary" data-mode="lower">Lower barrier opens <kbd>3</kbd></button>
<span class="mute-divider"></span>
<button class="muted" data-mode="skip">Skip <kbd>s</kbd></button>
<button class="warn" data-mode="unusable">Unusable <kbd>u</kbd></button>
<span class="mute-divider"></span>
<button class="muted" id="prev">◀ Previous</button>
<button class="muted" id="next">Next ▶</button>
</div>
<div id="help">
<kbd>Space</kbd> play/pause &nbsp;·&nbsp;
<kbd></kbd> / <kbd></kbd> ±5 s &nbsp;·&nbsp;
<kbd>Shift</kbd>+arrows ±30 s &nbsp;·&nbsp;
drag the seekbar for finer control
</div>
<div id="flash"></div>
</main>
<script>
const player = document.getElementById('player');
const info = document.getElementById('info');
const meta = document.getElementById('meta');
const progress = document.getElementById('progress');
const flash = document.getElementById('flash');
const userBadge = document.getElementById('user-badge');
const loginBackdrop = document.getElementById('login-backdrop');
const loginInput = document.getElementById('login-input');
const loginSubmit = document.getElementById('login-submit');
// ─── Analyst identity (persisted in localStorage) ───────────────────
function getAnalyst() {
try { return localStorage.getItem('cupido.analyst') || ''; }
catch (e) { return ''; }
}
function setAnalyst(s) {
try { localStorage.setItem('cupido.analyst', s); } catch (e) {}
renderUserBadge();
}
function renderUserBadge() {
const a = getAnalyst();
userBadge.textContent = a || 'sign in';
userBadge.classList.toggle('empty', !a);
}
function showLogin() {
loginBackdrop.classList.add('show');
loginInput.value = getAnalyst();
loginSubmit.disabled = !loginInput.value.trim();
setTimeout(() => loginInput.focus(), 50);
}
function hideLogin() { loginBackdrop.classList.remove('show'); }
function submitLogin() {
const v = loginInput.value.trim().toUpperCase();
if (!v) return;
setAnalyst(v);
hideLogin();
// After first login, show the welcome modal if not yet seen.
try {
if (localStorage.getItem('cupido.welcomed') !== '1') {
showModal(); // welcome modal — defined below
}
} catch (e) {}
}
loginInput.addEventListener('input', () => {
loginSubmit.disabled = !loginInput.value.trim();
});
loginInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && loginInput.value.trim()) submitLogin();
});
loginSubmit.addEventListener('click', submitLogin);
userBadge.addEventListener('click', showLogin);
let queue = [];
let cursor = 0;
function showFlash(msg, kind = 'ok') {
flash.textContent = msg;
flash.className = 'show ' + kind;
setTimeout(() => { flash.className = ''; }, 1800);
}
function updateProgress() {
const done = queue.filter(q => q.done).length;
progress.textContent = `${done}/${queue.length} done`;
}
function renderAnnotationBar(item) {
const bar = document.getElementById('annotation-bar');
bar.innerHTML = '';
const dur = item.duration_s;
const ex = item.existing;
// Always allow click-to-seek if we have a duration; only show as a real
// bar (with marks) when there's an existing annotation to display.
if (!dur) {
bar.classList.add('empty');
bar.onclick = null;
return;
}
if (!ex || ex.opening_s == null || isNaN(ex.opening_s)) {
bar.classList.add('empty');
bar.onclick = null;
return;
}
bar.classList.remove('empty');
// Click anywhere on the bar = seek to that fractional position.
bar.onclick = (e) => {
const rect = bar.getBoundingClientRect();
const t = (e.clientX - rect.left) / rect.width * dur;
player.currentTime = Math.max(0, Math.min(dur, t));
};
// Mark line at opening_s
const pct = (ex.opening_s / dur) * 100;
const line = document.createElement('div');
line.className = 'mark-line';
line.style.left = `calc(${pct}% - 1px)`;
// Click on the line specifically = snap to exact opening_s
line.onclick = (e) => {
e.stopPropagation();
player.currentTime = Math.max(0, Math.min(dur, ex.opening_s));
};
const tooltip = document.createElement('div');
tooltip.className = 'mark-tooltip';
const m = Math.floor(ex.opening_s / 60);
const s = (ex.opening_s % 60).toFixed(1);
const tag = ex.bad_rois ? ` · bad ROIs ${ex.bad_rois}` : '';
const who = ex.analyst ? ` · ${ex.analyst}` : '';
tooltip.textContent = `${m}:${s.padStart(4,'0')}${who}${tag}`;
// Position tooltip; if mark is near right edge, anchor right.
tooltip.style.left = pct < 90 ? `${pct}%` : `${pct}%`;
if (pct < 8) tooltip.style.transform = 'translateX(0)';
else if (pct > 92) tooltip.style.transform = 'translateX(-100%)';
bar.appendChild(line);
bar.appendChild(tooltip);
}
function renderMeta(m) {
meta.innerHTML = '';
if (!m) return;
const fields = [
['role', m.session_role,
m.session_role === 'training' ? 'role-training'
: m.session_role === 'testing' ? 'role-testing' : ''],
['species', m.species],
['memory', m.memory],
['training (hr)', m.training_length_hr],
['consol. (hr)', m.consolidation_length_hr],
['age (d)', m.age],
['flies', (m.n_trained || m.n_naive)
? `${m.n_trained || 0} trained · ${m.n_naive || 0} naive`
: null],
['training time', m.training_date_time],
['testing time', m.testing_date_time],
];
for (const [label, value, cls] of fields) {
if (value === undefined || value === null || value === '') continue;
const span = document.createElement('span');
span.className = 'pair';
span.innerHTML = `<span class="k">${label}:</span><span class="v ${cls||''}">${value}</span>`;
meta.appendChild(span);
}
}
function loadCursor() {
if (queue.length === 0) {
info.textContent = 'queue empty';
return;
}
cursor = ((cursor % queue.length) + queue.length) % queue.length;
const item = queue[cursor];
info.textContent =
`[${cursor + 1}/${queue.length}] ${item.machine_name} ${item.session_date} ${item.session_time} ` +
(item.duration_s ? `(${(item.duration_s/60).toFixed(1)} min)` : '') +
(item.done ? ' — already done' : '');
renderMeta(item.metadata);
renderAnnotationBar(item);
player.src = `/api/video/${item.idx}`;
player.load();
}
async function fetchQueue() {
const resp = await fetch('/api/queue');
queue = await resp.json();
// Jump to first not-yet-done
const firstUndone = queue.findIndex(q => !q.done);
cursor = firstUndone === -1 ? 0 : firstUndone;
updateProgress();
loadCursor();
}
async function submit(mode) {
if (queue.length === 0) return;
// Require initials before any picking action (skip is OK without).
if (mode !== 'skip' && !getAnalyst()) {
showLogin();
return;
}
const item = queue[cursor];
const payload = {
idx: item.idx,
time_s: (mode === 'skip' || mode === 'unusable') ? null : player.currentTime,
mode: mode,
analyst: getAnalyst(),
notes: '',
};
try {
const resp = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json();
showFlash('error: ' + (err.detail || resp.status), 'err');
return;
}
const result = await resp.json();
if (result.status === 'saved') {
item.done = true;
updateProgress();
const t = result.row.opening_s;
const bad = result.row.bad_rois;
showFlash(
`saved ${item.machine_name} ${item.session_date} ${item.session_time}: ` +
(Number.isNaN(t) || t === null ? 'unusable' :
`${t.toFixed(1)}s${bad ? ' (bad ROIs: ' + bad + ')' : ''}`)
);
} else if (result.status === 'skipped') {
showFlash(`skipped ${item.machine_name} ${item.session_date} ${item.session_time}`);
}
cursor = (cursor + 1) % queue.length;
loadCursor();
} catch (e) {
showFlash('network error: ' + e.message, 'err');
}
}
// Button handlers
document.querySelectorAll('button[data-mode]').forEach(btn => {
btn.addEventListener('click', () => submit(btn.dataset.mode));
});
document.getElementById('prev').addEventListener('click', () => {
cursor = (cursor - 1 + queue.length) % queue.length;
loadCursor();
});
document.getElementById('next').addEventListener('click', () => {
cursor = (cursor + 1) % queue.length;
loadCursor();
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Don't react to picker shortcuts while either modal is open.
if (modalBackdrop.classList.contains('show')) return;
if (loginBackdrop.classList.contains('show')) return;
// Prevent the browser default (e.g. video focus side effects on space).
const stop = () => { e.preventDefault(); e.stopPropagation(); };
switch (e.key) {
case ' ':
stop();
if (player.paused) player.play(); else player.pause();
break;
case 'ArrowLeft':
stop();
player.currentTime -= e.shiftKey ? 30 : 5;
break;
case 'ArrowRight':
stop();
player.currentTime += e.shiftKey ? 30 : 5;
break;
case '1': stop(); submit('all'); break;
case '2': stop(); submit('upper'); break;
case '3': stop(); submit('lower'); break;
case 's': stop(); submit('skip'); break;
case 'u': stop(); submit('unusable'); break;
}
});
// Welcome modal — show first time, dismissable; ? button reopens it.
const modalBackdrop = document.getElementById('modal-backdrop');
const modalClose = document.getElementById('modal-close');
const helpBtn = document.getElementById('help-btn');
function showModal() { modalBackdrop.classList.add('show'); }
function hideModal() {
modalBackdrop.classList.remove('show');
try { localStorage.setItem('cupido.welcomed', '1'); } catch (e) {}
}
modalClose.addEventListener('click', hideModal);
helpBtn.addEventListener('click', showModal);
modalBackdrop.addEventListener('click', (e) => {
if (e.target === modalBackdrop) hideModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modalBackdrop.classList.contains('show')) {
e.stopPropagation(); hideModal();
}
});
// On first visit, login first (mandatory) then welcome modal.
renderUserBadge();
if (!getAnalyst()) {
showLogin();
} else {
let welcomed = false;
try { welcomed = localStorage.getItem('cupido.welcomed') === '1'; } catch (e) {}
if (!welcomed) showModal();
}
fetchQueue();
</script>
</body>
</html>