Erster Commit

This commit is contained in:
2026-04-23 22:10:45 +02:00
commit 5d47482d2a
30 changed files with 6340 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
{
"permissions": {
"allow": [
"Bash(npm install *)",
"Bash(npx tsc *)",
"Bash(echo \"Exit: $?\")",
"Bash(npm run *)",
"Bash(pip install *)",
"Bash(pip show *)",
"Bash(pip3 show *)",
"Bash(pip3 list *)",
"Bash(.venv/bin/pip install *)",
"Bash(/opt/homebrew/opt/python@3.12/bin/python3.12 -m venv .venv --clear)",
"Bash(python3.13 -m venv .venv --clear)",
"Bash(.venv/bin/python -c \"import flask; import flask_cors; import ollama; import PIL; print\\('Alle Imports OK - Flask', flask.__version__\\)\")",
"Bash(.venv/bin/python app.py)",
"Bash(curl -s http://localhost:8000/api/images?mode=draw)",
"Bash(curl -s \"http://localhost:8000/api/images?mode=draw\")",
"Read(//tmp/**)",
"Bash(curl -s \"http://localhost:3000/\")",
"Bash(curl -s \"http://localhost:3000/api/images?mode=draw\")",
"mcp__coolify__ping",
"mcp__coolify__projects",
"mcp__coolify__servers",
"Bash(docker build *)",
"mcp__coolify__private-keys",
"Bash(pkill -f \"python app.py\")",
"Bash(curl -s http://localhost:8000/)"
]
}
}

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
# Python
.venv/
__pycache__/
*.pyc
*.pyo
# Node
frontend/node_modules/
frontend/dist/
# Nutzerdaten (werden als Volumes gemountet, nicht ins Image gepackt)
pictures/
objects_image/
sentence_object/
sentance_object/
# Git & IDE
.git/
.gitignore
.DS_Store
# Bereits gebaute React-Dateien (werden im Docker-Build neu gebaut)
static/react/

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Python
.venv/
__pycache__/
*.pyc
*.pyo
*.pyd
.env
# Node
frontend/node_modules/
# Nutzerdaten (lokal bleiben, nicht ins Repo)
pictures/
objects_image/
sentence_object/
sentance_object/
# Vite-Build (wird im Docker neu gebaut)
static/react/
# macOS
.DS_Store
# IDE
.vscode/
.idea/
# Logs
*.log

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# ── Stage 1: React-Frontend bauen ───────────────────────────────────────────
FROM node:20-alpine AS frontend-builder
# /repo entspricht dem Repo-Root im Build-Context
WORKDIR /repo/frontend
COPY frontend/package*.json ./
RUN npm ci --silent
COPY frontend/ .
# vite.config outDir: ../static/react → schreibt nach /repo/static/react
RUN npm run build
# ── Stage 2: Python/Flask Backend ────────────────────────────────────────────
FROM python:3.12-slim
WORKDIR /app
# Bildcodec-Abhängigkeiten für Pillow
RUN apt-get update && apt-get install -y --no-install-recommends \
libjpeg-dev libpng-dev libwebp-dev \
&& rm -rf /var/lib/apt/lists/*
# Python-Pakete
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Anwendungscode
COPY app.py .
COPY prompts/ prompts/
COPY templates/ templates/
# Leeres static/ vorbereiten, dann React-Build einfügen
RUN mkdir -p static/react
COPY --from=frontend-builder /repo/static/react ./static/react
# Datenverzeichnisse (in Coolify als Volumes konfigurieren)
RUN mkdir -p pictures objects_image sentence_object
# Ollama-Verbindung: Standard → Ollama auf dem Host-Gerät
# In Coolify als Umgebungsvariable überschreibbar
ENV OLLAMA_HOST=http://host.docker.internal:11434
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "app:app"]

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
## Bild-Ausschnitt-Tool
Kurzes Web-Tool, um ein lokales Bild aus dem Ordner `pictures` im Browser anzuzeigen, einen Rechteck-Ausschnitt mit der Maus zu wählen und den Ausschnitt in `objects_image` zu speichern.
### Installation
1. In den Projektordner wechseln:
```bash
cd /Users/tim/SynologyDrive/LanguageParent/content_mentor
```
2. (Optional, empfohlen) Virtuelle Umgebung anlegen/aktivieren und Abhängigkeiten installieren:
```bash
python -m venv .venv
source .venv/bin/activate # macOS / Linux
pip install -r requirements.txt
```
3. Lege deine Quellbilder in den Ordner `pictures` neben dieser Datei.
### Starten
```bash
python app.py
```
Dann im Browser aufrufen:
```text
http://localhost:5000
```
Den Port kannst du in `app.py` im `app.run(...)`-Aufruf anpassen und später per Nginx/Reverse-Proxy von außen erreichbar machen.

815
app.py Normal file
View File

@@ -0,0 +1,815 @@
from pathlib import Path
from datetime import datetime
from uuid import uuid4
import json
from flask import Flask, send_from_directory, request, jsonify
from flask_cors import CORS
from PIL import Image
import ollama
BASE_DIR = Path(__file__).resolve().parent
PICTURES_DIR = BASE_DIR / "pictures"
OBJECTS_DIR = BASE_DIR / "objects_image"
SENTENCE_DIR = BASE_DIR / "sentence_object"
PROMPTS_DIR = BASE_DIR / "prompts"
app = Flask(
__name__,
template_folder=str(BASE_DIR / "templates"),
static_folder=str(BASE_DIR / "static"),
)
CORS(app)
def read_prompt(filepath: Path, fallback: str) -> str:
"""
Hilfsfunktion, um Prompt-Dateien zu lesen.
"""
try:
if filepath.exists():
return filepath.read_text(encoding="utf-8").strip()
except Exception:
pass
return fallback.strip()
@app.route("/api/images", methods=["GET"])
def list_images():
"""
Gibt alle Bilder im pictures-Ordner zurück.
Query-Parameter: ?mode=draw (Standard) oder ?mode=generate
draw → Bilder ohne _saved-Suffix
generate → Bilder mit _saved-Suffix
"""
mode = request.args.get("mode", "draw")
PICTURES_DIR.mkdir(parents=True, exist_ok=True)
if mode == "generate":
image_paths = sorted(
[f for f in PICTURES_DIR.iterdir() if f.is_file() and f.stem.endswith("_saved")],
key=lambda p: p.stat().st_mtime,
)
else:
image_paths = sorted(
[f for f in PICTURES_DIR.iterdir() if f.is_file() and not f.stem.endswith("_saved")],
key=lambda p: p.stat().st_mtime,
)
return jsonify({"images": [p.name for p in image_paths]})
REACT_BUILD_DIR = BASE_DIR / "static" / "react"
def _serve_spa():
"""Liefert die React SPA index.html für alle Frontend-Routen."""
return send_from_directory(REACT_BUILD_DIR, "index.html")
@app.route("/")
@app.route("/draw")
@app.route("/generate")
def serve_spa():
return _serve_spa()
@app.route("/assets/<path:filename>")
def serve_react_assets(filename: str):
"""Liefert JS/CSS-Assets aus dem Vite-Build."""
return send_from_directory(REACT_BUILD_DIR / "assets", filename)
@app.route("/pictures/<path:filename>")
def serve_picture(filename: str):
# Statisches Ausliefern der Originalbilder
return send_from_directory(PICTURES_DIR, filename)
@app.route("/objects_image/<path:filename>")
def serve_object_image(filename: str):
# Ausliefern der gespeicherten Ausschnitt-Bilder
return send_from_directory(OBJECTS_DIR, filename)
@app.route("/api/objects", methods=["GET"])
def list_objects_for_image():
"""
Gibt alle gespeicherten Objekte (Ausschnitte) zu einem Quellbild zurück.
Query-Parameter:
?filename=testbild.png
"""
filename = request.args.get("filename")
if not filename:
return jsonify({"error": "Missing filename query parameter"}), 400
# Für *_saved-Bilder auch Objekte berücksichtigen, deren source_filename
# noch auf den ursprünglichen Namen ohne "_saved" zeigt (Kompatibilität)
base_filename = filename
legacy_filename = None
if base_filename.endswith(".png") or base_filename.endswith(".jpg") or base_filename.endswith(".jpeg") or base_filename.endswith(".webp"):
stem = base_filename.rsplit(".", 1)[0]
suffix = base_filename.rsplit(".", 1)[1]
if stem.endswith("_saved"):
legacy_filename = f"{stem[:-6]}.{suffix}"
OBJECTS_DIR.mkdir(parents=True, exist_ok=True)
objects = []
for meta_file in OBJECTS_DIR.glob("*.txt"):
try:
meta = json.loads(meta_file.read_text(encoding="utf-8"))
except Exception:
continue
src_name = meta.get("source_filename")
if src_name != filename and (legacy_filename is None or src_name != legacy_filename):
continue
objects.append(
{
"id": meta.get("id"),
"image_file": meta.get("image_file"),
"title_de": meta.get("title_de", ""),
"position_de": meta.get("position_de", ""),
"action_de": meta.get("action_de", ""),
"condition_de": meta.get("condition_de", ""),
# Von KI erzeugte Details (optional)
"label_en": meta.get("label_en"),
"label_de": meta.get("label_de"),
"label_se": meta.get("label_se"),
"color_en": meta.get("color_en"),
"adjective_en": meta.get("adjective_en"),
"action_verb_en": meta.get("action_verb_en"),
"preposition_en": meta.get("preposition_en"),
"relative_position_en": meta.get("relative_position_en"),
"season_en": meta.get("season_en"),
"mode": meta.get("mode"),
"bbox": meta.get("bbox"),
"polygon": meta.get("polygon"),
"hierarchy": meta.get("hierarchy", 1),
"parent_id": meta.get("parent_id"),
"created_at": meta.get("created_at"),
}
)
# Älteste zuerst sortieren (1 = ältestes Objekt)
objects.sort(key=lambda o: o.get("created_at") or "")
# Laufende Nummer je Bild (1..n) nach obiger Sortierung
for idx, obj in enumerate(objects, start=1):
obj["index"] = idx
return jsonify({"objects": objects})
@app.route("/api/object/<obj_id>/hierarchy", methods=["POST"])
def update_object_hierarchy(obj_id: str):
"""
Aktualisiert die Hierarchie (1, 2 oder 3) eines gespeicherten Objekts.
"""
data = request.get_json(force=True, silent=True) or {}
try:
hierarchy = int(data.get("hierarchy"))
except (TypeError, ValueError):
return jsonify({"error": "Invalid hierarchy"}), 400
if hierarchy not in (1, 2, 3):
return jsonify({"error": "Hierarchy must be 1, 2 or 3"}), 400
meta_path = OBJECTS_DIR / f"{obj_id}.txt"
if not meta_path.exists():
return jsonify({"error": "Object not found"}), 404
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
return jsonify({"error": "Could not read meta file"}), 500
meta["hierarchy"] = hierarchy
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return jsonify({"id": obj_id, "hierarchy": hierarchy})
@app.route("/api/object/<obj_id>", methods=["POST"])
def update_object_meta(obj_id: str):
"""
Aktualisiert Text-Metadaten (title_de, position_de, action_de, condition_de)
eines gespeicherten Objekts.
"""
data = request.get_json(force=True, silent=True) or {}
meta_path = OBJECTS_DIR / f"{obj_id}.txt"
if not meta_path.exists():
return jsonify({"error": "Object not found"}), 404
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
return jsonify({"error": "Could not read meta file"}), 500
for key in ("title_de", "position_de", "action_de", "condition_de"):
if key in data:
meta[key] = data.get(key, "")
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return jsonify({k: meta.get(k) for k in ("id", "title_de", "position_de", "action_de", "condition_de")})
@app.route("/api/object/<obj_id>/parent", methods=["POST"])
def update_object_parent(obj_id: str):
"""
Aktualisiert die Parent-Relation eines Objekts.
Erwartet JSON: { "parent_id": "<uuid>" | null }
"""
data = request.get_json(force=True, silent=True) or {}
parent_id = data.get("parent_id")
meta_path = OBJECTS_DIR / f"{obj_id}.txt"
if not meta_path.exists():
return jsonify({"error": "Object not found"}), 404
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
return jsonify({"error": "Could not read meta file"}), 500
# Optional: prüfen, ob parent_id existiert und zum selben Bild gehört
if parent_id:
parent_meta_path = OBJECTS_DIR / f"{parent_id}.txt"
if not parent_meta_path.exists():
return jsonify({"error": "Parent object not found"}), 400
try:
parent_meta = json.loads(parent_meta_path.read_text(encoding="utf-8"))
except Exception:
return jsonify({"error": "Could not read parent meta file"}), 500
if parent_meta.get("source_filename") != meta.get("source_filename"):
return jsonify({"error": "Parent must belong to same source image"}), 400
meta["parent_id"] = parent_id or None
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return jsonify({"id": obj_id, "parent_id": meta.get("parent_id")})
@app.route("/api/object/<obj_id>/generate_details", methods=["POST"])
def generate_object_details(obj_id: str):
"""
Erzeugt mit Llama 3.2 Vision zusätzliche englische Metadaten zu einem Objekt
auf Basis des ausgeschnittenen Objektbildes + vorhandener deutscher Felder.
"""
meta_path = OBJECTS_DIR / f"{obj_id}.txt"
if not meta_path.exists():
return jsonify({"error": "Object not found"}), 404
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
return jsonify({"error": "Could not read meta file"}), 500
image_file = meta.get("image_file")
if not image_file:
return jsonify({"error": "Object has no image_file"}), 400
image_path = OBJECTS_DIR / image_file
if not image_path.exists():
return jsonify({"error": "Object image not found"}), 404
# Prompt laden
prompt_template = read_prompt(
PROMPTS_DIR / "create_details.txt",
"Analyze this object and return one JSON object with the requested fields.",
)
title_de = meta.get("title_de", "") or ""
action_de = meta.get("action_de", "") or ""
condition_de = meta.get("condition_de", "") or ""
# Platzhalter im Prompt ersetzen (einfache .format-Verwendung)
try:
prompt = prompt_template.format(
title_de=title_de,
action_de=action_de,
condition_de=condition_de,
)
except Exception:
# Falls das Format fehlschlägt, einfach Rohprompt + Kontext anhängen
prompt = (
f"{prompt_template}\n\n"
f"Titel (Deutsch): {title_de}\n"
f"Status (Deutsch): {action_de}\n"
f"Zustand (Deutsch): {condition_de}\n"
)
# Bild als Bytes laden
try:
img_bytes = image_path.read_bytes()
except Exception:
return jsonify({"error": "Could not read object image"}), 500
try:
response = ollama.chat(
model="llama3.2-vision",
messages=[{"role": "user", "content": prompt, "images": [img_bytes]}],
format="json",
options={"temperature": 0},
)
except Exception as e:
return jsonify({"error": f"LLM request failed: {e}"}), 500
content = response.get("message", {}).get("content")
if not content:
return jsonify({"error": "Empty response from LLM"}), 500
try:
ai_data = json.loads(content)
except Exception:
# Fallback: versuchen, das erste JSON-Objekt zu extrahieren
try:
start = content.index("{")
end = content.rindex("}") + 1
ai_data = json.loads(content[start:end])
except Exception:
return jsonify({"error": "Could not parse JSON from LLM response"}), 500
# Debug-Ausgabe des rohen AI-Outputs
try:
print(f"[generate_details] Raw AI data for {obj_id}: {json.dumps(ai_data, ensure_ascii=False)}")
except Exception:
print(f"[generate_details] Raw AI data for {obj_id} (non-serializable): {ai_data!r}")
# Erwartete Felder übernehmen (gemäß neuem Prompt)
fields = [
"label_en",
"label_de",
"label_se",
"color_en",
"adjective_en",
"action_verb_en",
"preposition_en",
"relative_position_en",
"season_en",
]
for key in fields:
if key in ai_data:
meta[key] = ai_data.get(key)
# Metadatei zurückschreiben
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
# Nur die neuen Felder zurückgeben (None als leeren String konvertieren)
result = {key: (meta.get(key) or "") for key in fields}
try:
print(f"[generate_details] Stored meta for {obj_id}: {json.dumps(result, ensure_ascii=False)}")
except Exception:
print(f"[generate_details] Stored meta for {obj_id}: {result!r}")
return jsonify(result)
def load_sentences_for_object(obj_id: str):
"""
Lädt alle gespeicherten Sätze für ein Objekt aus sentence_object/<obj_id>.txt.
Rückgabe: Liste von Dicts mit mindestens question_en, answer_en, created_at.
"""
SENTENCE_DIR.mkdir(parents=True, exist_ok=True)
sentence_path = SENTENCE_DIR / f"{obj_id}.txt"
if not sentence_path.exists():
return []
try:
data = json.loads(sentence_path.read_text(encoding="utf-8"))
if isinstance(data, list):
return data
if isinstance(data, dict):
# Fallback: einzelnes Objekt in Liste wrappen
return [data]
return []
except Exception:
return []
@app.route("/api/object/<obj_id>/sentences", methods=["GET"])
def get_object_sentences(obj_id: str):
"""
Gibt alle bisher erzeugten KI-Sätze zu einem Objekt zurück.
"""
sentences = load_sentences_for_object(obj_id)
return jsonify({"object_id": obj_id, "sentences": sentences})
@app.route("/api/object/<obj_id>/generate_sentence", methods=["POST"])
def generate_object_sentence(obj_id: str):
"""
Erzeugt mit Llama 3.1 einen neuen englischen Frage-Antwort-Satz
zu einem Objekt basierend auf den KI-Details und bisherigen Sätzen.
Speichert alle Sätze in sentence_object/<obj_id>.txt.
"""
# Objekt-Metadaten laden (inkl. KI-Details)
meta_path = OBJECTS_DIR / f"{obj_id}.txt"
if not meta_path.exists():
return jsonify({"error": "Object not found"}), 404
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
return jsonify({"error": "Could not read meta file"}), 500
# Relevante Felder für den Satz (KI-Details)
details = {
"label_en": meta.get("label_en"),
"label_de": meta.get("label_de"),
"label_se": meta.get("label_se"),
"color_en": meta.get("color_en"),
"adjective_en": meta.get("adjective_en"),
"action_verb_en": meta.get("action_verb_en"),
"preposition_en": meta.get("preposition_en"),
"relative_position_en": meta.get("relative_position_en"),
"season_en": meta.get("season_en"),
"title_de": meta.get("title_de"),
"position_de": meta.get("position_de"),
"action_de": meta.get("action_de"),
"condition_de": meta.get("condition_de"),
}
# Bisherige Sätze laden
previous_sentences = load_sentences_for_object(obj_id)
# Prompt laden
prompt_template = read_prompt(
PROMPTS_DIR / "create_sentence.txt",
"Create one new English question_en and answer_en as JSON, based on the object details and avoiding previous sentences.",
)
# Vollständigen Prompt zusammensetzen
try:
details_json = json.dumps(details, ensure_ascii=False, indent=2)
prev_json = json.dumps(previous_sentences, ensure_ascii=False, indent=2)
except Exception:
details_json = str(details)
prev_json = str(previous_sentences)
prompt = (
f"{prompt_template}\n\n"
f"OBJECT_DETAILS_JSON:\n{details_json}\n\n"
f"PREVIOUS_SENTENCES_JSON:\n{prev_json}\n"
)
# LLM-Aufruf (nur Text, kein Bild nötig)
try:
response = ollama.chat(
model="llama3.1",
messages=[{"role": "user", "content": prompt}],
format="json",
options={"temperature": 0.2},
)
except Exception as e:
return jsonify({"error": f"LLM request failed: {e}"}), 500
content = response.get("message", {}).get("content")
if not content:
return jsonify({"error": "Empty response from LLM"}), 500
try:
ai_data = json.loads(content)
except Exception:
# Fallback: erstes JSON-Objekt extrahieren
try:
start = content.index("{")
end = content.rindex("}") + 1
ai_data = json.loads(content[start:end])
except Exception:
return jsonify({"error": "Could not parse JSON from LLM response"}), 500
question_simple = (ai_data.get("question_simple_en") or "").strip()
answer_simple = (ai_data.get("answer_simple_en") or "").strip()
question_advanced = (ai_data.get("question_advanced_en") or "").strip()
answer_advanced = (ai_data.get("answer_advanced_en") or "").strip()
if not question_simple or not answer_simple or not question_advanced or not answer_advanced:
return jsonify({"error": "LLM response missing required fields (question_simple_en, answer_simple_en, question_advanced_en, answer_advanced_en)"}), 500
# Neuen Satz mit Timestamp anreichern
entry = {
"object_id": obj_id,
"created_at": datetime.now().isoformat(),
"question_simple_en": question_simple,
"answer_simple_en": answer_simple,
"question_advanced_en": question_advanced,
"answer_advanced_en": answer_advanced,
}
sentences = previous_sentences + [entry]
# In Datei speichern
SENTENCE_DIR.mkdir(parents=True, exist_ok=True)
sentence_path = SENTENCE_DIR / f"{obj_id}.txt"
sentence_path.write_text(json.dumps(sentences, ensure_ascii=False, indent=2), encoding="utf-8")
return jsonify({"object_id": obj_id, "sentence": entry, "count": len(sentences)})
@app.route("/api/image/save", methods=["POST"])
def save_image():
"""
Markiert ein Bild und alle zugehörigen Objekte als 'gespeichert',
indem der Dateiname um '_saved' erweitert wird und die Metadaten
der Objekte angepasst werden.
"""
data = request.get_json(force=True, silent=True) or {}
filename = data.get("filename")
if not filename:
return jsonify({"error": "Missing filename"}), 400
src_path = PICTURES_DIR / filename
if not src_path.exists():
return jsonify({"error": "Image not found"}), 404
if src_path.stem.endswith("_saved"):
return jsonify({"error": "Image already saved"}), 400
new_name = f"{src_path.stem}_saved{src_path.suffix}"
dst_path = PICTURES_DIR / new_name
# Bild umbenennen
src_path.rename(dst_path)
# Zugehörige Objekte aktualisieren
OBJECTS_DIR.mkdir(parents=True, exist_ok=True)
for meta_file in OBJECTS_DIR.glob("*.txt"):
try:
meta = json.loads(meta_file.read_text(encoding="utf-8"))
except Exception:
continue
if meta.get("source_filename") != filename:
continue
meta["source_filename"] = new_name
meta_file.write_text(
json.dumps(meta, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return jsonify({"old_name": filename, "new_name": new_name})
@app.route("/api/crop", methods=["POST"])
def crop_image():
"""
Erwartet JSON:
{
"filename": "bild.jpg",
"selections": [
{
"number": 1,
"mode": "rect" | "polygon",
"bbox": {"x": 10, "y": 20, "width": 200, "height": 150}, // für rect
"polygon": [{"x": 10, "y": 20}, ...] // für polygon
},
...
],
"title_de": "...",
"position_de": "...",
"action_de": "...",
"condition_de": "..."
}
ODER (Legacy-Format für Kompatibilität):
{
"filename": "bild.jpg",
"mode": "rect" | "polygon",
"x": 10, "y": 20, "width": 200, "height": 150, // für rect
"polygon": [{"x": 10, "y": 20}, ...] // für polygon
}
"""
data = request.get_json(force=True, silent=True) or {}
if "filename" not in data:
return jsonify({"error": "Missing filename"}), 400
src_path = PICTURES_DIR / data["filename"]
if src_path.stem.endswith("_saved"):
return jsonify({"error": "Cannot crop on saved image"}), 400
if not src_path.exists():
return jsonify({"error": "Source image not found"}), 404
OBJECTS_DIR.mkdir(parents=True, exist_ok=True)
# UUID für Bild + Metadaten
obj_id = uuid4().hex
timestamp = datetime.now().isoformat()
out_image_name = f"{obj_id}{src_path.suffix}"
out_image_path = OBJECTS_DIR / out_image_name
out_meta_path = OBJECTS_DIR / f"{obj_id}.txt"
# Prüfen, ob neues Format (selections) oder Legacy-Format
selections = data.get("selections")
if selections and isinstance(selections, list) and len(selections) > 0:
# Neues Format: mehrere Auswahlen
processed_selections = []
with Image.open(src_path) as img:
img_w, img_h = img.size
for sel in selections:
sel_mode = sel.get("mode")
if sel_mode == "rect":
bbox = sel.get("bbox")
if not bbox:
continue
try:
x = int(bbox.get("x", 0))
y = int(bbox.get("y", 0))
w = int(bbox.get("width", 0))
h = int(bbox.get("height", 0))
except (ValueError, TypeError):
continue
if w <= 0 or h <= 0:
continue
x2 = min(x + w, img_w)
y2 = min(y + h, img_h)
x1 = max(0, x)
y1 = max(0, y)
if x1 >= x2 or y1 >= y2:
continue
processed_selections.append({
"number": sel.get("number", len(processed_selections) + 1),
"mode": "rect",
"bbox": {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1},
})
elif sel_mode == "polygon":
polygon = sel.get("polygon")
if not isinstance(polygon, list) or len(polygon) < 3:
continue
try:
xs = [int(p.get("x", 0)) for p in polygon]
ys = [int(p.get("y", 0)) for p in polygon]
except (KeyError, TypeError, ValueError):
continue
min_x = max(min(xs), 0)
min_y = max(min(ys), 0)
max_x = min(max(xs), img_w)
max_y = min(max(ys), img_h)
if min_x >= max_x or min_y >= max_y:
continue
processed_selections.append({
"number": sel.get("number", len(processed_selections) + 1),
"mode": "polygon",
"polygon": polygon,
"bbox": {"x": min_x, "y": min_y, "width": max_x - min_x, "height": max_y - min_y},
})
if not processed_selections:
return jsonify({"error": "No valid selections provided"}), 400
# Erstes Bild aus erster Auswahl erstellen
first_sel = processed_selections[0]
try:
with Image.open(src_path) as img:
if first_sel["mode"] == "rect":
bbox = first_sel["bbox"]
x1 = bbox["x"]
y1 = bbox["y"]
x2 = x1 + bbox["width"]
y2 = y1 + bbox["height"]
cropped = img.crop((x1, y1, x2, y2))
else: # polygon - verwende Bounding-Box für das Bild
bbox = first_sel["bbox"]
x1 = bbox["x"]
y1 = bbox["y"]
x2 = x1 + bbox["width"]
y2 = y1 + bbox["height"]
cropped = img.crop((x1, y1, x2, y2))
# Bild speichern (Format beibehalten)
img_format = img.format or "PNG"
cropped.save(out_image_path, format=img_format)
print(f"[crop_image] Bild gespeichert: {out_image_path} (Größe: {cropped.size}, Format: {img_format})")
except Exception as e:
print(f"[crop_image] Fehler beim Erstellen des Bildes: {e}")
return jsonify({"error": f"Failed to create image: {e}"}), 500
# Metadaten mit allen Auswahlen speichern
meta = {
"id": obj_id,
"created_at": timestamp,
"source_filename": data["filename"],
"mode": processed_selections[0]["mode"], # Legacy-Feld
"bbox": processed_selections[0]["bbox"], # Legacy-Feld
"image_file": out_image_name,
"hierarchy": 1,
"parent_id": None,
"title_de": data.get("title_de", ""),
"position_de": data.get("position_de", ""),
"action_de": data.get("action_de", ""),
"condition_de": data.get("condition_de", ""),
"selections": processed_selections, # Neue Feld: alle Auswahlen nummeriert
}
if processed_selections[0]["mode"] == "polygon":
meta["polygon"] = processed_selections[0]["polygon"] # Legacy-Feld
else:
# Legacy-Format: einzelne Auswahl (für Rückwärtskompatibilität)
mode = data.get("mode", "rect")
if mode not in {"rect", "polygon"}:
return jsonify({"error": "Invalid mode"}), 400
with Image.open(src_path) as img:
img_w, img_h = img.size
if mode == "rect":
try:
x = int(data["x"])
y = int(data["y"])
w = int(data["width"])
h = int(data["height"])
except (ValueError, TypeError, KeyError):
return jsonify({"error": "Invalid rectangle coordinates"}), 400
if w <= 0 or h <= 0:
return jsonify({"error": "Width and height must be positive"}), 400
x2 = min(x + w, img_w)
y2 = min(y + h, img_h)
x1 = max(0, x)
y1 = max(0, y)
if x1 >= x2 or y1 >= y2:
return jsonify({"error": "Crop area outside of image"}), 400
cropped = img.crop((x1, y1, x2, y2))
bbox = {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1}
else: # polygon
polygon = data.get("polygon") or []
if not isinstance(polygon, list) or len(polygon) < 3:
return jsonify({"error": "Polygon must have at least 3 points"}), 400
try:
xs = [int(p["x"]) for p in polygon]
ys = [int(p["y"]) for p in polygon]
except (KeyError, TypeError, ValueError):
return jsonify({"error": "Invalid polygon coordinates"}), 400
min_x = max(min(xs), 0)
min_y = max(min(ys), 0)
max_x = min(max(xs), img_w)
max_y = min(max(ys), img_h)
if min_x >= max_x or min_y >= max_y:
return jsonify({"error": "Polygon bounding box outside of image"}), 400
cropped = img.crop((min_x, min_y, max_x, max_y))
bbox = {
"x": min_x,
"y": min_y,
"width": max_x - min_x,
"height": max_y - min_y,
}
cropped.save(out_image_path)
# Metadaten als JSON in .txt speichern
meta = {
"id": obj_id,
"created_at": timestamp,
"source_filename": data["filename"],
"mode": mode,
"bbox": bbox,
"image_file": out_image_name,
"hierarchy": 1,
"parent_id": None,
"title_de": data.get("title_de", ""),
"position_de": data.get("position_de", ""),
"action_de": data.get("action_de", ""),
"condition_de": data.get("condition_de", ""),
}
if mode == "polygon":
meta["polygon"] = data.get("polygon")
out_meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return jsonify(
{
"id": obj_id,
"image_file": out_image_name,
"meta_file": out_meta_path.name,
}
)
if __name__ == "__main__":
# Du kannst den Port hier anpassen, z.B. 8000 oder 5000.
# Für externen Zugriff später einfach in Nginx/Reverse Proxy weiterleiten.
app.run(host="0.0.0.0", port=8000, debug=True)

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Content Mentor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1771
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "content-mentor-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.4.1"
}
}

13
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import DrawIt from './pages/DrawIt'
import GenerateIt from './pages/GenerateIt'
export default function App() {
return (
<Routes>
<Route path="/" element={<Navigate to="/draw" replace />} />
<Route path="/draw" element={<DrawIt />} />
<Route path="/generate" element={<GenerateIt />} />
</Routes>
)
}

106
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,106 @@
import type { ObjectMeta, Sentence } from './types'
export async function getImages(mode: 'draw' | 'generate'): Promise<string[]> {
const res = await fetch(`/api/images?mode=${mode}`)
if (!res.ok) throw new Error('Fehler beim Laden der Bilder')
const data = await res.json()
return data.images as string[]
}
export async function getObjects(filename: string): Promise<ObjectMeta[]> {
const res = await fetch(`/api/objects?filename=${encodeURIComponent(filename)}`)
if (!res.ok) throw new Error('Fehler beim Laden der Objekte')
const data = await res.json()
return (data.objects || []) as ObjectMeta[]
}
export async function cropImage(payload: {
filename: string
selections: Array<{
number: number
mode: string
bbox?: { x: number; y: number; width: number; height: number } | null
polygon?: Array<{ x: number; y: number }> | null
}>
title_de: string
position_de: string
action_de: string
condition_de: string
}): Promise<{ id: string; image_file: string; meta_file: string }> {
const res = await fetch('/api/crop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler beim Speichern des Ausschnitts')
return data
}
export async function saveImage(filename: string): Promise<{ old_name: string; new_name: string }> {
const res = await fetch('/api/image/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler beim Speichern des Bildes')
return data
}
export async function updateObjectMeta(
objId: string,
meta: { title_de: string; position_de: string; action_de: string; condition_de: string }
): Promise<void> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Metadaten')
}
export async function updateHierarchy(objId: string, hierarchy: number): Promise<void> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/hierarchy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hierarchy }),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Hierarchie')
}
export async function updateParent(objId: string, parentId: string | null): Promise<void> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/parent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_id: parentId }),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Parent-Relation')
}
export async function generateDetails(objId: string): Promise<Partial<ObjectMeta>> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/generate_details`, {
method: 'POST',
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler bei KI-Details')
return data
}
export async function getSentences(objId: string): Promise<Sentence[]> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/sentences`)
if (!res.ok) throw new Error('Fehler beim Laden der Sätze')
const data = await res.json()
return (data.sentences || []) as Sentence[]
}
export async function generateSentence(
objId: string
): Promise<{ sentence: Sentence; count: number }> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/generate_sentence`, {
method: 'POST',
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler bei KI-Sentence')
return data
}

View File

@@ -0,0 +1,71 @@
import type { ObjectMeta, Sentence } from '../types'
interface Props {
obj: ObjectMeta | null
objects: ObjectMeta[]
sentences: Sentence[]
}
function DetailRow({ label, value }: { label: string; value?: string }) {
return (
<div className="sidebar-row">
<label>{label}</label>
<div className="detail-value">{value || ''}</div>
</div>
)
}
export default function DetailsPanel({ obj, objects, sentences }: Props) {
const latestSentence = sentences.length > 0 ? sentences[sentences.length - 1] : null
const parentDisplay = obj?.parent_id
? (() => {
const parent = objects.find(o => o.id === obj.parent_id)
return parent ? `${parent.index} - ${parent.title_de || 'ohne Titel'}` : ''
})()
: ''
return (
<>
<div className="sidebar-section">
<h2>Details</h2>
<DetailRow label="Titel" value={obj?.title_de} />
<DetailRow label="Position" value={obj?.position_de} />
<DetailRow label="Status (sitzt/schwimmt/segelt)" value={obj?.action_de} />
<DetailRow label="Zustand (alt/jung/rostig)" value={obj?.condition_de} />
<DetailRow label="Hierarchie" value={obj?.hierarchy != null ? String(obj.hierarchy) : ''} />
<div className="sidebar-row">
<label>Gehört zu (Parent-Index)</label>
<div className="detail-value">{parentDisplay}</div>
</div>
</div>
<div className="sidebar-section">
<h2>KI-Details</h2>
<DetailRow label="Label (EN)" value={obj?.label_en} />
<DetailRow label="Label (DE)" value={obj?.label_de} />
<DetailRow label="Label (SE)" value={obj?.label_se} />
<DetailRow label="Farbe (EN)" value={obj?.color_en} />
<DetailRow label="Adjektiv (EN)" value={obj?.adjective_en} />
<DetailRow label="Action Verb (EN)" value={obj?.action_verb_en} />
<DetailRow label="Präposition (EN)" value={obj?.preposition_en} />
<DetailRow label="Relative Position (EN)" value={obj?.relative_position_en} />
<DetailRow label="Season (EN)" value={obj?.season_en} />
</div>
<div className="sidebar-section">
<h2>KI-Sentence</h2>
<h3 style={{ fontSize: '0.9rem', marginTop: 0, marginBottom: 8, color: '#475569' }}>
Einfach (für Kinder)
</h3>
<DetailRow label="Question (EN)" value={latestSentence?.question_simple_en} />
<DetailRow label="Answer (EN)" value={latestSentence?.answer_simple_en} />
<h3 style={{ fontSize: '0.9rem', marginTop: 12, marginBottom: 8, color: '#475569' }}>
Fortgeschritten
</h3>
<DetailRow label="Question (EN)" value={latestSentence?.question_advanced_en} />
<DetailRow label="Answer (EN)" value={latestSentence?.answer_advanced_en} />
</div>
</>
)
}

View File

@@ -0,0 +1,338 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from 'react'
import type { ObjectMeta, Point, Selection } from '../types'
export interface DrawCanvasHandle {
getCurrentSelection: () => Selection | null
resetSelection: () => void
}
interface Props {
imageSrc: string | null
objects: ObjectMeta[]
selectedObjectId: string | null
mode: 'rect' | 'polygon'
onHasSelection: (has: boolean) => void
}
export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
{ imageSrc, objects, selectedObjectId, mode, onHasSelection },
ref
) {
const canvasRef = useRef<HTMLCanvasElement>(null)
// Drawing state in refs — no re-renders needed
const isDragging = useRef(false)
const startXY = useRef({ x: 0, y: 0 })
const currentXY = useRef({ x: 0, y: 0 })
const polygonPoints = useRef<Point[]>([])
const isPolygonClosed = useRef(false)
const displayScale = useRef(1)
const imageRef = useRef<HTMLImageElement | null>(null)
// Keep latest props accessible from stable callbacks
const modeRef = useRef(mode)
const objectsRef = useRef(objects)
const selectedObjectIdRef = useRef(selectedObjectId)
const onHasSelectionRef = useRef(onHasSelection)
useEffect(() => { modeRef.current = mode }, [mode])
useEffect(() => { objectsRef.current = objects }, [objects])
useEffect(() => { selectedObjectIdRef.current = selectedObjectId }, [selectedObjectId])
useEffect(() => { onHasSelectionRef.current = onHasSelection }, [onHasSelection])
const redraw = useCallback(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const img = imageRef.current
if (img) ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
const scale = displayScale.current
const selectedId = selectedObjectIdRef.current
// Draw saved objects
for (const obj of objectsRef.current) {
if (obj.visible === false) continue
const isSelected = obj.id === selectedId
const h = obj.hierarchy || 1
let stroke = '#14532d'
let fill = 'rgba(20, 83, 45, 0.2)'
if (h === 1) { stroke = '#6b7280'; fill = 'rgba(107, 114, 128, 0.2)' }
else if (h === 2) { stroke = '#eab308'; fill = 'rgba(234, 179, 8, 0.3)' }
else if (h === 3) { stroke = '#dc2626'; fill = 'rgba(220, 38, 38, 0.3)' }
ctx.save()
ctx.strokeStyle = stroke
ctx.fillStyle = fill
ctx.lineWidth = isSelected ? 3 : 2
ctx.setLineDash(isSelected ? [2, 2] : [4, 3])
const { polygon, bbox } = obj
if (polygon && polygon.length >= 3) {
ctx.beginPath()
ctx.moveTo(polygon[0].x * scale, polygon[0].y * scale)
for (let i = 1; i < polygon.length; i++) ctx.lineTo(polygon[i].x * scale, polygon[i].y * scale)
ctx.closePath()
ctx.fill()
ctx.stroke()
} else if (bbox) {
ctx.fillRect(bbox.x * scale, bbox.y * scale, bbox.width * scale, bbox.height * scale)
ctx.strokeRect(bbox.x * scale, bbox.y * scale, bbox.width * scale, bbox.height * scale)
}
// White highlight ring for selected object
if (isSelected) {
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 2
ctx.setLineDash([])
if (polygon && polygon.length >= 3) {
ctx.beginPath()
ctx.moveTo(polygon[0].x * scale, polygon[0].y * scale)
for (let i = 1; i < polygon.length; i++) ctx.lineTo(polygon[i].x * scale, polygon[i].y * scale)
ctx.closePath()
ctx.stroke()
} else if (bbox) {
ctx.strokeRect(bbox.x * scale, bbox.y * scale, bbox.width * scale, bbox.height * scale)
}
}
// Index number in object center
const indexLabel = typeof obj.index === 'number' ? String(obj.index) : ''
if (indexLabel) {
let cx = 0, cy = 0
if (bbox) {
cx = (bbox.x + bbox.width / 2) * scale
cy = (bbox.y + bbox.height / 2) * scale
} else if (polygon && polygon.length > 0) {
const xs = polygon.map(p => p.x)
const ys = polygon.map(p => p.y)
cx = ((Math.min(...xs) + Math.max(...xs)) / 2) * scale
cy = ((Math.min(...ys) + Math.max(...ys)) / 2) * scale
}
ctx.save()
ctx.font = "bold 12px system-ui, -apple-system, sans-serif"
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = 'rgba(15, 23, 42, 0.7)'
ctx.beginPath()
ctx.arc(cx, cy, 10, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.fillText(indexLabel, cx, cy + 0.5)
ctx.restore()
}
ctx.restore()
}
// Draw current in-progress selection
const m = modeRef.current
const { x: sx, y: sy } = startXY.current
const { x: ex, y: ey } = currentXY.current
if (m === 'rect' && (isDragging.current || sx !== ex || sy !== ey)) {
const x = Math.min(sx, ex)
const y = Math.min(sy, ey)
const w = Math.abs(ex - sx)
const h2 = Math.abs(ey - sy)
if (w > 0 && h2 > 0) {
ctx.save()
ctx.strokeStyle = '#f97316'
ctx.fillStyle = 'rgba(249, 115, 22, 0.3)'
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.fillRect(x, y, w, h2)
ctx.strokeRect(x, y, w, h2)
ctx.restore()
}
} else if (m === 'polygon' && polygonPoints.current.length > 0) {
ctx.save()
ctx.strokeStyle = '#f97316'
ctx.fillStyle = 'rgba(249, 115, 22, 0.3)'
ctx.lineWidth = 2
ctx.setLineDash([])
const pts = polygonPoints.current
ctx.beginPath()
ctx.moveTo(pts[0].x, pts[0].y)
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y)
if (!isPolygonClosed.current && isDragging.current) ctx.lineTo(ex, ey)
if (isPolygonClosed.current) { ctx.closePath(); ctx.fill() }
ctx.stroke()
for (const p of pts) {
ctx.beginPath()
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
ctx.fillStyle = '#ea580c'
ctx.fill()
}
ctx.restore()
}
}, [])
useImperativeHandle(ref, () => ({
getCurrentSelection(): Selection | null {
const scale = displayScale.current
if (modeRef.current === 'rect') {
const { x: sx, y: sy } = startXY.current
const { x: ex, y: ey } = currentXY.current
const w = Math.abs(ex - sx)
const h = Math.abs(ey - sy)
if (w <= 0 || h <= 0) return null
return {
mode: 'rect',
bbox: {
x: Math.round(Math.min(sx, ex) / scale),
y: Math.round(Math.min(sy, ey) / scale),
width: Math.round(w / scale),
height: Math.round(h / scale),
},
}
} else {
if (!isPolygonClosed.current || polygonPoints.current.length < 3) return null
return {
mode: 'polygon',
polygon: polygonPoints.current.map(p => ({
x: Math.round(p.x / scale),
y: Math.round(p.y / scale),
})),
}
}
},
resetSelection() {
isDragging.current = false
startXY.current = { x: 0, y: 0 }
currentXY.current = { x: 0, y: 0 }
polygonPoints.current = []
isPolygonClosed.current = false
onHasSelectionRef.current(false)
redraw()
},
}), [redraw])
// Reset drawing when mode changes
useEffect(() => {
modeRef.current = mode
isDragging.current = false
startXY.current = { x: 0, y: 0 }
currentXY.current = { x: 0, y: 0 }
polygonPoints.current = []
isPolygonClosed.current = false
onHasSelectionRef.current(false)
redraw()
}, [mode, redraw])
// Load image when src changes
useEffect(() => {
if (!imageSrc) {
imageRef.current = null
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height)
return
}
const img = new Image()
img.onload = () => {
const canvas = canvasRef.current
if (!canvas) return
imageRef.current = img
const maxW = (canvas.parentElement?.clientWidth ?? 816) - 16
const maxH = window.innerHeight * 0.7
const scale = Math.min(maxW / img.width, maxH / img.height, 1)
displayScale.current = isFinite(scale) && scale > 0 ? scale : 1
canvas.width = img.width * displayScale.current
canvas.height = img.height * displayScale.current
redraw()
}
img.onerror = () => console.error('Fehler beim Laden des Bildes:', imageSrc)
img.src = imageSrc
}, [imageSrc, redraw])
// Redraw when objects or selection changes
useEffect(() => {
redraw()
}, [objects, selectedObjectId, redraw])
// Mouse event handlers
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const getPos = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect()
return {
x: (e.clientX - rect.left) * (canvas.width / rect.width),
y: (e.clientY - rect.top) * (canvas.height / rect.height),
}
}
const onMouseDown = (e: MouseEvent) => {
if (!imageRef.current) return
const { x, y } = getPos(e)
if (modeRef.current === 'rect') {
startXY.current = { x, y }
currentXY.current = { x, y }
isDragging.current = true
} else {
if (isPolygonClosed.current) {
polygonPoints.current = []
isPolygonClosed.current = false
onHasSelectionRef.current(false)
}
polygonPoints.current.push({ x, y })
isDragging.current = true
}
redraw()
}
const onMouseMove = (e: MouseEvent) => {
if (!imageRef.current || !isDragging.current) return
currentXY.current = getPos(e)
redraw()
}
const onMouseUp = (e: MouseEvent) => {
if (!imageRef.current) return
isDragging.current = false
if (modeRef.current === 'rect') {
const w = Math.abs(currentXY.current.x - startXY.current.x)
const h = Math.abs(currentXY.current.y - startXY.current.y)
onHasSelectionRef.current(w > 0 && h > 0)
} else {
if (e.detail === 2 && polygonPoints.current.length >= 3) {
isPolygonClosed.current = true
onHasSelectionRef.current(true)
}
}
redraw()
}
const onMouseLeave = () => {
if (isDragging.current) {
isDragging.current = false
redraw()
}
}
canvas.addEventListener('mousedown', onMouseDown)
canvas.addEventListener('mousemove', onMouseMove)
canvas.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('mouseleave', onMouseLeave)
return () => {
canvas.removeEventListener('mousedown', onMouseDown)
canvas.removeEventListener('mousemove', onMouseMove)
canvas.removeEventListener('mouseup', onMouseUp)
canvas.removeEventListener('mouseleave', onMouseLeave)
}
}, [redraw])
return <canvas ref={canvasRef} style={{ display: 'block', maxWidth: '100%', height: 'auto' }} />
})

View File

@@ -0,0 +1,181 @@
import { useState, useEffect } from 'react'
import { updateHierarchy, updateObjectMeta, updateParent } from '../api'
import type { ObjectMeta } from '../types'
interface Props {
objects: ObjectMeta[]
selectedObjectId: string | null
onSelect: (id: string) => void
onVisibilityChange?: (id: string, visible: boolean) => void
onObjectsChange: (objects: ObjectMeta[]) => void
isGeneratePage: boolean
onShowDetails?: (obj: ObjectMeta) => void
onLoadSentences?: (objId: string) => void
}
type EditForm = { title_de: string; position_de: string; action_de: string; condition_de: string }
export default function ObjectsList({
objects,
selectedObjectId,
onSelect,
onVisibilityChange,
onObjectsChange,
isGeneratePage,
onShowDetails,
onLoadSentences,
}: Props) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const [editForms, setEditForms] = useState<Record<string, EditForm>>({})
useEffect(() => {
const forms: Record<string, EditForm> = {}
for (const obj of objects) {
forms[obj.id] = {
title_de: obj.title_de || '',
position_de: obj.position_de || '',
action_de: obj.action_de || '',
condition_de: obj.condition_de || '',
}
}
setEditForms(forms)
}, [objects])
const handleHierarchyChange = async (obj: ObjectMeta, value: number) => {
onObjectsChange(objects.map(o => o.id === obj.id ? { ...o, hierarchy: value } : o))
try { await updateHierarchy(obj.id, value) } catch (e) { console.error(e) }
}
const handleParentChange = async (obj: ObjectMeta, parentId: string | null) => {
onObjectsChange(objects.map(o => o.id === obj.id ? { ...o, parent_id: parentId } : o))
try { await updateParent(obj.id, parentId) } catch (e) { console.error(e) }
}
const handleSaveMeta = async (obj: ObjectMeta) => {
const form = editForms[obj.id]
if (!form) return
try {
await updateObjectMeta(obj.id, form)
onObjectsChange(objects.map(o => o.id === obj.id ? { ...o, ...form } : o))
setExpandedId(null)
} catch (e) { console.error(e) }
}
if (objects.length === 0) {
return (
<div className="objects-list">
<div className="object-item-text">Noch keine Objekte gespeichert.</div>
</div>
)
}
return (
<>
<div className="objects-list">
{objects.map(obj => (
<div
key={obj.id}
className="object-item"
style={obj.id === selectedObjectId ? { borderColor: '#2563eb' } : undefined}
onClick={() => {
onSelect(obj.id)
if (isGeneratePage) {
onShowDetails?.(obj)
onLoadSentences?.(obj.id)
}
}}
>
<div className="object-item-header">
{!isGeneratePage && (
<input
type="checkbox"
checked={obj.visible !== false}
onClick={e => e.stopPropagation()}
onChange={e => onVisibilityChange?.(obj.id, e.target.checked)}
/>
)}
{obj.image_file && (
<img
src={`/objects_image/${encodeURIComponent(obj.image_file)}`}
alt={obj.title_de || obj.id}
/>
)}
<select
className="object-hierarchy-select"
value={obj.hierarchy || 1}
onClick={e => e.stopPropagation()}
onChange={e => handleHierarchyChange(obj, parseInt(e.target.value))}
>
{[1, 2, 3].map(l => <option key={l} value={l}>{l}</option>)}
</select>
<select
className="object-parent-select"
value={obj.parent_id || ''}
onClick={e => e.stopPropagation()}
onChange={e => handleParentChange(obj, e.target.value || null)}
>
<option value="">-</option>
{objects
.filter(o => o.id !== obj.id)
.map(o => (
<option key={o.id} value={o.id}>{o.index}</option>
))}
</select>
<div className="object-item-text">
<strong>{obj.title_de || obj.id || 'Ohne Titel'}</strong>
{obj.position_de && <span>{obj.position_de}</span>}
</div>
{!isGeneratePage && (
<button
type="button"
className="object-icon-button"
onClick={e => {
e.stopPropagation()
setExpandedId(expandedId === obj.id ? null : obj.id)
}}
>
📝
</button>
)}
</div>
{!isGeneratePage && expandedId === obj.id && (
<div className="object-item-details visible">
{(['title_de', 'position_de', 'action_de', 'condition_de'] as const).map(key => (
<div key={key}>
<label style={{ fontSize: '0.75rem' }}>{key}</label>
<input
type="text"
value={editForms[obj.id]?.[key] || ''}
onClick={e => e.stopPropagation()}
onChange={e =>
setEditForms(f => ({
...f,
[obj.id]: { ...f[obj.id], [key]: e.target.value },
}))
}
/>
</div>
))}
<button
type="button"
className="object-icon-button"
onClick={e => { e.stopPropagation(); handleSaveMeta(obj) }}
>
💾
</button>
</div>
)}
</div>
))}
</div>
<div className="objects-tags">
{objects.map(obj => (
<span key={obj.id} className="object-tag">
{obj.title_de || obj.id || 'Ohne Titel'}
</span>
))}
</div>
</>
)
}

View File

@@ -0,0 +1,34 @@
import type { Sentence } from '../types'
interface Props {
sentences: Sentence[]
}
export default function SentencesList({ sentences }: Props) {
if (sentences.length === 0) {
return (
<div className="sentences-list">
<div className="sentence-item-empty">Noch keine Sätze vorhanden.</div>
</div>
)
}
return (
<div className="sentences-list">
{[...sentences].reverse().map((s, i) => (
<div className="sentence-item" key={i}>
<div style={{ fontWeight: 600, color: '#1e40af', marginBottom: 4, fontSize: '0.85rem' }}>
Einfach:
</div>
<div className="sentence-item-question">{s.question_simple_en}</div>
<div className="sentence-item-answer">{s.answer_simple_en}</div>
<div style={{ fontWeight: 600, color: '#1e40af', marginTop: 8, marginBottom: 4, fontSize: '0.85rem' }}>
Fortgeschritten:
</div>
<div className="sentence-item-question">{s.question_advanced_en}</div>
<div className="sentence-item-answer">{s.answer_advanced_en}</div>
</div>
))}
</div>
)
}

382
frontend/src/index.css Normal file
View File

@@ -0,0 +1,382 @@
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 0;
background: #f5f7fb;
color: #222;
}
.container {
max-width: 1180px;
margin: 24px auto;
padding: 16px 20px 32px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
h1 {
margin-top: 0;
margin-bottom: 16px;
font-size: 1.6rem;
}
label {
font-weight: 500;
}
.panel {
margin: 12px 0;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.image-nav {
justify-content: space-between;
gap: 8px;
}
.image-nav button {
padding: 4px 10px;
border-radius: 999px;
}
.image-nav-left {
display: flex;
align-items: center;
gap: 8px;
}
.page-switch select {
padding: 4px 8px;
border-radius: 999px;
border: 1px solid #cbd5f0;
background: #f9fbff;
font-size: 0.9rem;
min-width: unset;
}
.main-layout {
display: flex;
gap: 16px;
align-items: flex-start;
}
.objects-pane {
flex: 0 0 260px;
}
.left-pane {
flex: 1 1 auto;
}
.right-pane {
flex: 0 0 280px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sentences-pane {
flex: 0 0 300px;
display: flex;
flex-direction: column;
gap: 12px;
}
.mode-option {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 400;
}
select {
min-width: 220px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid #cbd5f0;
background: #f9fbff;
}
.canvas-wrapper {
border-radius: 12px;
border: 1px solid #d4ddf5;
background: #f1f5ff;
overflow: visible;
padding: 8px;
}
canvas {
display: block;
max-width: 100%;
height: auto;
}
button {
padding: 8px 14px;
border-radius: 999px;
border: none;
background: #2563eb;
color: white;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 10px rgba(37, 99, 235, 0.4);
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
button:disabled {
background: #9ca3af;
cursor: not-allowed;
box-shadow: none;
}
button:not(:disabled):hover {
background: #1d4ed8;
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.5);
transform: translateY(-1px);
}
.status {
font-size: 0.9rem;
}
.status.ok {
color: #16a34a;
}
.status.error {
color: #dc2626;
}
.sidebar-section {
border-radius: 10px;
border: 1px solid #e5e7eb;
padding: 10px 12px;
background: #f9fafb;
}
.sidebar-section h2 {
font-size: 1rem;
margin: 0 0 6px;
}
.sidebar-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.sidebar-row input[type="text"] {
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.9rem;
}
.detail-value {
padding: 4px 6px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #f9fafb;
font-size: 0.9rem;
min-height: 24px;
}
.objects-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 260px;
overflow-y: auto;
padding-right: 4px;
}
.objects-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.object-tag {
padding: 2px 6px;
border-radius: 999px;
background: #e5e7eb;
font-size: 0.75rem;
white-space: nowrap;
}
.object-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #ffffff;
cursor: pointer;
}
.object-item-header {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
}
.object-hierarchy-select {
width: 40px;
min-width: 0;
padding: 2px 3px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.8rem;
background: #f9fafb;
}
.object-parent-select {
width: 50px;
min-width: 0;
padding: 2px 3px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.8rem;
background: #eef2ff;
}
.object-item img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.object-item-text {
display: flex;
flex-direction: column;
font-size: 0.8rem;
flex: 1;
min-width: 0;
}
.object-item-text strong {
font-size: 0.85rem;
}
.object-item-details {
padding-left: 24px;
display: none;
flex-direction: column;
gap: 4px;
margin-top: 4px;
}
.object-item-details.visible {
display: flex;
}
.object-item-details input[type="text"] {
padding: 4px 6px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.8rem;
}
.object-item-details label {
font-size: 0.75rem;
}
.object-icon-button {
padding: 2px 6px;
border-radius: 6px;
font-size: 0.85rem;
box-shadow: none;
background: #e5e7eb;
color: #374151;
flex-shrink: 0;
}
.object-icon-button:not(:disabled):hover {
background: #d1d5db;
box-shadow: none;
transform: none;
}
.sentences-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 70vh;
overflow-y: auto;
padding: 4px;
}
.sentence-item {
padding: 12px;
background: #f9fbff;
border: 1px solid #d4ddf5;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sentence-item-question {
font-weight: 600;
color: #1e40af;
font-size: 0.95rem;
}
.sentence-item-answer {
color: #475569;
font-size: 0.9rem;
padding-left: 12px;
border-left: 2px solid #cbd5f0;
}
.sentence-item-empty {
padding: 24px;
text-align: center;
color: #94a3b8;
font-style: italic;
}
.selections-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding: 8px;
background: #f9fbff;
border: 1px solid #d4ddf5;
border-radius: 8px;
margin-bottom: 8px;
}
.selection-item {
padding: 8px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 0.85rem;
color: #475569;
}
.selection-item strong {
color: #1e40af;
font-weight: 600;
}
.selections-empty {
padding: 16px;
text-align: center;
color: #94a3b8;
font-style: italic;
font-size: 0.85rem;
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)

View File

@@ -0,0 +1,298 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import ObjectsList from '../components/ObjectsList'
import { getImages, getObjects, cropImage, saveImage } from '../api'
import type { ObjectMeta, Selection } from '../types'
const FIELD_LABELS: Record<string, string> = {
title_de: 'Titel / title_de',
position_de: 'Position / position_de',
action_de: 'Status (sitzt/schwimmt/segelt) / action_de',
condition_de: 'Zustand (alt/jung/rostig) / condition_de',
}
const FIELD_PLACEHOLDERS: Record<string, string> = {
action_de: 'z.B. sitzt',
condition_de: 'z.B. rostig',
}
type FormKey = 'title_de' | 'position_de' | 'action_de' | 'condition_de'
export default function DrawIt() {
const navigate = useNavigate()
const [imageList, setImageList] = useState<string[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [objects, setObjects] = useState<ObjectMeta[]>([])
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
const [status, setStatus] = useState('')
const [statusError, setStatusError] = useState(false)
const [mode, setMode] = useState<'rect' | 'polygon'>('rect')
const [hasSelection, setHasSelection] = useState(false)
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const [form, setForm] = useState<Record<FormKey, string>>({
title_de: '',
position_de: '',
action_de: '',
condition_de: '',
})
const canvasRef = useRef<DrawCanvasHandle>(null)
const currentFilename = currentIndex >= 0 && currentIndex < imageList.length
? imageList[currentIndex]
: null
useEffect(() => {
getImages('draw')
.then(imgs => {
setImageList(imgs)
setCurrentIndex(imgs.length - 1)
})
.catch(console.error)
}, [])
useEffect(() => {
if (!currentFilename) {
setObjects([])
setSelectedObjectId(null)
return
}
getObjects(currentFilename)
.then(objs => {
setObjects(objs.map(o => ({ ...o, visible: true })))
setSelectedObjectId(objs[0]?.id ?? null)
})
.catch(console.error)
}, [currentFilename])
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
const showStatus = (msg: string, isError = false) => {
setStatus(msg)
setStatusError(isError)
}
const addSelection = () => {
const sel = canvasRef.current?.getCurrentSelection()
if (!sel) {
showStatus('Bitte zuerst einen Bereich auswählen.', true)
return
}
setCurrentSelections(prev => {
const next = [...prev, sel]
showStatus(`Auswahl ${next.length} hinzugefügt.`)
return next
})
canvasRef.current?.resetSelection()
setHasSelection(false)
}
const saveObject = async () => {
if (!currentFilename || currentSelections.length === 0) return
try {
showStatus('Speichere Objekt...')
const result = await cropImage({
filename: currentFilename,
selections: currentSelections.map((sel, idx) => ({
number: idx + 1,
mode: sel.mode,
bbox: sel.bbox ?? null,
polygon: sel.polygon ?? null,
})),
...form,
})
showStatus(`Gespeichert ID: ${result.id} (${currentSelections.length} Auswahl(en))`)
setCurrentSelections([])
setForm({ title_de: '', position_de: '', action_de: '', condition_de: '' })
const objs = await getObjects(currentFilename)
setObjects(objs.map(o => ({ ...o, visible: true })))
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true)
}
}
const handleSaveImage = async () => {
if (!currentFilename) return
try {
showStatus('Bild wird gespeichert...')
await saveImage(currentFilename)
const imgs = await getImages('draw')
setImageList(imgs)
setCurrentIndex(imgs.length - 1)
showStatus('Bild gespeichert.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler.', true)
}
}
return (
<div className="container">
<h1>DrawIt</h1>
<div className="panel image-nav">
<div className="image-nav-left">
<button
onClick={() => setCurrentIndex(i => i - 1)}
disabled={currentIndex <= 0}
>
</button>
<span>Bild: <code>{currentFilename || ''}</code></span>
<button
onClick={() => setCurrentIndex(i => i + 1)}
disabled={currentIndex >= imageList.length - 1}
>
</button>
<button onClick={handleSaveImage} disabled={!currentFilename}>💾</button>
</div>
<div className="page-switch">
<select value="/draw" onChange={e => navigate(e.target.value)}>
<option value="/draw">DrawIt</option>
<option value="/generate">GenerateIt</option>
</select>
</div>
</div>
<div className="main-layout">
{/* Left: Objects */}
<div className="objects-pane sidebar-section">
<h2>Objekte zu diesem Bild</h2>
<ObjectsList
objects={objects}
selectedObjectId={selectedObjectId}
onSelect={setSelectedObjectId}
onVisibilityChange={(id, visible) =>
setObjects(prev => prev.map(o => o.id === id ? { ...o, visible } : o))
}
onObjectsChange={setObjects}
isGeneratePage={false}
/>
</div>
{/* Center: Canvas */}
<div className="left-pane">
<div className="canvas-wrapper">
<DrawCanvas
ref={canvasRef}
imageSrc={
currentFilename
? `/pictures/${encodeURIComponent(currentFilename)}`
: null
}
objects={objects}
selectedObjectId={selectedObjectId}
mode={mode}
onHasSelection={handleHasSelection}
/>
</div>
</div>
{/* Right: Controls */}
<div className="right-pane">
<div className="sidebar-section">
<h2>Auswahl</h2>
<div className="sidebar-row">
<span>Auswahl-Typ (Interface / Backend):</span>
</div>
<div className="sidebar-row">
<label className="mode-option">
<input
type="radio"
name="mode"
value="rect"
checked={mode === 'rect'}
onChange={() => setMode('rect')}
/>
Rechteck / <code>BBox</code>
</label>
</div>
<div className="sidebar-row">
<label className="mode-option">
<input
type="radio"
name="mode"
value="polygon"
checked={mode === 'polygon'}
onChange={() => setMode('polygon')}
/>
Polygon / <code>polygon</code>
</label>
</div>
<div className="sidebar-row">
<button type="button" onClick={() => canvasRef.current?.resetSelection()}>
Auswahl zurücksetzen
</button>
</div>
</div>
<div className="sidebar-section">
<h2>Metadaten</h2>
{(['title_de', 'position_de', 'action_de', 'condition_de'] as FormKey[]).map(key => (
<div className="sidebar-row" key={key}>
<label htmlFor={key}>{FIELD_LABELS[key]}</label>
<input
id={key}
type="text"
value={form[key]}
placeholder={FIELD_PLACEHOLDERS[key] || ''}
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
/>
</div>
))}
</div>
<div className="sidebar-section">
<h2>Auswahlen</h2>
<div className="selections-list">
{currentSelections.length === 0 ? (
<div className="selections-empty">Noch keine Auswahlen hinzugefügt.</div>
) : (
currentSelections.map((sel, i) => (
<div className="selection-item" key={i}>
<strong>Auswahl {i + 1}</strong> ({sel.mode === 'rect' ? 'Rechteck' : 'Polygon'}):
{sel.mode === 'rect' && sel.bbox
? ` x=${sel.bbox.x}, y=${sel.bbox.y}, w=${sel.bbox.width}, h=${sel.bbox.height}`
: ` ${sel.polygon?.length ?? 0} Punkte`}
</div>
))
)}
</div>
<div className="sidebar-row">
<button
onClick={addSelection}
disabled={!hasSelection || !currentFilename}
>
Auswahl hinzufügen
</button>
</div>
<div className="sidebar-row">
<button
onClick={saveObject}
disabled={!currentFilename || currentSelections.length === 0}
>
💾 Objekt speichern
</button>
</div>
<div className="sidebar-row">
<button
type="button"
onClick={() => {
setCurrentSelections([])
canvasRef.current?.resetSelection()
showStatus('Alle Auswahlen gelöscht.')
}}
>
🗑 Alle Auswahlen löschen
</button>
</div>
{status && (
<span className={`status ${statusError ? 'error' : 'ok'}`}>{status}</span>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import ObjectsList from '../components/ObjectsList'
import DetailsPanel from '../components/DetailsPanel'
import SentencesList from '../components/SentencesList'
import { getImages, getObjects, generateDetails, generateSentence, getSentences } from '../api'
import type { ObjectMeta, Sentence } from '../types'
export default function GenerateIt() {
const navigate = useNavigate()
const [imageList, setImageList] = useState<string[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [objects, setObjects] = useState<ObjectMeta[]>([])
const [selectedObj, setSelectedObj] = useState<ObjectMeta | null>(null)
const [sentences, setSentences] = useState<Sentence[]>([])
const [isGeneratingDetails, setIsGeneratingDetails] = useState(false)
const [isGeneratingSentence, setIsGeneratingSentence] = useState(false)
const currentFilename =
currentIndex >= 0 && currentIndex < imageList.length ? imageList[currentIndex] : null
useEffect(() => {
getImages('generate')
.then(imgs => {
setImageList(imgs)
setCurrentIndex(imgs.length - 1)
})
.catch(console.error)
}, [])
useEffect(() => {
if (!currentFilename) {
setObjects([])
setSelectedObj(null)
setSentences([])
return
}
getObjects(currentFilename)
.then(objs => {
setObjects(objs)
if (objs.length > 0) {
setSelectedObj(objs[0])
loadSentences(objs[0].id)
} else {
setSelectedObj(null)
setSentences([])
}
})
.catch(console.error)
}, [currentFilename])
const loadSentences = async (objId: string) => {
try {
const s = await getSentences(objId)
setSentences(s)
} catch (e) {
console.error(e)
}
}
const handleGenerateDetails = async () => {
const target = selectedObj ?? objects[0]
if (!target) return
setIsGeneratingDetails(true)
try {
const data = await generateDetails(target.id)
const updated = { ...target, ...data }
setSelectedObj(updated)
setObjects(prev => prev.map(o => o.id === target.id ? updated : o))
} catch (e) {
alert(e instanceof Error ? e.message : 'Fehler bei KI-Details')
} finally {
setIsGeneratingDetails(false)
}
}
const handleGenerateSentence = async () => {
const target = selectedObj ?? objects[0]
if (!target) return
setIsGeneratingSentence(true)
try {
const data = await generateSentence(target.id)
setSentences(prev => [...prev, data.sentence])
setSelectedObj(prev => prev ? { ...prev, latest_sentence: data.sentence } : prev)
} catch (e) {
alert(e instanceof Error ? e.message : 'Fehler bei KI-Sentence')
} finally {
setIsGeneratingSentence(false)
}
}
return (
<div className="container">
<h1>GenerateIt</h1>
<div className="panel image-nav">
<div className="image-nav-left">
<button
onClick={() => setCurrentIndex(i => i - 1)}
disabled={currentIndex <= 0}
>
</button>
<span>Bild: <code>{currentFilename || ''}</code></span>
<button
onClick={() => setCurrentIndex(i => i + 1)}
disabled={currentIndex >= imageList.length - 1}
>
</button>
<button
onClick={handleGenerateDetails}
disabled={isGeneratingDetails || !selectedObj}
>
{isGeneratingDetails ? '⏳ KI-Details...' : '✨ KI-Details'}
</button>
<button
onClick={handleGenerateSentence}
disabled={isGeneratingSentence || !selectedObj}
>
{isGeneratingSentence ? '⏳ KI-Sentence...' : '💬 KI-Sentence'}
</button>
</div>
<div className="page-switch">
<select value="/generate" onChange={e => navigate(e.target.value)}>
<option value="/draw">DrawIt</option>
<option value="/generate">GenerateIt</option>
</select>
</div>
</div>
<div className="main-layout">
<div className="objects-pane sidebar-section">
<h2>Objekte zu diesem Bild</h2>
<ObjectsList
objects={objects}
selectedObjectId={selectedObj?.id ?? null}
onSelect={id => {
const obj = objects.find(o => o.id === id)
if (obj) {
setSelectedObj(obj)
loadSentences(obj.id)
}
}}
onObjectsChange={setObjects}
isGeneratePage={true}
onShowDetails={obj => setSelectedObj(obj)}
onLoadSentences={loadSentences}
/>
</div>
<div className="right-pane">
<DetailsPanel obj={selectedObj} objects={objects} sentences={sentences} />
</div>
<div className="sentences-pane">
<div className="sidebar-section">
<h2>Alle Sätze</h2>
<SentencesList sentences={sentences} />
</div>
</div>
</div>
</div>
)
}

53
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,53 @@
export interface BBox {
x: number
y: number
width: number
height: number
}
export interface Point {
x: number
y: number
}
export interface Selection {
mode: 'rect' | 'polygon'
bbox?: BBox
polygon?: Point[]
}
export interface Sentence {
object_id: string
created_at: string
question_simple_en: string
answer_simple_en: string
question_advanced_en: string
answer_advanced_en: string
}
export interface ObjectMeta {
id: string
image_file: string
title_de: string
position_de: string
action_de: string
condition_de: string
label_en?: string
label_de?: string
label_se?: string
color_en?: string
adjective_en?: string
action_verb_en?: string
preposition_en?: string
relative_position_en?: string
season_en?: string
mode: 'rect' | 'polygon'
bbox?: BBox
polygon?: Point[]
hierarchy: number
parent_id?: string | null
created_at: string
index?: number
visible?: boolean
latest_sentence?: Sentence
}

18
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:8000',
'/pictures': 'http://localhost:8000',
'/objects_image': 'http://localhost:8000',
},
},
build: {
outDir: '../static/react',
emptyOutDir: true,
},
})

