From c5fd4de09b0b5ca4814bfe1ae61db450d1fea2b9 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 16 Jul 2026 11:34:26 +0200 Subject: [PATCH] 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 --- .../src/spelunkai_labeling_backend/main.py | 23 +- .../routers/labels.py | 40 ++ .../src/spelunkai_labeling_backend/schemas.py | 5 + labeling/backend/tests/conftest.py | 3 +- labeling/backend/tests/test_images.py | 14 + labeling/backend/tests/test_labels.py | 31 ++ labeling/frontend/README.md | 57 ++- labeling/frontend/app.js | 415 ++++++++++++++++++ labeling/frontend/index.html | 74 ++++ labeling/frontend/style.css | 164 +++++++ 10 files changed, 820 insertions(+), 6 deletions(-) create mode 100644 labeling/backend/tests/test_images.py create mode 100644 labeling/frontend/app.js create mode 100644 labeling/frontend/index.html create mode 100644 labeling/frontend/style.css diff --git a/labeling/backend/src/spelunkai_labeling_backend/main.py b/labeling/backend/src/spelunkai_labeling_backend/main.py index e6574f3..3d77d29 100644 --- a/labeling/backend/src/spelunkai_labeling_backend/main.py +++ b/labeling/backend/src/spelunkai_labeling_backend/main.py @@ -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"} diff --git a/labeling/backend/src/spelunkai_labeling_backend/routers/labels.py b/labeling/backend/src/spelunkai_labeling_backend/routers/labels.py index 464a5be..bc13d81 100644 --- a/labeling/backend/src/spelunkai_labeling_backend/routers/labels.py +++ b/labeling/backend/src/spelunkai_labeling_backend/routers/labels.py @@ -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 diff --git a/labeling/backend/src/spelunkai_labeling_backend/schemas.py b/labeling/backend/src/spelunkai_labeling_backend/schemas.py index 5177137..166f2d5 100644 --- a/labeling/backend/src/spelunkai_labeling_backend/schemas.py +++ b/labeling/backend/src/spelunkai_labeling_backend/schemas.py @@ -107,3 +107,8 @@ class FrameSetStatusRead(BaseModel): class FrameSetStatusUpdate(BaseModel): status: LabelStatus updated_by: Optional[str] = None + + +class FrameWithStatus(BaseModel): + frame: FrameRead + status: LabelStatus diff --git a/labeling/backend/tests/conftest.py b/labeling/backend/tests/conftest.py index b78434f..2852f69 100644 --- a/labeling/backend/tests/conftest.py +++ b/labeling/backend/tests/conftest.py @@ -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 diff --git a/labeling/backend/tests/test_images.py b/labeling/backend/tests/test_images.py new file mode 100644 index 0000000..46c05ad --- /dev/null +++ b/labeling/backend/tests/test_images.py @@ -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 diff --git a/labeling/backend/tests/test_labels.py b/labeling/backend/tests/test_labels.py index 610ab2a..c62ea82 100644 --- a/labeling/backend/tests/test_labels.py +++ b/labeling/backend/tests/test_labels.py @@ -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 diff --git a/labeling/frontend/README.md b/labeling/frontend/README.md index f55e3ab..ab670a7 100644 --- a/labeling/frontend/README.md +++ b/labeling/frontend/README.md @@ -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 ` + + diff --git a/labeling/frontend/style.css b/labeling/frontend/style.css new file mode 100644 index 0000000..e11cad4 --- /dev/null +++ b/labeling/frontend/style.css @@ -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; +}