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
|
||||
|
||||
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"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -107,3 +107,8 @@ class FrameSetStatusRead(BaseModel):
|
||||
class FrameSetStatusUpdate(BaseModel):
|
||||
status: LabelStatus
|
||||
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()
|
||||
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
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
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
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