Implement labeling frontend and the backend support it needs

Add a framework-free HTML/CSS/JS labeling UI: set/session/status
filters, a frame browser, drag-to-draw/move/resize bounding boxes with
a per-set class picker, per-frame status control, and Left/Right frame
navigation. No login - a locally cached username is sent for
attribution only, matching the backend's get-or-create user model.

Backend additions the frontend needed: serve frame images from a
configurable FRAMES_ROOT via a /images static mount, permissive CORS
(internal tool, not publicly exposed), and a GET /sets/{id}/frames
endpoint returning frames joined with their per-set label status
(defaulting missing rows to unlabeled) for the frame browser.

Verified the full call chain end-to-end against a running backend +
static frontend server (set/class creation, frame ingest, image
serving, label CRUD, status updates, CORS preflight) - every field
name the JS reads matches the API responses. 18/18 backend tests
pass. Not yet verified: actual interactive browser use (no browser
tooling available here) - try drag-to-draw/resize locally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jonas 2026-07-16 11:34:26 +02:00
parent 4c9deda66a
commit c5fd4de09b
10 changed files with 820 additions and 6 deletions

View File

@ -8,29 +8,50 @@ fully isolated instance.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .db import Database, get_db
from .routers.frames import router as frames_router
from .routers.labels import router as labels_router
from .routers.sets import main_classes_router, sets_router
DEFAULT_FRAMES_ROOT = "./frames"
def create_app(database_url: Optional[str] = None) -> FastAPI:
def create_app(database_url: Optional[str] = None, frames_root: Optional[str] = None) -> FastAPI:
database = Database(database_url)
database.init_models()
frames_dir = Path(frames_root or os.environ.get("FRAMES_ROOT", DEFAULT_FRAMES_ROOT))
frames_dir.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="SpelunkAI Labeling Backend")
app.state.database = database
app.dependency_overrides[get_db] = database.get_session
# Internal tool on a private training server, not exposed publicly -
# permissive CORS so the frontend (served separately, no build step) can
# call this API from any local port without extra configuration.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(sets_router)
app.include_router(main_classes_router)
app.include_router(frames_router)
app.include_router(labels_router)
app.mount("/images", StaticFiles(directory=frames_dir), name="images")
@app.get("/health")
def health() -> dict:
return {"status": "ok"}

View File

