diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..902d65b Binary files /dev/null and b/.DS_Store differ diff --git a/.superpowers/brainstorm/46028-1775555821/state/server.log b/.superpowers/brainstorm/46028-1775555821/state/server.log new file mode 100644 index 0000000..e62ac03 --- /dev/null +++ b/.superpowers/brainstorm/46028-1775555821/state/server.log @@ -0,0 +1 @@ +env: node: No such file or directory diff --git a/.superpowers/brainstorm/46028-1775555821/state/server.pid b/.superpowers/brainstorm/46028-1775555821/state/server.pid new file mode 100644 index 0000000..b99a244 --- /dev/null +++ b/.superpowers/brainstorm/46028-1775555821/state/server.pid @@ -0,0 +1 @@ +46037 diff --git a/__pycache__/analyzer.cpython-314.pyc b/__pycache__/analyzer.cpython-314.pyc new file mode 100644 index 0000000..d1318eb Binary files /dev/null and b/__pycache__/analyzer.cpython-314.pyc differ diff --git a/analyzer.py b/analyzer.py index b0191f3..9b20114 100644 --- a/analyzer.py +++ b/analyzer.py @@ -1,6 +1,8 @@ import cv2 import numpy as np from PIL import Image +import imagehash +from typing import List def is_blurry(path: str, threshold: float = 100.0) -> bool: @@ -27,3 +29,37 @@ def is_overexposed(path: str, threshold: float = 240.0) -> bool: def is_underexposed(path: str, threshold: float = 30.0) -> bool: """Gibt True zurueck, wenn das Bild unterbelichtet ist.""" return _mean_brightness(path) < threshold + + +def find_duplicates(paths: List[str], threshold: int = 8) -> List[List[str]]: + """ + Findet Gruppen aehnlicher Bilder via perceptual hashing. + Das erste Element jeder Gruppe gilt als Original, der Rest als Duplikate. + """ + hashes = {} + for path in paths: + try: + h = imagehash.phash(Image.open(path)) + hashes[path] = h + except Exception: + continue + + groups = [] + used = set() + path_list = list(hashes.keys()) + + for i, p1 in enumerate(path_list): + if p1 in used: + continue + group = [p1] + for p2 in path_list[i + 1:]: + if p2 in used: + continue + if abs(hashes[p1] - hashes[p2]) <= threshold: + group.append(p2) + used.add(p2) + if len(group) > 1: + used.add(p1) + groups.append(group) + + return groups diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..1de1161 Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/superpowers/.DS_Store b/docs/superpowers/.DS_Store new file mode 100644 index 0000000..40970e7 Binary files /dev/null and b/docs/superpowers/.DS_Store differ diff --git a/docs/superpowers/plans/2026-04-07-foto-kurator.md b/docs/superpowers/plans/2026-04-07-foto-kurator.md new file mode 100644 index 0000000..876640c --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-foto-kurator.md @@ -0,0 +1,1088 @@ +# Foto-Kurator Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eine lokale Webanwendung, die den manuellen Foto-Culling-Prozess automatisiert — Fotos werden nach Qualitätskriterien (Unschärfe, Belichtung, Duplikate) analysiert und in einen `_aussortiert/`-Unterordner verschoben. + +**Architecture:** Python FastAPI-Backend analysiert Bilder direkt auf dem Dateisystem und stellt zwei REST-Endpunkte bereit (`/analyze`, `/move`). Ein einzelnes `index.html` dient als Frontend und kommuniziert mit dem Backend via `fetch()`. Das Backend startet automatisch den Browser. + +**Tech Stack:** Python 3.10+, FastAPI, Uvicorn, Pillow, OpenCV (`opencv-python-headless`), imagehash, anthropic SDK (optional), Vanilla JS/HTML/CSS + +--- + +## File Map + +| Datei | Verantwortlichkeit | +|-------|-------------------| +| `server.py` | FastAPI-App, Endpunkte `/analyze`, `/move`, `/preview`, startet Browser | +| `analyzer.py` | Bildanalyse: Unschärfe, Belichtung, Duplikate, Claude Vision | +| `index.html` | Single-Page Frontend: Einstellungen, Fortschritt, Review, Ergebnis | +| `requirements.txt` | Python-Abhängigkeiten | +| `.env.example` | Vorlage für `ANTHROPIC_API_KEY` | +| `tests/test_analyzer.py` | Unit-Tests für `analyzer.py` | + +--- + +## Task 1: Projektstruktur & Abhängigkeiten + +**Files:** +- Create: `requirements.txt` +- Create: `.env.example` +- Create: `tests/__init__.py` + +- [ ] **Schritt 1: requirements.txt erstellen** + +``` +fastapi==0.111.0 +uvicorn==0.29.0 +pillow==10.3.0 +opencv-python-headless==4.9.0.80 +imagehash==4.3.1 +python-dotenv==1.0.1 +anthropic==0.25.0 +pytest==8.1.1 +httpx==0.27.0 +``` + +- [ ] **Schritt 2: .env.example erstellen** + +``` +# Claude Vision API Key (optional — nur für KI-Analyse benötigt) +ANTHROPIC_API_KEY=sk-ant-... +``` + +- [ ] **Schritt 3: Abhängigkeiten installieren** + +```bash +pip install -r requirements.txt +``` + +Erwartete Ausgabe: Alle Pakete erfolgreich installiert, kein Fehler. + +- [ ] **Schritt 4: tests/__init__.py erstellen** + +Leere Datei. + +- [ ] **Schritt 5: Commit** + +```bash +git init +git add requirements.txt .env.example tests/__init__.py +git commit -m "chore: project setup" +``` + +--- + +## Task 2: Unschärfe-Erkennung + +**Files:** +- Create: `analyzer.py` +- Create: `tests/test_analyzer.py` + +- [ ] **Schritt 1: Failing test schreiben** + +`tests/test_analyzer.py`: +```python +import pytest +from pathlib import Path +from analyzer import is_blurry + + +def make_test_image(tmp_path, color=(200, 200, 200)): + from PIL import Image + img = Image.new("RGB", (100, 100), color=color) + p = tmp_path / "test.jpg" + img.save(p) + return str(p) + + +def test_solid_color_image_is_blurry(tmp_path): + path = make_test_image(tmp_path) + assert is_blurry(path, threshold=100) is True + + +def test_normal_image_is_not_blurry(tmp_path): + from PIL import Image, ImageDraw + img = Image.new("RGB", (100, 100), color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + for i in range(0, 100, 2): + draw.line([(i, 0), (i, 100)], fill=(0, 0, 0), width=1) + p = tmp_path / "sharp.jpg" + img.save(p) + assert is_blurry(str(p), threshold=100) is False +``` + +- [ ] **Schritt 2: Test fehlschlagen lassen** + +```bash +pytest tests/test_analyzer.py -v +``` + +Erwartete Ausgabe: `ImportError: cannot import name 'is_blurry' from 'analyzer'` + +- [ ] **Schritt 3: is_blurry implementieren** + +`analyzer.py`: +```python +import cv2 +import numpy as np + + +def is_blurry(path: str, threshold: float = 100.0) -> bool: + """Gibt True zurueck, wenn das Bild unscharf ist (Laplacian Variance < threshold).""" + img = cv2.imread(path, cv2.IMREAD_GRAYSCALE) + if img is None: + return False + variance = cv2.Laplacian(img, cv2.CV_64F).var() + return variance < threshold +``` + +- [ ] **Schritt 4: Tests bestehen lassen** + +```bash +pytest tests/test_analyzer.py -v +``` + +Erwartete Ausgabe: `2 passed` + +- [ ] **Schritt 5: Commit** + +```bash +git add analyzer.py tests/test_analyzer.py +git commit -m "feat: blur detection via Laplacian variance" +``` + +--- + +## Task 3: Belichtungs-Erkennung + +**Files:** +- Modify: `analyzer.py` +- Modify: `tests/test_analyzer.py` + +- [ ] **Schritt 1: Failing tests hinzufügen** + +An `tests/test_analyzer.py` anhängen: +```python +from analyzer import is_overexposed, is_underexposed + + +def test_white_image_is_overexposed(tmp_path): + path = make_test_image(tmp_path, color=(255, 255, 255)) + assert is_overexposed(path, threshold=240) is True + + +def test_dark_image_is_underexposed(tmp_path): + path = make_test_image(tmp_path, color=(10, 10, 10)) + assert is_underexposed(path, threshold=30) is True + + +def test_normal_image_is_neither(tmp_path): + path = make_test_image(tmp_path, color=(128, 128, 128)) + assert is_overexposed(path, threshold=240) is False + assert is_underexposed(path, threshold=30) is False +``` + +- [ ] **Schritt 2: Test fehlschlagen lassen** + +```bash +pytest tests/test_analyzer.py -v +``` + +Erwartete Ausgabe: `ImportError: cannot import name 'is_overexposed'` + +- [ ] **Schritt 3: Belichtungsfunktionen implementieren** + +An `analyzer.py` anhängen: +```python +from PIL import Image + + +def _mean_brightness(path: str) -> float: + """Durchschnittliche Helligkeit eines Bildes (0-255).""" + img = Image.open(path).convert("L") + arr = np.array(img, dtype=np.float32) + return float(arr.mean()) + + +def is_overexposed(path: str, threshold: float = 240.0) -> bool: + """Gibt True zurueck, wenn das Bild ueberbelichtet ist.""" + return _mean_brightness(path) > threshold + + +def is_underexposed(path: str, threshold: float = 30.0) -> bool: + """Gibt True zurueck, wenn das Bild unterbelichtet ist.""" + return _mean_brightness(path) < threshold +``` + +- [ ] **Schritt 4: Tests bestehen lassen** + +```bash +pytest tests/test_analyzer.py -v +``` + +Erwartete Ausgabe: `5 passed` + +- [ ] **Schritt 5: Commit** + +```bash +git add analyzer.py tests/test_analyzer.py +git commit -m "feat: exposure detection (over/underexposed)" +``` + +--- + +## Task 4: Duplikat-Erkennung + +**Files:** +- Modify: `analyzer.py` +- Modify: `tests/test_analyzer.py` + +- [ ] **Schritt 1: Failing tests hinzufügen** + +An `tests/test_analyzer.py` anhängen: +```python +from analyzer import find_duplicates + + +def test_identical_images_are_duplicates(tmp_path): + p1 = make_test_image(tmp_path, color=(100, 150, 200)) + import shutil + p2 = tmp_path / "copy.jpg" + shutil.copy(p1, p2) + groups = find_duplicates([p1, str(p2)], threshold=8) + assert len(groups) == 1 + assert len(groups[0]) == 2 + + +def test_different_images_are_not_duplicates(tmp_path): + from PIL import Image + p1 = make_test_image(tmp_path, color=(0, 0, 0)) + img = Image.new("RGB", (100, 100), color=(255, 0, 0)) + p2 = tmp_path / "red.jpg" + img.save(p2) + groups = find_duplicates([p1, str(p2)], threshold=8) + assert len(groups) == 0 +``` + +- [ ] **Schritt 2: Test fehlschlagen lassen** + +```bash +pytest tests/test_analyzer.py -v +``` + +Erwartete Ausgabe: `ImportError: cannot import name 'find_duplicates'` + +- [ ] **Schritt 3: find_duplicates implementieren** + +An `analyzer.py` anhängen: +```python +import imagehash +from typing import List + + +def find_duplicates(paths: List[str], threshold: int = 8) -> List[List[str]]: + """ + Findet Gruppen aehnlicher Bilder via perceptual hashing. + Das erste Element jeder Gruppe gilt als Original, der Rest als Duplikate. + """ + hashes = {} + for path in paths: + try: + h = imagehash.phash(Image.open(path)) + hashes[path] = h + except Exception: + continue + + groups = [] + used = set() + path_list = list(hashes.keys()) + + for i, p1 in enumerate(path_list): + if p1 in used: + continue + group = [p1] + for p2 in path_list[i + 1:]: + if p2 in used: + continue + if abs(hashes[p1] - hashes[p2]) <= threshold: + group.append(p2) + used.add(p2) + if len(group) > 1: + used.add(p1) + groups.append(group) + + return groups +``` + +- [ ] **Schritt 4: Tests bestehen lassen** + +```bash +pytest tests/test_analyzer.py -v +``` + +Erwartete Ausgabe: `7 passed` + +- [ ] **Schritt 5: Commit** + +```bash +git add analyzer.py tests/test_analyzer.py +git commit -m "feat: duplicate detection via perceptual hashing" +``` + +--- + +## Task 5: Haupt-Analysefunktion + +**Files:** +- Modify: `analyzer.py` +- Modify: `tests/test_analyzer.py` + +- [ ] **Schritt 1: Failing test hinzufügen** + +An `tests/test_analyzer.py` anhängen: +```python +from analyzer import analyze_folder + + +def test_analyze_folder_returns_results(tmp_path): + make_test_image(tmp_path, color=(128, 128, 128)) + from PIL import Image + white = tmp_path / "white.jpg" + Image.new("RGB", (100, 100), color=(255, 255, 255)).save(white) + + results = analyze_folder( + folder=str(tmp_path), + blur_threshold=100, + over_threshold=240, + under_threshold=30, + dup_threshold=8, + use_ai=False, + ) + reasons_flat = [r for item in results for r in item["reasons"]] + assert "ueberbelichtet" in reasons_flat +``` + +- [ ] **Schritt 2: Test fehlschlagen lassen** + +```bash +pytest tests/test_analyzer.py::test_analyze_folder_returns_results -v +``` + +Erwartete Ausgabe: `ImportError: cannot import name 'analyze_folder'` + +- [ ] **Schritt 3: analyze_folder implementieren** + +An `analyzer.py` anhängen: +```python +import os +from typing import Optional + + +SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png"} + + +def analyze_folder( + folder: str, + blur_threshold: float = 100.0, + over_threshold: float = 240.0, + under_threshold: float = 30.0, + dup_threshold: int = 8, + use_ai: bool = False, + api_key: Optional[str] = None, +) -> List[dict]: + """ + Analysiert alle Bilder im Ordner. + Gibt Liste zurueck: [{"path": "/foo/bar.jpg", "reasons": ["unscharf"]}, ...] + Nur Bilder mit mindestens einem Grund werden zurueckgegeben. + """ + paths = [ + os.path.join(folder, f) + for f in os.listdir(folder) + if os.path.splitext(f)[1].lower() in SUPPORTED_EXTENSIONS + ] + + results: dict = {path: [] for path in paths} + + for path in paths: + try: + if is_blurry(path, blur_threshold): + results[path].append("unscharf") + if is_overexposed(path, over_threshold): + results[path].append("ueberbelichtet") + if is_underexposed(path, under_threshold): + results[path].append("unterbelichtet") + except Exception: + continue + + dup_groups = find_duplicates(paths, dup_threshold) + for group in dup_groups: + original = os.path.basename(group[0]) + for dup_path in group[1:]: + results[dup_path].append(f"Duplikat von {original}") + + if use_ai and api_key: + ai_results = _analyze_with_ai(paths, api_key) + for path, ai_reasons in ai_results.items(): + results[path].extend(ai_reasons) + + return [ + {"path": path, "reasons": reasons} + for path, reasons in results.items() + if reasons + ] +``` + +Hinweis: `_analyze_with_ai` wird in Task 6 definiert. Da `analyze_folder` mit `use_ai=False` getestet wird, ist das noch kein Problem. + +- [ ] **Schritt 4: Tests bestehen lassen** + +```bash +pytest tests/test_analyzer.py -v +``` + +Erwartete Ausgabe: `8 passed` + +- [ ] **Schritt 5: Commit** + +```bash +git add analyzer.py tests/test_analyzer.py +git commit -m "feat: analyze_folder orchestrates all checks" +``` + +--- + +## Task 6: Claude Vision Integration + +**Files:** +- Modify: `analyzer.py` + +- [ ] **Schritt 1: _analyze_with_ai vor analyze_folder einfuegen** + +In `analyzer.py` VOR der `analyze_folder`-Funktion einfuegen: +```python +import base64 + + +def _analyze_with_ai(paths: List[str], api_key: str) -> dict: + """ + Sendet Bilder an Claude Vision API zur Qualitaetsanalyse. + Gibt {path: [reasons]} zurueck. Bei Fehler wird der Pfad uebersprungen. + """ + import anthropic + + client = anthropic.Anthropic(api_key=api_key) + ai_results: dict = {path: [] for path in paths} + + PROMPT = ( + "Analysiere dieses Foto auf Qualitaetsprobleme fuer einen professionellen Fotografen. " + "Antworte NUR mit einer kommagetrennten Liste von Problemen aus diesen Kategorien: " + "unscharf, ueberbelichtet, unterbelichtet, schlechter Bildausschnitt, stoerende Elemente, " + "schlechter Weissabgleich. Wenn das Bild in Ordnung ist, antworte mit 'ok'." + ) + + for path in paths: + try: + with open(path, "rb") as f: + img_data = base64.standard_b64encode(f.read()).decode("utf-8") + ext = os.path.splitext(path)[1].lower().lstrip(".") + media_type = "image/jpeg" if ext in ("jpg", "jpeg") else "image/png" + + response = client.messages.create( + model="claude-opus-4-6", + max_tokens=100, + messages=[{ + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": img_data, + }, + }, + {"type": "text", "text": PROMPT}, + ], + }], + ) + answer = response.content[0].text.strip().lower() + if answer != "ok": + reasons = [r.strip() for r in answer.split(",") if r.strip()] + ai_results[path].extend(reasons) + except Exception: + continue + + return ai_results +``` + +- [ ] **Schritt 2: Alle Tests nochmals ausfuehren** + +```bash +pytest tests/test_analyzer.py -v +``` + +Erwartete Ausgabe: `8 passed` (keine Regression) + +- [ ] **Schritt 3: Commit** + +```bash +git add analyzer.py +git commit -m "feat: Claude Vision AI analysis integration" +``` + +--- + +## Task 7: FastAPI Backend + +**Files:** +- Create: `server.py` + +- [ ] **Schritt 1: server.py erstellen** + +```python +import os +import shutil +import webbrowser +import threading +from typing import List + +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse, Response +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import uvicorn + +from analyzer import analyze_folder + +load_dotenv() + +app = FastAPI(title="Foto-Kurator") + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:8000"], + allow_methods=["GET", "POST"], + allow_headers=["Content-Type"], +) + + +class AnalyzeRequest(BaseModel): + folder: str + blur_threshold: float = 100.0 + over_threshold: float = 240.0 + under_threshold: float = 30.0 + dup_threshold: int = 8 + use_ai: bool = False + + +class MoveRequest(BaseModel): + paths: List[str] + folder: str + + +@app.get("/") +def serve_frontend(): + return FileResponse("index.html") + + +@app.get("/preview") +def preview(path: str): + if not os.path.isfile(path): + raise HTTPException(status_code=404, detail="Datei nicht gefunden") + ext = os.path.splitext(path)[1].lower() + media = "image/jpeg" if ext in (".jpg", ".jpeg") else "image/png" + with open(path, "rb") as f: + return Response(content=f.read(), media_type=media) + + +@app.post("/analyze") +def analyze(req: AnalyzeRequest): + if not os.path.isdir(req.folder): + raise HTTPException(status_code=400, detail=f"Ordner nicht gefunden: {req.folder}") + api_key = os.getenv("ANTHROPIC_API_KEY") if req.use_ai else None + results = analyze_folder( + folder=req.folder, + blur_threshold=req.blur_threshold, + over_threshold=req.over_threshold, + under_threshold=req.under_threshold, + dup_threshold=req.dup_threshold, + use_ai=req.use_ai, + api_key=api_key, + ) + return {"results": results} + + +@app.post("/move") +def move_files(req: MoveRequest): + target_dir = os.path.join(req.folder, "_aussortiert") + os.makedirs(target_dir, exist_ok=True) + moved = [] + errors = [] + for path in req.paths: + try: + dest = os.path.join(target_dir, os.path.basename(path)) + shutil.move(path, dest) + moved.append(path) + except Exception as e: + errors.append({"path": path, "error": str(e)}) + return {"moved": moved, "errors": errors} + + +def open_browser(): + webbrowser.open("http://localhost:8000") + + +if __name__ == "__main__": + threading.Timer(1.0, open_browser).start() + uvicorn.run(app, host="127.0.0.1", port=8000) +``` + +- [ ] **Schritt 2: Backend-Smoke-Test** + +```bash +python server.py & +sleep 2 +curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/ +``` + +Erwartete Ausgabe: `200` oder `404` (index.html fehlt noch — ok) + +```bash +kill %1 +``` + +- [ ] **Schritt 3: Commit** + +```bash +git add server.py +git commit -m "feat: FastAPI backend with /analyze, /move, /preview endpoints" +``` + +--- + +## Task 8: Frontend + +**Files:** +- Create: `index.html` + +- [ ] **Schritt 1: index.html erstellen** + +Wichtig: Alle Werte aus Server-Antworten werden per `textContent` gesetzt (kein `innerHTML` mit Nutzerdaten) um XSS zu verhindern. + +```html + + +
+ + +Automatisches Aussortieren von Fotos nach Qualitaet
+ + +Vorbereitung...
+ +Klicke "Behalten", um ein Foto von der Liste zu entfernen.
+ + +