feat: duplicate detection via perceptual hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
env: node: No such file or directory
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
46037
|
||||||
BIN
__pycache__/analyzer.cpython-314.pyc
Normal file
BIN
__pycache__/analyzer.cpython-314.pyc
Normal file
Binary file not shown.
36
analyzer.py
36
analyzer.py
@@ -1,6 +1,8 @@
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
import imagehash
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
def is_blurry(path: str, threshold: float = 100.0) -> bool:
|
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:
|
def is_underexposed(path: str, threshold: float = 30.0) -> bool:
|
||||||
"""Gibt True zurueck, wenn das Bild unterbelichtet ist."""
|
"""Gibt True zurueck, wenn das Bild unterbelichtet ist."""
|
||||||
return _mean_brightness(path) < threshold
|
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
|
||||||
|
|||||||
BIN
docs/.DS_Store
vendored
Normal file
BIN
docs/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
docs/superpowers/.DS_Store
vendored
Normal file
BIN
docs/superpowers/.DS_Store
vendored
Normal file
Binary file not shown.
1088
docs/superpowers/plans/2026-04-07-foto-kurator.md
Normal file
1088
docs/superpowers/plans/2026-04-07-foto-kurator.md
Normal file
File diff suppressed because it is too large
Load Diff
136
docs/superpowers/specs/2026-04-07-foto-kurator-design.md
Normal file
136
docs/superpowers/specs/2026-04-07-foto-kurator-design.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Foto-Kurator — Design Spec
|
||||||
|
|
||||||
|
**Datum:** 2026-04-07
|
||||||
|
**Status:** Genehmigt
|
||||||
|
|
||||||
|
## Überblick
|
||||||
|
|
||||||
|
Eine lokale Webanwendung, die den manuellen Foto-Culling-Prozess automatisiert. Das Tool analysiert einen Ordner voller Fotos und sortiert unbrauchbare Bilder (unscharf, über-/unterbelichtet, Duplikate) automatisch in einen Unterordner `_aussortiert/` aus.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Browser (Frontend) │
|
||||||
|
│ HTML/CSS/JS — Single Page App │
|
||||||
|
│ - Ordner auswählen │
|
||||||
|
│ - Einstellungen / Toggles │
|
||||||
|
│ - Fortschrittsanzeige │
|
||||||
|
│ - Review-Ansicht │
|
||||||
|
└────────────┬────────────────────┘
|
||||||
|
│ HTTP (localhost)
|
||||||
|
┌────────────▼────────────────────┐
|
||||||
|
│ Python FastAPI Backend │
|
||||||
|
│ - /analyze → Fotos analysieren│
|
||||||
|
│ - /move → Dateien verschieben│
|
||||||
|
│ - Pillow + OpenCV │
|
||||||
|
│ - optional: Claude Vision API │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
│ Dateisystem
|
||||||
|
┌────────────▼────────────────────┐
|
||||||
|
│ Lokaler Ordner (z.B. /Fotos/) │
|
||||||
|
│ ├── foto001.jpg │
|
||||||
|
│ ├── foto002.jpg │
|
||||||
|
│ └── _aussortiert/ │
|
||||||
|
│ └── foto003.jpg │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Backend:** Python 3.10+, FastAPI, Pillow, OpenCV, imagehash, anthropic SDK (optional)
|
||||||
|
- **Frontend:** Vanilla HTML/CSS/JS (eine einzelne `index.html`)
|
||||||
|
- **Start:** `python server.py` → öffnet automatisch den Browser
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
foto-kurator/
|
||||||
|
├── server.py # FastAPI Backend + Startup
|
||||||
|
├── analyzer.py # Bildanalyse-Logik (lokal + KI)
|
||||||
|
├── index.html # Frontend (Single Page App)
|
||||||
|
├── requirements.txt
|
||||||
|
└── .env # ANTHROPIC_API_KEY (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Analysekriterien
|
||||||
|
|
||||||
|
### Lokale Analyse (immer verfügbar)
|
||||||
|
|
||||||
|
| Kriterium | Methode | Standardschwellenwert |
|
||||||
|
|-----------|---------|----------------------|
|
||||||
|
| Unschärfe | Laplacian Variance (OpenCV) | < 100 |
|
||||||
|
| Überbelichtung | Durchschnittliche Helligkeit > Schwellenwert (Pillow) | > 240 |
|
||||||
|
| Unterbelichtung | Durchschnittliche Helligkeit < Schwellenwert (Pillow) | < 30 |
|
||||||
|
| Duplikate | Perceptual Hash (pHash, imagehash) | Hamming-Distanz ≤ 8 |
|
||||||
|
|
||||||
|
Alle Schwellenwerte sind in der UI per Schieberegler einstellbar.
|
||||||
|
|
||||||
|
### KI-Analyse (optional, Toggle)
|
||||||
|
|
||||||
|
Sendet Fotos batchartig an die Claude Vision API mit einem Qualitäts-Bewertungsprompt. Erkennt auch subtilere Probleme (schlechter Bildaufbau, störende Elemente, etc.).
|
||||||
|
|
||||||
|
- **Kosten:** ca. 0,002–0,005 € pro Foto
|
||||||
|
- **Anforderung:** Internetverbindung + `ANTHROPIC_API_KEY` in `.env`
|
||||||
|
- **Fallback:** Bei API-Fehler wird automatisch auf lokale Analyse zurückgefallen
|
||||||
|
|
||||||
|
## UI & Workflow
|
||||||
|
|
||||||
|
### Startseite
|
||||||
|
- Ordner-Auswahl: Texteingabe für den lokalen Pfad (z.B. `/Fotos/Shooting-2026-04`) + Button zum Bestätigen. Optional: nativer Ordner-Dialog via `tkinter` im Backend, der den Pfad zurückgibt.
|
||||||
|
- **Toggle:** Überprüfung vor dem Verschieben (an/aus)
|
||||||
|
- **Toggle:** KI-Analyse (an/aus) + Hinweis zu Kosten & Internetanforderung
|
||||||
|
- Schieberegler für alle Schwellenwerte
|
||||||
|
- Button: "Analyse starten"
|
||||||
|
|
||||||
|
### Während der Analyse
|
||||||
|
- Fortschrittsbalken mit aktuellem Dateinamen
|
||||||
|
- Abbruch-Button
|
||||||
|
|
||||||
|
### Review-Ansicht (wenn Toggle aktiv)
|
||||||
|
- Liste aller vorgeschlagenen Ausschüsse
|
||||||
|
- Pro Foto: Vorschaubild + Begründung (z.B. "unterbelichtet", "Duplikat von foto002.jpg")
|
||||||
|
- Jedes Foto einzeln bestätigen oder von der Liste entfernen
|
||||||
|
- Button: "Alle bestätigen & verschieben"
|
||||||
|
|
||||||
|
### Ohne Review (Toggle deaktiviert)
|
||||||
|
- Direktes Verschieben nach Analyse
|
||||||
|
- Kurze Zusammenfassung: "23 Fotos aussortiert"
|
||||||
|
|
||||||
|
### Ergebnisseite
|
||||||
|
- Anzahl analysierter Fotos
|
||||||
|
- Anzahl aussortierter Fotos, aufgeteilt nach Grund
|
||||||
|
- Hinweis auf `_aussortiert/`-Unterordner
|
||||||
|
|
||||||
|
## Datenfluss
|
||||||
|
|
||||||
|
1. Frontend schickt Ordnerpfad an `POST /analyze`
|
||||||
|
2. Backend iteriert über alle `.jpg/.jpeg/.png` im Ordner
|
||||||
|
3. Pro Foto: Unschärfe + Belichtung berechnen, pHash für Duplikatvergleich
|
||||||
|
4. Optional: Fotos batchartig an Claude Vision API senden
|
||||||
|
5. Antwort: JSON-Liste `[{ path, reasons: ["unscharf", ...] }]`
|
||||||
|
6. Frontend zeigt Review-Ansicht oder schickt direkt `POST /move`
|
||||||
|
7. Backend verschiebt Dateien in `_aussortiert/`
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
- Korrupte oder nicht lesbare Dateien werden übersprungen und in einem Log festgehalten
|
||||||
|
- API-Fehler (Claude Vision) fallen automatisch auf lokale Analyse zurück
|
||||||
|
- Kein Foto wird ohne expliziten `/move`-Aufruf verschoben — die Analyse ist immer nicht-destruktiv
|
||||||
|
|
||||||
|
## Unterstützte Formate
|
||||||
|
|
||||||
|
- **Primär:** `.jpg`, `.jpeg`, `.png`
|
||||||
|
- **Selten:** RAW-Formate (`.CR2`, `.NEF`, `.ARW`) — in Phase 1 nicht unterstützt, für spätere Version vorgesehen
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python server.py
|
||||||
|
# Browser öffnet automatisch http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Erweiterbarkeit
|
||||||
|
|
||||||
|
Die Architektur ist so ausgelegt, dass das Tool später als echter Webserver mit mehreren Nutzern betrieben werden kann. Der einzige erforderliche Schritt ist die Umstellung der Ordnerpfad-Logik auf nutzerspezifische Upload-Verzeichnisse.
|
||||||
BIN
tests/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
tests/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_analyzer.cpython-314-pytest-8.1.1.pyc
Normal file
BIN
tests/__pycache__/test_analyzer.cpython-314-pytest-8.1.1.pyc
Normal file
Binary file not shown.
@@ -44,3 +44,26 @@ def test_normal_image_is_neither(tmp_path):
|
|||||||
path = make_test_image(tmp_path, color=(128, 128, 128))
|
path = make_test_image(tmp_path, color=(128, 128, 128))
|
||||||
assert is_overexposed(path, threshold=240) is False
|
assert is_overexposed(path, threshold=240) is False
|
||||||
assert is_underexposed(path, threshold=30) is False
|
assert is_underexposed(path, threshold=30) is False
|
||||||
|
|
||||||
|
|
||||||
|
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=0)
|
||||||
|
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=0)
|
||||||
|
assert len(groups) == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user