@ -1,6 +1,8 @@
"""Bounding-box labels and per-frame/per-set label status."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
@ -95,3 +97,41 @@ def set_status(frame_id: int, set_id: int, payload: schemas.FrameSetStatusUpdate
db.commit()
db.refresh(status)
return status
@router.get("/sets/{set_id}/frames", response_model=list[schemas.FrameWithStatus], tags=["frames"])
def list_frames_for_set(
set_id: int,
session_name: Optional[str] = None,
status: Optional[models.LabelStatus] = None,
db: Session = Depends(get_db),
):
"""Frames plus their per-set label status, for the labeling UI's frame browser.
A frame with no `FrameSetStatus` row yet is implicitly `unlabeled` (that
row is only created lazily, on first status read/write), so it's filled
in here rather than requiring one to exist per frame.
"""
if db.get(models.LabelSet, set_id) is None:
raise HTTPException(404, "set not found")
query = db.query(models.Frame)
if session_name is not None:
query = query.filter_by(session_name=session_name)
frames = query.order_by(models.Frame.frame_index).all()
status_by_frame = {
s.frame_id: s.status
for s in db.query(models.FrameSetStatus).filter_by(set_id=set_id).all()
}
results = [
schemas.FrameWithStatus(
frame=schemas.FrameRead.model_validate(frame),
status=status_by_frame.get(frame.id, models.LabelStatus.UNLABELED),
)
for frame in frames
]
if status is not None:
results = [r for r in results if r.status == status]
return results

View File

@ -107,3 +107,8 @@ class FrameSetStatusRead(BaseModel):
class FrameSetStatusUpdate(BaseModel):
status: LabelStatus
updated_by: Optional[str] = None
class FrameWithStatus(BaseModel):
frame: FrameRead
status: LabelStatus

View File

@ -7,6 +7,7 @@ from spelunkai_labeling_backend.main import create_app
@pytest.fixture()
def client(tmp_path):
db_path = tmp_path / "test.db"
app = create_app(database_url=f"sqlite:///{db_path}")
frames_root = tmp_path / "frames"
app = create_app(database_url=f"sqlite:///{db_path}", frames_root=str(frames_root))
with TestClient(app) as test_client:
yield test_client

View File

@ -0,0 +1,14 @@
def test_image_is_served_from_frames_root(client, tmp_path):
frames_root = tmp_path / "frames"
frames_root.mkdir(parents=True, exist_ok=True)
(frames_root / "hello.png").write_bytes(b"fake-png-bytes")
resp = client.get("/images/hello.png")
assert resp.status_code == 200
assert resp.content == b"fake-png-bytes"
def test_missing_image_returns_404(client):
resp = client.get("/images/does-not-exist.png")
assert resp.status_code == 404

View File

@ -73,3 +73,34 @@ def test_frame_set_status_defaults_and_updates(client):
resp = client.get(f"/frames/{frame_id}/sets/{set_id}/status")
assert resp.json()["status"] == "reviewed"
def test_list_frames_for_set_defaults_to_unlabeled_and_filters_by_status(client):
set_id, _ = _make_set_with_subclass(client)
frame1 = _make_frame(client, session_name="run01", frame_index=0)
frame2 = _make_frame(client, session_name="run01", frame_index=1)
client.put(f"/frames/{frame2}/sets/{set_id}/status", json={"status": "reviewed"})
resp = client.get(f"/sets/{set_id}/frames")
assert resp.status_code == 200
status_by_frame = {row["frame"]["id"]: row["status"] for row in resp.json()}
assert status_by_frame[frame1] == "unlabeled"
assert status_by_frame[frame2] == "reviewed"
resp = client.get(f"/sets/{set_id}/frames", params={"status": "reviewed"})
assert [row["frame"]["id"] for row in resp.json()] == [frame2]
def test_list_frames_for_set_filters_by_session(client):
set_id, _ = _make_set_with_subclass(client)
_make_frame(client, session_name="run01", frame_index=0)
_make_frame(client, session_name="run02", frame_index=0)
resp = client.get(f"/sets/{set_id}/frames", params={"session_name": "run02"})
assert len(resp.json()) == 1
assert resp.json()[0]["frame"]["session_name"] == "run02"
def test_list_frames_for_set_requires_existing_set(client):
resp = client.get("/sets/999/frames")
assert resp.status_code == 404

View File

@ -1,9 +1,58 @@
# Labeling Tool — Frontend
UI for the bounding-box labeling tool (image canvas with drawable/editable boxes,
class picker, dataset/set navigation). Framework not yet chosen — deferred until this
component is actively built.
UI for the bounding-box labeling tool: pick a label set, browse its frames, draw/
edit/delete boxes, assign classes, and mark per-frame status.
See CLAUDE.md §3.2 for full requirements.
**Status:** not yet implemented.
**Status:** core labeling UI implemented — plain HTML/CSS/JS, **no framework, no
build step** (matches "simple frontend — functionality over polish"). Not yet
implemented: dataset-version browsing/promotion (backend doesn't have it yet
either — see `labeling/backend/README.md`), multi-box copy/paste, and any kind of
zoom/pan (assumes the fixed 1280x720 capture resolution fits on screen).
## How it works
- `index.html` / `style.css` — layout: top bar (set/session/status filters), a
frame view with an absolutely-positioned box overlay, and a class-picker sidebar.
- `app.js` — everything else: talks to the backend via `fetch`, renders boxes as
DOM `div`s scaled to the displayed image size, and handles drawing (drag on
empty space), moving (drag a box), resizing (drag its corner handle), selecting
and deleting (click, then Delete/Backspace), and frame navigation (buttons or
Left/Right arrow keys).
- No login: a username is captured once via a prompt on first use, cached in
`localStorage`, and sent as `created_by`/`updated_by` on writes purely for
attribution (see the backend's multi-user notes).
- Each sub-class gets a stable color derived from its id (`colorForId` in
`app.js`), reused consistently across the class picker and the boxes.
## Running it
This is static files — any static file server works, e.g. Python's stdlib one:
```
cd labeling/frontend
python3 -m http.server 5500
```
Then open `http://127.0.0.1:5500`. The backend must be running separately (see
`labeling/backend/README.md`); the frontend defaults to
`http://127.0.0.1:8000` and the backend allows all CORS origins (it's an
internal tool, not exposed publicly), so no extra configuration is needed for the
default setup. To point at a different backend, set
`window.SPELUNKAI_API_BASE = "http://host:port"` in a `<script>` tag before
`app.js` loads in `index.html`.
You'll also need at least one label set with classes and some ingested frames to
see anything — create those via the backend's API (`/docs` has interactive
Swagger UI once it's running) until there's a bulk-ingest script.
## Testing
No JS test framework/build step is set up (matches the "no build step" choice
above). Verified so far: the full backend API contract this UI relies on is
covered by the backend's pytest suite, and the static files + wiring (image
serving, CORS, correct field names) were checked end-to-end via curl against a
running backend. **Not yet verified: actual interactive use in a browser**
(drag-to-draw, drag-to-resize) — try it locally and report anything that feels
off.

415
labeling/frontend/app.js Normal file
View File

@ -0,0 +1,415 @@
"use strict";
// Point this at the backend if it's not running on the default local port.
const API_BASE = window.SPELUNKAI_API_BASE || "http://127.0.0.1:8000";
let currentSetId = null;
const subClassNamesById = new Map();
let activeSubClassId = null;
let frames = []; // [{frame, status}, ...] from GET /sets/{id}/frames
let currentIndex = 0;
let currentLabels = [];
let selectedLabelId = null;
// ---- API helpers ----------------------------------------------------------
async function apiRequest(method, path, body) {
const resp = await fetch(`${API_BASE}${path}`, {
method,
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`${method} ${path} -> ${resp.status} ${text}`);
}
if (resp.status === 204) return null;
return resp.json();
}
const apiGet = (path) => apiRequest("GET", path);
const apiPost = (path, body) => apiRequest("POST", path, body);
const apiPatch = (path, body) => apiRequest("PATCH", path, body);
const apiPut = (path, body) => apiRequest("PUT", path, body);
const apiDelete = (path) => apiRequest("DELETE", path);
// ---- misc helpers -----------------------------------------------------
function getUser() {
let user = localStorage.getItem("spelunkai_labeling_user");
if (!user) {
user = (window.prompt("Your username (for label attribution):") || "anonymous").trim() || "anonymous";
localStorage.setItem("spelunkai_labeling_user", user);
}
return user;
}
// Deterministic per-class color so the same sub-class always renders the
// same way across boxes, the class picker, and different frames.
function colorForId(id) {
const hue = (Number(id) * 47) % 360;
return `hsl(${hue} 70% 50%)`;
}
function imagePathToUrl(imagePath) {
return imagePath.split("/").map(encodeURIComponent).join("/");
}
// ---- sets & classes ---------------------------------------------------
async function loadSets() {
const sets = await apiGet("/sets");
const select = document.getElementById("set-select");
select.innerHTML = "";
if (sets.length === 0) {
select.innerHTML = '<option value="">No sets yet - create one via the API</option>';
return;
}
for (const s of sets) {
const option = document.createElement("option");
option.value = s.id;
option.textContent = s.name;
select.appendChild(option);
}
await onSetChange();
}
async function onSetChange() {
currentSetId = document.getElementById("set-select").value;
if (!currentSetId) return;
const labelSet = await apiGet(`/sets/${currentSetId}`);
renderClassList(labelSet);
await loadFrames();
}
function renderClassList(labelSet) {
subClassNamesById.clear();
activeSubClassId = null;
const container = document.getElementById("class-list");
container.innerHTML = "";
if (labelSet.main_classes.length === 0) {
container.innerHTML = '<p class="hint">No classes yet for this set.</p>';
return;
}
for (const mainClass of labelSet.main_classes) {
const heading = document.createElement("div");
heading.className = "main-class-label";
heading.textContent = mainClass.name;
container.appendChild(heading);
for (const subClass of mainClass.sub_classes) {
subClassNamesById.set(subClass.id, subClass.name);
const chip = document.createElement("button");
chip.type = "button";
chip.className = "class-chip";
chip.textContent = subClass.name;
chip.style.setProperty("--chip-color", colorForId(subClass.id));
chip.addEventListener("click", () => {
activeSubClassId = subClass.id;
document.querySelectorAll(".class-chip").forEach((c) => c.classList.remove("active"));
chip.classList.add("active");
});
container.appendChild(chip);
}
}
}
// ---- frames -------------------------------------------------------------
async function loadFrames() {
const sessionName = document.getElementById("session-filter").value.trim();
const status = document.getElementById("status-filter").value;
const params = new URLSearchParams();
if (sessionName) params.set("session_name", sessionName);
if (status) params.set("status", status);
const query = params.toString() ? `?${params}` : "";
frames = await apiGet(`/sets/${currentSetId}/frames${query}`);
currentIndex = 0;
const statusSelect = document.getElementById("frame-status");
if (frames.length === 0) {
document.getElementById("frame-image").removeAttribute("src");
document.getElementById("bbox-layer").innerHTML = "";
document.getElementById("frame-position").textContent = "No frames match the current filters.";
statusSelect.disabled = true;
currentLabels = [];
return;
}
statusSelect.disabled = false;
await showFrame(0);
}
function goToFrame(index) {
if (frames.length === 0) return;
showFrame(Math.max(0, Math.min(index, frames.length - 1)));
}
async function showFrame(index) {
currentIndex = Math.max(0, Math.min(index, frames.length - 1));
const entry = frames[currentIndex];
selectedLabelId = null;
const img = document.getElementById("frame-image");
img.src = `${API_BASE}/images/${imagePathToUrl(entry.frame.image_path)}`;
document.getElementById("frame-position").textContent =
`${currentIndex + 1} / ${frames.length}${entry.frame.session_name}#${entry.frame.frame_index}`;
document.getElementById("frame-status").value = entry.status;
await loadLabelsForCurrentFrame();
}
async function loadLabelsForCurrentFrame() {
const entry = frames[currentIndex];
currentLabels = await apiGet(`/frames/${entry.frame.id}/sets/${currentSetId}/labels`);
const img = document.getElementById("frame-image");
if (img.complete) {
renderBoxes();
} else {
img.onload = () => renderBoxes();
}
}
async function onStatusChange(event) {
const status = event.target.value;
const entry = frames[currentIndex];
try {
await apiPut(`/frames/${entry.frame.id}/sets/${currentSetId}/status`, { status, updated_by: getUser() });
entry.status = status;
} catch (err) {
alert(`Failed to update status: ${err.message}`);
}
}
// ---- bounding boxes -------------------------------------------------------
function renderBoxes() {
const layer = document.getElementById("bbox-layer");
layer.innerHTML = "";
const frame = frames[currentIndex].frame;
const containerRect = document.getElementById("image-container").getBoundingClientRect();
const toScreen = containerRect.width / frame.width;
for (const label of currentLabels) {
const div = document.createElement("div");
div.className = "bbox" + (label.id === selectedLabelId ? " selected" : "");
div.style.setProperty("--bbox-color", colorForId(label.sub_class_id));
div.style.left = `${label.x * toScreen}px`;
div.style.top = `${label.y * toScreen}px`;
div.style.width = `${label.width * toScreen}px`;
div.style.height = `${label.height * toScreen}px`;
const tag = document.createElement("span");
tag.className = "bbox-tag";
tag.textContent = subClassNamesById.get(label.sub_class_id) || "?";
div.appendChild(tag);
const handle = document.createElement("div");
handle.className = "bbox-handle";
div.appendChild(handle);
div.addEventListener("pointerdown", (e) => onBoxPointerDown(e, label, div, handle));
layer.appendChild(div);
}
}
function applyPendingBoxStyle(label, div) {
const frame = frames[currentIndex].frame;
const containerRect = document.getElementById("image-container").getBoundingClientRect();
const toScreen = containerRect.width / frame.width;
const x = label._pendingX ?? label.x;
const y = label._pendingY ?? label.y;
const w = label._pendingWidth ?? label.width;
const h = label._pendingHeight ?? label.height;
div.style.left = `${x * toScreen}px`;
div.style.top = `${y * toScreen}px`;
div.style.width = `${w * toScreen}px`;
div.style.height = `${h * toScreen}px`;
}
function onBoxPointerDown(e, label, div, handle) {
e.stopPropagation();
selectedLabelId = label.id;
document.querySelectorAll(".bbox").forEach((b) => b.classList.remove("selected"));
div.classList.add("selected");
const isResize = e.target === handle;
const startScreenX = e.clientX;
const startScreenY = e.clientY;
const startX = label.x;
const startY = label.y;
const startW = label.width;
const startH = label.height;
const frame = frames[currentIndex].frame;
const containerRect = document.getElementById("image-container").getBoundingClientRect();
const toImage = frame.width / containerRect.width;
function onMove(ev) {
const dxImg = (ev.clientX - startScreenX) * toImage;
const dyImg = (ev.clientY - startScreenY) * toImage;
if (isResize) {
label._pendingWidth = Math.max(4, startW + dxImg);
label._pendingHeight = Math.max(4, startH + dyImg);
} else {
label._pendingX = Math.max(0, startX + dxImg);
label._pendingY = Math.max(0, startY + dyImg);
}
applyPendingBoxStyle(label, div);
}
function onUp() {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
const patch = {};
if (label._pendingX !== undefined) {
patch.x = label._pendingX;
patch.y = label._pendingY;
}
if (label._pendingWidth !== undefined) {
patch.width = label._pendingWidth;
patch.height = label._pendingHeight;
}
delete label._pendingX;
delete label._pendingY;
delete label._pendingWidth;
delete label._pendingHeight;
if (Object.keys(patch).length > 0) {
Object.assign(label, patch);
apiPatch(`/labels/${label.id}`, patch).catch((err) => {
alert(`Failed to save box: ${err.message}`);
loadLabelsForCurrentFrame();
});
}
}
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
}
function onLayerPointerDown(e) {
const layer = document.getElementById("bbox-layer");
if (e.target !== layer) return; // a box handled its own pointerdown
if (!activeSubClassId) {
selectedLabelId = null;
renderBoxes();
return;
}
const containerRect = document.getElementById("image-container").getBoundingClientRect();
const startX = e.clientX;
const startY = e.clientY;
const preview = document.createElement("div");
preview.className = "bbox";
preview.style.setProperty("--bbox-color", colorForId(activeSubClassId));
layer.appendChild(preview);
function onMove(ev) {
const x0 = Math.min(startX, ev.clientX) - containerRect.left;
const y0 = Math.min(startY, ev.clientY) - containerRect.top;
const w = Math.abs(ev.clientX - startX);
const h = Math.abs(ev.clientY - startY);
preview.style.left = `${x0}px`;
preview.style.top = `${y0}px`;
preview.style.width = `${w}px`;
preview.style.height = `${h}px`;
preview.dataset.x0 = x0;
preview.dataset.y0 = y0;
preview.dataset.w = w;
preview.dataset.h = h;
}
function onUp() {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
const w = Number(preview.dataset.w || 0);
const h = Number(preview.dataset.h || 0);
preview.remove();
if (w < 4 || h < 4) {
selectedLabelId = null;
renderBoxes();
return;
}
const frame = frames[currentIndex].frame;
const toImage = frame.width / containerRect.width;
createLabel({
sub_class_id: activeSubClassId,
x: Number(preview.dataset.x0) * toImage,
y: Number(preview.dataset.y0) * toImage,
width: w * toImage,
height: h * toImage,
created_by: getUser(),
});
}
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
}
async function createLabel(payload) {
const setId = currentSetId;
const frameId = frames[currentIndex].frame.id;
try {
const label = await apiPost(`/frames/${frameId}/sets/${setId}/labels`, payload);
currentLabels.push(label);
selectedLabelId = label.id;
renderBoxes();
} catch (err) {
alert(`Failed to create label: ${err.message}`);
}
}
async function deleteSelectedLabel() {
const id = selectedLabelId;
try {
await apiDelete(`/labels/${id}`);
currentLabels = currentLabels.filter((l) => l.id !== id);
selectedLabelId = null;
renderBoxes();
} catch (err) {
alert(`Failed to delete label: ${err.message}`);
}
}
// ---- keyboard shortcuts -----------------------------------------------
function onKeyDown(e) {
if (e.target.tagName === "INPUT" || e.target.tagName === "SELECT") return;
if ((e.key === "Delete" || e.key === "Backspace") && selectedLabelId != null) {
e.preventDefault();
deleteSelectedLabel();
} else if (e.key === "ArrowLeft") {
goToFrame(currentIndex - 1);
} else if (e.key === "ArrowRight") {
goToFrame(currentIndex + 1);
}
}
// ---- init -----------------------------------------------------------------
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("set-select").addEventListener("change", onSetChange);
document.getElementById("apply-filters").addEventListener("click", loadFrames);
document.getElementById("prev-frame").addEventListener("click", () => goToFrame(currentIndex - 1));
document.getElementById("next-frame").addEventListener("click", () => goToFrame(currentIndex + 1));
document.getElementById("frame-status").addEventListener("change", onStatusChange);
document.getElementById("bbox-layer").addEventListener("pointerdown", onLayerPointerDown);
document.addEventListener("keydown", onKeyDown);
loadSets().catch((err) => alert(`Failed to load sets: ${err.message}`));
});

View File

@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SpelunkAI Labeling Tool</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<h1>SpelunkAI Labeling</h1>
<label>
Set
<select id="set-select"><option value="">Loading…</option></select>
</label>
<label>
Session
<input id="session-filter" type="text" placeholder="all sessions" />
</label>
<label>
Status
<select id="status-filter">
<option value="">all</option>
<option value="unlabeled">unlabeled</option>
<option value="auto_labeled">auto_labeled</option>
<option value="reviewed">reviewed</option>
</select>
</label>
<button id="apply-filters">Apply</button>
<span id="frame-position" class="frame-position"></span>
</header>
<main class="workspace">
<section class="canvas-area">
<div id="image-container">
<img id="frame-image" draggable="false" alt="" />
<div id="bbox-layer"></div>
</div>
<div class="nav-controls">
<button id="prev-frame">&larr; Prev</button>
<label>
Frame status
<select id="frame-status" disabled>
<option value="unlabeled">unlabeled</option>
<option value="auto_labeled">auto_labeled</option>
<option value="reviewed">reviewed</option>
</select>
</label>
<button id="next-frame">Next &rarr;</button>
</div>
</section>
<aside class="sidebar">
<h2>Classes</h2>
<div id="class-list"><p class="hint">Pick a set first.</p></div>
<p class="hint">
Click a class, then drag on the image to draw a box.<br />
Click an existing box to select it (Delete/Backspace removes it, drag
the body to move, drag the bottom-right handle to resize).<br />
Left/Right arrow keys move between frames.
</p>
</aside>
</main>
<script src="app.js" defer></script>
</body>
</html>

164
labeling/frontend/style.css Normal file
View File

@ -0,0 +1,164 @@
:root {
color-scheme: light dark;
--border: #888;
--bg: canvas;
--fg: canvastext;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
}
.topbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.topbar h1 {
font-size: 1.1rem;
margin: 0;
}
.topbar label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.9rem;
}
.frame-position {
margin-left: auto;
font-variant-numeric: tabular-nums;
opacity: 0.8;
}
.workspace {
display: flex;
gap: 1rem;
padding: 1rem;
align-items: flex-start;
}
.canvas-area {
flex: 1;
min-width: 0;
}
#image-container {
position: relative;
display: inline-block;
max-width: 100%;
border: 1px solid var(--border);
line-height: 0;
}
#frame-image {
display: block;
max-width: 100%;
height: auto;
user-select: none;
}
#bbox-layer {
position: absolute;
inset: 0;
cursor: crosshair;
}
.bbox {
position: absolute;
border: 2px solid var(--bbox-color, #ff4136);
background: color-mix(in srgb, var(--bbox-color, #ff4136) 15%, transparent);
cursor: move;
}
.bbox.selected {
outline: 2px dashed white;
outline-offset: 2px;
}
.bbox .bbox-tag {
position: absolute;
top: -1.3em;
left: -2px;
background: var(--bbox-color, #ff4136);
color: #fff;
font-size: 0.75rem;
padding: 0 0.3em;
white-space: nowrap;
}
.bbox .bbox-handle {
position: absolute;
right: -5px;
bottom: -5px;
width: 10px;
height: 10px;
background: var(--bbox-color, #ff4136);
cursor: nwse-resize;
}
.nav-controls {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.75rem;
}
.sidebar {
width: 220px;
flex-shrink: 0;
}
.sidebar h2 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
#class-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 1rem;
}
.main-class-label {
font-size: 0.8rem;
opacity: 0.7;
margin-top: 0.5rem;
}
.class-chip {
display: block;
width: 100%;
text-align: left;
padding: 0.3rem 0.5rem;
border: 1px solid var(--border);
border-left: 6px solid var(--chip-color, #888);
background: none;
color: inherit;
cursor: pointer;
font-size: 0.9rem;
}
.class-chip.active {
background: color-mix(in srgb, var(--chip-color, #888) 25%, transparent);
font-weight: 600;
}
.hint {
font-size: 0.8rem;
opacity: 0.75;
line-height: 1.4;
}