Self-hosted web app for annotating microscopy image tiles with an iPad + Apple Pencil. Draws bounding boxes and freehand masks; runs over your LAN, installed as a PWA — no App Store, no cloud. Work is organised into projects, each with its own images, labels and exports. Class labels come from backend/config.yaml (seeding every new project), so it works for any ML labeling task.
Current state: multi-project workspaces, upload + drag-drop ingestion, boxes + freehand brush masks + polygons, undo/redo, auto-save, per-project COCO/YOLOv8-seg export (verbatim tiles) + one-click dataset zip download, progress dashboard. Runs via uv/npm or docker-compose. The schema is already user-scoped (projects have an owner + members) so real multi-user auth can be added later without a redesign.
One script handles everything:
./run.sh # build frontend, serve app on :8000 (iPad/PWA mode)
./run.sh dev # hot-reload: vite on :5173 (proxies /api) + backend on :8000./run.sh syncs backend deps (uv), installs frontend deps, builds the frontend, and serves the whole app on http://localhost:8000 — open that (or http://<workstation-ip>:8000 on the iPad) and annotate.
For active development use ./run.sh dev and open http://localhost:5173; Vite hot-reloads on save while the backend still runs on :8000.
For the iPad over LAN the backend must listen on 0.0.0.0 — both script modes already pass --host 0.0.0.0. The server.host value in config.yaml is not read by the uvicorn CLI.
# Backend (from backend/)
uv sync
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# Frontend dev server (from frontend/)
npm install
npm run devDeploy the whole app (built frontend + backend) in one container with docker-compose. Needs Docker with the Compose plugin.
One-time prep — the container runs as uid 1000 so it can write the host data dir. Create ./data and give it to that uid (adjust if your user's uid isn't 1000):
mkdir -p data
sudo chown -R 1000:1000 dataBuild and start:
docker compose up -d --buildOpen http://<host-ip>:8000 (or http://localhost:8000 on the same machine). On the iPad over the same LAN, open that address in Safari → Share → Add to Home Screen to install the PWA. The container also exposes a healthcheck (/api/health) — docker compose ps shows it as healthy.
Where your data lives: everything is on the host in ./data/ — app.db (project metadata) plus projects/<slug>/{tiles,annotations,exports,classes.json}. Back it up by copying that directory. Nothing important is stored inside the container.
First run: if you already have a pre-project data/{tiles,annotations,exports,classes.json} from an older version, it is auto-migrated into a project named Default (data/projects/default/), with the annotations and classes backed up to data/.migration-backup/.
Adding images: use the Upload button / drag-and-drop on a project page (≤ 100 MB per file, uploaded one at a time), or drop tile files into data/projects/<slug>/tiles/ on the host and hit Scan in the app.
Optional config override: the image ships with backend/config.yaml. To use your own, mount it over the image's copy:
# docker-compose.yml — add under services.annotator.volumes:
- ./my-config.yaml:/srv/backend/config.yaml:roStop / rebuild:
docker compose down # stop (data persists in ./data)
docker compose up -d --build # rebuild after pulling new code or editing DockerfileThe app opens on a project list; each project owns a data/projects/<slug>/ directory. There is no global tiles folder. To add images to a project:
- Upload button / drag & drop on the project page (
POST /api/projects/<slug>/upload, multipartfiles) — files are uploaded one at a time and capped at 100 MB each (larger files are skipped with a 413/notice), or - drop tile files directly into
data/projects/<slug>/tiles/and hit Scan, or curl -X POST localhost:8000/api/projects/<slug>/images/scan
On first boot with an existing pre-project data/{tiles,annotations,exports,classes.json}, the app migrates that data into a project named Default (data/projects/default/), backing up the annotations and classes to data/.migration-backup/.
Project management: Settings on a project page renames it, archives it, or deletes it (delete requires archiving first). To remove uploaded images, hit Select on the project page, tap the images to mark them (✓), then Delete — the tile, its annotations and preview are removed.
Tiles are served with immutable cache headers; non-browser formats (e.g. .tif) are auto-converted to PNG previews.
Open the Queue panel to pick a tile. Pen/mouse = annotate, finger = pan/pinch.
| Tool | Hotkey | Use |
|---|---|---|
| Select | V |
tap to select (box or mask — thin/small ones select when you tap near them); drag a box body or mask to move, drag box handles to resize; Delete/Backspace or the Delete button removes it |
| Box Sel | M |
drag a rectangle to select every box/mask inside it; drag one of them to move the whole group, Delete removes them all, the class palette re-classes them together |
| Box | B |
drag corner-to-corner |
| Brush | D |
pressure-sensitive freehand stroke |
| Polygon | P |
tap vertices, double-tap (or tap near the first vertex) to close |
| Eraser | E |
scrub to thin brush strokes, or delete a whole box/polygon it touches |
| Class palette | 1 2 3 |
pick active class; with something selected it re-classes it |
| Labels | – | Labels header button: add, remove, rename, recolor or drag-to-reorder the class list; changes persist to the project's classes.json |
| Annotate | – | open an image from the project page's grid (or a deep link like /p/<slug>/annotate?img=<id>) |
⌘Z undo, ⌘⇧Z redo, ⌘S force save, Esc cancels an in-progress polygon. Changes auto-save ~2s after you stop editing — the status bar shows saved (green), unsaved (amber), or an error (red), and the Save button saves immediately. The Export header button writes COCO + YOLO to the project's exports/ dir.
Hit Export (or POST /api/projects/<slug>/export) after annotating. Writes to the project's data/projects/<slug>/exports/:
coco/annotations.json— boxes as bbox, polygons as segmentation, brush strokes as RLE; tiles copied tococo/images/.yolo/— per-tile labels inyolo/labels/<image>.txt(YOLOv8-seg normalized polygons),classes.txt, and adata.yaml(train/val split fromconfig.yaml), tiles inyolo/images/.
Exports copy each tile verbatim (original file, no format conversion), so JPEG/TIFF/RAW images stay exactly as uploaded. Only annotated images are exported.
The Download header button (or GET /api/projects/<slug>/export/download) serves the whole COCO + YOLO dataset as a single <slug>-dataset.zip and also saves it to exports/.
Everything is server-side: annotations live in data/projects/<slug>/annotations/<image_id>.json. Refreshing or switching devices loses nothing.
# from frontend/
npm run build
# restart backend; it serves the built SPA (assets + index.html fallback) at /
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000On the iPad, open http://<workstation-ip>:8000 in Safari → Share → Add to Home Screen to install as a standalone PWA. The service worker caches the shell and lets the last-viewed tile work offline. Deep links like /p/<slug>/annotate work standalone — the backend falls back to index.html for any non-API path.
backend/config.yaml — classes (id, name, color, hotkey), legacy dataset paths, annotator name, export split. Paths resolve relative to the file's directory. backend/config.example.yaml is a fully-commented template; delete config.yaml and restart to bootstrap from it.
Class edits made in the Labels UI persist to the project's <project>/classes.json and override the classes: list in config.yaml for that project — so the committed config stays the default/seed for every new project, and your live labels survive in the gitignored data/ dir. Deleting a class that still has annotations is blocked (delete or re-class those first). Project metadata (owner, members) lives in the SQLite DB data/app.db.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/projects |
project list |
| POST | /api/projects |
create project ({name, slug?}) |
| GET/PATCH/DELETE | /api/projects/{slug} |
project detail / rename+archive / delete (archive first) |
| POST | /api/projects/{slug}/upload |
multipart upload of tile files |
| GET | /api/projects/{slug}/images?status=… |
image queue |
| POST | /api/projects/{slug}/images/scan |
re-scan the project's tiles dir |
| POST | /api/projects/{slug}/images/delete |
batch-delete images ({"image_ids":[…]}) |
| GET | /api/projects/{slug}/images/{id}/file |
tile image (or PNG preview) |
| GET/PUT/DELETE | /api/projects/{slug}/annotations/{id} |
annotation doc (404 if none) |
| GET/PUT | /api/projects/{slug}/classes |
class labels (config seed, or project classes.json override) |
| GET | /api/projects/{slug}/progress |
pending/annotated/skipped counts |
| POST | /api/projects/{slug}/export |
write COCO + YOLO exports to the project's exports/ |
| GET | /api/projects/{slug}/export/download |
download the COCO + YOLO dataset as <slug>-dataset.zip |
run.sh one-command build+serve (see Quick start) or `./run.sh dev`
backend/ FastAPI app (uv) — app/{main,config,storage,schemas,db,projects,deps}.py, app/routers/, app/export/
frontend/ Vite + Konva (npm) — src/{api,main,router,styles}.ts/.css, src/canvas/, src/input/, src/state/, src/tools/, src/ui/, src/views/
data/ gitignored user data — app.db, projects/<slug>/{tiles,annotations,exports,classes.json}
See AGENTS.md for developer commands and hard-won gotchas.