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>
This commit is contained in:
Giorgio Gilestro 2026-05-01 14:27:33 +01:00
parent 2623df4172
commit d0f0e2d443
2 changed files with 83 additions and 5 deletions

View file

@ -73,6 +73,7 @@ class QueueItem:
duration_s: float | None duration_s: float | None
done: bool done: bool
metadata: dict # experimental fields aggregated from the merged TSV metadata: dict # experimental fields aggregated from the merged TSV
existing: dict | None # current barrier_opening.csv row if already picked
# ─── Queue building ───────────────────────────────────────────────────── # ─── Queue building ─────────────────────────────────────────────────────
@ -142,13 +143,19 @@ def _build_queue() -> list[QueueItem]:
"duration_s": float(r.duration_s) if pd.notna(r.duration_s) else None, "duration_s": float(r.duration_s) if pd.notna(r.duration_s) else None,
} }
existing_by_key: dict[tuple[str, str, str], dict] = {}
if OUTPUT_CSV.exists(): if OUTPUT_CSV.exists():
out = pd.read_csv(OUTPUT_CSV) out = pd.read_csv(OUTPUT_CSV)
done_keys = set(zip(out["machine_name"], for _, r in out.iterrows():
out["session_date"], key = (r["machine_name"], r["session_date"], r["session_time"])
out["session_time"])) existing_by_key[key] = {
else: "opening_s": float(r["opening_s"]) if pd.notna(r["opening_s"]) else None,
done_keys = set() "trim_first_s": int(r["trim_first_s"]) if pd.notna(r.get("trim_first_s")) else 0,
"bad_rois": str(r.get("bad_rois", "") or "") if pd.notna(r.get("bad_rois")) else "",
"analyst": str(r.get("analyst", "") or "") if pd.notna(r.get("analyst")) else "",
"notes": str(r.get("notes", "") or "") if pd.notna(r.get("notes")) else "",
}
done_keys = set(existing_by_key)
seen: set[tuple[str, str, str]] = set() seen: set[tuple[str, str, str]] = set()
items: list[QueueItem] = [] items: list[QueueItem] = []
@ -189,6 +196,7 @@ def _build_queue() -> list[QueueItem]:
duration_s=inv_row["duration_s"], duration_s=inv_row["duration_s"],
done=key in done_keys, done=key in done_keys,
metadata=metadata, metadata=metadata,
existing=existing_by_key.get(key),
)) ))
return items return items
@ -217,6 +225,7 @@ async def get_queue() -> JSONResponse:
"duration_s": q.duration_s, "duration_s": q.duration_s,
"done": q.done, "done": q.done,
"metadata": q.metadata, "metadata": q.metadata,
"existing": q.existing,
} }
for q in queue for q in queue
]) ])

View file

@ -107,6 +107,24 @@
font-size: 0.95rem; } font-size: 0.95rem; }
#login-submit:hover { background: #3e6; } #login-submit:hover { background: #3e6; }
#login-submit:disabled { background: #444; color: #888; cursor: not-allowed; } #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> </style>
</head> </head>
<body> <body>
@ -178,6 +196,7 @@
<main> <main>
<div id="meta"></div> <div id="meta"></div>
<video id="player" controls preload="auto"></video> <video id="player" controls preload="auto"></video>
<div id="annotation-bar" class="empty"></div>
<div id="controls"> <div id="controls">
<button class="primary" data-mode="all">All barriers open <kbd>1</kbd></button> <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="upper">Upper barrier opens <kbd>2</kbd></button>
@ -268,6 +287,55 @@
progress.textContent = `${done}/${queue.length} done`; 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) { function renderMeta(m) {
meta.innerHTML = ''; meta.innerHTML = '';
if (!m) return; if (!m) return;
@ -307,6 +375,7 @@
(item.duration_s ? `(${(item.duration_s/60).toFixed(1)} min)` : '') + (item.duration_s ? `(${(item.duration_s/60).toFixed(1)} min)` : '') +
(item.done ? ' — already done' : ''); (item.done ? ' — already done' : '');
renderMeta(item.metadata); renderMeta(item.metadata);
renderAnnotationBar(item);
player.src = `/api/video/${item.idx}`; player.src = `/api/video/${item.idx}`;
player.load(); player.load();
} }