Spaces:
Sleeping
Sleeping
deploy: labeling server
Browse files- Dockerfile +29 -0
- README.md +32 -3
- configs/base_prompts.yaml +189 -0
- configs/distractor_policy.yaml +22 -0
- configs/generation.yaml +4 -0
- configs/profile_vocab.json +34 -0
- labeling/instructions.md +25 -0
- labeling/static/app.js +162 -0
- labeling/static/index.html +36 -0
- labeling/static/style.css +146 -0
- pyproject.toml +38 -0
- spaces/DEPLOY.md +77 -0
- spaces/Dockerfile +29 -0
- spaces/README.md +39 -0
- spaces/push_dataset.py +75 -0
- spaces/push_space.py +74 -0
- spaces/push_to_space.sh +21 -0
- spaces/requirements.txt +6 -0
- spaces/space_entry.py +155 -0
- src/aamcq/__init__.py +3 -0
- src/aamcq/annotation/__init__.py +0 -0
- src/aamcq/annotation/api.py +205 -0
- src/aamcq/annotation/assignment.py +72 -0
- src/aamcq/annotation/db.py +256 -0
- src/aamcq/distractors.py +187 -0
- src/aamcq/generation/__init__.py +10 -0
- src/aamcq/generation/base.py +38 -0
- src/aamcq/generation/flux2_klein.py +74 -0
- src/aamcq/generation/registry.py +18 -0
- src/aamcq/instance_plan.py +170 -0
- src/aamcq/profile.py +90 -0
- src/aamcq/prompt_render.py +113 -0
- src/aamcq/utils/__init__.py +0 -0
- src/aamcq/utils/io.py +37 -0
- src/aamcq/utils/seeding.py +16 -0
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
PYTHONPATH=/app/src
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
RUN apt-get update && \
|
| 11 |
+
apt-get install -y --no-install-recommends git && \
|
| 12 |
+
rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
COPY spaces/requirements.txt /app/spaces/requirements.txt
|
| 15 |
+
RUN pip install -r /app/spaces/requirements.txt
|
| 16 |
+
|
| 17 |
+
COPY pyproject.toml /app/
|
| 18 |
+
COPY src /app/src
|
| 19 |
+
COPY labeling /app/labeling
|
| 20 |
+
COPY configs /app/configs
|
| 21 |
+
COPY spaces /app/spaces
|
| 22 |
+
|
| 23 |
+
# HF Spaces mounts a writable /data directory when Persistent Storage is
|
| 24 |
+
# enabled; fall back to an in-container path when running locally.
|
| 25 |
+
ENV AAMCQ_DATA_DIR=/data
|
| 26 |
+
RUN mkdir -p /data && chmod 777 /data
|
| 27 |
+
|
| 28 |
+
EXPOSE 7860
|
| 29 |
+
CMD ["python", "/app/spaces/space_entry.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,39 @@
|
|
| 1 |
---
|
| 2 |
title: Aesthetic Annotators
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: pink
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Aesthetic Annotators
|
| 3 |
+
emoji: 🎨
|
| 4 |
+
colorFrom: purple
|
| 5 |
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Aesthetic Annotators
|
| 12 |
+
|
| 13 |
+
Public-URL labeling server for the AestheticMCQ dataset. Any visitor is
|
| 14 |
+
auto-issued an anonymous annotator id on first hit; each session labels up
|
| 15 |
+
to `AAMCQ_PER_ANNOTATOR_CAP` items (default 20) pulled breadth-first from
|
| 16 |
+
the pool so every item receives one label before any receives a second.
|
| 17 |
+
|
| 18 |
+
## Configuration
|
| 19 |
+
|
| 20 |
+
Space secrets:
|
| 21 |
+
|
| 22 |
+
| name | required | default | notes |
|
| 23 |
+
|---|---|---|---|
|
| 24 |
+
| `HF_TOKEN` | yes | — | write scope on the companion dataset repo |
|
| 25 |
+
| `AAMCQ_DATASET_REPO` | no | `lanczos/aesthetic-annotators` | source of images + mcq + label backups |
|
| 26 |
+
| `AAMCQ_PER_ANNOTATOR_CAP` | no | `20` | items per session before "all done" |
|
| 27 |
+
| `AAMCQ_LABELS_PER_ITEM` | no | `3` | target labels per item |
|
| 28 |
+
| `AAMCQ_BACKUP_INTERVAL` | no | `60` | SQLite → dataset repo push interval (seconds) |
|
| 29 |
+
|
| 30 |
+
## Data flow
|
| 31 |
+
|
| 32 |
+
1. On boot, the Space pulls `images/*.png`, `mcq_unlabeled.jsonl`, and any
|
| 33 |
+
prior `labels/annotations.sqlite` from the dataset repo.
|
| 34 |
+
2. Annotators land on the root URL → JS calls `POST /api/register` → server
|
| 35 |
+
mints a fresh `anon_*` id + token, cached in localStorage.
|
| 36 |
+
3. `/api/task` hands out the least-labeled item the annotator hasn't seen.
|
| 37 |
+
4. Every `AAMCQ_BACKUP_INTERVAL` seconds the server pushes the SQLite back
|
| 38 |
+
to `labels/annotations.sqlite` in the dataset repo, so Space
|
| 39 |
+
restarts/sleeps lose at most one backup interval's worth of labels.
|
configs/base_prompts.yaml
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Content-neutral base prompts rotated across MCQ items.
|
| 2 |
+
#
|
| 3 |
+
# Filter rules applied when curating this pool:
|
| 4 |
+
# - no embedded style words (epic / mystical / cinematic / gloomy / surreal / cozy / vibrant)
|
| 5 |
+
# - single subject, simple composition
|
| 6 |
+
# - realistic / non-fantastical content
|
| 7 |
+
# - no text-in-image requirement
|
| 8 |
+
# - content-orthogonal across categories
|
| 9 |
+
#
|
| 10 |
+
# Each prompt has a `quality`: `clean` means style-uncontaminated; `mild` means a
|
| 11 |
+
# weak style word slipped through. build_plan() loads only `clean` entries.
|
| 12 |
+
|
| 13 |
+
meta:
|
| 14 |
+
version: "1.0"
|
| 15 |
+
|
| 16 |
+
recommended:
|
| 17 |
+
portrait: "a 20 year old woman studying"
|
| 18 |
+
landscape: "a tree in a field in front of a mountain"
|
| 19 |
+
animal: "a ginger cat looking out the window"
|
| 20 |
+
still_life: "sunflowers in a glass mason jar"
|
| 21 |
+
|
| 22 |
+
categories:
|
| 23 |
+
|
| 24 |
+
portrait:
|
| 25 |
+
description: "Human subjects — skin tone, lighting, mood style signals strongest"
|
| 26 |
+
prompts:
|
| 27 |
+
- { text: "a 20 year old woman studying", quality: clean, recommended: true }
|
| 28 |
+
- { text: "a young man looking at his reflection in a mirror", quality: clean }
|
| 29 |
+
- { text: "a woman staring into the camera, scratching her head", quality: clean }
|
| 30 |
+
- { text: "a woman crossing a footbridge in a park", quality: clean }
|
| 31 |
+
- { text: "a man sitting enjoying a tree's shade", quality: clean }
|
| 32 |
+
- { text: "a boy walking down a forest in fall", quality: clean }
|
| 33 |
+
- { text: "a female climber in a bouldering gym", quality: clean }
|
| 34 |
+
- { text: "a cyclist on a road bicycle", quality: clean }
|
| 35 |
+
- { text: "a female cyclist on a road bicycle", quality: clean }
|
| 36 |
+
- { text: "a surfer girl holding a surf board", quality: clean }
|
| 37 |
+
- { text: "a man ordering food at a restaurant", quality: clean }
|
| 38 |
+
- { text: "a couple on a first date", quality: clean }
|
| 39 |
+
- { text: "a tennis player playing tennis at Roland Garros", quality: clean }
|
| 40 |
+
- { text: "a bodybuilder training hard with weights in the gym", quality: clean }
|
| 41 |
+
- { text: "a happy nepalese girl in a village", quality: clean }
|
| 42 |
+
- { text: "a boy sitting in a poor village street corner playing an acoustic guitar", quality: clean }
|
| 43 |
+
- { text: "a 20 year old woman wearing workout clothes", quality: clean }
|
| 44 |
+
- { text: "an elderly woman in a rocking chair", quality: clean }
|
| 45 |
+
- { text: "a woman pointing straight at viewer", quality: clean }
|
| 46 |
+
- { text: "two hands typing on a computer keyboard", quality: clean }
|
| 47 |
+
- { text: "a man waving goodbye while looking at camera", quality: clean }
|
| 48 |
+
- { text: "a young woman with red hair and green eyes", quality: clean }
|
| 49 |
+
- { text: "a ginger woman with short wavy hair and pale skin and freckles", quality: clean }
|
| 50 |
+
- { text: "an 18 year old girl, looking at viewer", quality: clean }
|
| 51 |
+
- { text: "young woman with freckled face, on the beach", quality: clean }
|
| 52 |
+
- { text: "a videographer in a busy place", quality: clean }
|
| 53 |
+
- { text: "a man and a woman eating dinner at home", quality: clean }
|
| 54 |
+
- { text: "high school students playing during the school break", quality: clean }
|
| 55 |
+
- { text: "a woman helping an old man", quality: clean }
|
| 56 |
+
- { text: "an indian man learning to ride a bicycle", quality: clean }
|
| 57 |
+
- { text: "a man in overalls strumming a guitar", quality: clean }
|
| 58 |
+
- { text: "a woman using a silk screen", quality: clean }
|
| 59 |
+
- { text: "a woman heat pressing a shirt", quality: clean }
|
| 60 |
+
- { text: "a cute woman with freckles and pale skin", quality: clean }
|
| 61 |
+
- { text: "a person with long hair wearing glasses, freckles, blue tshirt", quality: clean }
|
| 62 |
+
- { text: "a woman stepping in a puddle on a rainy day", quality: clean }
|
| 63 |
+
- { text: "father and daughter watching sunrise at the beach sitting on a bench", quality: clean }
|
| 64 |
+
- { text: "a man sitting by the ocean watching the sunset", quality: mild }
|
| 65 |
+
- { text: "a cool looking middle aged man with white hair and beard typing on his laptop in a dark room", quality: mild }
|
| 66 |
+
|
| 67 |
+
landscape:
|
| 68 |
+
description: "Natural scenes — lighting, color palette, season style signals strongest"
|
| 69 |
+
prompts:
|
| 70 |
+
- { text: "a tree in a field in front of a mountain", quality: clean, recommended: true }
|
| 71 |
+
- { text: "a flower blooming out of a crack on a boulder", quality: clean }
|
| 72 |
+
- { text: "a plant with green leaves and a single white flower", quality: clean }
|
| 73 |
+
- { text: "a flower at the top of a mountain", quality: clean }
|
| 74 |
+
- { text: "a white mushroom in the forest", quality: clean }
|
| 75 |
+
- { text: "a pond in a forest at night", quality: clean }
|
| 76 |
+
- { text: "a field of flowers in the middle of a forest", quality: clean }
|
| 77 |
+
- { text: "milky way on a clear night, with a forest as backdrop", quality: clean }
|
| 78 |
+
- { text: "a night sky with full moon", quality: clean }
|
| 79 |
+
- { text: "two blue flowers in front of a meadow", quality: clean }
|
| 80 |
+
- { text: "snow pea plants in a garden", quality: clean }
|
| 81 |
+
- { text: "pink flower in a pot on the window in the sunshine", quality: clean }
|
| 82 |
+
- { text: "a tiny frog on a leaf, tropical settings", quality: clean }
|
| 83 |
+
- { text: "a macro photo of a ladybug on a flower", quality: clean }
|
| 84 |
+
- { text: "macro close up photo of a snowflake", quality: clean }
|
| 85 |
+
- { text: "a red cardinal on a bird feeder", quality: clean }
|
| 86 |
+
- { text: "an old stone well in a field of grain with a farmhouse in the distance", quality: clean }
|
| 87 |
+
- { text: "a view of a tall hill with a big forest treeline as viewed from below", quality: clean }
|
| 88 |
+
- { text: "a cottage with a small garden in the front yard, in the forest", quality: clean }
|
| 89 |
+
- { text: "a white house with a garden", quality: clean }
|
| 90 |
+
- { text: "aerial view of a small tropical island with a beach and palm trees", quality: clean }
|
| 91 |
+
- { text: "aerial view of a small town on the bank of a river", quality: clean }
|
| 92 |
+
- { text: "large mossy tree lying in a spring forest", quality: clean }
|
| 93 |
+
- { text: "hiker in the forest walking in snow covered trees along a trail", quality: clean }
|
| 94 |
+
- { text: "a glass jar terrarium filled with plants", quality: clean }
|
| 95 |
+
- { text: "a glass jar terrarium filled with flowering plants", quality: clean }
|
| 96 |
+
- { text: "balcony with a lot of plants, with citrus tree in a bucket", quality: clean }
|
| 97 |
+
- { text: "picture of rays of light shining through the trees onto a meadow", quality: clean }
|
| 98 |
+
- { text: "a snowy mountain peak with a lone hiker standing at the top", quality: clean }
|
| 99 |
+
- { text: "a path of polished bricks leading into the sky, dusk", quality: mild }
|
| 100 |
+
- { text: "mountains, river and cottage at dusk", quality: mild }
|
| 101 |
+
- { text: "two horses running in a field in the foggy daytime", quality: mild }
|
| 102 |
+
|
| 103 |
+
animal:
|
| 104 |
+
description: "Animal subjects — fur texture, background atmosphere style signals clearest"
|
| 105 |
+
prompts:
|
| 106 |
+
- { text: "a ginger cat looking out the window", quality: clean, recommended: true }
|
| 107 |
+
- { text: "a golden retriever running through a water puddle", quality: clean }
|
| 108 |
+
- { text: "a cat on a rocking chair", quality: clean }
|
| 109 |
+
- { text: "photo of a fluffy white kitten", quality: clean }
|
| 110 |
+
- { text: "a cute kitten sitting on a couch", quality: clean }
|
| 111 |
+
- { text: "a happy puppy on a couch staring out a window", quality: clean }
|
| 112 |
+
- { text: "a Shiba Inu dog in a wicker basket of flowers", quality: clean }
|
| 113 |
+
- { text: "a cute hamster eating sunflower seeds", quality: clean }
|
| 114 |
+
- { text: "a cute hamster climbing a cage", quality: clean }
|
| 115 |
+
- { text: "a siamese cat with blue eyes", quality: clean }
|
| 116 |
+
- { text: "a cat playing in the grass", quality: clean }
|
| 117 |
+
- { text: "a cat playing with a ball", quality: clean }
|
| 118 |
+
- { text: "a cat jumping for a toy", quality: clean }
|
| 119 |
+
- { text: "a cat seated on a rock in the forest", quality: clean }
|
| 120 |
+
- { text: "an orange cat hugging a rock", quality: clean }
|
| 121 |
+
- { text: "two adorable cats, one black and white and one short haired orange tabby", quality: clean }
|
| 122 |
+
- { text: "a pitbull playing with a toy ball", quality: clean }
|
| 123 |
+
- { text: "a dog catching a ball in the air", quality: clean }
|
| 124 |
+
- { text: "a golden retriever jumping over a box", quality: clean }
|
| 125 |
+
- { text: "a cute husky wagging its tail", quality: clean }
|
| 126 |
+
- { text: "Australian Shepherd sitting on a mountain cliff edge", quality: clean }
|
| 127 |
+
- { text: "bernese mountain dog running in the grass, blue sky", quality: clean }
|
| 128 |
+
- { text: "a Corgi and a goldendoodle playing together on a large green couch", quality: clean }
|
| 129 |
+
- { text: "two beagles playing in the forest", quality: clean }
|
| 130 |
+
- { text: "a Yorkshire terrier at the botanical garden", quality: clean }
|
| 131 |
+
- { text: "Landseer Newfoundland dog sitting by a lake", quality: clean }
|
| 132 |
+
- { text: "a dog sitting on a beach", quality: clean }
|
| 133 |
+
- { text: "an otter poking its head out of water", quality: clean }
|
| 134 |
+
- { text: "a baby otter playing with a ball", quality: clean }
|
| 135 |
+
- { text: "a garden lizard sitting on a plank of wood", quality: clean }
|
| 136 |
+
- { text: "a kingfisher sitting on a pole", quality: clean }
|
| 137 |
+
- { text: "a white peacock in a lilac tree", quality: clean }
|
| 138 |
+
- { text: "honeybee collecting nectar from a bunch of marigolds", quality: clean }
|
| 139 |
+
- { text: "a close-up of a small bird perched on a flower", quality: clean }
|
| 140 |
+
- { text: "a robin on a large balcony with luscious green trees in spring", quality: clean }
|
| 141 |
+
- { text: "a jack russell eating a cabbage", quality: clean }
|
| 142 |
+
- { text: "a Brittany dog with a pheasant", quality: clean }
|
| 143 |
+
- { text: "a shar pei on the beach", quality: clean }
|
| 144 |
+
- { text: "a rabbit with a carrot in its hand", quality: clean }
|
| 145 |
+
|
| 146 |
+
still_life:
|
| 147 |
+
description: "Objects and food — color, material, lighting style signals most controlled"
|
| 148 |
+
prompts:
|
| 149 |
+
- { text: "sunflowers in a glass mason jar", quality: clean, recommended: true }
|
| 150 |
+
- { text: "a red apple sitting to the right of a peach", quality: clean }
|
| 151 |
+
- { text: "blueberries, raspberries, apples on a plate", quality: clean }
|
| 152 |
+
- { text: "blueberries, raspberries, watermelon in a dish", quality: clean }
|
| 153 |
+
- { text: "a plate of chocolate chip cookies", quality: clean }
|
| 154 |
+
- { text: "bacon and eggs on a plate", quality: clean }
|
| 155 |
+
- { text: "an omelet with strawberries on a plate with a coffee cup", quality: clean }
|
| 156 |
+
- { text: "a bowl of strawberries and sliced bananas in milk", quality: clean }
|
| 157 |
+
- { text: "a pizza with mortadella on top", quality: clean }
|
| 158 |
+
- { text: "a burger with cheese and salad", quality: clean }
|
| 159 |
+
- { text: "spaghetti and a pack of flour", quality: clean }
|
| 160 |
+
- { text: "a bowl of popcorn with nacho cheese seasoning", quality: clean }
|
| 161 |
+
- { text: "photo of an orange block of cheese", quality: clean }
|
| 162 |
+
- { text: "a slice of swiss cheese on a wooden cutting board", quality: clean }
|
| 163 |
+
- { text: "a rubber ducky next to a box of legos on a wooden floor", quality: clean }
|
| 164 |
+
- { text: "photo of a table with a teapot on it", quality: clean }
|
| 165 |
+
- { text: "a close up of a book on a coffee table", quality: clean }
|
| 166 |
+
- { text: "a chess board with Staunton pieces, initial position", quality: clean }
|
| 167 |
+
- { text: "a longsword on a wooden table", quality: clean }
|
| 168 |
+
- { text: "a poodle toy laying on a wooden floor, interior", quality: clean }
|
| 169 |
+
- { text: "a collection of small bits and bobs, flat lay", quality: clean }
|
| 170 |
+
- { text: "a red box on top of a blue box", quality: clean }
|
| 171 |
+
- { text: "a white champignon in the forest", quality: clean }
|
| 172 |
+
|
| 173 |
+
architecture:
|
| 174 |
+
description: "Buildings and interiors — atmosphere, light quality, texture style signals strong"
|
| 175 |
+
prompts:
|
| 176 |
+
- { text: "a park at the center of a city", quality: clean }
|
| 177 |
+
- { text: "a car parked on a leafy street", quality: clean }
|
| 178 |
+
- { text: "photo of a bicycle in venice", quality: clean }
|
| 179 |
+
- { text: "aerial view of a small town on the bank of a river", quality: clean }
|
| 180 |
+
- { text: "view down a road with skyscrapers each side", quality: clean }
|
| 181 |
+
- { text: "a photo of a messy kitchen", quality: clean }
|
| 182 |
+
- { text: "a small bathroom with a washing machine", quality: clean }
|
| 183 |
+
- { text: "a well furnished bedroom with two double beds", quality: clean }
|
| 184 |
+
- { text: "a photo of a cute bookstore with floor-to-ceiling windows", quality: clean }
|
| 185 |
+
- { text: "the university of edinburgh old college", quality: clean }
|
| 186 |
+
- { text: "picture of an airport with parallel runways", quality: clean }
|
| 187 |
+
- { text: "crowd in the street of NYC", quality: clean }
|
| 188 |
+
- { text: "a small hut on a beach in Thailand", quality: clean }
|
| 189 |
+
- { text: "a comfy designer chair standing near the window, a vintage lamp on a round coffee table", quality: clean }
|
configs/distractor_policy.yaml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Mix of distractor strategies used by instance_plan.py.
|
| 2 |
+
# Each value is the fraction of items assigned that strategy.
|
| 3 |
+
# Must sum to 1.0 (validated at load time).
|
| 4 |
+
|
| 5 |
+
strategy_mix:
|
| 6 |
+
one_axis_swap: 0.40
|
| 7 |
+
two_axis_swap: 0.30
|
| 8 |
+
axis_cluster: 0.15
|
| 9 |
+
random: 0.15
|
| 10 |
+
|
| 11 |
+
# Per-axis weight when computing "closeness" for axis_cluster strategy.
|
| 12 |
+
# Higher weight = bigger visible effect, so mismatches on these axes hurt
|
| 13 |
+
# more. Weights are tie-breaking only; Hamming distance is the primary key.
|
| 14 |
+
axis_weights:
|
| 15 |
+
art_style: 3.0
|
| 16 |
+
art_medium: 2.5
|
| 17 |
+
color: 2.0
|
| 18 |
+
lighting: 1.5
|
| 19 |
+
|
| 20 |
+
# Gold-item injection rate for quality control. 10% of each annotator's
|
| 21 |
+
# load is replaced with random-strategy items whose answer is unambiguous.
|
| 22 |
+
gold_injection_rate: 0.10
|
configs/generation.yaml
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
backend_mix:
|
| 2 |
+
flux2_klein: 1.0
|
| 3 |
+
|
| 4 |
+
image_size: [1024, 1024]
|
configs/profile_vocab.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"art_style": [
|
| 3 |
+
"Impressionism",
|
| 4 |
+
"Minimalism",
|
| 5 |
+
"Anime",
|
| 6 |
+
"Photorealism",
|
| 7 |
+
"Cubism",
|
| 8 |
+
"Art Deco"
|
| 9 |
+
],
|
| 10 |
+
"color": [
|
| 11 |
+
"Warm Reds",
|
| 12 |
+
"Cool Blues",
|
| 13 |
+
"Earth Tones",
|
| 14 |
+
"Monochrome",
|
| 15 |
+
"Pastel Palette",
|
| 16 |
+
"Electric Neon"
|
| 17 |
+
],
|
| 18 |
+
"art_medium": [
|
| 19 |
+
"Oil Painting",
|
| 20 |
+
"Watercolor",
|
| 21 |
+
"Ink Drawing",
|
| 22 |
+
"Digital Painting",
|
| 23 |
+
"Pixel Art",
|
| 24 |
+
"Pencil Sketch"
|
| 25 |
+
],
|
| 26 |
+
"lighting": [
|
| 27 |
+
"Golden Hour",
|
| 28 |
+
"Moody Low-Key",
|
| 29 |
+
"Soft Overcast",
|
| 30 |
+
"Harsh Noon",
|
| 31 |
+
"Neon Glow",
|
| 32 |
+
"Candlelit"
|
| 33 |
+
]
|
| 34 |
+
}
|
labeling/instructions.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AestheticMCQ annotation — instructions
|
| 2 |
+
|
| 3 |
+
You will see an image and four candidate **aesthetic profiles**, labelled A-D.
|
| 4 |
+
Each profile is five axes:
|
| 5 |
+
|
| 6 |
+
- **art_style**: Impressionism, Realism, Abstract, Surrealism, Minimalism, Pop Art, Anime, Classical
|
| 7 |
+
- **color**: Warm, Cool, Neutral, Earth, Vibrant, Monochrome
|
| 8 |
+
- **art_medium**: Oil Painting, Watercolor, Digital, Pencil, Ink, Pastel
|
| 9 |
+
- **detail**: Fine, Moderate, Minimal
|
| 10 |
+
- **saturation**: Vivid, Moderate, Muted
|
| 11 |
+
|
| 12 |
+
Pick the single profile that **best** describes the aesthetic style of the image.
|
| 13 |
+
|
| 14 |
+
## Rules of thumb
|
| 15 |
+
|
| 16 |
+
- If two feel equally good, pick the *closer* one.
|
| 17 |
+
- If none feels quite right, still pick the *closest* — do not skip.
|
| 18 |
+
- Base your choice on **aesthetic style**, not on the subject matter.
|
| 19 |
+
- Aim for ~15 seconds per item.
|
| 20 |
+
|
| 21 |
+
## Privacy / data
|
| 22 |
+
|
| 23 |
+
Your per-item time and choice are logged, along with your annotator ID. We do
|
| 24 |
+
not log IP or identity. You can stop at any time and resume later from the
|
| 25 |
+
same share URL.
|
labeling/static/app.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
const AXES = ["art_style", "color", "art_medium", "lighting"];
|
| 4 |
+
const TOKEN_STORAGE_KEY = "aamcq_token";
|
| 5 |
+
|
| 6 |
+
async function fetchJSON(path, init) {
|
| 7 |
+
const resp = await fetch(path, init);
|
| 8 |
+
if (!resp.ok) {
|
| 9 |
+
const body = await resp.text();
|
| 10 |
+
throw new Error(`${resp.status}: ${body}`);
|
| 11 |
+
}
|
| 12 |
+
return resp.json();
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Tokens come from three places, in order:
|
| 16 |
+
// 1. ?token=... in the URL (coordinator-issued personal link — old flow)
|
| 17 |
+
// 2. localStorage (returning visitor)
|
| 18 |
+
// 3. POST /api/register (fresh anonymous session; only works when the
|
| 19 |
+
// server was launched with --anonymous-register)
|
| 20 |
+
async function ensureToken() {
|
| 21 |
+
const urlToken = new URL(window.location.href).searchParams.get("token");
|
| 22 |
+
if (urlToken) {
|
| 23 |
+
localStorage.setItem(TOKEN_STORAGE_KEY, urlToken);
|
| 24 |
+
return urlToken;
|
| 25 |
+
}
|
| 26 |
+
const stored = localStorage.getItem(TOKEN_STORAGE_KEY);
|
| 27 |
+
if (stored) return stored;
|
| 28 |
+
const resp = await fetch("/api/register", { method: "POST" });
|
| 29 |
+
if (!resp.ok) {
|
| 30 |
+
throw new Error(
|
| 31 |
+
"No ?token= in URL and anonymous registration is disabled on this server."
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
const { token } = await resp.json();
|
| 35 |
+
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
| 36 |
+
return token;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function renderProfileCard(idx, profile) {
|
| 40 |
+
const ul = document.createElement("ul");
|
| 41 |
+
ul.className = "profile";
|
| 42 |
+
for (const axis of AXES) {
|
| 43 |
+
const li = document.createElement("li");
|
| 44 |
+
const key = document.createElement("span");
|
| 45 |
+
key.className = "axis";
|
| 46 |
+
key.textContent = axis.replace("_", " ") + ": ";
|
| 47 |
+
const val = document.createElement("span");
|
| 48 |
+
val.className = "value";
|
| 49 |
+
val.textContent = profile[axis] ?? "?";
|
| 50 |
+
li.appendChild(key);
|
| 51 |
+
li.appendChild(val);
|
| 52 |
+
ul.appendChild(li);
|
| 53 |
+
}
|
| 54 |
+
const wrapper = document.createElement("label");
|
| 55 |
+
wrapper.className = "option";
|
| 56 |
+
const input = document.createElement("input");
|
| 57 |
+
input.type = "radio";
|
| 58 |
+
input.name = "choice";
|
| 59 |
+
input.value = String(idx);
|
| 60 |
+
wrapper.appendChild(input);
|
| 61 |
+
const badge = document.createElement("div");
|
| 62 |
+
badge.className = "badge";
|
| 63 |
+
badge.textContent = String.fromCharCode(65 + idx);
|
| 64 |
+
wrapper.appendChild(badge);
|
| 65 |
+
wrapper.appendChild(ul);
|
| 66 |
+
return wrapper;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
let currentItem = null;
|
| 70 |
+
let shownAt = 0;
|
| 71 |
+
|
| 72 |
+
async function loadNext(token) {
|
| 73 |
+
const data = await fetchJSON(`/api/task?token=${encodeURIComponent(token)}`);
|
| 74 |
+
const card = document.getElementById("card");
|
| 75 |
+
const submit = document.getElementById("submit");
|
| 76 |
+
const err = document.getElementById("error");
|
| 77 |
+
err.textContent = "";
|
| 78 |
+
if (data.done) {
|
| 79 |
+
const labeled = data.labeled ?? 0;
|
| 80 |
+
const msg =
|
| 81 |
+
data.reason === "cap_reached"
|
| 82 |
+
? `All done — you labeled ${labeled} items. Thank you!`
|
| 83 |
+
: `All items are fully labeled (you contributed ${labeled}). Thank you!`;
|
| 84 |
+
card.innerHTML = `<p class='done'>${msg}</p>`;
|
| 85 |
+
submit.disabled = true;
|
| 86 |
+
updateProgress(data.labeled, data.cap);
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
currentItem = data;
|
| 90 |
+
shownAt = performance.now();
|
| 91 |
+
document.getElementById("stimulus").src = data.image_url;
|
| 92 |
+
document.getElementById("base-prompt").textContent =
|
| 93 |
+
data.payload.base_prompt ? `"${data.payload.base_prompt}"` : "";
|
| 94 |
+
const form = document.getElementById("options");
|
| 95 |
+
form.innerHTML = "";
|
| 96 |
+
const options = data.payload.options || [];
|
| 97 |
+
options.forEach((opt, i) => {
|
| 98 |
+
form.appendChild(renderProfileCard(i, opt));
|
| 99 |
+
});
|
| 100 |
+
submit.disabled = true;
|
| 101 |
+
form.querySelectorAll("input[type=radio]").forEach((el) => {
|
| 102 |
+
el.addEventListener("change", () => {
|
| 103 |
+
submit.disabled = false;
|
| 104 |
+
});
|
| 105 |
+
});
|
| 106 |
+
updateProgress(data.labeled, data.cap);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function updateProgress(labeled, cap) {
|
| 110 |
+
const el = document.getElementById("progress");
|
| 111 |
+
if (cap != null) {
|
| 112 |
+
el.textContent = `${labeled ?? 0} / ${cap} done`;
|
| 113 |
+
} else {
|
| 114 |
+
el.textContent = `${labeled ?? 0} labeled`;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
async function submitLabel(token) {
|
| 119 |
+
const err = document.getElementById("error");
|
| 120 |
+
err.textContent = "";
|
| 121 |
+
const chosen = document.querySelector("input[name=choice]:checked");
|
| 122 |
+
if (!chosen || !currentItem) return;
|
| 123 |
+
const elapsed = (performance.now() - shownAt) / 1000;
|
| 124 |
+
try {
|
| 125 |
+
await fetchJSON("/api/label", {
|
| 126 |
+
method: "POST",
|
| 127 |
+
headers: { "content-type": "application/json" },
|
| 128 |
+
body: JSON.stringify({
|
| 129 |
+
token,
|
| 130 |
+
item_id: currentItem.item_id,
|
| 131 |
+
chosen_index: Number(chosen.value),
|
| 132 |
+
seconds: elapsed,
|
| 133 |
+
confidence: null,
|
| 134 |
+
}),
|
| 135 |
+
});
|
| 136 |
+
await loadNext(token);
|
| 137 |
+
} catch (e) {
|
| 138 |
+
err.textContent = `Submit failed: ${e.message}`;
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
async function main() {
|
| 143 |
+
let token;
|
| 144 |
+
try {
|
| 145 |
+
token = await ensureToken();
|
| 146 |
+
} catch (e) {
|
| 147 |
+
document.getElementById("error").textContent = e.message;
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
document.getElementById("submit").addEventListener("click", () => submitLabel(token));
|
| 151 |
+
try {
|
| 152 |
+
await loadNext(token);
|
| 153 |
+
} catch (e) {
|
| 154 |
+
document.getElementById("error").textContent = `Load failed: ${e.message}`;
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
if (document.readyState === "loading") {
|
| 159 |
+
document.addEventListener("DOMContentLoaded", main);
|
| 160 |
+
} else {
|
| 161 |
+
main();
|
| 162 |
+
}
|
labeling/static/index.html
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>AestheticMCQ — Annotation</title>
|
| 7 |
+
<link rel="stylesheet" href="/style.css?v=4" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<main>
|
| 11 |
+
<header>
|
| 12 |
+
<h1>AestheticMCQ</h1>
|
| 13 |
+
<div id="progress">loading…</div>
|
| 14 |
+
</header>
|
| 15 |
+
<section id="instructions">
|
| 16 |
+
<p>
|
| 17 |
+
Pick the aesthetic profile that best describes the image. If two feel
|
| 18 |
+
equally good, pick the closer one; if none feels right, still pick the
|
| 19 |
+
closest.
|
| 20 |
+
</p>
|
| 21 |
+
</section>
|
| 22 |
+
<section id="card">
|
| 23 |
+
<figure>
|
| 24 |
+
<img id="stimulus" alt="image to annotate" />
|
| 25 |
+
<figcaption id="base-prompt"></figcaption>
|
| 26 |
+
</figure>
|
| 27 |
+
<form id="options"></form>
|
| 28 |
+
</section>
|
| 29 |
+
<footer>
|
| 30 |
+
<button id="submit" disabled>Submit & next</button>
|
| 31 |
+
<span id="error"></span>
|
| 32 |
+
</footer>
|
| 33 |
+
</main>
|
| 34 |
+
<script src="/app.js?v=5"></script>
|
| 35 |
+
</body>
|
| 36 |
+
</html>
|
labeling/static/style.css
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #111;
|
| 3 |
+
--fg: #eee;
|
| 4 |
+
--muted: #888;
|
| 5 |
+
--accent: #4aa3ff;
|
| 6 |
+
--card: #1c1c1c;
|
| 7 |
+
--border: #2a2a2a;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
* { box-sizing: border-box; }
|
| 11 |
+
|
| 12 |
+
body {
|
| 13 |
+
margin: 0;
|
| 14 |
+
padding: 0;
|
| 15 |
+
background: var(--bg);
|
| 16 |
+
color: var(--fg);
|
| 17 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 18 |
+
line-height: 1.4;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
main {
|
| 22 |
+
max-width: 900px;
|
| 23 |
+
margin: 0 auto;
|
| 24 |
+
padding: 16px;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
header {
|
| 28 |
+
display: flex;
|
| 29 |
+
align-items: baseline;
|
| 30 |
+
justify-content: space-between;
|
| 31 |
+
border-bottom: 1px solid var(--border);
|
| 32 |
+
padding-bottom: 8px;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
header h1 { font-size: 1.2rem; margin: 0; }
|
| 36 |
+
|
| 37 |
+
#progress { color: var(--muted); font-size: 0.9rem; }
|
| 38 |
+
|
| 39 |
+
#instructions p {
|
| 40 |
+
color: var(--muted);
|
| 41 |
+
font-size: 0.9rem;
|
| 42 |
+
margin: 12px 0;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
figure {
|
| 46 |
+
margin: 0;
|
| 47 |
+
text-align: center;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
#stimulus {
|
| 51 |
+
max-width: 100%;
|
| 52 |
+
max-height: 60vh;
|
| 53 |
+
border-radius: 8px;
|
| 54 |
+
border: 1px solid var(--border);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#base-prompt {
|
| 58 |
+
color: var(--fg);
|
| 59 |
+
font-size: 1.05rem;
|
| 60 |
+
margin: 12px auto 0;
|
| 61 |
+
font-style: italic;
|
| 62 |
+
padding: 8px 14px;
|
| 63 |
+
background: var(--card);
|
| 64 |
+
border: 1px solid var(--border);
|
| 65 |
+
border-radius: 6px;
|
| 66 |
+
display: block;
|
| 67 |
+
width: fit-content;
|
| 68 |
+
max-width: 90%;
|
| 69 |
+
text-align: center;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
#options {
|
| 73 |
+
display: grid;
|
| 74 |
+
grid-template-columns: repeat(2, 1fr);
|
| 75 |
+
gap: 12px;
|
| 76 |
+
margin: 16px 0;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
@media (max-width: 600px) {
|
| 80 |
+
#options { grid-template-columns: 1fr; }
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.option {
|
| 84 |
+
display: flex;
|
| 85 |
+
flex-direction: column;
|
| 86 |
+
gap: 8px;
|
| 87 |
+
padding: 12px;
|
| 88 |
+
background: var(--card);
|
| 89 |
+
border: 2px solid var(--border);
|
| 90 |
+
border-radius: 8px;
|
| 91 |
+
cursor: pointer;
|
| 92 |
+
position: relative;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.option:has(input:checked) {
|
| 96 |
+
border-color: var(--accent);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.option input[type=radio] {
|
| 100 |
+
position: absolute;
|
| 101 |
+
opacity: 0;
|
| 102 |
+
pointer-events: none;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.badge {
|
| 106 |
+
display: inline-block;
|
| 107 |
+
width: 24px;
|
| 108 |
+
height: 24px;
|
| 109 |
+
line-height: 24px;
|
| 110 |
+
text-align: center;
|
| 111 |
+
border-radius: 12px;
|
| 112 |
+
background: var(--border);
|
| 113 |
+
font-weight: bold;
|
| 114 |
+
color: var(--fg);
|
| 115 |
+
font-size: 0.8rem;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.profile { list-style: none; margin: 0; padding: 0; }
|
| 119 |
+
.profile li { font-size: 0.9rem; padding: 2px 0; }
|
| 120 |
+
.axis { color: var(--muted); text-transform: capitalize; }
|
| 121 |
+
.value { color: var(--fg); }
|
| 122 |
+
|
| 123 |
+
footer {
|
| 124 |
+
display: flex;
|
| 125 |
+
align-items: center;
|
| 126 |
+
gap: 16px;
|
| 127 |
+
margin-top: 16px;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
button#submit {
|
| 131 |
+
background: var(--accent);
|
| 132 |
+
color: #fff;
|
| 133 |
+
border: 0;
|
| 134 |
+
padding: 10px 20px;
|
| 135 |
+
border-radius: 6px;
|
| 136 |
+
font-size: 1rem;
|
| 137 |
+
cursor: pointer;
|
| 138 |
+
}
|
| 139 |
+
button#submit:disabled {
|
| 140 |
+
background: var(--border);
|
| 141 |
+
color: var(--muted);
|
| 142 |
+
cursor: not-allowed;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
#error { color: #e66; font-size: 0.9rem; }
|
| 146 |
+
.done { text-align: center; font-size: 1.2rem; color: var(--muted); }
|
pyproject.toml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "aamcq"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "AestheticMCQ — human-labeled aesthetic similarity MCQ dataset"
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
dependencies = [
|
| 11 |
+
"fastapi>=0.110",
|
| 12 |
+
"uvicorn[standard]>=0.27",
|
| 13 |
+
"pyyaml>=6.0",
|
| 14 |
+
"pydantic>=2.5",
|
| 15 |
+
"numpy>=1.24",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
[project.optional-dependencies]
|
| 19 |
+
test = ["pytest>=8.0", "httpx>=0.26"]
|
| 20 |
+
generation = [
|
| 21 |
+
# Flux2KleinPipeline currently only ships in the diffusers git dev branch:
|
| 22 |
+
# uv pip install git+https://github.com/huggingface/diffusers.git
|
| 23 |
+
"diffusers>=0.29",
|
| 24 |
+
"torch",
|
| 25 |
+
"transformers",
|
| 26 |
+
"accelerate>=0.30",
|
| 27 |
+
"pillow",
|
| 28 |
+
"sentencepiece",
|
| 29 |
+
"protobuf",
|
| 30 |
+
]
|
| 31 |
+
hf = ["datasets>=2.18", "huggingface_hub>=0.20", "pillow"]
|
| 32 |
+
|
| 33 |
+
[tool.setuptools.packages.find]
|
| 34 |
+
where = ["src"]
|
| 35 |
+
|
| 36 |
+
[tool.pytest.ini_options]
|
| 37 |
+
testpaths = ["tests"]
|
| 38 |
+
addopts = "-q"
|
spaces/DEPLOY.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying to HF Spaces
|
| 2 |
+
|
| 3 |
+
All commands below assume `HF_TOKEN` is exported and has **write** scope on
|
| 4 |
+
the `lanczos` namespace.
|
| 5 |
+
|
| 6 |
+
## 1. Seed the dataset repo (images + mcq)
|
| 7 |
+
|
| 8 |
+
```bash
|
| 9 |
+
# Creates lanczos/aesthetic-annotators (private dataset) if missing,
|
| 10 |
+
# uploads 810 PNGs + mcq_unlabeled.jsonl. Takes ~2 min for 1.3 GB.
|
| 11 |
+
HF_TOKEN=$HF_TOKEN .venv/bin/python spaces/push_dataset.py \
|
| 12 |
+
--repo lanczos/aesthetic-annotators \
|
| 13 |
+
--images data/images_final \
|
| 14 |
+
--mcq data/mcq_unlabeled.jsonl
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Verify at <https://huggingface.co/datasets/lanczos/aesthetic-annotators>.
|
| 18 |
+
|
| 19 |
+
## 2. Create the Space
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
.venv/bin/huggingface-cli repo create aesthetic-annotators \
|
| 23 |
+
--type space --space_sdk docker \
|
| 24 |
+
--organization lanczos
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
Or in the web UI: New Space → name `aesthetic-annotators` → SDK: **Docker**.
|
| 28 |
+
|
| 29 |
+
## 3. Set the Space secret
|
| 30 |
+
|
| 31 |
+
In the Space's Settings → Variables and secrets, add a **secret**:
|
| 32 |
+
|
| 33 |
+
| name | value |
|
| 34 |
+
|---|---|
|
| 35 |
+
| `HF_TOKEN` | same token (needs write scope on the dataset repo) |
|
| 36 |
+
|
| 37 |
+
The Docker container will read `HF_TOKEN` from env to pull images on boot
|
| 38 |
+
and push SQLite label backups every 60 s.
|
| 39 |
+
|
| 40 |
+
## 4. Push the code to the Space
|
| 41 |
+
|
| 42 |
+
The Space is a git repo. Add it as a remote, then run the bundled deploy
|
| 43 |
+
script — it overlays `spaces/README.md` at repo root (HF reads its metadata
|
| 44 |
+
from the root README frontmatter) on a temp branch and pushes that to
|
| 45 |
+
`space/main`, so the GitHub root README stays untouched.
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
# One-time
|
| 49 |
+
git remote add space https://huggingface.co/spaces/lanczos/aesthetic-annotators
|
| 50 |
+
|
| 51 |
+
# Each deploy (HF prompts for credentials: user=lanczos, password=$HF_TOKEN)
|
| 52 |
+
./spaces/push_to_space.sh
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
First build ~3 min; subsequent pushes ~1 min.
|
| 56 |
+
|
| 57 |
+
## 5. Hand out the URL
|
| 58 |
+
|
| 59 |
+
```
|
| 60 |
+
https://lanczos-aesthetic-annotators.hf.space/
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
No `?token=` needed — first visit auto-registers. Labels persist across
|
| 64 |
+
Space restarts because of the 60 s SQLite → dataset repo backup.
|
| 65 |
+
|
| 66 |
+
## Reading labels back
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
# Download the latest SQLite backup and inspect
|
| 70 |
+
huggingface-cli download lanczos/aesthetic-annotators \
|
| 71 |
+
labels/annotations.sqlite \
|
| 72 |
+
--repo-type dataset \
|
| 73 |
+
--local-dir ./backup
|
| 74 |
+
|
| 75 |
+
sqlite3 backup/labels/annotations.sqlite \
|
| 76 |
+
"SELECT annotator_id, COUNT(*) FROM labels GROUP BY annotator_id"
|
| 77 |
+
```
|
spaces/Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
PYTHONPATH=/app/src
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
RUN apt-get update && \
|
| 11 |
+
apt-get install -y --no-install-recommends git && \
|
| 12 |
+
rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
COPY spaces/requirements.txt /app/spaces/requirements.txt
|
| 15 |
+
RUN pip install -r /app/spaces/requirements.txt
|
| 16 |
+
|
| 17 |
+
COPY pyproject.toml /app/
|
| 18 |
+
COPY src /app/src
|
| 19 |
+
COPY labeling /app/labeling
|
| 20 |
+
COPY configs /app/configs
|
| 21 |
+
COPY spaces /app/spaces
|
| 22 |
+
|
| 23 |
+
# HF Spaces mounts a writable /data directory when Persistent Storage is
|
| 24 |
+
# enabled; fall back to an in-container path when running locally.
|
| 25 |
+
ENV AAMCQ_DATA_DIR=/data
|
| 26 |
+
RUN mkdir -p /data && chmod 777 /data
|
| 27 |
+
|
| 28 |
+
EXPOSE 7860
|
| 29 |
+
CMD ["python", "/app/spaces/space_entry.py"]
|
spaces/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Aesthetic Annotators
|
| 3 |
+
emoji: 🎨
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Aesthetic Annotators
|
| 12 |
+
|
| 13 |
+
Public-URL labeling server for the AestheticMCQ dataset. Any visitor is
|
| 14 |
+
auto-issued an anonymous annotator id on first hit; each session labels up
|
| 15 |
+
to `AAMCQ_PER_ANNOTATOR_CAP` items (default 20) pulled breadth-first from
|
| 16 |
+
the pool so every item receives one label before any receives a second.
|
| 17 |
+
|
| 18 |
+
## Configuration
|
| 19 |
+
|
| 20 |
+
Space secrets:
|
| 21 |
+
|
| 22 |
+
| name | required | default | notes |
|
| 23 |
+
|---|---|---|---|
|
| 24 |
+
| `HF_TOKEN` | yes | — | write scope on the companion dataset repo |
|
| 25 |
+
| `AAMCQ_DATASET_REPO` | no | `lanczos/aesthetic-annotators` | source of images + mcq + label backups |
|
| 26 |
+
| `AAMCQ_PER_ANNOTATOR_CAP` | no | `20` | items per session before "all done" |
|
| 27 |
+
| `AAMCQ_LABELS_PER_ITEM` | no | `3` | target labels per item |
|
| 28 |
+
| `AAMCQ_BACKUP_INTERVAL` | no | `60` | SQLite → dataset repo push interval (seconds) |
|
| 29 |
+
|
| 30 |
+
## Data flow
|
| 31 |
+
|
| 32 |
+
1. On boot, the Space pulls `images/*.png`, `mcq_unlabeled.jsonl`, and any
|
| 33 |
+
prior `labels/annotations.sqlite` from the dataset repo.
|
| 34 |
+
2. Annotators land on the root URL → JS calls `POST /api/register` → server
|
| 35 |
+
mints a fresh `anon_*` id + token, cached in localStorage.
|
| 36 |
+
3. `/api/task` hands out the least-labeled item the annotator hasn't seen.
|
| 37 |
+
4. Every `AAMCQ_BACKUP_INTERVAL` seconds the server pushes the SQLite back
|
| 38 |
+
to `labels/annotations.sqlite` in the dataset repo, so Space
|
| 39 |
+
restarts/sleeps lose at most one backup interval's worth of labels.
|
spaces/push_dataset.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-shot upload of images + mcq_unlabeled.jsonl to the companion HF dataset repo.
|
| 2 |
+
|
| 3 |
+
Creates the repo if it doesn't exist. Runs locally (needs HF_TOKEN in env with
|
| 4 |
+
write scope). Safe to re-run; only changed files are re-uploaded.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
HF_TOKEN=... .venv/bin/python spaces/push_dataset.py \
|
| 8 |
+
--repo lanczos/aesthetic-annotators \
|
| 9 |
+
--images data/images_final \
|
| 10 |
+
--mcq data/mcq_unlabeled.jsonl
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import os
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
from huggingface_hub import HfApi, create_repo, upload_file, upload_folder
|
| 20 |
+
|
| 21 |
+
REPO = Path(__file__).resolve().parents[1]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main() -> int:
|
| 25 |
+
ap = argparse.ArgumentParser()
|
| 26 |
+
ap.add_argument("--repo", default="lanczos/aesthetic-annotators")
|
| 27 |
+
ap.add_argument("--images", type=Path, default=REPO / "data" / "images_final")
|
| 28 |
+
ap.add_argument("--mcq", type=Path, default=REPO / "data" / "mcq_unlabeled.jsonl")
|
| 29 |
+
ap.add_argument("--private", action="store_true", default=True,
|
| 30 |
+
help="create the repo as private (default)")
|
| 31 |
+
ap.add_argument("--public", action="store_true",
|
| 32 |
+
help="override: create the repo as public")
|
| 33 |
+
args = ap.parse_args()
|
| 34 |
+
if args.public:
|
| 35 |
+
args.private = False
|
| 36 |
+
|
| 37 |
+
token = os.environ.get("HF_TOKEN")
|
| 38 |
+
if not token:
|
| 39 |
+
raise SystemExit("HF_TOKEN not set in env")
|
| 40 |
+
|
| 41 |
+
api = HfApi(token=token)
|
| 42 |
+
|
| 43 |
+
print(f"creating/confirming dataset repo {args.repo} (private={args.private}) ...")
|
| 44 |
+
create_repo(
|
| 45 |
+
repo_id=args.repo, repo_type="dataset",
|
| 46 |
+
private=args.private, exist_ok=True, token=token,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
print(f"uploading {args.mcq.name} ...")
|
| 50 |
+
upload_file(
|
| 51 |
+
path_or_fileobj=str(args.mcq),
|
| 52 |
+
path_in_repo="mcq_unlabeled.jsonl",
|
| 53 |
+
repo_id=args.repo, repo_type="dataset",
|
| 54 |
+
token=token,
|
| 55 |
+
commit_message="update mcq_unlabeled.jsonl",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
n_images = len(list(args.images.glob("*.png")))
|
| 59 |
+
print(f"uploading {n_images} images from {args.images}/ → images/ ...")
|
| 60 |
+
upload_folder(
|
| 61 |
+
folder_path=str(args.images),
|
| 62 |
+
path_in_repo="images",
|
| 63 |
+
repo_id=args.repo, repo_type="dataset",
|
| 64 |
+
token=token,
|
| 65 |
+
commit_message=f"upload {n_images} images",
|
| 66 |
+
allow_patterns=["*.png"],
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
print("done.")
|
| 70 |
+
print(f" {args.repo} @ https://huggingface.co/datasets/{args.repo}")
|
| 71 |
+
return 0
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
raise SystemExit(main())
|
spaces/push_space.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Push the Space files to the HF Space repo using huggingface_hub.
|
| 2 |
+
|
| 3 |
+
Uses HfApi.upload_folder with a staging dir so we don't touch the repo's
|
| 4 |
+
root README.md / Dockerfile (which would pollute the GitHub view). The
|
| 5 |
+
Space-only overlay (`spaces/README.md` at root, `spaces/Dockerfile` at root)
|
| 6 |
+
is materialized inside the staging dir only.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import os
|
| 13 |
+
import shutil
|
| 14 |
+
import tempfile
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from huggingface_hub import HfApi
|
| 18 |
+
|
| 19 |
+
REPO = Path(__file__).resolve().parents[1]
|
| 20 |
+
|
| 21 |
+
# What to ship to the Space, as (source_rel, dest_rel) pairs. Dirs are
|
| 22 |
+
# recursively copied; files are copied verbatim.
|
| 23 |
+
SHIPMENT = [
|
| 24 |
+
("spaces/README.md", "README.md"),
|
| 25 |
+
("spaces/Dockerfile", "Dockerfile"),
|
| 26 |
+
("pyproject.toml", "pyproject.toml"),
|
| 27 |
+
("src", "src"),
|
| 28 |
+
("labeling", "labeling"),
|
| 29 |
+
("configs", "configs"),
|
| 30 |
+
("spaces", "spaces"),
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main() -> int:
|
| 35 |
+
ap = argparse.ArgumentParser()
|
| 36 |
+
ap.add_argument("--repo", default="lanczos/aesthetic-annotators")
|
| 37 |
+
args = ap.parse_args()
|
| 38 |
+
|
| 39 |
+
token = os.environ.get("HF_TOKEN")
|
| 40 |
+
if not token:
|
| 41 |
+
raise SystemExit("HF_TOKEN not set")
|
| 42 |
+
|
| 43 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 44 |
+
staging = Path(tmp) / "space"
|
| 45 |
+
staging.mkdir()
|
| 46 |
+
for src_rel, dst_rel in SHIPMENT:
|
| 47 |
+
src = REPO / src_rel
|
| 48 |
+
dst = staging / dst_rel
|
| 49 |
+
if src.is_dir():
|
| 50 |
+
shutil.copytree(src, dst)
|
| 51 |
+
else:
|
| 52 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
shutil.copy2(src, dst)
|
| 54 |
+
|
| 55 |
+
# Ignore Python cache / editor cruft inside src/
|
| 56 |
+
for bad in staging.rglob("__pycache__"):
|
| 57 |
+
if bad.is_dir():
|
| 58 |
+
shutil.rmtree(bad)
|
| 59 |
+
|
| 60 |
+
api = HfApi(token=token)
|
| 61 |
+
print(f"uploading to {args.repo} (space) ...")
|
| 62 |
+
api.upload_folder(
|
| 63 |
+
folder_path=str(staging),
|
| 64 |
+
repo_id=args.repo,
|
| 65 |
+
repo_type="space",
|
| 66 |
+
commit_message="deploy: labeling server",
|
| 67 |
+
)
|
| 68 |
+
print("done. Space will build now:")
|
| 69 |
+
print(f" https://huggingface.co/spaces/{args.repo}")
|
| 70 |
+
return 0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
if __name__ == "__main__":
|
| 74 |
+
raise SystemExit(main())
|
spaces/push_to_space.sh
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Push the current commit to the HF Space remote, with spaces/README.md
|
| 3 |
+
# overlaid at repo root (HF Spaces reads its metadata from root README.md
|
| 4 |
+
# frontmatter; the GitHub root README stays untouched).
|
| 5 |
+
#
|
| 6 |
+
# Prereq once: git remote add space https://huggingface.co/spaces/lanczos/aesthetic-annotators
|
| 7 |
+
# When git prompts for credentials on push, user = `lanczos`, password = $HF_TOKEN.
|
| 8 |
+
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
|
| 11 |
+
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
| 12 |
+
TEMP=space-deploy-$(date +%s)
|
| 13 |
+
|
| 14 |
+
trap 'git checkout "$BRANCH" >/dev/null 2>&1 || true; git branch -D "$TEMP" >/dev/null 2>&1 || true' EXIT
|
| 15 |
+
|
| 16 |
+
git checkout -b "$TEMP"
|
| 17 |
+
cp spaces/README.md README.md
|
| 18 |
+
git add README.md
|
| 19 |
+
git commit --no-verify -m "deploy: use spaces/README.md as root"
|
| 20 |
+
git push -f space "$TEMP:main"
|
| 21 |
+
echo "pushed to space/main (overlaid README from spaces/README.md)"
|
spaces/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110
|
| 2 |
+
uvicorn[standard]>=0.27
|
| 3 |
+
pyyaml>=6.0
|
| 4 |
+
pydantic>=2.5
|
| 5 |
+
numpy>=1.24
|
| 6 |
+
huggingface_hub>=0.20
|
spaces/space_entry.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entry point for the AestheticMCQ labeling server on HF Spaces.
|
| 2 |
+
|
| 3 |
+
On boot:
|
| 4 |
+
1. Pull images/*.png and mcq_unlabeled.jsonl from the companion dataset repo.
|
| 5 |
+
2. Pull labels/annotations.sqlite from the dataset repo if it exists.
|
| 6 |
+
3. Bootstrap the SQLite items table from the mcq file.
|
| 7 |
+
4. Launch the FastAPI app in pool-mode + anonymous-register configuration.
|
| 8 |
+
5. Every BACKUP_INTERVAL seconds, push the SQLite back to the dataset repo
|
| 9 |
+
so labels survive Space restarts / sleeps.
|
| 10 |
+
|
| 11 |
+
Env vars:
|
| 12 |
+
HF_TOKEN required; write access to AAMCQ_DATASET_REPO
|
| 13 |
+
AAMCQ_DATASET_REPO default: lanczos/aesthetic-annotators
|
| 14 |
+
AAMCQ_PER_ANNOTATOR_CAP default: 20
|
| 15 |
+
AAMCQ_LABELS_PER_ITEM default: 3
|
| 16 |
+
AAMCQ_BACKUP_INTERVAL default: 60 (seconds)
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import shutil
|
| 25 |
+
import time
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import uvicorn
|
| 29 |
+
from huggingface_hub import hf_hub_download, snapshot_download, upload_file
|
| 30 |
+
|
| 31 |
+
from aamcq.annotation import db as dbmod
|
| 32 |
+
from aamcq.annotation.api import create_app
|
| 33 |
+
|
| 34 |
+
DATASET_REPO = os.environ.get("AAMCQ_DATASET_REPO", "lanczos/aesthetic-annotators")
|
| 35 |
+
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 36 |
+
DATA_DIR = Path(os.environ.get("AAMCQ_DATA_DIR", "/data"))
|
| 37 |
+
IMAGE_DIR = DATA_DIR / "images"
|
| 38 |
+
DB_PATH = DATA_DIR / "annotations.sqlite"
|
| 39 |
+
MCQ_PATH = DATA_DIR / "mcq_unlabeled.jsonl"
|
| 40 |
+
|
| 41 |
+
BACKUP_INTERVAL = int(os.environ.get("AAMCQ_BACKUP_INTERVAL", "60"))
|
| 42 |
+
PER_ANNOTATOR_CAP = int(os.environ.get("AAMCQ_PER_ANNOTATOR_CAP", "20"))
|
| 43 |
+
LABELS_PER_ITEM = int(os.environ.get("AAMCQ_LABELS_PER_ITEM", "3"))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _require_token() -> str:
|
| 47 |
+
if not HF_TOKEN:
|
| 48 |
+
raise SystemExit(
|
| 49 |
+
"HF_TOKEN is unset. Set it as a Space secret with write access to "
|
| 50 |
+
f"{DATASET_REPO}."
|
| 51 |
+
)
|
| 52 |
+
return HF_TOKEN
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def bootstrap_from_dataset() -> None:
|
| 56 |
+
"""Pull images, mcq file, and any existing labels SQLite from the dataset repo."""
|
| 57 |
+
token = _require_token()
|
| 58 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
|
| 61 |
+
print(f"pulling images + mcq from {DATASET_REPO} ...")
|
| 62 |
+
snapshot_download(
|
| 63 |
+
DATASET_REPO,
|
| 64 |
+
repo_type="dataset",
|
| 65 |
+
local_dir=str(DATA_DIR),
|
| 66 |
+
allow_patterns=["images/*.png", "mcq_unlabeled.jsonl"],
|
| 67 |
+
token=token,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Best-effort: restore previous SQLite so Space restarts don't lose labels.
|
| 71 |
+
try:
|
| 72 |
+
local = hf_hub_download(
|
| 73 |
+
DATASET_REPO,
|
| 74 |
+
"labels/annotations.sqlite",
|
| 75 |
+
repo_type="dataset",
|
| 76 |
+
token=token,
|
| 77 |
+
)
|
| 78 |
+
shutil.copy2(local, DB_PATH)
|
| 79 |
+
print(f"restored labels SQLite from {DATASET_REPO}/labels/")
|
| 80 |
+
except Exception as e: # 404 on first run is normal
|
| 81 |
+
print(f"no prior SQLite backup ({type(e).__name__}); starting fresh")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def init_items_in_db() -> None:
|
| 85 |
+
"""Load items from mcq_unlabeled.jsonl into the SQLite items table."""
|
| 86 |
+
if not MCQ_PATH.exists():
|
| 87 |
+
raise SystemExit(f"{MCQ_PATH} missing — dataset repo may be empty")
|
| 88 |
+
conn = dbmod.connect(DB_PATH)
|
| 89 |
+
dbmod.init_schema(conn)
|
| 90 |
+
existing = {row["item_id"] for row in conn.execute("SELECT item_id FROM items")}
|
| 91 |
+
added = 0
|
| 92 |
+
with open(MCQ_PATH) as f:
|
| 93 |
+
for line in f:
|
| 94 |
+
line = line.strip()
|
| 95 |
+
if not line:
|
| 96 |
+
continue
|
| 97 |
+
row = json.loads(line)
|
| 98 |
+
if row["item_id"] in existing:
|
| 99 |
+
continue
|
| 100 |
+
dbmod.insert_item(conn, row["item_id"], row, is_gold=bool(row.get("is_gold")))
|
| 101 |
+
added += 1
|
| 102 |
+
conn.close()
|
| 103 |
+
print(f"items table: {len(existing)} existing + {added} new")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
async def periodic_backup() -> None:
|
| 107 |
+
"""Push the SQLite file to the dataset repo whenever it's been written since last push."""
|
| 108 |
+
token = _require_token()
|
| 109 |
+
last_mtime = 0.0
|
| 110 |
+
while True:
|
| 111 |
+
await asyncio.sleep(BACKUP_INTERVAL)
|
| 112 |
+
try:
|
| 113 |
+
mtime = DB_PATH.stat().st_mtime
|
| 114 |
+
except FileNotFoundError:
|
| 115 |
+
continue
|
| 116 |
+
if mtime <= last_mtime:
|
| 117 |
+
continue
|
| 118 |
+
try:
|
| 119 |
+
upload_file(
|
| 120 |
+
path_or_fileobj=str(DB_PATH),
|
| 121 |
+
path_in_repo="labels/annotations.sqlite",
|
| 122 |
+
repo_id=DATASET_REPO,
|
| 123 |
+
repo_type="dataset",
|
| 124 |
+
token=token,
|
| 125 |
+
commit_message=f"backup @ {int(time.time())}",
|
| 126 |
+
)
|
| 127 |
+
last_mtime = mtime
|
| 128 |
+
print(f"pushed SQLite backup to {DATASET_REPO}/labels/")
|
| 129 |
+
except Exception as e:
|
| 130 |
+
print(f"backup upload failed: {type(e).__name__}: {e}")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main() -> int:
|
| 134 |
+
bootstrap_from_dataset()
|
| 135 |
+
init_items_in_db()
|
| 136 |
+
|
| 137 |
+
app = create_app(
|
| 138 |
+
db_path=DB_PATH,
|
| 139 |
+
image_dir=IMAGE_DIR,
|
| 140 |
+
pool_mode=True,
|
| 141 |
+
anonymous_register=True,
|
| 142 |
+
max_labels_per_item=LABELS_PER_ITEM,
|
| 143 |
+
max_labels_per_annotator=PER_ANNOTATOR_CAP,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
@app.on_event("startup")
|
| 147 |
+
async def _start_backup() -> None:
|
| 148 |
+
asyncio.create_task(periodic_backup())
|
| 149 |
+
|
| 150 |
+
uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info")
|
| 151 |
+
return 0
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
raise SystemExit(main())
|
src/aamcq/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AestheticMCQ — human-labeled MCQ dataset for aesthetic similarity."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
src/aamcq/annotation/__init__.py
ADDED
|
File without changes
|
src/aamcq/annotation/api.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI app serving the annotator web UI + /api/task, /api/label, /api/progress."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import sqlite3
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from fastapi import Depends, FastAPI, HTTPException, Query
|
| 10 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from pydantic import BaseModel, Field, conint
|
| 13 |
+
|
| 14 |
+
from aamcq.annotation import db as dbmod
|
| 15 |
+
from aamcq.annotation.assignment import bootstrap_annotators
|
| 16 |
+
|
| 17 |
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
| 18 |
+
DEFAULT_DB = REPO_ROOT / "data" / "annotations.sqlite"
|
| 19 |
+
DEFAULT_IMAGE_DIR = REPO_ROOT / "data" / "images"
|
| 20 |
+
DEFAULT_STATIC_DIR = REPO_ROOT / "labeling" / "static"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class LabelPayload(BaseModel):
|
| 24 |
+
token: str = Field(min_length=8, max_length=128)
|
| 25 |
+
item_id: str = Field(min_length=1, max_length=128)
|
| 26 |
+
chosen_index: conint(ge=0, le=3) # type: ignore[valid-type]
|
| 27 |
+
seconds: float | None = Field(default=None, ge=0, le=3600)
|
| 28 |
+
confidence: int | None = Field(default=None, ge=1, le=5)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _sanitize_item(payload: dict) -> dict:
|
| 32 |
+
"""Strip `correct_index` before sending to annotator."""
|
| 33 |
+
return {k: v for k, v in payload.items() if k != "correct_index"}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def create_app(
|
| 37 |
+
db_path: str | os.PathLike[str] | None = None,
|
| 38 |
+
image_dir: str | os.PathLike[str] | None = None,
|
| 39 |
+
static_dir: str | os.PathLike[str] | None = None,
|
| 40 |
+
pool_mode: bool = False,
|
| 41 |
+
anonymous_register: bool = False,
|
| 42 |
+
max_labels_per_item: int = 3,
|
| 43 |
+
max_labels_per_annotator: int | None = None,
|
| 44 |
+
) -> FastAPI:
|
| 45 |
+
"""Labeling server.
|
| 46 |
+
|
| 47 |
+
`pool_mode=False` (default): annotators see only items pre-assigned to them
|
| 48 |
+
(round-robin). Requires bootstrap_annotators() + assign_items_round_robin()
|
| 49 |
+
before serving.
|
| 50 |
+
|
| 51 |
+
`pool_mode=True`: ignore pre-assignment; dispatch any item that still needs
|
| 52 |
+
labels and this annotator hasn't labeled. Items are handed out breadth-first
|
| 53 |
+
over existing-label-count — every item gets one label before anyone gets a
|
| 54 |
+
second. Unfinished work from one annotator is naturally picked up by the
|
| 55 |
+
next person who logs in. Cap each session with `max_labels_per_annotator`.
|
| 56 |
+
|
| 57 |
+
`anonymous_register=True`: `POST /api/register` mints a fresh annotator_id
|
| 58 |
+
+ token on demand, so a single public URL can serve any number of
|
| 59 |
+
concurrent anonymous annotators (each browser session = one annotator).
|
| 60 |
+
Intended for public-URL crowdsourcing.
|
| 61 |
+
"""
|
| 62 |
+
db_path = Path(db_path or DEFAULT_DB)
|
| 63 |
+
image_dir = Path(image_dir or DEFAULT_IMAGE_DIR)
|
| 64 |
+
static_dir = Path(static_dir or DEFAULT_STATIC_DIR)
|
| 65 |
+
|
| 66 |
+
app = FastAPI(title="AestheticMCQ Annotation")
|
| 67 |
+
conn = dbmod.connect(db_path)
|
| 68 |
+
dbmod.init_schema(conn)
|
| 69 |
+
app.state.conn = conn
|
| 70 |
+
app.state.image_dir = image_dir
|
| 71 |
+
app.state.pool_mode = pool_mode
|
| 72 |
+
app.state.anonymous_register = anonymous_register
|
| 73 |
+
app.state.max_labels_per_item = max_labels_per_item
|
| 74 |
+
app.state.max_labels_per_annotator = max_labels_per_annotator
|
| 75 |
+
|
| 76 |
+
def get_conn() -> sqlite3.Connection:
|
| 77 |
+
return app.state.conn
|
| 78 |
+
|
| 79 |
+
def resolve_annotator(
|
| 80 |
+
token: str,
|
| 81 |
+
conn: sqlite3.Connection = Depends(get_conn),
|
| 82 |
+
) -> str:
|
| 83 |
+
annotator_id = dbmod.get_annotator_by_token(conn, token)
|
| 84 |
+
if not annotator_id:
|
| 85 |
+
raise HTTPException(status_code=401, detail="invalid token")
|
| 86 |
+
return annotator_id
|
| 87 |
+
|
| 88 |
+
def _next_task_payload(annotator_id: str, conn: sqlite3.Connection, n_done: int) -> dict:
|
| 89 |
+
cap = app.state.max_labels_per_annotator
|
| 90 |
+
if cap is not None and n_done >= cap:
|
| 91 |
+
return {"done": True, "reason": "cap_reached", "labeled": n_done, "cap": cap}
|
| 92 |
+
if app.state.pool_mode:
|
| 93 |
+
item = dbmod.next_pooled_item(conn, annotator_id, app.state.max_labels_per_item)
|
| 94 |
+
else:
|
| 95 |
+
item = dbmod.next_unlabeled_item(conn, annotator_id)
|
| 96 |
+
if item is None:
|
| 97 |
+
return {"done": True, "reason": "pool_empty", "labeled": n_done}
|
| 98 |
+
return {
|
| 99 |
+
"done": False,
|
| 100 |
+
"item_id": item.item_id,
|
| 101 |
+
"payload": _sanitize_item(item.payload),
|
| 102 |
+
"image_url": f"/images/{item.item_id}.png",
|
| 103 |
+
"labeled": n_done,
|
| 104 |
+
"cap": cap,
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
@app.post("/api/register")
|
| 108 |
+
def api_register(conn: sqlite3.Connection = Depends(get_conn)):
|
| 109 |
+
"""Mint a fresh anonymous annotator. Only enabled when anonymous_register."""
|
| 110 |
+
if not app.state.anonymous_register:
|
| 111 |
+
raise HTTPException(status_code=404, detail="anonymous register disabled")
|
| 112 |
+
existing = {row["annotator_id"] for row in conn.execute(
|
| 113 |
+
"SELECT annotator_id FROM annotators"
|
| 114 |
+
)}
|
| 115 |
+
n = 0
|
| 116 |
+
while True:
|
| 117 |
+
candidate = f"anon_{dbmod.mint_token()[:10]}"
|
| 118 |
+
if candidate not in existing:
|
| 119 |
+
break
|
| 120 |
+
n += 1
|
| 121 |
+
if n > 8:
|
| 122 |
+
raise HTTPException(status_code=500, detail="could not mint unique id")
|
| 123 |
+
tokens = bootstrap_annotators(conn, [candidate])
|
| 124 |
+
return {"annotator_id": candidate, "token": tokens[candidate]}
|
| 125 |
+
|
| 126 |
+
@app.get("/api/task")
|
| 127 |
+
def api_task(
|
| 128 |
+
token: str = Query(min_length=8, max_length=128),
|
| 129 |
+
conn: sqlite3.Connection = Depends(get_conn),
|
| 130 |
+
):
|
| 131 |
+
annotator_id = resolve_annotator(token, conn)
|
| 132 |
+
n_done = dbmod.count_annotator_labels(conn, annotator_id)
|
| 133 |
+
return _next_task_payload(annotator_id, conn, n_done)
|
| 134 |
+
|
| 135 |
+
@app.post("/api/label")
|
| 136 |
+
def api_label(
|
| 137 |
+
payload: LabelPayload,
|
| 138 |
+
conn: sqlite3.Connection = Depends(get_conn),
|
| 139 |
+
):
|
| 140 |
+
annotator_id = resolve_annotator(payload.token, conn)
|
| 141 |
+
item_row = dbmod.get_item(conn, payload.item_id)
|
| 142 |
+
if item_row is None:
|
| 143 |
+
raise HTTPException(status_code=404, detail="unknown item_id")
|
| 144 |
+
if not app.state.pool_mode:
|
| 145 |
+
# Pre-assigned mode: require an assignment row.
|
| 146 |
+
assigned = conn.execute(
|
| 147 |
+
"SELECT 1 FROM assignments WHERE item_id = ? AND annotator_id = ? LIMIT 1",
|
| 148 |
+
(payload.item_id, annotator_id),
|
| 149 |
+
).fetchone()
|
| 150 |
+
if assigned is None:
|
| 151 |
+
raise HTTPException(status_code=403, detail="item not assigned to annotator")
|
| 152 |
+
dbmod.record_label(
|
| 153 |
+
conn,
|
| 154 |
+
payload.item_id,
|
| 155 |
+
annotator_id,
|
| 156 |
+
int(payload.chosen_index),
|
| 157 |
+
payload.seconds,
|
| 158 |
+
payload.confidence,
|
| 159 |
+
)
|
| 160 |
+
return {"ok": True}
|
| 161 |
+
|
| 162 |
+
@app.get("/api/progress")
|
| 163 |
+
def api_progress(
|
| 164 |
+
token: str = Query(min_length=8, max_length=128),
|
| 165 |
+
conn: sqlite3.Connection = Depends(get_conn),
|
| 166 |
+
):
|
| 167 |
+
annotator_id = resolve_annotator(token, conn)
|
| 168 |
+
n_done = dbmod.count_annotator_labels(conn, annotator_id)
|
| 169 |
+
if app.state.pool_mode:
|
| 170 |
+
cap = app.state.max_labels_per_annotator
|
| 171 |
+
return {
|
| 172 |
+
"labeled": n_done,
|
| 173 |
+
"assigned": cap if cap is not None else 0,
|
| 174 |
+
}
|
| 175 |
+
return dbmod.progress(conn, annotator_id)
|
| 176 |
+
|
| 177 |
+
@app.get("/images/{item_id}.png")
|
| 178 |
+
def serve_image(item_id: str):
|
| 179 |
+
# Defense against path traversal — allow only [A-Za-z0-9_-.] in item_id.
|
| 180 |
+
if not item_id or any(c not in _ALLOWED_ITEM_CHARS for c in item_id):
|
| 181 |
+
raise HTTPException(status_code=400, detail="bad item_id")
|
| 182 |
+
path = (app.state.image_dir / f"{item_id}.png").resolve()
|
| 183 |
+
if app.state.image_dir.resolve() not in path.parents:
|
| 184 |
+
raise HTTPException(status_code=400, detail="bad path")
|
| 185 |
+
if not path.exists():
|
| 186 |
+
raise HTTPException(status_code=404, detail="image missing")
|
| 187 |
+
return FileResponse(path, media_type="image/png")
|
| 188 |
+
|
| 189 |
+
@app.get("/healthz")
|
| 190 |
+
def healthz():
|
| 191 |
+
return {"ok": True}
|
| 192 |
+
|
| 193 |
+
if static_dir.exists():
|
| 194 |
+
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
| 195 |
+
else:
|
| 196 |
+
@app.get("/")
|
| 197 |
+
def root():
|
| 198 |
+
return JSONResponse({"detail": "static dir missing; /api/* still usable"})
|
| 199 |
+
|
| 200 |
+
return app
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
_ALLOWED_ITEM_CHARS = frozenset(
|
| 204 |
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-."
|
| 205 |
+
)
|
src/aamcq/annotation/assignment.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Assign MCQ items to annotators with gold-item injection + k-fold coverage."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import random
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Iterable
|
| 8 |
+
|
| 9 |
+
from aamcq.annotation import db as dbmod
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class AssignmentPolicy:
|
| 14 |
+
labels_per_item: int = 3
|
| 15 |
+
gold_injection_rate: float = 0.10
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def assign_items_round_robin(
|
| 19 |
+
conn,
|
| 20 |
+
annotator_ids: list[str],
|
| 21 |
+
item_ids: list[str],
|
| 22 |
+
gold_item_ids: list[str],
|
| 23 |
+
policy: AssignmentPolicy,
|
| 24 |
+
rng: random.Random,
|
| 25 |
+
) -> dict[str, list[str]]:
|
| 26 |
+
"""Create assignments so each non-gold item gets `labels_per_item` distinct annotators.
|
| 27 |
+
|
| 28 |
+
Gold items are inserted randomly into each annotator's queue at the requested rate.
|
| 29 |
+
Returns a mapping {annotator_id: [item_id, ...] in assigned order}.
|
| 30 |
+
"""
|
| 31 |
+
if not annotator_ids:
|
| 32 |
+
raise ValueError("need at least 1 annotator")
|
| 33 |
+
if policy.labels_per_item > len(annotator_ids):
|
| 34 |
+
raise ValueError(
|
| 35 |
+
f"labels_per_item={policy.labels_per_item} > annotators={len(annotator_ids)}"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
queues: dict[str, list[str]] = {aid: [] for aid in annotator_ids}
|
| 39 |
+
|
| 40 |
+
shuffled_items = list(item_ids)
|
| 41 |
+
rng.shuffle(shuffled_items)
|
| 42 |
+
for item_id in shuffled_items:
|
| 43 |
+
chosen = rng.sample(annotator_ids, policy.labels_per_item)
|
| 44 |
+
for aid in chosen:
|
| 45 |
+
queues[aid].append(item_id)
|
| 46 |
+
|
| 47 |
+
if gold_item_ids and policy.gold_injection_rate > 0:
|
| 48 |
+
for aid, queue in queues.items():
|
| 49 |
+
n_gold = max(1, int(round(len(queue) * policy.gold_injection_rate)))
|
| 50 |
+
gold_pick = rng.choices(gold_item_ids, k=n_gold)
|
| 51 |
+
# interleave golds at random positions
|
| 52 |
+
for gold_id in gold_pick:
|
| 53 |
+
pos = rng.randrange(len(queue) + 1)
|
| 54 |
+
queue.insert(pos, gold_id)
|
| 55 |
+
|
| 56 |
+
for aid, queue in queues.items():
|
| 57 |
+
for item_id in queue:
|
| 58 |
+
dbmod.insert_assignment(conn, item_id, aid)
|
| 59 |
+
|
| 60 |
+
return queues
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def bootstrap_annotators(
|
| 64 |
+
conn, annotator_ids: Iterable[str]
|
| 65 |
+
) -> dict[str, str]:
|
| 66 |
+
"""Create annotator rows with freshly minted tokens. Returns {annotator_id: token}."""
|
| 67 |
+
tokens: dict[str, str] = {}
|
| 68 |
+
for aid in annotator_ids:
|
| 69 |
+
token = dbmod.mint_token()
|
| 70 |
+
dbmod.insert_annotator(conn, aid, token)
|
| 71 |
+
tokens[aid] = token
|
| 72 |
+
return tokens
|
src/aamcq/annotation/db.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite schema + DAO for the annotation backend.
|
| 2 |
+
|
| 3 |
+
Single-writer model: annotations are append-only, assignments are created up
|
| 4 |
+
front. We use stdlib sqlite3 rather than SQLAlchemy to keep the install
|
| 5 |
+
footprint small.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import secrets
|
| 12 |
+
import sqlite3
|
| 13 |
+
import time
|
| 14 |
+
from contextlib import contextmanager
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Iterator
|
| 18 |
+
|
| 19 |
+
SCHEMA = """
|
| 20 |
+
CREATE TABLE IF NOT EXISTS items (
|
| 21 |
+
item_id TEXT PRIMARY KEY,
|
| 22 |
+
payload_json TEXT NOT NULL,
|
| 23 |
+
is_gold INTEGER NOT NULL DEFAULT 0
|
| 24 |
+
);
|
| 25 |
+
CREATE TABLE IF NOT EXISTS annotators (
|
| 26 |
+
annotator_id TEXT PRIMARY KEY,
|
| 27 |
+
token TEXT NOT NULL UNIQUE,
|
| 28 |
+
created_at REAL NOT NULL
|
| 29 |
+
);
|
| 30 |
+
CREATE TABLE IF NOT EXISTS assignments (
|
| 31 |
+
item_id TEXT NOT NULL,
|
| 32 |
+
annotator_id TEXT NOT NULL,
|
| 33 |
+
assigned_at REAL NOT NULL,
|
| 34 |
+
PRIMARY KEY (item_id, annotator_id),
|
| 35 |
+
FOREIGN KEY (item_id) REFERENCES items(item_id),
|
| 36 |
+
FOREIGN KEY (annotator_id) REFERENCES annotators(annotator_id)
|
| 37 |
+
);
|
| 38 |
+
CREATE TABLE IF NOT EXISTS labels (
|
| 39 |
+
item_id TEXT NOT NULL,
|
| 40 |
+
annotator_id TEXT NOT NULL,
|
| 41 |
+
chosen_index INTEGER NOT NULL,
|
| 42 |
+
seconds REAL,
|
| 43 |
+
confidence INTEGER,
|
| 44 |
+
submitted_at REAL NOT NULL,
|
| 45 |
+
PRIMARY KEY (item_id, annotator_id),
|
| 46 |
+
FOREIGN KEY (item_id) REFERENCES items(item_id),
|
| 47 |
+
FOREIGN KEY (annotator_id) REFERENCES annotators(annotator_id)
|
| 48 |
+
);
|
| 49 |
+
CREATE INDEX IF NOT EXISTS idx_assignments_annotator
|
| 50 |
+
ON assignments(annotator_id);
|
| 51 |
+
CREATE INDEX IF NOT EXISTS idx_labels_annotator
|
| 52 |
+
ON labels(annotator_id);
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@dataclass(frozen=True)
|
| 57 |
+
class ItemRow:
|
| 58 |
+
item_id: str
|
| 59 |
+
payload: dict
|
| 60 |
+
is_gold: bool
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@dataclass(frozen=True)
|
| 64 |
+
class LabelRow:
|
| 65 |
+
item_id: str
|
| 66 |
+
annotator_id: str
|
| 67 |
+
chosen_index: int
|
| 68 |
+
seconds: float | None
|
| 69 |
+
confidence: int | None
|
| 70 |
+
submitted_at: float
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def connect(db_path: str | Path) -> sqlite3.Connection:
|
| 74 |
+
db_path = Path(db_path)
|
| 75 |
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
conn = sqlite3.connect(db_path, check_same_thread=False, isolation_level=None)
|
| 77 |
+
conn.row_factory = sqlite3.Row
|
| 78 |
+
conn.execute("PRAGMA foreign_keys = ON")
|
| 79 |
+
conn.execute("PRAGMA journal_mode = WAL")
|
| 80 |
+
return conn
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def init_schema(conn: sqlite3.Connection) -> None:
|
| 84 |
+
conn.executescript(SCHEMA)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def mint_token() -> str:
|
| 88 |
+
return secrets.token_urlsafe(16)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def insert_item(conn: sqlite3.Connection, item_id: str, payload: dict, is_gold: bool = False) -> None:
|
| 92 |
+
conn.execute(
|
| 93 |
+
"INSERT OR REPLACE INTO items(item_id, payload_json, is_gold) VALUES (?, ?, ?)",
|
| 94 |
+
(item_id, json.dumps(payload, sort_keys=True), int(is_gold)),
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def insert_annotator(conn: sqlite3.Connection, annotator_id: str, token: str) -> None:
|
| 99 |
+
conn.execute(
|
| 100 |
+
"INSERT OR REPLACE INTO annotators(annotator_id, token, created_at) VALUES (?, ?, ?)",
|
| 101 |
+
(annotator_id, token, time.time()),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def insert_assignment(conn: sqlite3.Connection, item_id: str, annotator_id: str) -> None:
|
| 106 |
+
conn.execute(
|
| 107 |
+
"INSERT OR IGNORE INTO assignments(item_id, annotator_id, assigned_at) "
|
| 108 |
+
"VALUES (?, ?, ?)",
|
| 109 |
+
(item_id, annotator_id, time.time()),
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def get_annotator_by_token(conn: sqlite3.Connection, token: str) -> str | None:
|
| 114 |
+
row = conn.execute(
|
| 115 |
+
"SELECT annotator_id FROM annotators WHERE token = ?", (token,)
|
| 116 |
+
).fetchone()
|
| 117 |
+
return row["annotator_id"] if row else None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def get_item(conn: sqlite3.Connection, item_id: str) -> ItemRow | None:
|
| 121 |
+
row = conn.execute(
|
| 122 |
+
"SELECT item_id, payload_json, is_gold FROM items WHERE item_id = ?",
|
| 123 |
+
(item_id,),
|
| 124 |
+
).fetchone()
|
| 125 |
+
if not row:
|
| 126 |
+
return None
|
| 127 |
+
return ItemRow(
|
| 128 |
+
item_id=row["item_id"],
|
| 129 |
+
payload=json.loads(row["payload_json"]),
|
| 130 |
+
is_gold=bool(row["is_gold"]),
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def next_unlabeled_item(
|
| 135 |
+
conn: sqlite3.Connection, annotator_id: str
|
| 136 |
+
) -> ItemRow | None:
|
| 137 |
+
"""Pre-assigned dispatch: hand out the annotator's next un-labeled assignment."""
|
| 138 |
+
row = conn.execute(
|
| 139 |
+
"""
|
| 140 |
+
SELECT items.item_id, items.payload_json, items.is_gold
|
| 141 |
+
FROM assignments
|
| 142 |
+
JOIN items ON items.item_id = assignments.item_id
|
| 143 |
+
LEFT JOIN labels
|
| 144 |
+
ON labels.item_id = assignments.item_id
|
| 145 |
+
AND labels.annotator_id = assignments.annotator_id
|
| 146 |
+
WHERE assignments.annotator_id = ?
|
| 147 |
+
AND labels.item_id IS NULL
|
| 148 |
+
ORDER BY assignments.assigned_at ASC
|
| 149 |
+
LIMIT 1
|
| 150 |
+
""",
|
| 151 |
+
(annotator_id,),
|
| 152 |
+
).fetchone()
|
| 153 |
+
if not row:
|
| 154 |
+
return None
|
| 155 |
+
return ItemRow(
|
| 156 |
+
item_id=row["item_id"],
|
| 157 |
+
payload=json.loads(row["payload_json"]),
|
| 158 |
+
is_gold=bool(row["is_gold"]),
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def next_pooled_item(
|
| 163 |
+
conn: sqlite3.Connection,
|
| 164 |
+
annotator_id: str,
|
| 165 |
+
max_labels_per_item: int,
|
| 166 |
+
) -> ItemRow | None:
|
| 167 |
+
"""Pull-based dispatch: pick any item needing more labels that this
|
| 168 |
+
annotator hasn't seen yet. Breadth-first over coverage so every item gets
|
| 169 |
+
at least one label before anyone gets a second."""
|
| 170 |
+
row = conn.execute(
|
| 171 |
+
"""
|
| 172 |
+
SELECT items.item_id, items.payload_json, items.is_gold,
|
| 173 |
+
COALESCE(counts.n, 0) AS n_labels
|
| 174 |
+
FROM items
|
| 175 |
+
LEFT JOIN (
|
| 176 |
+
SELECT item_id, COUNT(*) AS n
|
| 177 |
+
FROM labels
|
| 178 |
+
GROUP BY item_id
|
| 179 |
+
) AS counts ON counts.item_id = items.item_id
|
| 180 |
+
LEFT JOIN labels mine
|
| 181 |
+
ON mine.item_id = items.item_id AND mine.annotator_id = ?
|
| 182 |
+
WHERE mine.item_id IS NULL
|
| 183 |
+
AND COALESCE(counts.n, 0) < ?
|
| 184 |
+
ORDER BY n_labels ASC, items.item_id ASC
|
| 185 |
+
LIMIT 1
|
| 186 |
+
""",
|
| 187 |
+
(annotator_id, max_labels_per_item),
|
| 188 |
+
).fetchone()
|
| 189 |
+
if not row:
|
| 190 |
+
return None
|
| 191 |
+
return ItemRow(
|
| 192 |
+
item_id=row["item_id"],
|
| 193 |
+
payload=json.loads(row["payload_json"]),
|
| 194 |
+
is_gold=bool(row["is_gold"]),
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def count_annotator_labels(conn: sqlite3.Connection, annotator_id: str) -> int:
|
| 199 |
+
return int(conn.execute(
|
| 200 |
+
"SELECT COUNT(*) AS n FROM labels WHERE annotator_id = ?",
|
| 201 |
+
(annotator_id,),
|
| 202 |
+
).fetchone()["n"])
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def record_label(
|
| 206 |
+
conn: sqlite3.Connection,
|
| 207 |
+
item_id: str,
|
| 208 |
+
annotator_id: str,
|
| 209 |
+
chosen_index: int,
|
| 210 |
+
seconds: float | None,
|
| 211 |
+
confidence: int | None,
|
| 212 |
+
) -> None:
|
| 213 |
+
conn.execute(
|
| 214 |
+
"""
|
| 215 |
+
INSERT OR REPLACE INTO labels
|
| 216 |
+
(item_id, annotator_id, chosen_index, seconds, confidence, submitted_at)
|
| 217 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 218 |
+
""",
|
| 219 |
+
(item_id, annotator_id, chosen_index, seconds, confidence, time.time()),
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def progress(conn: sqlite3.Connection, annotator_id: str) -> dict[str, int]:
|
| 224 |
+
assigned = conn.execute(
|
| 225 |
+
"SELECT COUNT(*) AS n FROM assignments WHERE annotator_id = ?",
|
| 226 |
+
(annotator_id,),
|
| 227 |
+
).fetchone()["n"]
|
| 228 |
+
labeled = conn.execute(
|
| 229 |
+
"SELECT COUNT(*) AS n FROM labels WHERE annotator_id = ?",
|
| 230 |
+
(annotator_id,),
|
| 231 |
+
).fetchone()["n"]
|
| 232 |
+
return {"assigned": int(assigned), "labeled": int(labeled)}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def iter_labels(conn: sqlite3.Connection) -> Iterator[LabelRow]:
|
| 236 |
+
for row in conn.execute(
|
| 237 |
+
"SELECT item_id, annotator_id, chosen_index, seconds, confidence, submitted_at FROM labels"
|
| 238 |
+
):
|
| 239 |
+
yield LabelRow(
|
| 240 |
+
item_id=row["item_id"],
|
| 241 |
+
annotator_id=row["annotator_id"],
|
| 242 |
+
chosen_index=int(row["chosen_index"]),
|
| 243 |
+
seconds=row["seconds"],
|
| 244 |
+
confidence=row["confidence"],
|
| 245 |
+
submitted_at=float(row["submitted_at"]),
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@contextmanager
|
| 250 |
+
def open_db(db_path: str | Path):
|
| 251 |
+
conn = connect(db_path)
|
| 252 |
+
try:
|
| 253 |
+
init_schema(conn)
|
| 254 |
+
yield conn
|
| 255 |
+
finally:
|
| 256 |
+
conn.close()
|
src/aamcq/distractors.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Distractor sampling policies for MCQ items."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Literal
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from aamcq.profile import AXES, VisualProfile, enumerate_profiles
|
| 10 |
+
|
| 11 |
+
Strategy = Literal["one_axis_swap", "two_axis_swap", "axis_cluster", "random"]
|
| 12 |
+
STRATEGIES: tuple[Strategy, ...] = (
|
| 13 |
+
"one_axis_swap",
|
| 14 |
+
"two_axis_swap",
|
| 15 |
+
"axis_cluster",
|
| 16 |
+
"random",
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
DEFAULT_AXIS_WEIGHTS: dict[str, float] = {
|
| 20 |
+
"art_style": 3.0,
|
| 21 |
+
"art_medium": 2.5,
|
| 22 |
+
"color": 2.0,
|
| 23 |
+
"lighting": 1.5,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _mutate_axes(
|
| 28 |
+
gt: VisualProfile,
|
| 29 |
+
axes_to_mutate: tuple[str, ...],
|
| 30 |
+
vocab: dict[str, list[str]],
|
| 31 |
+
rng: np.random.Generator,
|
| 32 |
+
) -> VisualProfile:
|
| 33 |
+
new_values = gt.to_dict()
|
| 34 |
+
for axis in axes_to_mutate:
|
| 35 |
+
options = [v for v in vocab[axis] if v != getattr(gt, axis)]
|
| 36 |
+
if not options:
|
| 37 |
+
continue
|
| 38 |
+
new_values[axis] = str(rng.choice(options))
|
| 39 |
+
return VisualProfile.from_dict(new_values)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _one_axis_swap(
|
| 43 |
+
gt: VisualProfile, vocab: dict[str, list[str]], rng: np.random.Generator
|
| 44 |
+
) -> list[VisualProfile]:
|
| 45 |
+
axes = list(AXES)
|
| 46 |
+
rng.shuffle(axes)
|
| 47 |
+
picks: list[VisualProfile] = []
|
| 48 |
+
seen: set[tuple[str, ...]] = {gt.to_tuple()}
|
| 49 |
+
for axis in axes:
|
| 50 |
+
options = [v for v in vocab[axis] if v != getattr(gt, axis)]
|
| 51 |
+
rng.shuffle(options)
|
| 52 |
+
for value in options:
|
| 53 |
+
candidate = VisualProfile.from_dict({**gt.to_dict(), axis: value})
|
| 54 |
+
key = candidate.to_tuple()
|
| 55 |
+
if key not in seen:
|
| 56 |
+
picks.append(candidate)
|
| 57 |
+
seen.add(key)
|
| 58 |
+
break
|
| 59 |
+
if len(picks) == 3:
|
| 60 |
+
return picks
|
| 61 |
+
# Fallback (should only trigger for degenerate vocabs).
|
| 62 |
+
return _ensure_three(picks, gt, vocab, rng)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _two_axis_swap(
|
| 66 |
+
gt: VisualProfile, vocab: dict[str, list[str]], rng: np.random.Generator
|
| 67 |
+
) -> list[VisualProfile]:
|
| 68 |
+
"""Return 3 distractors at exact Hamming distance 2 from gt.
|
| 69 |
+
|
| 70 |
+
With a 5-axis vocab (min 3 values per axis), Hamming-2 neighbors always
|
| 71 |
+
exist in sufficient number, so the random-sample loop converges reliably.
|
| 72 |
+
We enumerate the full Hamming-2 set as a fallback to avoid the generic
|
| 73 |
+
`_ensure_three` path leaking mixed Hamming distances.
|
| 74 |
+
"""
|
| 75 |
+
axes = list(AXES)
|
| 76 |
+
picks: list[VisualProfile] = []
|
| 77 |
+
seen: set[tuple[str, ...]] = {gt.to_tuple()}
|
| 78 |
+
attempts = 0
|
| 79 |
+
while len(picks) < 3 and attempts < 128:
|
| 80 |
+
attempts += 1
|
| 81 |
+
pair_idx = rng.choice(len(axes), size=2, replace=False)
|
| 82 |
+
pair = (axes[int(pair_idx[0])], axes[int(pair_idx[1])])
|
| 83 |
+
candidate = _mutate_axes(gt, pair, vocab, rng)
|
| 84 |
+
key = candidate.to_tuple()
|
| 85 |
+
if key not in seen:
|
| 86 |
+
picks.append(candidate)
|
| 87 |
+
seen.add(key)
|
| 88 |
+
if len(picks) < 3:
|
| 89 |
+
pool = [
|
| 90 |
+
p for p in enumerate_profiles(vocab)
|
| 91 |
+
if p.hamming(gt) == 2 and p.to_tuple() not in seen
|
| 92 |
+
]
|
| 93 |
+
rng.shuffle(pool)
|
| 94 |
+
picks.extend(pool[: 3 - len(picks)])
|
| 95 |
+
if len(picks) < 3:
|
| 96 |
+
raise RuntimeError("not enough Hamming-2 neighbors; vocab too small")
|
| 97 |
+
return picks[:3]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _axis_cluster(
|
| 101 |
+
gt: VisualProfile,
|
| 102 |
+
vocab: dict[str, list[str]],
|
| 103 |
+
rng: np.random.Generator,
|
| 104 |
+
axis_weights: dict[str, float] | None = None,
|
| 105 |
+
) -> list[VisualProfile]:
|
| 106 |
+
weights = axis_weights or DEFAULT_AXIS_WEIGHTS
|
| 107 |
+
# Candidates at Hamming distance 1 or 2.
|
| 108 |
+
scored: list[tuple[float, int, VisualProfile]] = []
|
| 109 |
+
# Tie-break seed drawn from rng so ordering is reproducible.
|
| 110 |
+
noise_seed = int(rng.integers(0, 2**31 - 1))
|
| 111 |
+
noise_rng = np.random.default_rng(noise_seed)
|
| 112 |
+
for candidate in enumerate_profiles(vocab):
|
| 113 |
+
if candidate == gt:
|
| 114 |
+
continue
|
| 115 |
+
diffs = candidate.differs_on(gt)
|
| 116 |
+
if not (1 <= len(diffs) <= 2):
|
| 117 |
+
continue
|
| 118 |
+
cost = sum(weights.get(axis, 1.0) for axis in diffs)
|
| 119 |
+
jitter = float(noise_rng.random()) * 1e-3
|
| 120 |
+
scored.append((cost + jitter, len(diffs), candidate))
|
| 121 |
+
scored.sort(key=lambda t: (t[0], t[1]))
|
| 122 |
+
picks = [c for _, _, c in scored[:3]]
|
| 123 |
+
return _ensure_three(picks, gt, vocab, rng)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _random(
|
| 127 |
+
gt: VisualProfile, vocab: dict[str, list[str]], rng: np.random.Generator
|
| 128 |
+
) -> list[VisualProfile]:
|
| 129 |
+
all_profiles = [p for p in enumerate_profiles(vocab) if p != gt]
|
| 130 |
+
idx = rng.choice(len(all_profiles), size=3, replace=False)
|
| 131 |
+
return [all_profiles[int(i)] for i in idx]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _ensure_three(
|
| 135 |
+
picks: list[VisualProfile],
|
| 136 |
+
gt: VisualProfile,
|
| 137 |
+
vocab: dict[str, list[str]],
|
| 138 |
+
rng: np.random.Generator,
|
| 139 |
+
) -> list[VisualProfile]:
|
| 140 |
+
seen = {gt.to_tuple(), *(p.to_tuple() for p in picks)}
|
| 141 |
+
if len(picks) >= 3:
|
| 142 |
+
return picks[:3]
|
| 143 |
+
pool = [p for p in enumerate_profiles(vocab) if p.to_tuple() not in seen]
|
| 144 |
+
rng.shuffle(pool)
|
| 145 |
+
picks.extend(pool[: 3 - len(picks)])
|
| 146 |
+
return picks[:3]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def sample_distractors(
|
| 150 |
+
gt: VisualProfile,
|
| 151 |
+
strategy: Strategy,
|
| 152 |
+
vocab: dict[str, list[str]],
|
| 153 |
+
rng: np.random.Generator,
|
| 154 |
+
axis_weights: dict[str, float] | None = None,
|
| 155 |
+
) -> list[VisualProfile]:
|
| 156 |
+
"""Return exactly 3 distractor profiles; none equals gt or any other pick."""
|
| 157 |
+
if strategy == "one_axis_swap":
|
| 158 |
+
picks = _one_axis_swap(gt, vocab, rng)
|
| 159 |
+
elif strategy == "two_axis_swap":
|
| 160 |
+
picks = _two_axis_swap(gt, vocab, rng)
|
| 161 |
+
elif strategy == "axis_cluster":
|
| 162 |
+
picks = _axis_cluster(gt, vocab, rng, axis_weights)
|
| 163 |
+
elif strategy == "random":
|
| 164 |
+
picks = _random(gt, vocab, rng)
|
| 165 |
+
else:
|
| 166 |
+
raise ValueError(f"unknown strategy {strategy!r}")
|
| 167 |
+
if len(picks) != 3:
|
| 168 |
+
raise RuntimeError(f"distractor sampler returned {len(picks)} items, expected 3")
|
| 169 |
+
tuples = {p.to_tuple() for p in picks}
|
| 170 |
+
if gt.to_tuple() in tuples or len(tuples) != 3:
|
| 171 |
+
raise RuntimeError(f"duplicate or gt leakage: gt={gt}, picks={picks}")
|
| 172 |
+
return picks
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def build_options(
|
| 176 |
+
gt: VisualProfile,
|
| 177 |
+
distractors: list[VisualProfile],
|
| 178 |
+
rng: np.random.Generator,
|
| 179 |
+
) -> tuple[list[VisualProfile], int]:
|
| 180 |
+
"""Shuffle gt + 3 distractors, return (options, correct_index)."""
|
| 181 |
+
if len(distractors) != 3:
|
| 182 |
+
raise ValueError("expected 3 distractors")
|
| 183 |
+
combined = [gt, *distractors]
|
| 184 |
+
order = rng.permutation(4)
|
| 185 |
+
options = [combined[int(i)] for i in order]
|
| 186 |
+
correct_index = int(np.where(order == 0)[0][0])
|
| 187 |
+
return options, correct_index
|
src/aamcq/generation/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image-generation backends.
|
| 2 |
+
|
| 3 |
+
Only `flux2_klein` is active for the current dataset; see
|
| 4 |
+
`configs/generation.yaml` and `src/aamcq/prompt_render.py` for the pin.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from aamcq.generation.base import GenerationBackend, GenerationResult
|
| 8 |
+
from aamcq.generation.registry import BACKEND_REGISTRY, get_backend
|
| 9 |
+
|
| 10 |
+
__all__ = ["GenerationBackend", "GenerationResult", "BACKEND_REGISTRY", "get_backend"]
|
src/aamcq/generation/base.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generation backend Protocol."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Protocol
|
| 7 |
+
|
| 8 |
+
from PIL import Image
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass(frozen=True)
|
| 12 |
+
class GenerationResult:
|
| 13 |
+
image: Image.Image
|
| 14 |
+
backend: str
|
| 15 |
+
model_id: str
|
| 16 |
+
prompt: str
|
| 17 |
+
negative_prompt: str | None
|
| 18 |
+
seed: int
|
| 19 |
+
num_inference_steps: int
|
| 20 |
+
guidance_scale: float
|
| 21 |
+
height: int
|
| 22 |
+
width: int
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class GenerationBackend(Protocol):
|
| 26 |
+
name: str
|
| 27 |
+
|
| 28 |
+
def load(self) -> None: ...
|
| 29 |
+
|
| 30 |
+
def generate(
|
| 31 |
+
self,
|
| 32 |
+
*,
|
| 33 |
+
prompt: str,
|
| 34 |
+
negative_prompt: str | None,
|
| 35 |
+
seed: int,
|
| 36 |
+
height: int,
|
| 37 |
+
width: int,
|
| 38 |
+
) -> GenerationResult: ...
|
src/aamcq/generation/flux2_klein.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FLUX.2-klein-9B backend (lazy-loaded, CPU offload for 24GB GPUs)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from diffusers import Flux2KleinPipeline
|
| 9 |
+
|
| 10 |
+
from aamcq.generation.base import GenerationResult
|
| 11 |
+
from aamcq.prompt_render import MODEL_SPECS
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Flux2KleinBackend:
|
| 15 |
+
"""Single-GPU FLUX.2-klein backend with `enable_model_cpu_offload`.
|
| 16 |
+
|
| 17 |
+
Peak allocated ~19GB on an RTX A5000 24GB at 1024x1024 bf16, measured on
|
| 18 |
+
our smoke test. The pipeline is loaded on first `generate()` call and
|
| 19 |
+
reused thereafter.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
name = "flux2_klein"
|
| 23 |
+
|
| 24 |
+
def __init__(self, *, cpu_offload: bool = True, torch_dtype: Any = torch.bfloat16) -> None:
|
| 25 |
+
self._pipe: Flux2KleinPipeline | None = None
|
| 26 |
+
self._cpu_offload = cpu_offload
|
| 27 |
+
self._dtype = torch_dtype
|
| 28 |
+
self._spec = MODEL_SPECS[self.name]
|
| 29 |
+
self.model_id: str = str(self._spec["model_id"])
|
| 30 |
+
self.num_inference_steps: int = int(self._spec["num_inference_steps"]) # type: ignore[arg-type]
|
| 31 |
+
self.guidance_scale: float = float(self._spec["guidance_scale"]) # type: ignore[arg-type]
|
| 32 |
+
|
| 33 |
+
def load(self) -> None:
|
| 34 |
+
if self._pipe is not None:
|
| 35 |
+
return
|
| 36 |
+
pipe = Flux2KleinPipeline.from_pretrained(self.model_id, torch_dtype=self._dtype)
|
| 37 |
+
if self._cpu_offload:
|
| 38 |
+
pipe.enable_model_cpu_offload()
|
| 39 |
+
else:
|
| 40 |
+
pipe.to("cuda")
|
| 41 |
+
self._pipe = pipe
|
| 42 |
+
|
| 43 |
+
def generate(
|
| 44 |
+
self,
|
| 45 |
+
*,
|
| 46 |
+
prompt: str,
|
| 47 |
+
negative_prompt: str | None,
|
| 48 |
+
seed: int,
|
| 49 |
+
height: int,
|
| 50 |
+
width: int,
|
| 51 |
+
) -> GenerationResult:
|
| 52 |
+
self.load()
|
| 53 |
+
assert self._pipe is not None
|
| 54 |
+
gen = torch.Generator(device="cpu").manual_seed(int(seed))
|
| 55 |
+
img = self._pipe(
|
| 56 |
+
prompt=prompt,
|
| 57 |
+
height=height,
|
| 58 |
+
width=width,
|
| 59 |
+
num_inference_steps=self.num_inference_steps,
|
| 60 |
+
guidance_scale=self.guidance_scale,
|
| 61 |
+
generator=gen,
|
| 62 |
+
).images[0]
|
| 63 |
+
return GenerationResult(
|
| 64 |
+
image=img,
|
| 65 |
+
backend=self.name,
|
| 66 |
+
model_id=self.model_id,
|
| 67 |
+
prompt=prompt,
|
| 68 |
+
negative_prompt=negative_prompt,
|
| 69 |
+
seed=int(seed),
|
| 70 |
+
num_inference_steps=self.num_inference_steps,
|
| 71 |
+
guidance_scale=self.guidance_scale,
|
| 72 |
+
height=height,
|
| 73 |
+
width=width,
|
| 74 |
+
)
|
src/aamcq/generation/registry.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backend name -> class registry."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Callable
|
| 6 |
+
|
| 7 |
+
from aamcq.generation.base import GenerationBackend
|
| 8 |
+
from aamcq.generation.flux2_klein import Flux2KleinBackend
|
| 9 |
+
|
| 10 |
+
BACKEND_REGISTRY: dict[str, Callable[..., GenerationBackend]] = {
|
| 11 |
+
"flux2_klein": Flux2KleinBackend,
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_backend(name: str, **kwargs) -> GenerationBackend:
|
| 16 |
+
if name not in BACKEND_REGISTRY:
|
| 17 |
+
raise ValueError(f"unknown backend {name!r}; expected one of {list(BACKEND_REGISTRY)}")
|
| 18 |
+
return BACKEND_REGISTRY[name](**kwargs)
|
src/aamcq/instance_plan.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic plan of (profile, base_prompt, backend, seed, strategy) tuples.
|
| 2 |
+
|
| 3 |
+
The plan is produced once and written to `data/plan.jsonl`. Image generation and
|
| 4 |
+
MCQ construction both consume this file, so as long as the plan is stable the
|
| 5 |
+
entire dataset is reproducible bit-for-bit.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import itertools
|
| 11 |
+
from dataclasses import asdict, dataclass
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import yaml
|
| 16 |
+
|
| 17 |
+
from aamcq.distractors import STRATEGIES, Strategy
|
| 18 |
+
from aamcq.profile import VisualProfile, enumerate_profiles
|
| 19 |
+
from aamcq.utils.seeding import item_seed
|
| 20 |
+
|
| 21 |
+
BASE_PROMPT_CATEGORIES = ("portrait", "landscape", "animal", "still_life", "architecture")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class PlanItem:
|
| 26 |
+
item_id: str
|
| 27 |
+
gt_profile: dict[str, str]
|
| 28 |
+
base_prompt: str
|
| 29 |
+
base_prompt_category: str
|
| 30 |
+
backend: str
|
| 31 |
+
seed: int
|
| 32 |
+
distractor_strategy: str
|
| 33 |
+
difficulty: str
|
| 34 |
+
|
| 35 |
+
def to_dict(self) -> dict:
|
| 36 |
+
return asdict(self)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _load_base_prompts(path: str | Path) -> dict[str, list[str]]:
|
| 40 |
+
"""Load `clean`-quality prompts per category from base_prompts.yaml."""
|
| 41 |
+
with open(path) as f:
|
| 42 |
+
data = yaml.safe_load(f)
|
| 43 |
+
cats = data.get("categories", {})
|
| 44 |
+
recommended = data.get("recommended", {})
|
| 45 |
+
out: dict[str, list[str]] = {}
|
| 46 |
+
for category in BASE_PROMPT_CATEGORIES:
|
| 47 |
+
entries = cats.get(category, {}).get("prompts", [])
|
| 48 |
+
prompts = [e["text"] for e in entries if e.get("quality") == "clean"]
|
| 49 |
+
if not prompts and recommended.get(category):
|
| 50 |
+
prompts = [recommended[category]]
|
| 51 |
+
if not prompts:
|
| 52 |
+
raise ValueError(f"base_prompts.yaml missing category {category!r}")
|
| 53 |
+
out[category] = prompts
|
| 54 |
+
return out
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _allocate_mix(
|
| 58 |
+
n: int, mix: dict[str, float], rng: np.random.Generator, what: str
|
| 59 |
+
) -> list[str]:
|
| 60 |
+
if abs(sum(mix.values()) - 1.0) > 1e-6:
|
| 61 |
+
raise ValueError(f"{what} mix must sum to 1.0, got {sum(mix.values())}")
|
| 62 |
+
counts = {name: int(round(frac * n)) for name, frac in mix.items()}
|
| 63 |
+
drift = n - sum(counts.values())
|
| 64 |
+
names = list(mix.keys())
|
| 65 |
+
i = 0
|
| 66 |
+
while drift != 0:
|
| 67 |
+
name = names[i % len(names)]
|
| 68 |
+
if drift > 0:
|
| 69 |
+
counts[name] += 1
|
| 70 |
+
drift -= 1
|
| 71 |
+
elif counts[name] > 0:
|
| 72 |
+
counts[name] -= 1
|
| 73 |
+
drift += 1
|
| 74 |
+
i += 1
|
| 75 |
+
slots: list[str] = []
|
| 76 |
+
for name, count in counts.items():
|
| 77 |
+
slots.extend([name] * count)
|
| 78 |
+
rng.shuffle(slots)
|
| 79 |
+
return slots
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def stratified_sample_by_style(
|
| 83 |
+
vocab: dict[str, list[str]],
|
| 84 |
+
n_target: int,
|
| 85 |
+
rng: np.random.Generator,
|
| 86 |
+
small_pool_cap: int = 50,
|
| 87 |
+
) -> list[VisualProfile]:
|
| 88 |
+
"""Proportional stratified sample over art_style groups.
|
| 89 |
+
|
| 90 |
+
Pools smaller than `small_pool_cap` (e.g. Photorealism's 36 under the
|
| 91 |
+
current compat filter) are sampled in full so every profile in a
|
| 92 |
+
minority style appears at least once. Final size is approximately
|
| 93 |
+
`n_target` but may vary by a few due to rounding + small-pool expansion.
|
| 94 |
+
"""
|
| 95 |
+
all_profiles = list(enumerate_profiles(vocab))
|
| 96 |
+
by_style: dict[str, list[VisualProfile]] = {}
|
| 97 |
+
for p in all_profiles:
|
| 98 |
+
by_style.setdefault(p.art_style, []).append(p)
|
| 99 |
+
|
| 100 |
+
total = len(all_profiles)
|
| 101 |
+
sampled: list[VisualProfile] = []
|
| 102 |
+
for pool in by_style.values():
|
| 103 |
+
if len(pool) < small_pool_cap:
|
| 104 |
+
want = len(pool)
|
| 105 |
+
else:
|
| 106 |
+
want = min(round(len(pool) / total * n_target), len(pool))
|
| 107 |
+
idx = rng.choice(len(pool), size=want, replace=False)
|
| 108 |
+
sampled.extend(pool[int(i)] for i in idx)
|
| 109 |
+
rng.shuffle(sampled)
|
| 110 |
+
return sampled
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def build_plan(
|
| 114 |
+
vocab: dict[str, list[str]],
|
| 115 |
+
base_prompts_path: str | Path,
|
| 116 |
+
distractor_policy: dict,
|
| 117 |
+
generation_mix: dict[str, float],
|
| 118 |
+
n_random: int,
|
| 119 |
+
master_seed: int = 202,
|
| 120 |
+
stratified: bool = False,
|
| 121 |
+
) -> list[PlanItem]:
|
| 122 |
+
rng = np.random.default_rng(master_seed)
|
| 123 |
+
base_prompts = _load_base_prompts(base_prompts_path)
|
| 124 |
+
|
| 125 |
+
if n_random <= 0:
|
| 126 |
+
return []
|
| 127 |
+
|
| 128 |
+
for name in distractor_policy["strategy_mix"]:
|
| 129 |
+
if name not in STRATEGIES:
|
| 130 |
+
raise ValueError(f"unknown strategy {name!r}; expected {STRATEGIES}")
|
| 131 |
+
|
| 132 |
+
if stratified:
|
| 133 |
+
profiles = stratified_sample_by_style(vocab, n_random, rng)
|
| 134 |
+
else:
|
| 135 |
+
all_profiles = list(enumerate_profiles(vocab))
|
| 136 |
+
idx = rng.choice(len(all_profiles), size=n_random, replace=False)
|
| 137 |
+
profiles = [all_profiles[int(i)] for i in idx]
|
| 138 |
+
n_items = len(profiles)
|
| 139 |
+
sources: list[tuple[str, VisualProfile]] = [
|
| 140 |
+
(f"rnd_{k:04d}", p) for k, p in enumerate(profiles)
|
| 141 |
+
]
|
| 142 |
+
|
| 143 |
+
strategies = _allocate_mix(n_items, distractor_policy["strategy_mix"], rng, "strategy")
|
| 144 |
+
backends = _allocate_mix(n_items, generation_mix, rng, "backend")
|
| 145 |
+
|
| 146 |
+
prompt_cycle = itertools.cycle(BASE_PROMPT_CATEGORIES)
|
| 147 |
+
for _ in range(int(rng.integers(0, len(BASE_PROMPT_CATEGORIES)))):
|
| 148 |
+
next(prompt_cycle)
|
| 149 |
+
|
| 150 |
+
plan: list[PlanItem] = []
|
| 151 |
+
for (iid, profile), strat, backend in zip(sources, strategies, backends):
|
| 152 |
+
category = next(prompt_cycle)
|
| 153 |
+
pool = base_prompts[category]
|
| 154 |
+
prompt_rng = np.random.default_rng(item_seed(iid, master_seed, "prompt"))
|
| 155 |
+
base_prompt = str(pool[int(prompt_rng.integers(0, len(pool)))])
|
| 156 |
+
|
| 157 |
+
item_id = f"ab_mcq_{len(plan):05d}_{iid}"
|
| 158 |
+
plan.append(
|
| 159 |
+
PlanItem(
|
| 160 |
+
item_id=item_id,
|
| 161 |
+
gt_profile=profile.to_dict(),
|
| 162 |
+
base_prompt=base_prompt,
|
| 163 |
+
base_prompt_category=category,
|
| 164 |
+
backend=backend,
|
| 165 |
+
seed=item_seed(item_id, master_seed, "gen"),
|
| 166 |
+
distractor_strategy=strat,
|
| 167 |
+
difficulty="medium",
|
| 168 |
+
)
|
| 169 |
+
)
|
| 170 |
+
return plan
|
src/aamcq/profile.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VisualProfile — 4-axis aesthetic profile: art_style × color × art_medium × lighting."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import itertools
|
| 6 |
+
import json
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Iterator
|
| 10 |
+
|
| 11 |
+
AXES: tuple[str, ...] = ("art_style", "color", "art_medium", "lighting")
|
| 12 |
+
|
| 13 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 14 |
+
DEFAULT_VOCAB_PATH = REPO_ROOT / "configs" / "profile_vocab.json"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class VisualProfile:
|
| 19 |
+
"""4-axis aesthetic profile."""
|
| 20 |
+
|
| 21 |
+
art_style: str
|
| 22 |
+
color: str
|
| 23 |
+
art_medium: str
|
| 24 |
+
lighting: str
|
| 25 |
+
|
| 26 |
+
def to_dict(self) -> dict[str, str]:
|
| 27 |
+
return {axis: getattr(self, axis) for axis in AXES}
|
| 28 |
+
|
| 29 |
+
def to_tuple(self) -> tuple[str, str, str, str]:
|
| 30 |
+
return (self.art_style, self.color, self.art_medium, self.lighting)
|
| 31 |
+
|
| 32 |
+
@classmethod
|
| 33 |
+
def from_dict(cls, data: dict[str, str]) -> "VisualProfile":
|
| 34 |
+
return cls(**{axis: data[axis] for axis in AXES})
|
| 35 |
+
|
| 36 |
+
def validate(self, vocab: dict[str, list[str]]) -> list[str]:
|
| 37 |
+
errors: list[str] = []
|
| 38 |
+
for axis in AXES:
|
| 39 |
+
value = getattr(self, axis)
|
| 40 |
+
if value not in vocab.get(axis, []):
|
| 41 |
+
errors.append(f"{axis}={value!r} not in {vocab.get(axis)}")
|
| 42 |
+
return errors
|
| 43 |
+
|
| 44 |
+
def differs_on(self, other: "VisualProfile") -> list[str]:
|
| 45 |
+
return [axis for axis in AXES if getattr(self, axis) != getattr(other, axis)]
|
| 46 |
+
|
| 47 |
+
def hamming(self, other: "VisualProfile") -> int:
|
| 48 |
+
return len(self.differs_on(other))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def load_vocab(path: str | Path | None = None) -> dict[str, list[str]]:
|
| 52 |
+
if path is None:
|
| 53 |
+
path = DEFAULT_VOCAB_PATH
|
| 54 |
+
with open(path) as f:
|
| 55 |
+
vocab = json.load(f)
|
| 56 |
+
missing = [axis for axis in AXES if axis not in vocab]
|
| 57 |
+
if missing:
|
| 58 |
+
raise ValueError(f"vocab missing axes: {missing}")
|
| 59 |
+
return vocab
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# Style × medium compatibility rules. Combinations where the medium would
|
| 63 |
+
# override the style's signature are filtered out of profile enumeration.
|
| 64 |
+
_STYLE_MEDIUM_ALLOWLIST: dict[str, set[str]] = {
|
| 65 |
+
"Photorealism": {"Digital Painting"},
|
| 66 |
+
"Anime": {"Digital Painting", "Pixel Art", "Watercolor", "Ink Drawing"},
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def is_compatible(profile: VisualProfile) -> bool:
|
| 71 |
+
allowed = _STYLE_MEDIUM_ALLOWLIST.get(profile.art_style)
|
| 72 |
+
if allowed is not None and profile.art_medium not in allowed:
|
| 73 |
+
return False
|
| 74 |
+
return True
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def enumerate_profiles(
|
| 78 |
+
vocab: dict[str, list[str]],
|
| 79 |
+
compat_filter: bool = True,
|
| 80 |
+
) -> Iterator[VisualProfile]:
|
| 81 |
+
"""Yield profile combinations in a fixed order.
|
| 82 |
+
|
| 83 |
+
With `compat_filter=True` (default) the `_STYLE_MEDIUM_ALLOWLIST` rules are
|
| 84 |
+
applied so only renderable combinations are yielded.
|
| 85 |
+
"""
|
| 86 |
+
for values in itertools.product(*(vocab[axis] for axis in AXES)):
|
| 87 |
+
profile = VisualProfile(*values)
|
| 88 |
+
if compat_filter and not is_compatible(profile):
|
| 89 |
+
continue
|
| 90 |
+
yield profile
|
src/aamcq/prompt_render.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt rendering for the FLUX.2-klein backend."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Literal
|
| 7 |
+
|
| 8 |
+
from aamcq.profile import VisualProfile
|
| 9 |
+
|
| 10 |
+
# Template structure follows the BFL klein prompting guide: scene front-loaded,
|
| 11 |
+
# natural-language prose, trailing "Key: value." markers. Lighting is embedded
|
| 12 |
+
# mid-sentence as an environmental modifier — klein responds to it more
|
| 13 |
+
# strongly there than as a trailing key.
|
| 14 |
+
# docs.bfl.ml/guides/prompting_guide_flux2_klein
|
| 15 |
+
PROMPT_TEMPLATES: dict[str, str] = {
|
| 16 |
+
"flux2_klein": (
|
| 17 |
+
"{scene}, lit by {lighting_phrase}, rendered as {medium_phrase} "
|
| 18 |
+
"in {style_phrase}. Color palette: {color_lc}."
|
| 19 |
+
),
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
# Medium-to-phrase map: bare vocab words like "Ink Drawing" or "Pixel Art"
|
| 23 |
+
# aren't idiomatic article-bearing noun phrases, so we standardize the wording
|
| 24 |
+
# here to keep the rendered prompt grammatical.
|
| 25 |
+
ART_MEDIUM_PHRASES: dict[str, str] = {
|
| 26 |
+
"Oil Painting": "an oil painting",
|
| 27 |
+
"Watercolor": "a watercolor painting",
|
| 28 |
+
"Ink Drawing": "an ink drawing",
|
| 29 |
+
"Digital Painting": "a digital painting",
|
| 30 |
+
"Pixel Art": "a pixel-art illustration",
|
| 31 |
+
"Pencil Sketch": "a pencil sketch",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# Style-to-phrase map. Most styles are rendered as "{X} style"; Minimalism and
|
| 35 |
+
# Art Deco use expanded phrases because the bare word doesn't activate klein's
|
| 36 |
+
# flat-design / decorative-geometric signals.
|
| 37 |
+
ART_STYLE_PHRASES: dict[str, str] = {
|
| 38 |
+
"Impressionism": "Impressionism style",
|
| 39 |
+
"Anime": "Anime style",
|
| 40 |
+
"Photorealism": "Photorealism style",
|
| 41 |
+
"Cubism": "Cubism style",
|
| 42 |
+
"Minimalism": "flat minimalist style with sparse composition",
|
| 43 |
+
"Art Deco": "Art Deco style with bold geometric shapes, clean symmetry, and ornamental lines",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# Lighting-to-phrase map, spanning a (temperature × hardness × key × source)
|
| 47 |
+
# grid so the 6 values are maximally distinguishable in the rendered image.
|
| 48 |
+
LIGHTING_PHRASES: dict[str, str] = {
|
| 49 |
+
"Golden Hour": "golden hour sunset light",
|
| 50 |
+
"Moody Low-Key": "moody low-key dramatic light",
|
| 51 |
+
"Soft Overcast": "soft diffused overcast daylight",
|
| 52 |
+
"Harsh Noon": "harsh direct noon sunlight",
|
| 53 |
+
"Neon Glow": "pink and cyan neon glow",
|
| 54 |
+
"Candlelit": "warm dim amber glow",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
NEGATIVE_PROMPTS: dict[str, str | None] = {
|
| 58 |
+
"flux2_klein": None,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
MODEL_SPECS: dict[str, dict[str, object]] = {
|
| 62 |
+
"flux2_klein": {
|
| 63 |
+
"model_id": "black-forest-labs/FLUX.2-klein-9B",
|
| 64 |
+
"num_inference_steps": 4,
|
| 65 |
+
"guidance_scale": 1.0,
|
| 66 |
+
},
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
Backend = Literal["flux2_klein"]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@dataclass(frozen=True)
|
| 73 |
+
class RenderedPrompt:
|
| 74 |
+
backend: str
|
| 75 |
+
prompt: str
|
| 76 |
+
negative_prompt: str | None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _format_kwargs(profile: VisualProfile, base_prompt: str) -> dict[str, str]:
|
| 80 |
+
if profile.art_medium not in ART_MEDIUM_PHRASES:
|
| 81 |
+
raise ValueError(f"no ART_MEDIUM_PHRASES entry for {profile.art_medium!r}")
|
| 82 |
+
if profile.lighting not in LIGHTING_PHRASES:
|
| 83 |
+
raise ValueError(f"no LIGHTING_PHRASES entry for {profile.lighting!r}")
|
| 84 |
+
if profile.art_style not in ART_STYLE_PHRASES:
|
| 85 |
+
raise ValueError(f"no ART_STYLE_PHRASES entry for {profile.art_style!r}")
|
| 86 |
+
return {
|
| 87 |
+
"scene": base_prompt,
|
| 88 |
+
"medium_phrase": ART_MEDIUM_PHRASES[profile.art_medium],
|
| 89 |
+
"lighting_phrase": LIGHTING_PHRASES[profile.lighting],
|
| 90 |
+
"style_phrase": ART_STYLE_PHRASES[profile.art_style],
|
| 91 |
+
"color_lc": profile.color.lower(),
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def render(profile: VisualProfile, base_prompt: str, backend: Backend = "flux2_klein") -> RenderedPrompt:
|
| 96 |
+
if backend not in PROMPT_TEMPLATES:
|
| 97 |
+
raise ValueError(f"unknown backend {backend!r}; expected one of {list(PROMPT_TEMPLATES)}")
|
| 98 |
+
prompt = PROMPT_TEMPLATES[backend].format(**_format_kwargs(profile, base_prompt))
|
| 99 |
+
prompt = _post_process(prompt)
|
| 100 |
+
return RenderedPrompt(
|
| 101 |
+
backend=backend,
|
| 102 |
+
prompt=prompt,
|
| 103 |
+
negative_prompt=NEGATIVE_PROMPTS[backend],
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _post_process(prompt: str) -> str:
|
| 108 |
+
return (
|
| 109 |
+
" ".join(prompt.split())
|
| 110 |
+
.replace(" ,", ",")
|
| 111 |
+
.replace(",,", ",")
|
| 112 |
+
.rstrip(", ")
|
| 113 |
+
)
|
src/aamcq/utils/__init__.py
ADDED
|
File without changes
|
src/aamcq/utils/io.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small IO helpers used by the pipeline."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import tempfile
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Iterable, Iterator
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def atomic_write_text(path: str | Path, text: str) -> None:
|
| 13 |
+
path = Path(path)
|
| 14 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 15 |
+
with tempfile.NamedTemporaryFile(
|
| 16 |
+
mode="w", dir=path.parent, delete=False, suffix=".tmp"
|
| 17 |
+
) as tmp:
|
| 18 |
+
tmp.write(text)
|
| 19 |
+
tmp.flush()
|
| 20 |
+
os.fsync(tmp.fileno())
|
| 21 |
+
tmp_path = tmp.name
|
| 22 |
+
os.replace(tmp_path, path)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def write_jsonl(path: str | Path, rows: Iterable[dict]) -> None:
|
| 26 |
+
lines = "\n".join(json.dumps(row, sort_keys=True, ensure_ascii=False) for row in rows)
|
| 27 |
+
if lines:
|
| 28 |
+
lines += "\n"
|
| 29 |
+
atomic_write_text(path, lines)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def read_jsonl(path: str | Path) -> Iterator[dict]:
|
| 33 |
+
with open(path) as f:
|
| 34 |
+
for line in f:
|
| 35 |
+
line = line.strip()
|
| 36 |
+
if line:
|
| 37 |
+
yield json.loads(line)
|
src/aamcq/utils/seeding.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic seed derivation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def item_seed(item_id: str, master_seed: int = 0, purpose: str = "") -> int:
|
| 9 |
+
"""Derive a 32-bit seed from (item_id, master_seed, purpose).
|
| 10 |
+
|
| 11 |
+
Stable across runs and across machines — we rely on SHA-256, not Python's
|
| 12 |
+
string hash randomization.
|
| 13 |
+
"""
|
| 14 |
+
blob = f"{master_seed}|{item_id}|{purpose}".encode("utf-8")
|
| 15 |
+
digest = hashlib.sha256(blob).digest()
|
| 16 |
+
return int.from_bytes(digest[:4], "big", signed=False)
|