chrimons commited on
Commit
3c8c1fc
·
1 Parent(s): c785767

Build Gradio CLIP scoring app

Browse files
Files changed (14) hide show
  1. .env.example +2 -0
  2. .gitignore +15 -0
  3. README.md +109 -2
  4. app.py +319 -0
  5. db.py +227 -0
  6. embeddings/.gitkeep +0 -0
  7. images.csv +4 -0
  8. model.py +191 -0
  9. precompute_embeddings.py +75 -0
  10. requirements.txt +12 -0
  11. runtime.txt +1 -0
  12. tests/conftest.py +6 -0
  13. tests/test_db.py +38 -0
  14. tests/test_model.py +14 -0
.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # PostgreSQL-Verbindungszeichenfolge im Format postgresql+psycopg2://user:password@host:port/dbname
2
+ DATABASE_URL=postgresql+psycopg2://user:password@host:5432/database
.gitignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .env
6
+ .env.local
7
+ pytest_cache/
8
+ .DS_Store
9
+ .idea/
10
+ .vscode/
11
+ .eggs/
12
+ *.egg-info/
13
+ build/
14
+ dist/
15
+ .cache/
README.md CHANGED
@@ -1,2 +1,109 @@
1
- # game2
2
- A web game
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLIP Score Challenge
2
+
3
+ Eine produktionsnahe Gradio-App, die Benutzertexte gegen einen festen Bildpool bewertet. Die App nutzt CLIP (open-clip ViT-B-32, Pretrained "openai") für Text- und Bild-Embeddings und speichert alle Scores in einer externen PostgreSQL-Datenbank.
4
+
5
+ ## Features
6
+
7
+ - Fester Bildpool aus `images.csv` (nur externe URLs, keine Uploads)
8
+ - Vorberechnete Bild-Embeddings (Kosinus-Ähnlichkeit → Score 0–1000)
9
+ - Deutschsprachige UI mit Leaderboard (global, pro Bild, eigene letzten Scores)
10
+ - Persistenz via PostgreSQL (SQLAlchemy + psycopg2)
11
+ - Keine Moderation, kein Rate-Limit
12
+ - Skript zur Vorberechnung der Embeddings
13
+ - Automatische Schema-Erstellung beim Start
14
+ - Light-Tests (Score-Mapping + DB Roundtrip)
15
+
16
+ ## Projektstruktur
17
+
18
+ ```
19
+ .
20
+ ├── app.py # Gradio UI & Callback-Logik
21
+ ├── db.py # Datenbank-Modelle & Hilfsfunktionen
22
+ ├── model.py # CLIP-Laden, Embeddings, Score-Berechnung
23
+ ├── precompute_embeddings.py # Skript zum Vorberechnen der Bild-Embeddings
24
+ ├── images.csv # Bildpool (image_id, image_url, clip_model, embedding_path)
25
+ ├── embeddings/ # .npy-Embeddings (initial leer, Skript befüllt)
26
+ ├── tests/ # Pytest-Tests
27
+ ├── requirements.txt
28
+ ├── runtime.txt # Python-Version für HF Spaces (3.10)
29
+ ├── .env.example # Beispiel für lokale Entwicklung
30
+ └── README.md
31
+ ```
32
+
33
+ ## Vorbereitung
34
+
35
+ 1. **Python 3.10** installieren (lokal oder via venv/conda).
36
+ 2. Repository klonen und Abhängigkeiten installieren:
37
+ ```bash
38
+ pip install -r requirements.txt
39
+ ```
40
+ 3. `.env` anlegen (siehe `.env.example`) oder `DATABASE_URL` direkt exportieren.
41
+ ```bash
42
+ export DATABASE_URL=postgresql+psycopg2://user:pass@host:5432/dbname
43
+ ```
44
+
45
+ ## Embeddings vorrechnen
46
+
47
+ Das Skript lädt jedes Bild aus `images.csv`, berechnet das CLIP-Embedding und speichert es unter `embedding_path`.
48
+
49
+ ```bash
50
+ python precompute_embeddings.py --csv images.csv --model-name ViT-B-32 --pretrained openai
51
+ ```
52
+
53
+ Hinweise:
54
+ - Die Bild-URLs müssen öffentlich erreichbar sein.
55
+ - Beim ersten Lauf wird das Modell automatisch geladen (~1x pro Space).
56
+ - Embedding-Dateien (`.npy`) werden im `embeddings/` Ordner gespeichert. Diese Dateien **müssen** ins Repo eingecheckt oder im Space verfügbar sein.
57
+
58
+ ## Lokale Entwicklung
59
+
60
+ 1. Embeddings berechnen (siehe oben).
61
+ 2. App starten:
62
+ ```bash
63
+ python app.py
64
+ ```
65
+ 3. Gradio öffnet standardmäßig `http://127.0.0.1:7860`.
66
+
67
+ ## Tests
68
+
69
+ ```bash
70
+ pytest
71
+ ```
72
+
73
+ ## Deployment auf Hugging Face Spaces
74
+
75
+ 1. **Space anlegen**
76
+ - Typ: *Gradio*
77
+ - Runtime: Python 3.10 (`runtime.txt` ist bereits enthalten)
78
+
79
+ 2. **Secrets setzen**
80
+ - Im Space `Settings` → `New secret`
81
+ - Schlüssel: `DATABASE_URL`
82
+ - Wert: PostgreSQL-Verbindungsstring (z. B. von Neon/Supabase)
83
+
84
+ 3. **Embeddings bereitstellen**
85
+ - Lokal `precompute_embeddings.py` ausführen
86
+ - Die erzeugten `.npy`-Dateien unter `embeddings/` committen und zum Space pushen (z. B. via `git add embeddings/*.npy`)
87
+ - Alternativ die Dateien manuell im Space hochladen
88
+
89
+ 4. **Code pushen**
90
+ - Repo-Inhalt in den Space pushen (oder per `Add file` hochladen)
91
+
92
+ 5. **Space starten**
93
+ - Beim Start erstellt `app.py` automatisch das DB-Schema (`users`, `scores` + Indizes)
94
+ - UI erscheint mit deutschem Leaderboard
95
+
96
+ ## Datenbankschema
97
+
98
+ - `users`: speichert `username` (kanonisch, lowercase) und `display_name`
99
+ - `scores`: enthält `username`, `canonical_username`, `image_id`, `score`, `similarity`, `text`, `created_at`
100
+ - Mehrere Scores pro Benutzer sind erlaubt, keine Deduplication
101
+
102
+ ## Hinweise
103
+
104
+ - Scores werden deterministisch berechnet (gleicher Text + Bild → gleicher Score)
105
+ - Keine IP- oder personenbezogene Daten werden geloggt
106
+ - Für produktiven Einsatz unbedingt abgesicherte Postgres-Instanz verwenden
107
+ - Bei neuen Bildern `images.csv` erweitern, Skript erneut laufen lassen und `.npy`-Dateien committen
108
+
109
+ Viel Spaß beim Scoren! 🎯
app.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+
8
+ import gradio as gr
9
+ from dotenv import load_dotenv
10
+
11
+ from db import (
12
+ configure_database,
13
+ create_score,
14
+ ensure_user,
15
+ get_global_top,
16
+ get_image_top,
17
+ get_user_recent,
18
+ init_db,
19
+ normalize_username,
20
+ scores_to_rows,
21
+ session_scope,
22
+ validate_username,
23
+ )
24
+ from model import ClipScorer, ImageEntry, load_image_entries
25
+
26
+ load_dotenv()
27
+
28
+ logging.basicConfig(level=logging.INFO)
29
+ logger = logging.getLogger("app")
30
+
31
+ DATABASE_URL = os.getenv("DATABASE_URL")
32
+ if not DATABASE_URL:
33
+ raise RuntimeError("DATABASE_URL ist nicht gesetzt. Bitte in den Space-Secrets hinterlegen.")
34
+
35
+ configure_database(DATABASE_URL)
36
+ init_db()
37
+
38
+ IMAGE_ENTRIES: List[ImageEntry] = []
39
+ SCORER: Optional[ClipScorer] = None
40
+ EMBEDDING_ERROR: Optional[str] = None
41
+
42
+ try:
43
+ IMAGE_ENTRIES = load_image_entries(Path("images.csv"))
44
+ except Exception as exc: # noqa: BLE001
45
+ EMBEDDING_ERROR = f"images.csv konnte nicht geladen werden: {exc}"
46
+ logger.exception("Fehler beim Laden der images.csv", exc_info=exc)
47
+
48
+ if EMBEDDING_ERROR is None and IMAGE_ENTRIES:
49
+ try:
50
+ SCORER = ClipScorer()
51
+ SCORER.load_precomputed_embeddings(IMAGE_ENTRIES)
52
+ except Exception as exc: # noqa: BLE001
53
+ EMBEDDING_ERROR = (
54
+ "Embeddings konnten nicht geladen werden. Bitte precompute_embeddings.py ausführen."
55
+ f"\nFehler: {exc}"
56
+ )
57
+ logger.exception("Fehler beim Laden der Embeddings", exc_info=exc)
58
+
59
+ APP_READY = EMBEDDING_ERROR is None
60
+
61
+ HELP_TEXT = (
62
+ "Beschreibe das angezeigte Bild in 3 bis 500 Zeichen. "
63
+ "CLIP vergleicht deine Beschreibung mit dem vorab berechneten Bild-Embedding. "
64
+ "Der Score reicht von 0 (gar nicht passend) bis 1000 (perfekte Übereinstimmung)."
65
+ )
66
+
67
+ LEADERBOARD_HEADERS = [
68
+ "Platz",
69
+ "Benutzername",
70
+ "Bild-ID",
71
+ "Score",
72
+ "Ähnlichkeit",
73
+ "Text",
74
+ "Zeitstempel",
75
+ ]
76
+
77
+
78
+ def fetch_global_rows() -> List[List[object]]:
79
+ with session_scope() as session:
80
+ scores = get_global_top(session)
81
+ return scores_to_rows(scores, include_rank=True)
82
+
83
+
84
+ def fetch_image_rows(image_id: str) -> List[List[object]]:
85
+ if not image_id:
86
+ return []
87
+ with session_scope() as session:
88
+ scores = get_image_top(session, image_id)
89
+ return scores_to_rows(scores, include_rank=True)
90
+
91
+
92
+ def fetch_user_rows(username: str) -> List[List[object]]:
93
+ if not username:
94
+ return []
95
+ canonical = normalize_username(username)
96
+ with session_scope() as session:
97
+ scores = get_user_recent(session, canonical)
98
+ return scores_to_rows(scores, include_rank=True)
99
+
100
+
101
+ def handle_score(username: str, text: str, image_index: int | None):
102
+ if not APP_READY or SCORER is None or not IMAGE_ENTRIES:
103
+ raise gr.Error(
104
+ "Embeddings sind nicht verfügbar. Bitte vor dem Start precompute_embeddings.py ausführen."
105
+ )
106
+
107
+ username_clean = (username or "").strip()
108
+ if not validate_username(username_clean):
109
+ raise gr.Error("Ungültiger Benutzername. Erlaubt sind 3-20 Zeichen aus A-Z, a-z, 0-9, _.-")
110
+
111
+ text_clean = (text or "").strip()
112
+ if len(text_clean) < 3:
113
+ raise gr.Error("Bitte gib mindestens 3 Zeichen Text ein.")
114
+ if len(text_clean) > 500:
115
+ raise gr.Error("Der Beschreibungstext darf höchstens 500 Zeichen enthalten.")
116
+
117
+ if image_index is None:
118
+ image_index = 0
119
+ if image_index < 0 or image_index >= len(IMAGE_ENTRIES):
120
+ image_index = 0
121
+ entry = IMAGE_ENTRIES[image_index]
122
+
123
+ similarity, score = SCORER.score_text_for_image(text_clean, entry.image_id)
124
+
125
+ with session_scope() as session:
126
+ user = ensure_user(session, username_clean)
127
+ create_score(
128
+ session,
129
+ user=user,
130
+ image_id=entry.image_id,
131
+ score_value=score,
132
+ similarity=similarity,
133
+ text=text_clean,
134
+ )
135
+
136
+ global_rows = fetch_global_rows()
137
+ image_rows = fetch_image_rows(entry.image_id)
138
+ user_rows = fetch_user_rows(username_clean)
139
+
140
+ gr.Info("Score gespeichert!")
141
+
142
+ return (
143
+ gr.update(value=score),
144
+ gr.update(value=round(similarity, 4)),
145
+ gr.update(value=global_rows),
146
+ gr.update(value=image_rows),
147
+ gr.update(value=user_rows),
148
+ )
149
+
150
+
151
+ def handle_next_image(current_index: int | None):
152
+ if not IMAGE_ENTRIES:
153
+ raise gr.Error("Keine Bilder konfiguriert.")
154
+ if current_index is None:
155
+ current_index = 0
156
+ new_index = (current_index + 1) % len(IMAGE_ENTRIES)
157
+ entry = IMAGE_ENTRIES[new_index]
158
+ image_rows = fetch_image_rows(entry.image_id)
159
+ return (
160
+ new_index,
161
+ gr.update(value=entry.image_url),
162
+ gr.update(value=f"**Bild-ID:** {entry.image_id}"),
163
+ gr.update(value=entry.image_id),
164
+ gr.update(value=image_rows),
165
+ )
166
+
167
+
168
+ def handle_image_dropdown(image_id: str):
169
+ rows = fetch_image_rows(image_id)
170
+ return gr.update(value=rows)
171
+
172
+
173
+ def handle_username_change(username: str):
174
+ if not username:
175
+ return gr.update(value=[])
176
+ username_clean = username.strip()
177
+ if not validate_username(username_clean):
178
+ gr.Warning("Benutzername ungültig. Zeige keine Ergebnisse.")
179
+ return gr.update(value=[])
180
+ rows = fetch_user_rows(username_clean)
181
+ return gr.update(value=rows)
182
+
183
+
184
+ def build_interface() -> gr.Blocks:
185
+ status_message = ""
186
+ if EMBEDDING_ERROR:
187
+ status_message = f"⚠️ {EMBEDDING_ERROR}"
188
+ elif not IMAGE_ENTRIES:
189
+ status_message = "⚠️ Keine Bilder konfiguriert."
190
+ else:
191
+ status_message = "✅ Bereit zum Scoren!"
192
+
193
+ initial_index = 0 if IMAGE_ENTRIES else None
194
+ initial_entry = IMAGE_ENTRIES[0] if IMAGE_ENTRIES else None
195
+ global_rows = fetch_global_rows() if APP_READY else []
196
+ image_rows = fetch_image_rows(initial_entry.image_id) if initial_entry else []
197
+
198
+ image_choices = [entry.image_id for entry in IMAGE_ENTRIES]
199
+
200
+ with gr.Blocks(title="CLIP Score Challenge", theme=gr.themes.Soft()) as demo:
201
+ gr.Markdown("# CLIP Score Challenge 🇩🇪")
202
+ gr.Markdown(HELP_TEXT)
203
+ gr.Markdown(status_message)
204
+
205
+ image_state = gr.State(initial_index)
206
+
207
+ with gr.Row():
208
+ with gr.Column(scale=3):
209
+ image_component = gr.Image(
210
+ value=initial_entry.image_url if initial_entry else None,
211
+ label="Aktuelles Bild",
212
+ show_download_button=False,
213
+ )
214
+ image_info = gr.Markdown(
215
+ f"**Bild-ID:** {initial_entry.image_id}" if initial_entry else "Kein Bild geladen."
216
+ )
217
+ next_button = gr.Button(
218
+ "Nächstes Bild",
219
+ variant="secondary",
220
+ interactive=bool(IMAGE_ENTRIES),
221
+ )
222
+ with gr.Column(scale=2):
223
+ username_input = gr.Textbox(
224
+ label="Benutzername",
225
+ placeholder="3-20 Zeichen (A-Z, a-z, 0-9, _.-)",
226
+ )
227
+ text_input = gr.Textbox(
228
+ label="Beschreibungstext",
229
+ placeholder="Was siehst du auf dem Bild?",
230
+ lines=5,
231
+ )
232
+ score_button = gr.Button(
233
+ "Scoren",
234
+ variant="primary",
235
+ interactive=APP_READY and bool(IMAGE_ENTRIES),
236
+ )
237
+ score_output = gr.Number(label="Score", value=0, precision=0)
238
+ similarity_output = gr.Number(label="Ähnlichkeit", value=0.0, precision=4)
239
+
240
+ gr.Markdown("### Leaderboard")
241
+ with gr.Tabs():
242
+ with gr.Tab("Global Top 50"):
243
+ global_df = gr.Dataframe(
244
+ headers=LEADERBOARD_HEADERS,
245
+ value=global_rows,
246
+ datatype=[
247
+ "number",
248
+ "str",
249
+ "str",
250
+ "number",
251
+ "number",
252
+ "str",
253
+ "str",
254
+ ],
255
+ interactive=False,
256
+ wrap=True,
257
+ )
258
+ with gr.Tab("Dieses Bild Top 50"):
259
+ image_dropdown = gr.Dropdown(
260
+ choices=image_choices,
261
+ value=initial_entry.image_id if initial_entry else None,
262
+ label="Bild auswählen",
263
+ interactive=bool(image_choices),
264
+ )
265
+ image_df = gr.Dataframe(
266
+ headers=LEADERBOARD_HEADERS,
267
+ value=image_rows,
268
+ datatype=[
269
+ "number",
270
+ "str",
271
+ "str",
272
+ "number",
273
+ "number",
274
+ "str",
275
+ "str",
276
+ ],
277
+ interactive=False,
278
+ wrap=True,
279
+ )
280
+ with gr.Tab("Meine letzten 50"):
281
+ user_df = gr.Dataframe(
282
+ headers=LEADERBOARD_HEADERS,
283
+ value=[],
284
+ datatype=[
285
+ "number",
286
+ "str",
287
+ "str",
288
+ "number",
289
+ "number",
290
+ "str",
291
+ "str",
292
+ ],
293
+ interactive=False,
294
+ wrap=True,
295
+ )
296
+
297
+ next_button.click(
298
+ handle_next_image,
299
+ inputs=[image_state],
300
+ outputs=[image_state, image_component, image_info, image_dropdown, image_df],
301
+ )
302
+
303
+ score_button.click(
304
+ handle_score,
305
+ inputs=[username_input, text_input, image_state],
306
+ outputs=[score_output, similarity_output, global_df, image_df, user_df],
307
+ )
308
+
309
+ image_dropdown.change(handle_image_dropdown, inputs=[image_dropdown], outputs=[image_df])
310
+ username_input.change(handle_username_change, inputs=[username_input], outputs=[user_df])
311
+
312
+ return demo
313
+
314
+
315
+ demo = build_interface()
316
+
317
+
318
+ if __name__ == "__main__":
319
+ demo.launch()
db.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from contextlib import contextmanager
6
+ from datetime import datetime, timezone
7
+ from typing import List, Optional, Sequence
8
+
9
+ from sqlalchemy import (
10
+ Column,
11
+ DateTime,
12
+ Float,
13
+ ForeignKey,
14
+ Index,
15
+ Integer,
16
+ String,
17
+ Text,
18
+ create_engine,
19
+ func,
20
+ select,
21
+ )
22
+ from sqlalchemy.engine import Engine
23
+ from sqlalchemy.orm import Session, declarative_base, relationship, sessionmaker
24
+
25
+ Base = declarative_base()
26
+
27
+
28
+ USERNAME_REGEX = re.compile(r"^[A-Za-z0-9_.-]{3,20}$")
29
+
30
+ _engine: Optional[Engine] = None
31
+ _SessionLocal: Optional[sessionmaker] = None
32
+
33
+
34
+ class User(Base):
35
+ __tablename__ = "users"
36
+
37
+ id = Column(Integer, primary_key=True)
38
+ username = Column(String(20), unique=True, nullable=False, index=True) # kanonisch (lowercase)
39
+ display_name = Column(String(20), nullable=False)
40
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
41
+
42
+ scores = relationship("Score", back_populates="user", cascade="all, delete-orphan")
43
+
44
+
45
+ class Score(Base):
46
+ __tablename__ = "scores"
47
+
48
+ id = Column(Integer, primary_key=True)
49
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
50
+ username = Column(String(20), nullable=False) # Originalschreibweise
51
+ canonical_username = Column(String(20), nullable=False, index=True)
52
+ image_id = Column(String(100), nullable=False, index=True)
53
+ score = Column(Integer, nullable=False)
54
+ similarity = Column(Float, nullable=False)
55
+ text = Column(Text, nullable=False)
56
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
57
+
58
+ user = relationship("User", back_populates="scores")
59
+
60
+ __table_args__ = (
61
+ Index("ix_scores_global", score.desc(), created_at.asc(), id.asc()),
62
+ Index("ix_scores_image", "image_id", score.desc(), created_at.asc()),
63
+ Index("ix_scores_user", "canonical_username", created_at.desc(), id.desc()),
64
+ )
65
+
66
+
67
+ def configure_database(database_url: Optional[str] = None) -> Engine:
68
+ """Initialisiert Engine und SessionFactory."""
69
+
70
+ global _engine, _SessionLocal
71
+
72
+ if database_url is None:
73
+ database_url = os.getenv("DATABASE_URL")
74
+ if not database_url:
75
+ raise RuntimeError("DATABASE_URL ist nicht gesetzt.")
76
+
77
+ _engine = create_engine(database_url, future=True, pool_pre_ping=True)
78
+ _SessionLocal = sessionmaker(bind=_engine, autoflush=False, expire_on_commit=False, future=True)
79
+ return _engine
80
+
81
+
82
+ def get_engine() -> Engine:
83
+ if _engine is None:
84
+ raise RuntimeError("Datenbank wurde noch nicht konfiguriert. Rufen Sie configure_database() auf.")
85
+ return _engine
86
+
87
+
88
+ def init_db() -> None:
89
+ engine = get_engine()
90
+ Base.metadata.create_all(engine)
91
+
92
+
93
+ @contextmanager
94
+ def session_scope() -> Session:
95
+ if _SessionLocal is None:
96
+ raise RuntimeError("SessionFactory nicht initialisiert. configure_database() zuerst aufrufen.")
97
+ session: Session = _SessionLocal()
98
+ try:
99
+ yield session
100
+ session.commit()
101
+ except Exception:
102
+ session.rollback()
103
+ raise
104
+ finally:
105
+ session.close()
106
+
107
+
108
+ def normalize_username(username: str) -> str:
109
+ return username.strip().lower()
110
+
111
+
112
+ def validate_username(username: str) -> bool:
113
+ return bool(USERNAME_REGEX.match(username))
114
+
115
+
116
+ def ensure_user(session: Session, username_input: str) -> User:
117
+ """Sucht oder erstellt einen Nutzer."""
118
+
119
+ normalized = normalize_username(username_input)
120
+ stmt = select(User).where(User.username == normalized)
121
+ user = session.execute(stmt).scalar_one_or_none()
122
+ if user:
123
+ return user
124
+
125
+ user = User(username=normalized, display_name=username_input.strip())
126
+ session.add(user)
127
+ session.flush()
128
+ return user
129
+
130
+
131
+ def create_score(
132
+ session: Session,
133
+ *,
134
+ user: User,
135
+ image_id: str,
136
+ score_value: int,
137
+ similarity: float,
138
+ text: str,
139
+ ) -> Score:
140
+ entry = Score(
141
+ user_id=user.id,
142
+ username=user.display_name,
143
+ canonical_username=user.username,
144
+ image_id=image_id,
145
+ score=score_value,
146
+ similarity=similarity,
147
+ text=text,
148
+ )
149
+ session.add(entry)
150
+ session.flush()
151
+ return entry
152
+
153
+
154
+ def _format_timestamp(value: datetime | str | None) -> str:
155
+ if value is None:
156
+ return ""
157
+ if isinstance(value, str):
158
+ return value
159
+ if value.tzinfo is None:
160
+ value = value.replace(tzinfo=timezone.utc)
161
+ return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
162
+
163
+
164
+ def scores_to_rows(scores: Sequence[Score], include_rank: bool = True) -> List[List[object]]:
165
+ rows: List[List[object]] = []
166
+ for index, score in enumerate(scores, start=1):
167
+ base_row = [
168
+ score.username,
169
+ score.image_id,
170
+ score.score,
171
+ round(score.similarity, 4),
172
+ score.text,
173
+ _format_timestamp(score.created_at),
174
+ ]
175
+ if include_rank:
176
+ rows.append([index, *base_row])
177
+ else:
178
+ rows.append(base_row)
179
+ return rows
180
+
181
+
182
+ def get_global_top(session: Session, limit: int = 50) -> List[Score]:
183
+ stmt = (
184
+ select(Score)
185
+ .order_by(Score.score.desc(), Score.created_at.asc(), Score.id.asc())
186
+ .limit(limit)
187
+ )
188
+ return list(session.scalars(stmt))
189
+
190
+
191
+ def get_image_top(session: Session, image_id: str, limit: int = 50) -> List[Score]:
192
+ stmt = (
193
+ select(Score)
194
+ .where(Score.image_id == image_id)
195
+ .order_by(Score.score.desc(), Score.created_at.asc(), Score.id.asc())
196
+ .limit(limit)
197
+ )
198
+ return list(session.scalars(stmt))
199
+
200
+
201
+ def get_user_recent(session: Session, canonical_username: str, limit: int = 50) -> List[Score]:
202
+ stmt = (
203
+ select(Score)
204
+ .where(Score.canonical_username == canonical_username)
205
+ .order_by(Score.created_at.desc(), Score.id.desc())
206
+ .limit(limit)
207
+ )
208
+ return list(session.scalars(stmt))
209
+
210
+
211
+ __all__ = [
212
+ "Base",
213
+ "Score",
214
+ "User",
215
+ "configure_database",
216
+ "create_score",
217
+ "ensure_user",
218
+ "get_engine",
219
+ "get_global_top",
220
+ "get_image_top",
221
+ "get_user_recent",
222
+ "init_db",
223
+ "normalize_username",
224
+ "scores_to_rows",
225
+ "session_scope",
226
+ "validate_username",
227
+ ]
embeddings/.gitkeep ADDED
File without changes
images.csv ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ image_id,image_url,clip_model,embedding_path
2
+ cat_wiki,https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/640px-Cat03.jpg,ViT-B-32,embeddings/cat_wiki.npy
3
+ mountain_sunrise,https://upload.wikimedia.org/wikipedia/commons/thumb/9/99/Swiss_Mountains_%28228640584%29.jpeg/640px-Swiss_Mountains_%28228640584%29.jpeg,ViT-B-32,embeddings/mountain_sunrise.npy
4
+ metro_station,https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Berlin_U-Bahn_station_Brandenburger_Tor_platform.jpg/640px-Berlin_U-Bahn_station_Brandenburger_Tor_platform.jpg,ViT-B-32,embeddings/metro_station.npy
model.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Iterable, List, Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ImageEntry:
14
+ """Container für Bildmetadaten und Pfade zu Embeddings."""
15
+
16
+ image_id: str
17
+ image_url: str
18
+ clip_model: str
19
+ embedding_path: Path
20
+
21
+
22
+ def load_image_entries(csv_path: Path | str) -> List[ImageEntry]:
23
+ """Liest die Bildliste aus einer CSV-Datei."""
24
+
25
+ path = Path(csv_path)
26
+ if not path.exists():
27
+ raise FileNotFoundError(f"Die Datei {path} existiert nicht.")
28
+
29
+ entries: List[ImageEntry] = []
30
+ with path.open("r", encoding="utf-8") as handle:
31
+ reader = csv.DictReader(handle)
32
+ required_fields = {"image_id", "image_url", "clip_model", "embedding_path"}
33
+ if reader.fieldnames is None:
34
+ raise ValueError("images.csv besitzt keine Kopfzeile.")
35
+ missing_fields = required_fields.difference(reader.fieldnames)
36
+ if missing_fields:
37
+ raise ValueError(f"images.csv fehlt/fehlen folgende Felder: {sorted(missing_fields)}")
38
+
39
+ for row in reader:
40
+ image_id = row["image_id"].strip()
41
+ image_url = row["image_url"].strip()
42
+ clip_model = row["clip_model"].strip()
43
+ embedding_path = Path(row["embedding_path"].strip())
44
+ if not image_id or not image_url or not clip_model:
45
+ logger.warning("Zeile in images.csv übersprungen: %s", row)
46
+ continue
47
+ if not embedding_path.is_absolute():
48
+ embedding_path = (path.parent / embedding_path).resolve()
49
+ entries.append(
50
+ ImageEntry(
51
+ image_id=image_id,
52
+ image_url=image_url,
53
+ clip_model=clip_model,
54
+ embedding_path=embedding_path,
55
+ )
56
+ )
57
+
58
+ if not entries:
59
+ raise ValueError("images.csv enthält keine gültigen Einträge.")
60
+
61
+ return entries
62
+
63
+
64
+ def similarity_to_score(similarity: float) -> int:
65
+ """Wandelt eine Kosinusähnlichkeit (-1 bis 1) in einen Score von 0 bis 1000 um."""
66
+
67
+ clipped = max(-1.0, min(1.0, similarity))
68
+ score = int(round(((clipped + 1.0) / 2.0) * 1000))
69
+ return score
70
+
71
+
72
+ def _require_numpy():
73
+ try:
74
+ import numpy as np # type: ignore
75
+ except ModuleNotFoundError as exc: # pragma: no cover - defensive fallback
76
+ raise ModuleNotFoundError("numpy wird benötigt, ist aber nicht installiert.") from exc
77
+ return np
78
+
79
+
80
+ def _require_torch():
81
+ try:
82
+ import torch # type: ignore
83
+ except ModuleNotFoundError as exc: # pragma: no cover - defensive fallback
84
+ raise ModuleNotFoundError("torch wird benötigt, ist aber nicht installiert.") from exc
85
+ return torch
86
+
87
+
88
+ def _require_open_clip():
89
+ try:
90
+ import open_clip # type: ignore
91
+ except ModuleNotFoundError as exc: # pragma: no cover - defensive fallback
92
+ raise ModuleNotFoundError("open-clip-torch wird benötigt, ist aber nicht installiert.") from exc
93
+ return open_clip
94
+
95
+
96
+ class ClipScorer:
97
+ """Wrapper um CLIP für Text-/Bild-Embeddings und Scores."""
98
+
99
+ def __init__(
100
+ self,
101
+ model_name: str = "ViT-B-32",
102
+ pretrained: str = "openai",
103
+ device: Optional[str] = None,
104
+ ) -> None:
105
+ self.model_name = model_name
106
+ self.pretrained = pretrained
107
+ torch = _require_torch()
108
+ open_clip = _require_open_clip()
109
+ self._torch = torch
110
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
111
+ logger.info("Lade CLIP Modell %s (%s) auf %s", model_name, pretrained, self.device)
112
+ self.model, _, self.preprocess = open_clip.create_model_and_transforms(
113
+ model_name,
114
+ pretrained=pretrained,
115
+ )
116
+ self.model.to(self.device)
117
+ self.model.eval()
118
+ for parameter in self.model.parameters():
119
+ parameter.requires_grad = False
120
+ self.tokenizer = open_clip.get_tokenizer(model_name)
121
+ self._image_embeddings: Dict[str, Any] = {}
122
+
123
+ def load_precomputed_embeddings(self, entries: Iterable[ImageEntry]) -> None:
124
+ """Lädt Embeddings aus .npy-Dateien und speichert sie intern."""
125
+
126
+ loaded = 0
127
+ for entry in entries:
128
+ if entry.clip_model != self.model_name:
129
+ logger.warning(
130
+ "Überspringe Bild %s: erwartet Modell %s, gefunden %s",
131
+ entry.image_id,
132
+ self.model_name,
133
+ entry.clip_model,
134
+ )
135
+ continue
136
+ if not entry.embedding_path.exists():
137
+ raise FileNotFoundError(
138
+ f"Embedding-Datei für {entry.image_id} fehlt: {entry.embedding_path}"
139
+ )
140
+ np = _require_numpy()
141
+ torch = self._torch
142
+ array = np.load(entry.embedding_path)
143
+ if array.ndim > 1:
144
+ array = array.squeeze()
145
+ tensor = torch.from_numpy(array).to(self.device)
146
+ tensor = tensor.to(dtype=torch.float32)
147
+ norm = torch.linalg.norm(tensor)
148
+ if norm == 0:
149
+ raise ValueError(f"Embedding für {entry.image_id} hat Norm 0.")
150
+ tensor = tensor / norm
151
+ self._image_embeddings[entry.image_id] = tensor
152
+ loaded += 1
153
+
154
+ if loaded == 0:
155
+ raise ValueError("Keine Embeddings konnten geladen werden.")
156
+ logger.info("%d Embeddings geladen.", loaded)
157
+
158
+ def encode_text(self, text: str) -> Any:
159
+ torch = self._torch
160
+ tokens = self.tokenizer([text])
161
+ tokens = tokens.to(self.device)
162
+ with torch.no_grad():
163
+ text_features = self.model.encode_text(tokens).float()
164
+ text_features = text_features / text_features.norm(dim=-1, keepdim=True)
165
+ return text_features[0]
166
+
167
+ def get_image_embedding(self, image_id: str) -> Any:
168
+ try:
169
+ return self._image_embeddings[image_id]
170
+ except KeyError as exc:
171
+ raise KeyError(f"Kein Embedding für Bild-ID {image_id} geladen.") from exc
172
+
173
+ def compute_similarity(self, text_embedding: Any, image_embedding: Any) -> float:
174
+ torch = self._torch
175
+ similarity = torch.matmul(text_embedding, image_embedding)
176
+ return float(similarity.item())
177
+
178
+ def score_text_for_image(self, text: str, image_id: str) -> tuple[float, int]:
179
+ text_embedding = self.encode_text(text)
180
+ image_embedding = self.get_image_embedding(image_id)
181
+ similarity = self.compute_similarity(text_embedding, image_embedding)
182
+ score = similarity_to_score(similarity)
183
+ return similarity, score
184
+
185
+
186
+ __all__ = [
187
+ "ClipScorer",
188
+ "ImageEntry",
189
+ "load_image_entries",
190
+ "similarity_to_score",
191
+ ]
precompute_embeddings.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import io
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ import numpy as np
10
+ import requests
11
+ import torch
12
+ from PIL import Image
13
+
14
+ from model import ClipScorer, ImageEntry, load_image_entries
15
+
16
+ logging.basicConfig(level=logging.INFO)
17
+ logger = logging.getLogger("precompute_embeddings")
18
+
19
+
20
+ def download_image(url: str) -> Image.Image:
21
+ response = requests.get(url, timeout=60)
22
+ response.raise_for_status()
23
+ image = Image.open(io.BytesIO(response.content)).convert("RGB")
24
+ return image
25
+
26
+
27
+ def save_embedding(path: Path, embedding: torch.Tensor) -> None:
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ array = embedding.detach().cpu().numpy().astype(np.float32)
30
+ np.save(path, array)
31
+
32
+
33
+ def compute_embeddings(entries: Iterable[ImageEntry], model_name: str = "ViT-B-32", pretrained: str = "openai") -> None:
34
+ scorer = ClipScorer(model_name=model_name, pretrained=pretrained)
35
+ processed = 0
36
+ for entry in entries:
37
+ if entry.clip_model != model_name:
38
+ logger.info(
39
+ "Überspringe %s, da clip_model %s nicht zum Modell %s passt.",
40
+ entry.image_id,
41
+ entry.clip_model,
42
+ model_name,
43
+ )
44
+ continue
45
+ if entry.embedding_path.exists():
46
+ logger.info("Embedding für %s existiert bereits (%s)", entry.image_id, entry.embedding_path)
47
+ continue
48
+ logger.info("Lade Bild %s", entry.image_id)
49
+ image = download_image(entry.image_url)
50
+ image_tensor = scorer.preprocess(image).unsqueeze(0).to(scorer.device)
51
+ with torch.no_grad():
52
+ features = scorer.model.encode_image(image_tensor).float()
53
+ features = features / features.norm(dim=-1, keepdim=True)
54
+ save_embedding(entry.embedding_path, features[0])
55
+ processed += 1
56
+ logger.info("Embedding für %s gespeichert (%s)", entry.image_id, entry.embedding_path)
57
+ logger.info("Fertig. %d Embeddings erzeugt.", processed)
58
+
59
+
60
+ def parse_args() -> argparse.Namespace:
61
+ parser = argparse.ArgumentParser(description="Berechnet CLIP-Embeddings für die Bilder aus images.csv")
62
+ parser.add_argument("--csv", default="images.csv", help="Pfad zur images.csv")
63
+ parser.add_argument("--model-name", default="ViT-B-32", help="open-clip Modellname")
64
+ parser.add_argument("--pretrained", default="openai", help="Checkpoint/Pretraining-Name")
65
+ return parser.parse_args()
66
+
67
+
68
+ def main() -> None:
69
+ args = parse_args()
70
+ entries = load_image_entries(Path(args.csv))
71
+ compute_embeddings(entries, model_name=args.model_name, pretrained=args.pretrained)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.29.0,<5.0.0
2
+ open-clip-torch>=2.24.0
3
+ torch>=2.1.0
4
+ torchvision>=0.16.0
5
+ numpy>=1.24.0
6
+ pandas>=2.0.0
7
+ sqlalchemy>=2.0.0
8
+ psycopg2-binary>=2.9.0
9
+ requests>=2.31.0
10
+ pillow>=10.0.0
11
+ python-dotenv>=1.0.0
12
+ pytest>=7.4.0
runtime.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ python-3.10
tests/conftest.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ ROOT = Path(__file__).resolve().parent.parent
5
+ if str(ROOT) not in sys.path:
6
+ sys.path.insert(0, str(ROOT))
tests/test_db.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import db
2
+
3
+
4
+ def test_db_roundtrip(tmp_path):
5
+ database_path = tmp_path / "test.db"
6
+ database_url = f"sqlite+pysqlite:///{database_path}"
7
+ db.configure_database(database_url)
8
+ db.init_db()
9
+
10
+ with db.session_scope() as session:
11
+ user = db.ensure_user(session, "TestUser")
12
+ db.create_score(
13
+ session,
14
+ user=user,
15
+ image_id="image_1",
16
+ score_value=777,
17
+ similarity=0.42,
18
+ text="Ein Testeintrag",
19
+ )
20
+ user_id = user.id
21
+
22
+ with db.session_scope() as session:
23
+ same_user = db.ensure_user(session, "testuser")
24
+ assert same_user.id == user_id
25
+ global_scores = db.get_global_top(session)
26
+ assert len(global_scores) == 1
27
+ assert global_scores[0].score == 777
28
+ image_scores = db.get_image_top(session, "image_1")
29
+ assert len(image_scores) == 1
30
+ user_scores = db.get_user_recent(session, db.normalize_username("TESTUSER"))
31
+ assert len(user_scores) == 1
32
+ rows = db.scores_to_rows(global_scores)
33
+ assert rows[0][0] == 1 # Rang
34
+ assert rows[0][1] == "TestUser"
35
+ assert rows[0][2] == "image_1"
36
+ assert rows[0][3] == 777
37
+ assert rows[0][4] == round(0.42, 4)
38
+ assert "Ein Testeintrag" in rows[0][5]
tests/test_model.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from model import similarity_to_score
2
+
3
+
4
+ def test_similarity_to_score_boundaries():
5
+ assert similarity_to_score(-1.0) == 0
6
+ assert similarity_to_score(1.0) == 1000
7
+ assert similarity_to_score(0.0) == 500
8
+
9
+
10
+ def test_similarity_to_score_clipping_and_rounding():
11
+ assert similarity_to_score(1.2) == 1000
12
+ assert similarity_to_score(-1.3) == 0
13
+ assert similarity_to_score(0.1234) == 562
14
+ assert similarity_to_score(-0.3333) == 333