lanczos commited on
Commit
871ff87
·
verified ·
1 Parent(s): b991a77

deploy: labeling server

Browse files
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: blue
5
  colorTo: pink
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &amp; 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)