Jonas 4e993ccf52 Add dataset version promotion (enemy-v1, enemy-v2, ...)
Add DatasetVersion/DatasetVersionFrame/DatasetVersionLabel and a
promote endpoint (POST /sets/{id}/dataset-versions) that freezes a
set's currently-reviewed frames (or an explicit frame_ids selection)
into a named, immutable snapshot: it copies each label's data at
promotion time rather than referencing the live rows, so later edits
or deletes to those labels can't retroactively change an already
-promoted version. GET /dataset-versions/{id} returns the frozen
frames+labels - this is what the training pipeline will eventually
pull from.

This was the labeling backend's last deliberately-deferred piece from
the original data model (needed its own design pass for snapshot
semantics). 31/31 backend tests pass, including one that promotes a
version, edits and deletes the live label afterward, and asserts the
snapshot is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 13:08:17 +02:00
..

Labeling Tool — Backend

API + data model for the bounding-box labeling tool. See CLAUDE.md §3.2 for the full requirements this is built against.

Status: core data model + CRUD API implemented (sets, hierarchical classes, frames, labels, per-frame/per-set status), frame image serving, bulk frame ingest from a recording session, and dataset version promotion (see labeling/frontend/ for the UI). Not yet implemented: auth/login (see below) or the active-learning auto-label workflow.

Stack

  • FastAPI + SQLAlchemy (2.0), SQLite by default (./labeling.db), swappable via the DATABASE_URL env var (e.g. to Postgres later without code changes — one Postgres-compatible ORM).
  • Multi-user, no auth yet: labels/status changes take a plain created_by / updated_by username string, resolved via get-or-create (users.py). There's no login flow — attribution only, since there's no UI yet that would need real auth.

Data model

  • LabelSet — a label set (Enemy, Items, Traps, ...), one per detector model.
  • MainClass / SubClass — the per-set Main → Sub class hierarchy (e.g. EnemyBat, Snake), created ad hoc via the API, no migration needed to add classes.
  • Frame — one labelable image, identified by (session_name, frame_index) — matches the recording tool's frame-extraction output 1:1.
  • Label — one bounding box (x, y, width, height in pixel space), scoped to a frame + set + sub-class.
  • FrameSetStatus — per-frame, per-set label state (unlabeled / auto_labeled / reviewed).
  • DatasetVersion / DatasetVersionFrame / DatasetVersionLabel — a named, immutable snapshot of a set's frames+labels as of promotion time (enemy-v1, enemy-v2, ...). Labels stay live-editable at any time; a promoted version copies the label data at that moment, so it stays reproducible regardless of later edits or deletions to the live labels.

Frame images are served from FRAMES_ROOT (env var, default ./frames) at /images/<image_path>; Frame.image_path is always relative to that root.

Setup

python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

Run

spelunkai-labeling-backend
# or: uvicorn spelunkai_labeling_backend.main:create_app --factory --reload

Interactive API docs at http://127.0.0.1:8000/docs once running.

Bulk-ingesting a recording session

After a session's extracted frames (recording/'s extract-frames output) have been copied/rsynced onto this machine under FRAMES_ROOT, register them all in one shot (writes directly to the database, no server needs to be running):

spelunkai-labeling-backend ingest-session \
  --frames-dir /path/under/FRAMES_ROOT/run01_frames \
  --session-name run01

--image-path-prefix defaults to the frames directory's own name (here run01_frames) — override it if the directory was copied under a different name. Safe to re-run: frames already registered for that session (by frame index) are skipped. --width/--height default to 1280x720 (Spelunky Classic HD's fixed capture resolution).

Promoting a dataset version

Once frames are marked reviewed (see the frontend, or PUT /frames/{id}/sets/{id}/status), freeze them into a named, reproducible version:

POST /sets/{set_id}/dataset-versions
{"name": "enemy-v1", "description": "first reviewed batch"}

Defaults to every currently-reviewed frame in that set; pass an explicit frame_ids list to promote a different selection instead. Fetch the frozen result (what training will eventually consume) via GET /dataset-versions/{id} — it returns each frame plus the exact labels that existed at promotion time, unaffected by any later edits to the live labels.

Testing

pytest

Each test gets a fully isolated app + SQLite file via create_app(database_url=...) (see tests/conftest.py) — no shared state between tests, no real server needed.