This commit is contained in:
team3
2026-08-12 19:30:41 +02:00
commit 38d5a8cfae
30 changed files with 4157 additions and 0 deletions

122
backend/app/imagehash.py Normal file
View File

@@ -0,0 +1,122 @@
"""Duplikate unter den TMDB-Backdrops finden.
TMDB kennzeichnet nicht, welches Bild ein Szenenfoto und welches ein Crop
desselben Frames ist. Also vergleichen wir die Bilder selbst.
Zwei Metriken, weil eine allein nicht reicht:
- dHash über eine 9x8-Graustufenminiatur erkennt gleiche Bilder zuverlässig,
scheitert aber an Crops. Gemessen: ein echter Crop lag bei Distanz 14, zwei
völlig verschiedene Bilder bei 13.
- Das Farbhistogramm trennt genau diese Fälle. Der Crop lag bei 0.12, die
verschiedenen Bilder bei über 0.27.
"""
import asyncio
import io
import logging
import httpx
from PIL import Image
from .config import IMAGE_BASE
log = logging.getLogger("imagehash")
HASH_SIZE = 8
# Bis hierher entscheidet der dHash allein
STRICT_DISTANCE = 10
# Darüber hinaus nur zusammen mit sehr ähnlicher Farbverteilung
LOOSE_DISTANCE = 18
MAX_HIST_DISTANCE = 0.15
HIST_BINS = 32
THUMB_SIZE = "w300"
CONCURRENCY = 12
def dhash(img):
"""64-Bit-Fingerabdruck: je Pixelpaar ein Bit, ob links heller ist."""
thumb = img.convert("L").resize((HASH_SIZE + 1, HASH_SIZE), Image.LANCZOS)
pixels = list(thumb.getdata())
bits = 0
for row in range(HASH_SIZE):
offset = row * (HASH_SIZE + 1)
for col in range(HASH_SIZE):
bits <<= 1
if pixels[offset + col] > pixels[offset + col + 1]:
bits |= 1
return bits
def histogram(img):
"""96 Bytes Farbverteilung, unabhängig von Bildausschnitt und Größe."""
small = img.convert("RGB").resize((64, 64), Image.LANCZOS)
raw = small.histogram()
total = 64 * 64
step = 256 // HIST_BINS
packed = bytearray()
for channel in range(3):
base = channel * 256
for start in range(0, 256, step):
share = sum(raw[base + start : base + start + step]) / total
packed.append(min(255, round(share * 255)))
return bytes(packed)
def fingerprint(data):
with Image.open(io.BytesIO(data)) as img:
return dhash(img), histogram(img)
def distance(a, b):
return bin(a ^ b).count("1")
def hist_distance(a, b):
"""0 = gleiche Farbverteilung, 1 = keine Überschneidung."""
overlap = sum(min(x, y) for x, y in zip(a, b)) / 255
return max(0.0, 1 - overlap / 3)
def is_duplicate(a, b):
gap = distance(a[0], b[0])
if gap <= STRICT_DISTANCE:
return True
return gap <= LOOSE_DISTANCE and hist_distance(a[1], b[1]) <= MAX_HIST_DISTANCE
async def fetch_fingerprint(http, path, semaphore):
async with semaphore:
try:
resp = await http.get(f"{IMAGE_BASE}/{THUMB_SIZE}{path}")
resp.raise_for_status()
return path, fingerprint(resp.content)
except (httpx.HTTPError, OSError):
log.warning("Bild nicht lesbar: %s", path)
return path, None
async def fingerprint_many(paths):
semaphore = asyncio.Semaphore(CONCURRENCY)
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http:
pairs = await asyncio.gather(
*(fetch_fingerprint(http, p, semaphore) for p in paths)
)
return {path: value for path, value in pairs if value is not None}
def dedupe(paths, prints):
"""Reihenfolge bleibt, jedes weitere Bild muss sich von allen bisherigen
unterscheiden. Bilder ohne Fingerabdruck fallen raus."""
kept, kept_prints = [], []
for path in paths:
current = prints.get(path)
if current is None:
continue
if any(is_duplicate(current, other) for other in kept_prints):
continue
kept.append(path)
kept_prints.append(current)
return kept