174 lines
5.4 KiB
Python
174 lines
5.4 KiB
Python
import cv2
|
|
import numpy as np
|
|
from PIL import Image
|
|
import imagehash
|
|
import os
|
|
import base64
|
|
from typing import List, Optional
|
|
|
|
|
|
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 bool(variance < threshold)
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
]
|