From 3d22b41bf2b36fe1498de27586a9f78e1fa793ff Mon Sep 17 00:00:00 2001 From: Ferdinand Date: Tue, 7 Apr 2026 13:23:01 +0200 Subject: [PATCH] feat: duplicate detection via perceptual hashing Co-Authored-By: Claude Sonnet 4.6 --- .DS_Store | Bin 0 -> 6148 bytes .../46028-1775555821/state/server.log | 1 + .../46028-1775555821/state/server.pid | 1 + __pycache__/analyzer.cpython-314.pyc | Bin 0 -> 4092 bytes analyzer.py | 36 + docs/.DS_Store | Bin 0 -> 6148 bytes docs/superpowers/.DS_Store | Bin 0 -> 6148 bytes .../plans/2026-04-07-foto-kurator.md | 1088 +++++++++++++++++ .../specs/2026-04-07-foto-kurator-design.md | 136 +++ tests/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 158 bytes ...test_analyzer.cpython-314-pytest-8.1.1.pyc | Bin 0 -> 11971 bytes tests/test_analyzer.py | 23 + 12 files changed, 1285 insertions(+) create mode 100644 .DS_Store create mode 100644 .superpowers/brainstorm/46028-1775555821/state/server.log create mode 100644 .superpowers/brainstorm/46028-1775555821/state/server.pid create mode 100644 __pycache__/analyzer.cpython-314.pyc create mode 100644 docs/.DS_Store create mode 100644 docs/superpowers/.DS_Store create mode 100644 docs/superpowers/plans/2026-04-07-foto-kurator.md create mode 100644 docs/superpowers/specs/2026-04-07-foto-kurator-design.md create mode 100644 tests/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/__pycache__/test_analyzer.cpython-314-pytest-8.1.1.pyc diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..902d65bacedf57932d1a33f66a27b8e2542c5235 GIT binary patch literal 6148 zcmeHKPfyf96rTsHbU~!RF5i;sHVlE&*KH-Lh87%-VKYk&x^M z(6b-F58&OCegHjs_23uq;u+ukDF|r186$nk%8LrqdyWfYq#x5~34AG;AC* z-Prv?xSzLU(xEIFMBsDG1gz=?%*8REFqM>nl!1Sn0ls%Lq(&I2lIY0(wa|BcPlzaD zKhAAM4YkkTxsAY&;!^1|baZA;o$k`Qv>t8Me!^OI?8ME;a>8XkT3{@26WwtaeXmu~ zb5o4Qj!(T%B=|KCLslO7w8mN%YtmXIa&76*vRbyHU)b5XHab2wS~SK>6T3xY=g!Si z(YQ4>zPp>%@`W3B=PMh*CS@-r@qk|@A-xI{c!kP-MSgWlRRkZCioWQ2Qd73O=S=U} zKD~cnus_$I&*z8E4WA#lc&VZfx=wYY9`a|MVgU=Spy^fgz6dLxsWLhjdRtg&Irg5% zw*LZM14k0(z1rgg)53NGV{!)uG3kTQ^XPIt^lP*0>ya0if6|xuYSoYh!5oYH*k^QU zMXVlsn=^Es;fnkTW*giUEqY;ucr2&9z=i}8aS>O%uf+ZQ(clI| zQ(;>oT2MfyBFa=yR}4@$2axILH&uLFqD&_sR>pbM%Ajs2pe`O@h;Ra?#I#QtNEtZH zK&Kq){QSRl=OP6Ok#HCHK^O;Vn_9sM?o)GtSz+fbOVt zQb+R4H}ie_%{Sk_57tzR2nP4;p*HD9=pS_B4t^8Z_yB+$x_}gJ2-(Gzvpul)4Do}4 zEi@O;ytWt4_#xk*-}W~nrA869BgMNN>5|UbRf-RGe!8;*N?56aU0@R@Dv~0?KDfCL zDz!=o_SKvFkixZ~Xyh3c=&dN{c!#FdDGY!xqFHv5gJb%m!v{Yq_{IsCawv{2c#eLE zPzuhP(9gIur*PwdDGtRm?kU0ixZuK_d<(iKJfk>AFjYJ41ne}yRyw1n93R1UmKgVV zhqr9vcqjFYYCFP2CZmHyzR3)kR&9&Qv)4J?`Ev^st8)^QlxC<#sxz4ZV5)O6V`n-p zYbl)RxPm8C)9h3YRiBx~BxcSyp+rJ8jEt??IFTT=z^V#<)*hI0=mqj|ZR>RUjxIjFZVod z>|AN=%!`L|X9|AtgI_)g*RO^n^Y34Of9ah&Eq4$6;Xppz@i^SI67I@}dvb%T!SF)W zinK2uY<(cKk_hnoR-jN3SfCtYH@$wKbb3JBJb`S1iVvh2B)c?No`g-F;b-OoLMznr-V^GL7Xy ztn1p;C9K&p)(l+qTv+xW>gYavI2v=f5t;#0_Kb<6RfL+*5i%w=9Dg!nT*kz9Jcj9b z!3)(H$Ir4;Pd77+BTQq}0NXLYq*icvAhRfsk~2yPN|cx&+Em(x`z^8`(9CXc0ziE- zT=dDjxa+aFcSYQLyC*OHD0gO649)MqzJH--dE)mMe|Pbr7=^Q-v{1LW7hZ zeAUcYI7K=DN1wDo100MKY+UNg2lw0_S_yVM5IX+v>fQ}vy{zt*qPkmu2z7&YURT|Q zy;a@hAjn7BsgRXg4uh7nMyjmlE})=KAu&3=luT(rQRmqB6-?jPb(DLKb{d1519}mm z1oP!$Spk(?vGTi0nD#ADc4%sLytHqzSoI++L-U04XK5PBkzs#Vkn9CYi@Fv~)6HCD zoleWWclghatx@nxh!^anlplLu78t6vI4%O#se|V6vwV!;HZDOL*Y280R=P=9Q$*{ z7a-$!ol7+qt+ead!OC5GMJ00OfV6WQp()UH6B>q!9v4R&Twdih9(3uOQQSAWy^L|8 zN{Zq0MGcPcEMZ0W`wQIBPfGXw8t!{Fw}49z_jz9_QHf(S8opb+FYawYG8!*cWFJa_ z^_x&LL|{KREF+|$pYXep#zE8fp*WyHpxW|Tk~N_U$|_D9tdbJe^I;-i)>PTVB#BKs ztLk!EwbIan$HYEp3!yo;vD~lYX>8c?MGPUo$w{8lbX!(+OMV+lAvEuLyG*ePhTf4L z_hn68y96zIOnfU^4NV;VPePeP=+T9hX0iu#=LGt%(%ZF+0sk2={8`W;)jPr^JYzWl zY-Fb~0X|TSCoIPYe+ICTf=vo09UIXQ=>aCiKGH`yV~Z6Bhf6!YDU!*WmLp`L7&rkM z^9jbB(;Sc4<#-t$z;287XSC;ZN3ouSCqaFQ6n#q)WU$CjfsBR!e*nm#H4)WFxq+ur z!e2}ZXC|_ ztxEL^@x}LUz4!4i@={A~=$qPIOUEA7wqEnjafM*@&Ero(^@ZBH1!2xxh&0~jKI>l^ z$VXb{P8Y(pH)o$nJD|;7IP%-$w|$?Bf3Jfs_uv;ZkHSX_Qo~~CR%l6iD9Ht>ezEFS z)l%fs(0UNn?Ry?Vq3~Sx#_=WYqmzY@G(UK4a6!2_QV2!n&s{sW)cPnSukS>WwzVb{ zisa7y>-j+>?R<{7K*Q5e{TdJJzbzW=*J6F!JomQ+``WzsTRE7c+!P$cc=yc`Xq3T| z06wFR8?7ewusB{Yt~oj%?*c5G5 zbz(Y`%If$e83jCTJ}gM22;}9sr$~H?B44A1r%3u5ZGYi|KZgH|Kv(^u1{#|eBAeVy I&Jy*10oxq 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 0000000000000000000000000000000000000000..1de116126c776538f4e4b3cbab14c91b67544c37 GIT binary patch literal 6148 zcmeHKPfrs;6n_I%wp3(+7V%%Qu@@7n7Ey@t;8HM#c!013O90DmyVR9srtWU3NJ#nt z^y~-l19@J@^H@c*Zw>Hi)%HjhKDO%n*h)OK*Pc@ z)sNM0jF0ouVLhrRg-FC40W^R?3tR{~qLYq9fkc6SO#%MyW*~$nzPbFZJ$iq`d7o7s zpE(%8KMy4=*AIoE*Ole8!iHkKa~i%EM#bW1($kwdb-GXM(=ytI^@O#o$c~y}$qv?d zYnd_MiGABy@!VEL&(1O$*&cNRLGWrWhO9sGXpOZ>tVwI3;97D(OKa(heqn$A+Qii4 zM8TLUP9GGE{W~{{1>@G_)WJbo%jIv}U8-#RJCwZ?834hrmaqYZF}y;jiz;4qSLH`O z#`%5G^*FjhlYP4EV;GEM_tQ%4UEYib%;qH1g=Zh>Va2V*xCx+sQi<^ z%2%rf%=Z^r=tUl*tLtL*$laNzTMSp^J6LG&p>V|wLc|VEdDRXAo7UaZ-iB{GuD?J_ zEQ;7p*~2XO&c%{e!>)R?p1=1^YMRcQSEZ*zK9*U_#-2iwf4^-Af0;7sB56^M*9|0jlQY8xfRRw+k1%2Og literal 0 HcmV?d00001 diff --git a/docs/superpowers/.DS_Store b/docs/superpowers/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..40970e7c34b6ef6f67e66d058608e77c91285492 GIT binary patch literal 6148 zcmeHK%}x|S5Uz$rW^AGj(7n#gvLYeb z2hg(*-~)K~qz|A+uO55>FP^dbXX4^&;>j49O1i)5>Zb3>J)$p00tI; znLez3A);TD4(ZaC3?h+ZL<<40cmel1d{aZpK+3?s$AH+oJrF@1C{&<*|B54_h~?Um z6uI;^Pp=u(^-h25)dN3@CnrCVuI^0Fsa~Vk=r=Z<$GqvpZrq59Zn!R5E1U;jqPyOz zPn%^kH^*7*`izD$z^_tt*?8!)DsL8fgH!=PRYh=sj`GbS2Q!_UvC#;$2sly5D z;I`0jPEQ{mW{rH|`km$SPO!`P3%P0_csfZL(3rqWRN8X#D|I6tGOIkA9d|_Uq`A`(coS-VZ5d>$T9YE^co}G%mI2Yht!a$b(>sM}F*c zwzeT>kLm6L+vYfNWV$hQ6DcBFRivi;>3l40+Q)s~} zcnxphJ$!^O@C}a002w8h$vBxHGvpSr$t+nW>tu_#vz|AuXM}(SJhnP18&nNE!HZ2E_SbVG(F+Y$=pS2UhY4fGB<=q@XT-97K$z z(bm{fh&?Dog(9j@qMsN **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 + + + + + + Foto-Kurator + + + +

Foto-Kurator

+

Automatisches Aussortieren von Fotos nach Qualitaet

+ + +
+ + + +
+
+
+
Ueberpruefung vor dem Verschieben
+
Zeigt aussortierte Fotos zur Bestaetigung an
+
+ +
+
+
+
KI-Analyse (Claude Vision)
+
Genauer, aber ~0,003 EUR pro Foto · Internetverbindung erforderlich
+
+ +
+
+ +
+ Schwellenwerte anpassen +
+
+
Unschaerfe-Grenze100
+ +
+
+
Ueberbelichtung (Helligkeit >)240
+ +
+
+
Unterbelichtung (Helligkeit <)30
+ +
+
+
Duplikat-Aehnlichkeit (pHash ≤)8
+ +
+
+
+ + +
+ + +
+

Analyse laeuft...

+ +

Vorbereitung...

+ +
+ + +
+

Vorschau aussortierter Fotos

+

Klicke "Behalten", um ein Foto von der Liste zu entfernen.

+
+ +
+ + +
+

Fertig!

+
+ +
+ + + + +``` + +- [ ] **Schritt 2: Integrationstest manuell durchfuehren** + +```bash +python server.py +``` + +1. Browser oeffnet `http://localhost:8000` +2. Ordnerpfad mit Testfotos eingeben +3. "Analyse starten" — Fortschrittsanzeige pruefen +4. Review-Liste: Vorschaubilder und Gruende sichtbar (als Text, nicht HTML)? +5. "Behalten" toggeln — Foto wird ausgegraut? +6. "Alle bestaetigen" — `_aussortiert/` Ordner pruefen + +- [ ] **Schritt 3: Alle Tests ausfuehren** + +```bash +pytest tests/ -v +``` + +Erwartete Ausgabe: `8 passed` + +- [ ] **Schritt 4: Commit** + +```bash +git add index.html +git commit -m "feat: complete frontend with review flow and XSS-safe DOM rendering" +``` + +--- + +## Task 9: README + +**Files:** +- Create: `README.md` + +- [ ] **Schritt 1: README.md erstellen** + +```markdown +# Foto-Kurator + +Automatisches Aussortieren von Fotos nach Qualitaetskriterien. + +## Setup + +```bash +pip install -r requirements.txt +``` + +Fuer KI-Analyse (optional): +```bash +cp .env.example .env +# ANTHROPIC_API_KEY in .env eintragen +``` + +## Starten + +```bash +python server.py +``` + +Der Browser oeffnet automatisch http://localhost:8000. + +## Kriterien + +- **Unscharf** - Laplacian Variance (einstellbar) +- **Ueberbelichtet / Unterbelichtet** - Durchschnittliche Helligkeit (einstellbar) +- **Duplikate** - Perceptual Hashing (einstellbar) +- **KI-Analyse** - Claude Vision API (optional, ca. 0,003 EUR / Foto) + +Aussortierte Fotos landen in `_aussortiert/` im analysierten Ordner. +``` + +- [ ] **Schritt 2: Commit** + +```bash +git add README.md +git commit -m "docs: add README with setup instructions" +``` + +--- + +## Fertig + +Nach Task 9 ist die App vollstaendig: +- `python server.py` startet alles, Browser oeffnet automatisch +- Ordnerpfad eingeben, Analyse starten +- Optional: Review vor dem Verschieben +- Optional: KI-Analyse via Claude Vision +- Alle Schwellenwerte per Schieberegler einstellbar +- Aussortierte Fotos landen sicher in `_aussortiert/` diff --git a/docs/superpowers/specs/2026-04-07-foto-kurator-design.md b/docs/superpowers/specs/2026-04-07-foto-kurator-design.md new file mode 100644 index 0000000..a93756f --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-foto-kurator-design.md @@ -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. diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a36004618b2465f30552ca41f9fceff2d321145 GIT binary patch literal 158 zcmdPq2KczG$)vkyYXcow}Vi4mKGb1Bo5i^hl0P-6qd;kCd literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_analyzer.cpython-314-pytest-8.1.1.pyc b/tests/__pycache__/test_analyzer.cpython-314-pytest-8.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33bb689fc6c4bd1f316d2c9dcc003bc9c511d691 GIT binary patch literal 11971 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