Add barrier_picker_app — Dockerised web picker for barrier opening
A FastAPI app + plain HTML5 video page that replaces the matplotlib picker. Browse to http://host:8000/, scrub through each video with arrow keys (±5 s, ±1 s with Shift, ±0.1 s with Ctrl, ±1 frame with ,/.), and click one of three buttons: - All barriers open — every ROI usable - Upper barrier opens — ROIs 1,3,5 usable; lower row marked bad - Lower barrier opens — ROIs 2,4,6 usable; upper row marked bad The current playhead time is recorded as opening_s; bad_rois is set accordingly. Also keyboard shortcuts (1/2/3 for the three modes, s/u for skip/unusable). Refresh-safe: every submission persists to data/metadata/barrier_opening.csv before advancing. Server uses byte-range streaming so seeking inside long videos is fast. Dockerfile + docker-compose.yml mount the data volume RO and the metadata folder RW. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
24403e0474
commit
1a7542def2
7 changed files with 611 additions and 5 deletions
214
scripts/barrier_picker_app/static/index.html
Normal file
214
scripts/barrier_picker_app/static/index.html
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<!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; }
|
||||
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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Cupido — barrier picker</h1>
|
||||
<span id="info">loading…</span>
|
||||
<span id="progress"></span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<video id="player" controls preload="auto"></video>
|
||||
<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 ·
|
||||
<kbd>←</kbd> / <kbd>→</kbd> ±5 s ·
|
||||
<kbd>Shift</kbd>+arrows ±1 s ·
|
||||
<kbd>Ctrl</kbd>+arrows ±0.1 s ·
|
||||
<kbd>,</kbd> / <kbd>.</kbd> ±1 frame
|
||||
</div>
|
||||
<div id="flash"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const player = document.getElementById('player');
|
||||
const info = document.getElementById('info');
|
||||
const progress = document.getElementById('progress');
|
||||
const flash = document.getElementById('flash');
|
||||
|
||||
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 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' : '');
|
||||
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;
|
||||
const item = queue[cursor];
|
||||
const payload = {
|
||||
idx: item.idx,
|
||||
time_s: (mode === 'skip' || mode === 'unusable') ? null : player.currentTime,
|
||||
mode: mode,
|
||||
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;
|
||||
// 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.ctrlKey ? 0.1 : (e.shiftKey ? 1 : 5);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
stop();
|
||||
player.currentTime += e.ctrlKey ? 0.1 : (e.shiftKey ? 1 : 5);
|
||||
break;
|
||||
case ',':
|
||||
stop();
|
||||
// Step back one frame (assume 25 fps if unknown)
|
||||
player.currentTime -= 1 / 25;
|
||||
break;
|
||||
case '.':
|
||||
stop();
|
||||
player.currentTime += 1 / 25;
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
fetchQueue();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue