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 from __future__ import annotations
import os
from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .db import Database, get_db from .db import Database, get_db
from .routers.frames import router as frames_router from .routers.frames import router as frames_router
from .routers.labels import router as labels_router from .routers.labels import router as labels_router
from .routers.sets import main_classes_router, sets_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 = Database(database_url)
database.init_models() 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 = FastAPI(title="SpelunkAI Labeling Backend")
app.state.database = database app.state.database = database
app.dependency_overrides[get_db] = database.get_session 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(sets_router)
app.include_router(main_classes_router) app.include_router(main_classes_router)
app.include_router(frames_router) app.include_router(frames_router)
app.include_router(labels_router) app.include_router(labels_router)
app.mount("/images", StaticFiles(directory=frames_dir), name="images")
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"status": "ok"} return {"status": "ok"}

View File

@ -1,6 +1,8 @@
"""Bounding-box labels and per-frame/per-set label status.""" """Bounding-box labels and per-frame/per-set label status."""
from __future__ import annotations from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -95,3 +97,41 @@ def set_status(frame_id: int, set_id: int, payload: schemas.FrameSetStatusUpdate
db.commit() db.commit()
db.refresh(status) db.refresh(status)
return 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): class FrameSetStatusUpdate(BaseModel):
status: LabelStatus status: LabelStatus
updated_by: Optional[str] = None 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() @pytest.fixture()
def client(tmp_path): def client(tmp_path):
db_path = tmp_path / "test.db" 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: with TestClient(app) as test_client:
yield 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") resp = client.get(f"/frames/{frame_id}/sets/{set_id}/status")
assert resp.json()["status"] == "reviewed" 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 # Labeling Tool — Frontend
UI for the bounding-box labeling tool (image canvas with drawable/editable boxes, UI for the bounding-box labeling tool: pick a label set, browse its frames, draw/
class picker, dataset/set navigation). Framework not yet chosen — deferred until this edit/delete boxes, assign classes, and mark per-frame status.
component is actively built.
See CLAUDE.md §3.2 for full requirements. 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;
}