View File

@@ -0,0 +1,39 @@
Du bist eine hilfreiche Bild-KI auf Basis von Llama 3.1.
Du erhältst:
- einen BILDAUSSCHNITT eines einzelnen Objektes (z.B. ausgeschnittenes Boot, Mensch, Tier, Gebäude, usw.)
- bereits vorhandene Metadaten zum Objekt in DEUTSCH.
Deine Aufgabe:
- Analysiere ausschließlich dieses eine Objekt im Bildausschnitt.
- Nutze die deutschen Metadaten nur als Zusatzkontext, falls sinnvoll.
- Antworte **immer** mit GENAU EINEM JSON-OBJEKT, ohne erklärenden Text davor oder dahinter.
- Verwende **englische** Begriffe in den Werten, außer dort, wo explizit etwas anderes verlangt ist.
Rules for fields:
1. label_en: Single noun, singular (e.g., "cloud", "lighthouse").
2. label_de: German translation, singular, lowercase (e.g., "wolke").
3. label_se: Swedish translation, singular, lowercase (e.g., "moln").
4. color_en: Main color as a single word (e.g., "white", "grey").
5. adjective_en: One descriptive adjective about condition/appearance (e.g., "fluffy", "dark", "weathered").
6. action_verb_en: One verb ending in -ing describing the state (e.g., "floating", "shining", "standing").
7. preposition_en: The most fitting preposition to describe its location (e.g., "in", "on", "above", "behind").
8. relative_position_en: The object it is positioned relative to (e.g., "sky", "water", "hill").
9. season_en: The most likely season (e.g., "summer", "autumn").
Output format:
{
"label_en": "...",
"label_de": "...",
"label_se": "...",
"color_en": "...",
"adjective_en": "...",
"action_verb_en": "...",
"preposition_en": "...",
"relative_position_en": "...",
"season_en": "..."
}
WICHTIG:
- Gib **nur** dieses JSON-Objekt zurück.
- Keine Erklärungen, keine Kommentare, kein Markdown.

View File

@@ -0,0 +1,44 @@
You are an expert ESL teacher and dialogue writer.
Your goal: Create **TWO** short, natural **English question and answer pairs** that a language teacher can use to talk about ONE object in an image.
You will receive, in the user message:
- JSON with details about the object (fields like label_en, label_de, color_en, adjective_en, action_verb_en, preposition_en, relative_position_en, season_en, etc.)
- JSON list of previous sentences for this object (each with question_simple_en, answer_simple_en, question_advanced_en, answer_advanced_en).
### Task
1. Read the object details and understand what the object is, what it looks like, what it is doing and where it is.
2. Read the previous sentences and **avoid repeating the same question or answer meaning**.
3. Create **TWO** question and answer pairs in English:
- **SIMPLE** (for children): Very simple language, A1 level. Short words, no complex grammar. Avoid "like", "tam tam" or similar filler words. Direct and clear.
- **ADVANCED** (for learners): Natural, clear English, about A2B1 level. More descriptive, natural flow.
### Style examples
**Simple (for children):**
- Q: "Can you see the boat?"
A: "great, you can see the boat."
- Q: "Where is the bird?"
A: "The bird is in the sky."
**Advanced (for learners):**
- Q: "Can you see the seagull flying in the sky?"
A: "Yes, you can see a seagull flying in the sky."
- Q: "Where is the red boat?"
A: "The red boat is on the water near the lighthouse."
### Output format
Return **exactly one** JSON object, with **no extra text** before or after:
{
"question_simple_en": "...",
"answer_simple_en": "...",
"question_advanced_en": "...",
"answer_advanced_en": "..."
}
Important:
- Do NOT add any other fields.
- Do NOT wrap this object in a list.
- Do NOT explain your reasoning.
- Simple sentences must be VERY simple - suitable for young children learning English.
- Never use "what" or "why"

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Flask==3.0.3
Flask-Cors==4.0.1
gunicorn==22.0.0
Pillow==11.0.0
ollama==0.3.0

1115
static/script.js Normal file

File diff suppressed because it is too large Load Diff

369
static/style.css Normal file
View File

@@ -0,0 +1,369 @@
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 0;
background: #f5f7fb;
color: #222;
}
.container {
max-width: 1180px;
margin: 24px auto;
padding: 16px 20px 32px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
h1 {
margin-top: 0;
margin-bottom: 16px;
font-size: 1.6rem;
}
label {
font-weight: 500;
}
.panel {
margin: 12px 0;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.image-nav {
justify-content: space-between;
gap: 8px;
}
.image-nav button {
padding: 4px 10px;
border-radius: 999px;
}
.image-nav-left {
display: flex;
align-items: center;
gap: 8px;
}
.page-switch select {
padding: 4px 8px;
border-radius: 999px;
border: 1px solid #cbd5f0;
background: #f9fbff;
font-size: 0.9rem;
}
.main-layout {
display: flex;
gap: 16px;
align-items: flex-start;
}
.objects-pane {
flex: 0 0 260px;
}
.left-pane {
flex: 1 1 auto;
}
.right-pane {
flex: 0 0 280px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sentences-pane {
flex: 0 0 300px;
display: flex;
flex-direction: column;
gap: 12px;
}
.mode-option {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 400;
}
select {
min-width: 220px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid #cbd5f0;
background: #f9fbff;
}
.canvas-wrapper {
border-radius: 12px;
border: 1px solid #d4ddf5;
background: #f1f5ff;
overflow: visible;
padding: 8px;
}
canvas {
display: block;
max-width: 100%;
height: auto;
}
button {
padding: 8px 14px;
border-radius: 999px;
border: none;
background: #2563eb;
color: white;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 10px rgba(37, 99, 235, 0.4);
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
button:disabled {
background: #9ca3af;
cursor: not-allowed;
box-shadow: none;
}
button:not(:disabled):hover {
background: #1d4ed8;
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.5);
transform: translateY(-1px);
}
.status {
font-size: 0.9rem;
}
.status.ok {
color: #16a34a;
}
.status.error {
color: #dc2626;
}
.sidebar-section {
border-radius: 10px;
border: 1px solid #e5e7eb;
padding: 10px 12px;
background: #f9fafb;
}
.sidebar-section h2 {
font-size: 1rem;
margin: 0 0 6px;
}
.sidebar-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.sidebar-row input[type="text"] {
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.9rem;
}
.detail-value {
padding: 4px 6px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #f9fafb;
font-size: 0.9rem;
min-height: 24px;
}
.objects-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 260px;
overflow-y: auto;
padding-right: 4px;
}
.objects-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.object-tag {
padding: 2px 6px;
border-radius: 999px;
background: #e5e7eb;
font-size: 0.75rem;
white-space: nowrap;
}
.object-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #ffffff;
}
.object-item-header {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
}
.object-hierarchy-select {
width: 40px;
min-width: 0;
padding: 2px 3px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.8rem;
background: #f9fafb;
}
.object-parent-select {
width: 50px;
min-width: 0;
padding: 2px 3px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.8rem;
background: #eef2ff;
}
.object-item img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.object-item-text {
display: flex;
flex-direction: column;
font-size: 0.8rem;
}
.object-item-details {
padding-left: 24px;
display: none;
flex-direction: column;
gap: 4px;
margin-top: 4px;
}
.object-item-details.visible {
display: flex;
}
.object-item-details input[type="text"] {
padding: 4px 6px;
border-radius: 6px;
border: 1px solid #d1d5db;
font-size: 0.8rem;
}
.object-item-details label {
font-size: 0.75rem;
}
.sentences-pane {
flex: 0 0 300px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sentences-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 70vh;
overflow-y: auto;
padding: 4px;
}
.sentence-item {
padding: 12px;
background: #f9fbff;
border: 1px solid #d4ddf5;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sentence-item-question {
font-weight: 600;
color: #1e40af;
font-size: 0.95rem;
}
.sentence-item-answer {
color: #475569;
font-size: 0.9rem;
padding-left: 12px;
border-left: 2px solid #cbd5f0;
}
.sentence-item-empty {
padding: 24px;
text-align: center;
color: #94a3b8;
font-style: italic;
}
.object-item-text strong {
font-size: 0.85rem;
}
.selections-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding: 8px;
background: #f9fbff;
border: 1px solid #d4ddf5;
border-radius: 8px;
margin-bottom: 8px;
}
.selection-item {
padding: 8px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 0.85rem;
color: #475569;
}
.selection-item strong {
color: #1e40af;
font-weight: 600;
}
.selections-empty {
padding: 16px;
text-align: center;
color: #94a3b8;
font-style: italic;
font-size: 0.85rem;
}

159
templates/generate.html Normal file
View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>GenerateIt</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="container">
<h1>GenerateIt</h1>
<div class="panel image-nav">
<div class="image-nav-left">
<button id="prevImageBtn" type="button">⬅️</button>
<span>Bild: <code id="currentImageName">{{ current_image or "" }}</code></span>
<button id="nextImageBtn" type="button">➡️</button>
<button id="generateDetailsBtn" type="button">✨ KIDetails</button>
<button id="generateSentenceBtn" type="button">💬 KISentence</button>
</div>
<div class="page-switch">
<select id="pageSelect">
<option value="/draw">DrawIt</option>
<option value="/generate" selected>GenerateIt</option>
</select>
</div>
</div>
<div class="main-layout">
<div class="objects-pane sidebar-section">
<h2>Objekte zu diesem Bild</h2>
<div id="objectsList" class="objects-list">
<!-- Wird per JS gefüllt -->
</div>
<div id="objectsTags" class="objects-tags">
<!-- Tags mit Objektnamen -->
</div>
</div>
<div class="right-pane">
<div class="sidebar-section">
<h2>Details</h2>
<div class="sidebar-row">
<label>Titel</label>
<div id="detailTitle" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Position</label>
<div id="detailPosition" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Status (sitzt/schwimmt/segelt)</label>
<div id="detailAction" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Zustand (alt/jung/rostig)</label>
<div id="detailCondition" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Hierarchie</label>
<div id="detailHierarchy" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Gehört zu (Parent-Index)</label>
<div id="detailParent" class="detail-value"></div>
</div>
</div>
<div class="sidebar-section">
<h2>KIDetails</h2>
<div class="sidebar-row">
<label>Label (EN)</label>
<div id="detailLabelEn" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Label (DE)</label>
<div id="detailLabelDe" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Label (SE)</label>
<div id="detailLabelSe" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Farbe (EN)</label>
<div id="detailColorEn" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Adjektiv (EN)</label>
<div id="detailAdjectiveEn" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Action Verb (EN)</label>
<div id="detailActionVerbEn" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Präposition (EN)</label>
<div id="detailPrepositionEn" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Relative Position (EN)</label>
<div id="detailRelativePositionEn" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Season (EN)</label>
<div id="detailSeasonEn" class="detail-value"></div>
</div>
</div>
<div class="sidebar-section">
<h2>KISentence</h2>
<h3 style="font-size: 0.9rem; margin-top: 0; margin-bottom: 8px; color: #475569;">Einfach (für Kinder)</h3>
<div class="sidebar-row">
<label>Question (EN)</label>
<div id="detailSentenceQuestionSimple" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Answer (EN)</label>
<div id="detailSentenceAnswerSimple" class="detail-value"></div>
</div>
<h3 style="font-size: 0.9rem; margin-top: 12px; margin-bottom: 8px; color: #475569;">Fortgeschritten</h3>
<div class="sidebar-row">
<label>Question (EN)</label>
<div id="detailSentenceQuestionAdvanced" class="detail-value"></div>
</div>
<div class="sidebar-row">
<label>Answer (EN)</label>
<div id="detailSentenceAnswerAdvanced" class="detail-value"></div>
</div>
</div>
</div>
<div class="sentences-pane">
<div class="sidebar-section">
<h2>Alle Sätze</h2>
<div id="sentencesList" class="sentences-list">
<!-- Wird per JS gefüllt -->
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
window.isGeneratePage = true;
const pageSelect = document.getElementById("pageSelect");
if (pageSelect) {
pageSelect.addEventListener("change", (e) => {
window.location.href = e.target.value;
});
}
window.initialImages = {{ images|tojson|safe }};
window.initialImageIndex = {{ current_index if current_index is not none else -1 }};
})();
</script>
<script src="/static/script.js"></script>
</body>
</html>

124
templates/index.html Normal file
View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Bild-Ausschnitt wählen</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="container">
<h1>Bild-Ausschnitt wählen</h1>
<div class="panel image-nav">
<div class="image-nav-left">
<button id="prevImageBtn" type="button">⬅️</button>
<span>Bild: <code id="currentImageName">{{ current_image or "" }}</code></span>
<button id="nextImageBtn" type="button">➡️</button>
<button id="saveImageBtn" type="button">💾</button>
</div>
<div class="page-switch">
<select id="pageSelect">
<option value="/draw" selected>DrawIt</option>
<option value="/generate">GenerateIt</option>
</select>
</div>
</div>
<div class="main-layout">
<div class="objects-pane sidebar-section">
<h2>Objekte zu diesem Bild</h2>
<div id="objectsList" class="objects-list">
<!-- Wird per JS gefüllt -->
</div>
<div id="objectsTags" class="objects-tags">
<!-- Tags mit Objektnamen -->
</div>
</div>
<div class="left-pane">
<div class="canvas-wrapper">
<canvas id="imageCanvas"></canvas>
</div>
</div>
<div class="right-pane">
<div class="sidebar-section">
<h2>Auswahl</h2>
<div class="sidebar-row">
<span>Auswahl-Typ (Interface / Backend):</span>
</div>
<div class="sidebar-row">
<label class="mode-option">
<input type="radio" name="mode" value="rect" checked />
Rechteck / <code>BBox</code>
</label>
</div>
<div class="sidebar-row">
<label class="mode-option">
<input type="radio" name="mode" value="polygon" />
Polygon / <code>polygon</code>
</label>
</div>
<div class="sidebar-row">
<button id="clearSelectionBtn" type="button">Auswahl zurücksetzen</button>
</div>
</div>
<div class="sidebar-section">
<h2>Metadaten</h2>
<div class="sidebar-row">
<label for="title_de">Titel / <code>title_de</code></label>
<input id="title_de" type="text" />
</div>
<div class="sidebar-row">
<label for="position_de">Position / <code>position_de</code></label>
<input id="position_de" type="text" />
</div>
<div class="sidebar-row">
<label for="action_de">Status (sitzt/schwimmt/segelt) / <code>action_de</code></label>
<input id="action_de" type="text" placeholder="z.B. sitzt" />
</div>
<div class="sidebar-row">
<label for="condition_de">Zustand (alt/jung/rostig) / <code>condition_de</code></label>
<input id="condition_de" type="text" placeholder="z.B. rostig" />
</div>
</div>
<div class="sidebar-section">
<h2>Auswahlen</h2>
<div id="selectionsList" class="selections-list">
<div class="selections-empty">Noch keine Auswahlen hinzugefügt.</div>
</div>
<div class="sidebar-row">
<button id="addSelectionBtn" disabled> Auswahl hinzufügen</button>
</div>
<div class="sidebar-row">
<button id="saveCropBtn" disabled>💾 Objekt speichern</button>
</div>
<div class="sidebar-row">
<button id="clearAllSelectionsBtn" type="button">🗑️ Alle Auswahlen löschen</button>
</div>
<span id="status"></span>
</div>
</div>
</div>
</div>
<script>
(function () {
window.isGeneratePage = false;
const pageSelect = document.getElementById("pageSelect");
if (pageSelect) {
pageSelect.addEventListener("change", (e) => {
window.location.href = e.target.value;
});
}
window.initialImages = {{ images|tojson|safe }};
window.initialImageIndex = {{ current_index if current_index is not none else -1 }};
})();
</script>
<script src="/static/script.js"></script>
</body>
</html>