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:
parent
4c9deda66a
commit
c5fd4de09b
@ -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"}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
14
labeling/backend/tests/test_images.py
Normal file
14
labeling/backend/tests/test_images.py
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
415
labeling/frontend/app.js
Normal 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}`));
|
||||||
|
});
|
||||||
74
labeling/frontend/index.html
Normal file
74
labeling/frontend/index.html
Normal 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">← 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 →</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
164
labeling/frontend/style.css
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user