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:
parent
2623df4172
commit
d0f0e2d443
2 changed files with 83 additions and 5 deletions
|
|
@ -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
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue