Erster Commit
This commit is contained in:
31
.claude/settings.local.json
Normal file
31
.claude/settings.local.json
Normal 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
23
.dockerignore
Normal 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
29
.gitignore
vendored
Normal 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
43
Dockerfile
Normal 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
36
README.md
Normal 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
815
app.py
Normal 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
12
frontend/index.html
Normal 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
1771
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal 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
13
frontend/src/App.tsx
Normal 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
106
frontend/src/api.ts
Normal 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
|
||||||
|
}
|
||||||
71
frontend/src/components/DetailsPanel.tsx
Normal file
71
frontend/src/components/DetailsPanel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
338
frontend/src/components/DrawCanvas.tsx
Normal file
338
frontend/src/components/DrawCanvas.tsx
Normal 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' }} />
|
||||||
|
})
|
||||||
181
frontend/src/components/ObjectsList.tsx
Normal file
181
frontend/src/components/ObjectsList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
frontend/src/components/SentencesList.tsx
Normal file
34
frontend/src/components/SentencesList.tsx
Normal 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
382
frontend/src/index.css
Normal 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
13
frontend/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
298
frontend/src/pages/DrawIt.tsx
Normal file
298
frontend/src/pages/DrawIt.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
frontend/src/pages/GenerateIt.tsx
Normal file
166
frontend/src/pages/GenerateIt.tsx
Normal 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
53
frontend/src/types.ts
Normal 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
18
frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
18
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
39
prompts/create_details.txt
Normal file
39
prompts/create_details.txt
Normal 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.
|
||||||
44
prompts/create_sentence.txt
Normal file
44
prompts/create_sentence.txt
Normal 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 A2–B1 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
6
requirements.txt
Normal 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
1115
static/script.js
Normal file
File diff suppressed because it is too large
Load Diff
369
static/style.css
Normal file
369
static/style.css
Normal 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
159
templates/generate.html
Normal 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">✨ KI‑Details</button>
|
||||||
|
<button id="generateSentenceBtn" type="button">💬 KI‑Sentence</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>KI‑Details</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>KI‑Sentence</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
124
templates/index.html
Normal 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>
|
||||||
|
|
||||||
Reference in New Issue
Block a user