Skip to content

Repository files navigation

Image Annotator

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.

Quick start

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.

Without the script

# 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 dev

Docker deployment

Deploy 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 data

Build and start:

docker compose up -d --build

Open 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:ro

Stop / rebuild:

docker compose down            # stop (data persists in ./data)
docker compose up -d --build   # rebuild after pulling new code or editing Dockerfile

Projects & adding images

The 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, multipart files) — 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.

Annotating

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.

Exporting

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 to coco/images/.
  • yolo/ — per-tile labels in yolo/labels/<image>.txt (YOLOv8-seg normalized polygons), classes.txt, and a data.yaml (train/val split from config.yaml), tiles in yolo/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.

Serving the built app through the backend (prod/PWA)

# 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 8000

On 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.

Configuration

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.

API

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

Project layout

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.

About

Web Annotation App with Apple Pencil Support for doing ML Image Annotations that little bit nicer

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages