Compare commits
11 Commits
8b50a85620
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f44b8c4f2 | |||
|
|
a90c542d9f | ||
|
|
2ee0d055fa | ||
|
|
3057538642 | ||
|
|
35dccd4f1b | ||
|
|
8a80021983 | ||
|
|
3dcf4bf5e8 | ||
|
|
c58817becc | ||
|
|
9f4985a444 | ||
|
|
08dabfb57c | ||
|
|
ccf878485d |
1
.vch-description
Normal file
1
.vch-description
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Foto-Kurator hilft Fotografen dabei, schlechte Fotos aus einem Shooting schnell auszusortieren. Die App analysiert einen Ordner automatisch auf unscharfe, über- oder unterbelichtete Fotos sowie Duplikate — optional auch mit KI. Bevor Fotos verschoben werden, zeigt die App eine Übersicht zur manuellen Kontrolle an.
|
||||||
40
README.md
Normal file
40
README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Foto-Kurator
|
||||||
|
|
||||||
|
Automatisches Aussortieren von Fotos nach Qualitätskriterien.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Für KI-Analyse (optional):
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# ANTHROPIC_API_KEY in .env eintragen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Browser öffnet automatisch http://localhost:8000.
|
||||||
|
|
||||||
|
## Kriterien
|
||||||
|
|
||||||
|
- **Unscharf** — Laplacian Variance (einstellbar)
|
||||||
|
- **Überbelichtet / Unterbelichtet** — Durchschnittliche Helligkeit (einstellbar)
|
||||||
|
- **Duplikate** — Perceptual Hashing (einstellbar)
|
||||||
|
- **KI-Analyse** — Claude Vision API (optional, ca. 0,003 € / Foto)
|
||||||
|
|
||||||
|
Aussortierte Fotos landen in `_aussortiert/` im analysierten Ordner.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend:** Python 3, FastAPI, Uvicorn
|
||||||
|
- **Bildanalyse:** OpenCV (Laplacian Variance), Pillow, ImageHash (pHash/MD5)
|
||||||
|
- **KI-Analyse (optional):** Anthropic Claude Vision API
|
||||||
|
- **Frontend:** Vanilla HTML/CSS/JavaScript (kein Framework)
|
||||||
|
- **Konfiguration:** python-dotenv
|
||||||
33
analyzer.py
33
analyzer.py
@@ -33,6 +33,28 @@ def is_underexposed(path: str, threshold: float = 30.0) -> bool:
|
|||||||
return _mean_brightness(path) < threshold
|
return _mean_brightness(path) < threshold
|
||||||
|
|
||||||
|
|
||||||
|
def find_exact_copies(paths: List[str]) -> List[List[str]]:
|
||||||
|
"""
|
||||||
|
Findet exakte Kopien anhand von MD5-Hash (byte-identische Dateien).
|
||||||
|
Das erste Element jeder Gruppe gilt als Original, der Rest als Kopien.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
hashes: dict = {}
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
h = hashlib.md5()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
digest = h.hexdigest()
|
||||||
|
hashes.setdefault(digest, []).append(path)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return [group for group in hashes.values() if len(group) > 1]
|
||||||
|
|
||||||
|
|
||||||
def find_duplicates(paths: List[str], threshold: int = 8) -> List[List[str]]:
|
def find_duplicates(paths: List[str], threshold: int = 8) -> List[List[str]]:
|
||||||
"""
|
"""
|
||||||
Findet Gruppen aehnlicher Bilder via perceptual hashing.
|
Findet Gruppen aehnlicher Bilder via perceptual hashing.
|
||||||
@@ -155,7 +177,16 @@ def analyze_folder(
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dup_groups = find_duplicates(paths, dup_threshold)
|
exact_copy_paths: set = set()
|
||||||
|
exact_groups = find_exact_copies(paths)
|
||||||
|
for group in exact_groups:
|
||||||
|
original = os.path.basename(group[0])
|
||||||
|
for copy_path in group[1:]:
|
||||||
|
results[copy_path].append(f"exakte Kopie von {original}")
|
||||||
|
exact_copy_paths.add(copy_path)
|
||||||
|
|
||||||
|
dup_paths = [p for p in paths if p not in exact_copy_paths]
|
||||||
|
dup_groups = find_duplicates(dup_paths, dup_threshold)
|
||||||
for group in dup_groups:
|
for group in dup_groups:
|
||||||
original = os.path.basename(group[0])
|
original = os.path.basename(group[0])
|
||||||
for dup_path in group[1:]:
|
for dup_path in group[1:]:
|
||||||
|
|||||||
435
index.html
Normal file
435
index.html
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Foto-Kurator</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 2rem 1rem; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 0.25rem; color: #fff; }
|
||||||
|
.subtitle { color: #888; margin-bottom: 2rem; font-size: 0.95rem; }
|
||||||
|
.card { background: #16213e; border-radius: 12px; padding: 1.5rem; width: 100%; max-width: 640px; margin-bottom: 1.5rem; }
|
||||||
|
label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.4rem; }
|
||||||
|
.folder-row { display: flex; gap: 0.5rem; align-items: stretch; }
|
||||||
|
.folder-row input[type="text"] { flex: 1; }
|
||||||
|
input[type="text"] { width: 100%; padding: 0.6rem 0.8rem; border-radius: 8px; border: 1px solid #333; background: #0f3460; color: #fff; font-size: 1rem; }
|
||||||
|
input[type="text"]:focus { outline: 2px solid #e94560; }
|
||||||
|
.pick-btn { padding: 0 1rem; border-radius: 8px; border: 1px solid #555; background: #0f3460; color: #aaa; cursor: pointer; font-size: 1.1rem; white-space: nowrap; transition: border-color 0.15s, color 0.15s; }
|
||||||
|
.pick-btn:hover { border-color: #e94560; color: #fff; }
|
||||||
|
button.primary { margin-top: 1rem; width: 100%; padding: 0.75rem; border-radius: 8px; border: none; background: #e94560; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; }
|
||||||
|
button.primary:hover { background: #c73652; }
|
||||||
|
button.primary:disabled { background: #555; cursor: not-allowed; }
|
||||||
|
.toggle-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
||||||
|
.toggle-label { font-size: 0.95rem; }
|
||||||
|
.toggle-note { font-size: 0.78rem; color: #888; margin-top: 0.2rem; }
|
||||||
|
.switch { position: relative; width: 44px; height: 24px; flex-shrink: 0; }
|
||||||
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.knob { position: absolute; inset: 0; background: #333; border-radius: 24px; cursor: pointer; transition: background 0.2s; }
|
||||||
|
.knob::before { content: ""; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: transform 0.2s; }
|
||||||
|
input:checked + .knob { background: #e94560; }
|
||||||
|
input:checked + .knob::before { transform: translateX(20px); }
|
||||||
|
.slider-row { margin-bottom: 0.75rem; }
|
||||||
|
.slider-label { display: flex; justify-content: space-between; font-size: 0.85rem; color: #aaa; margin-bottom: 0.3rem; }
|
||||||
|
input[type="range"] { width: 100%; accent-color: #e94560; }
|
||||||
|
.view { display: none; }
|
||||||
|
.view.active { display: block; }
|
||||||
|
progress { width: 100%; height: 12px; border-radius: 6px; overflow: hidden; appearance: none; }
|
||||||
|
progress::-webkit-progress-bar { background: #0f3460; border-radius: 6px; }
|
||||||
|
progress::-webkit-progress-value { background: #e94560; border-radius: 6px; }
|
||||||
|
.progress-label { font-size: 0.85rem; color: #aaa; margin-top: 0.5rem; text-align: center; }
|
||||||
|
.photo-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid #222; }
|
||||||
|
.photo-item img { width: 64px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
|
||||||
|
.photo-info { flex: 1; min-width: 0; }
|
||||||
|
.photo-name { font-size: 0.9rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.photo-reasons { font-size: 0.8rem; color: #e94560; margin-top: 0.2rem; }
|
||||||
|
.keep-btn { padding: 0.3rem 0.7rem; border-radius: 6px; border: 1px solid #555; background: transparent; color: #aaa; cursor: pointer; font-size: 0.8rem; flex-shrink: 0; }
|
||||||
|
.keep-btn:hover { border-color: #fff; color: #fff; }
|
||||||
|
.kept { opacity: 0.35; }
|
||||||
|
.kept .photo-name { text-decoration: line-through; }
|
||||||
|
.stat { display: flex; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid #222; font-size: 0.95rem; }
|
||||||
|
.stat-value { font-weight: 700; color: #e94560; }
|
||||||
|
.hint { margin-top: 1rem; color: #888; font-size: 0.85rem; }
|
||||||
|
.section-divider { font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #555; margin: 1.2rem 0 0.5rem; }
|
||||||
|
.badge-reject { font-size: 0.75rem; font-weight: 600; color: #e94560; background: rgba(233,69,96,0.12); border-radius: 4px; padding: 0.15rem 0.45rem; margin-left: 0.4rem; }
|
||||||
|
.badge-ok { font-size: 0.75rem; font-weight: 600; color: #4caf7d; background: rgba(76,175,125,0.12); border-radius: 4px; padding: 0.15rem 0.45rem; margin-left: 0.4rem; }
|
||||||
|
.photo-item img { cursor: zoom-in; }
|
||||||
|
/* Lightbox */
|
||||||
|
.lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.88); z-index: 1000; align-items: center; justify-content: center; }
|
||||||
|
.lightbox.open { display: flex; }
|
||||||
|
.lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 8px; object-fit: contain; box-shadow: 0 8px 40px rgba(0,0,0,0.6); }
|
||||||
|
.lightbox-close { position: absolute; top: 1.2rem; right: 1.5rem; font-size: 2rem; color: #fff; cursor: pointer; line-height: 1; opacity: 0.7; }
|
||||||
|
.lightbox-close:hover { opacity: 1; }
|
||||||
|
.lightbox-name { position: absolute; bottom: 1.5rem; color: #ccc; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Foto-Kurator</h1>
|
||||||
|
<p class="subtitle">Automatisches Aussortieren von Fotos nach Qualitaet</p>
|
||||||
|
|
||||||
|
<!-- Start -->
|
||||||
|
<div id="view-start" class="view active card">
|
||||||
|
<label for="folder-input">Ordnerpfad</label>
|
||||||
|
<div class="folder-row">
|
||||||
|
<input type="text" id="folder-input" placeholder="/Users/name/Fotos/Shooting-2026" />
|
||||||
|
<button class="pick-btn" id="pick-btn" title="Ordner auswählen">📁</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 1.5rem;">
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="toggle-label">Ueberpruefung vor dem Verschieben</div>
|
||||||
|
<div class="toggle-note">Zeigt aussortierte Fotos zur Bestaetigung an</div>
|
||||||
|
</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="toggle-review" checked>
|
||||||
|
<span class="knob"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="toggle-label">KI-Analyse (Claude Vision)</div>
|
||||||
|
<div class="toggle-note">Genauer, aber ~0,003 EUR pro Foto · Internetverbindung erforderlich</div>
|
||||||
|
</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="toggle-ai">
|
||||||
|
<span class="knob"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details style="margin-top: 1rem;">
|
||||||
|
<summary style="cursor: pointer; color: #aaa; font-size: 0.9rem; user-select: none;">Schwellenwerte anpassen</summary>
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="slider-label"><span>Preset</span></div>
|
||||||
|
<select id="preset-select" style="width:100%; padding:0.4rem 0.6rem; border-radius:8px; border:1px solid #333; background:#0f3460; color:#fff; font-size:0.9rem; cursor:pointer;">
|
||||||
|
<option value="">— Preset wählen —</option>
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="outdoor">Draußen</option>
|
||||||
|
<option value="night">Nacht</option>
|
||||||
|
<option value="portrait">Portrait</option>
|
||||||
|
<option value="nature">Natur</option>
|
||||||
|
<option value="studio">Studio</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="slider-label"><span>Unschaerfe-Grenze</span><span id="blur-val">100</span></div>
|
||||||
|
<input type="range" id="blur-threshold" min="10" max="500" value="100">
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="slider-label"><span>Ueberbelichtung (Helligkeit >)</span><span id="over-val">240</span></div>
|
||||||
|
<input type="range" id="over-threshold" min="180" max="255" value="240">
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="slider-label"><span>Unterbelichtung (Helligkeit <)</span><span id="under-val">30</span></div>
|
||||||
|
<input type="range" id="under-threshold" min="0" max="80" value="30">
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="slider-label"><span>Duplikat-Aehnlichkeit (pHash ≤)</span><span id="dup-val">8</span></div>
|
||||||
|
<input type="range" id="dup-threshold" min="0" max="20" value="8">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<button class="primary" id="start-btn">Analyse starten</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress -->
|
||||||
|
<div id="view-progress" class="view card">
|
||||||
|
<h2 style="margin-bottom: 1rem;">Analyse laeuft...</h2>
|
||||||
|
<progress id="progress-bar"></progress>
|
||||||
|
<p class="progress-label" id="progress-label">Vorbereitung...</p>
|
||||||
|
<button style="margin-top: 1rem; padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #555; background: transparent; color: #aaa; cursor: pointer;" id="cancel-btn">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Review -->
|
||||||
|
<div id="view-review" class="view card">
|
||||||
|
<h2 style="margin-bottom: 0.5rem;">Sortierübersicht</h2>
|
||||||
|
<p style="color: #888; font-size: 0.85rem; margin-bottom: 1rem;">Alle Fotos auf einen Blick — klicke auf ein Vorschaubild zum Vergrößern oder ändere die Entscheidung per Button.</p>
|
||||||
|
<div id="review-list"></div>
|
||||||
|
<button class="primary" id="confirm-btn">Alle bestaetigen & verschieben</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<div id="view-result" class="view card">
|
||||||
|
<h2 style="margin-bottom: 1rem;">Fertig!</h2>
|
||||||
|
<div id="result-stats"></div>
|
||||||
|
<button class="primary" id="restart-btn">Neuen Ordner analysieren</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightbox -->
|
||||||
|
<div class="lightbox" id="lightbox">
|
||||||
|
<span class="lightbox-close" id="lightbox-close">×</span>
|
||||||
|
<img id="lightbox-img" src="" alt="">
|
||||||
|
<span class="lightbox-name" id="lightbox-name"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- State ---
|
||||||
|
let analysisResults = [];
|
||||||
|
let okPaths = [];
|
||||||
|
let currentFolder = "";
|
||||||
|
|
||||||
|
// --- Lightbox ---
|
||||||
|
function openLightbox(src, name) {
|
||||||
|
el("lightbox-img").src = src;
|
||||||
|
el("lightbox-name").textContent = name;
|
||||||
|
el("lightbox").classList.add("open");
|
||||||
|
}
|
||||||
|
el("lightbox-close").addEventListener("click", () => el("lightbox").classList.remove("open"));
|
||||||
|
el("lightbox").addEventListener("click", e => { if (e.target === el("lightbox")) el("lightbox").classList.remove("open"); });
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
function showView(id) {
|
||||||
|
document.querySelectorAll(".view").forEach(v => v.classList.remove("active"));
|
||||||
|
document.getElementById(id).classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
// --- Slider labels ---
|
||||||
|
["blur", "over", "under", "dup"].forEach(key => {
|
||||||
|
const input = el(key + "-threshold");
|
||||||
|
const label = el(key + "-val");
|
||||||
|
input.addEventListener("input", () => { label.textContent = input.value; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Presets ---
|
||||||
|
const PRESETS = {
|
||||||
|
standard: { blur: 100, over: 240, under: 30, dup: 8 },
|
||||||
|
outdoor: { blur: 80, over: 235, under: 15, dup: 8 },
|
||||||
|
night: { blur: 40, over: 210, under: 5, dup: 10 },
|
||||||
|
portrait: { blur: 150, over: 245, under: 30, dup: 5 },
|
||||||
|
nature: { blur: 60, over: 238, under: 12, dup: 10 },
|
||||||
|
studio: { blur: 160, over: 248, under: 40, dup: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
el("preset-select").addEventListener("change", () => {
|
||||||
|
const preset = PRESETS[el("preset-select").value];
|
||||||
|
if (!preset) return;
|
||||||
|
["blur", "over", "under", "dup"].forEach(key => {
|
||||||
|
const input = el(key + "-threshold");
|
||||||
|
input.value = preset[key];
|
||||||
|
el(key + "-val").textContent = preset[key];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Folder picker ---
|
||||||
|
el("pick-btn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("pick-folder");
|
||||||
|
if (res.status === 204) return;
|
||||||
|
const data = await res.json();
|
||||||
|
el("folder-input").value = data.folder;
|
||||||
|
} catch (e) {
|
||||||
|
alert("Ordner konnte nicht geöffnet werden: " + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Cancel ---
|
||||||
|
el("cancel-btn").addEventListener("click", () => location.reload());
|
||||||
|
el("restart-btn").addEventListener("click", () => location.reload());
|
||||||
|
|
||||||
|
// --- Start Analysis ---
|
||||||
|
el("start-btn").addEventListener("click", async () => {
|
||||||
|
const folder = el("folder-input").value.trim();
|
||||||
|
if (!folder) { alert("Bitte einen Ordnerpfad eingeben."); return; }
|
||||||
|
currentFolder = folder;
|
||||||
|
showView("view-progress");
|
||||||
|
el("progress-label").textContent = "Analyse startet...";
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
folder,
|
||||||
|
blur_threshold: parseFloat(el("blur-threshold").value),
|
||||||
|
over_threshold: parseFloat(el("over-threshold").value),
|
||||||
|
under_threshold: parseFloat(el("under-threshold").value),
|
||||||
|
dup_threshold: parseInt(el("dup-threshold").value, 10),
|
||||||
|
use_ai: el("toggle-ai").checked,
|
||||||
|
};
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const res = await fetch("analyze", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail || "Serverfehler");
|
||||||
|
}
|
||||||
|
data = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Fehler: " + e.message);
|
||||||
|
showView("view-start");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
analysisResults = data.results;
|
||||||
|
okPaths = data.ok_paths || [];
|
||||||
|
|
||||||
|
if (analysisResults.length === 0) {
|
||||||
|
renderResult(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el("toggle-review").checked) {
|
||||||
|
renderReview();
|
||||||
|
showView("view-review");
|
||||||
|
} else {
|
||||||
|
await doMove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Review ---
|
||||||
|
function makeThumb(path, name) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = "preview?path=" + encodeURIComponent(path);
|
||||||
|
img.alt = name;
|
||||||
|
img.onerror = function() { this.style.display = "none"; };
|
||||||
|
img.addEventListener("click", () => openLightbox(img.src, name));
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReview() {
|
||||||
|
const list = el("review-list");
|
||||||
|
list.textContent = "";
|
||||||
|
|
||||||
|
// Alle Fotos zusammenführen und nach Dateiname sortieren
|
||||||
|
const flaggedMap = {};
|
||||||
|
analysisResults.forEach((item, idx) => { flaggedMap[item.path] = { item, idx }; });
|
||||||
|
|
||||||
|
const allEntries = [
|
||||||
|
...analysisResults.map((item, idx) => ({ path: item.path, flagged: true, reasons: item.reasons, idx })),
|
||||||
|
...okPaths.map(path => ({ path, flagged: false, reasons: [], idx: null })),
|
||||||
|
].sort((a, b) => a.path.split("/").pop().localeCompare(b.path.split("/").pop()));
|
||||||
|
|
||||||
|
allEntries.forEach(entry => {
|
||||||
|
const name = entry.path.split("/").pop();
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "photo-item";
|
||||||
|
if (entry.idx !== null) row.id = "item-" + entry.idx;
|
||||||
|
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "photo-info";
|
||||||
|
|
||||||
|
const nameEl = document.createElement("div");
|
||||||
|
nameEl.className = "photo-name";
|
||||||
|
nameEl.textContent = name;
|
||||||
|
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = entry.flagged ? "badge-reject" : "badge-ok";
|
||||||
|
badge.textContent = entry.flagged ? "n.i.O." : "i.O.";
|
||||||
|
nameEl.appendChild(badge);
|
||||||
|
|
||||||
|
info.appendChild(nameEl);
|
||||||
|
|
||||||
|
if (entry.reasons.length > 0) {
|
||||||
|
const reasonsEl = document.createElement("div");
|
||||||
|
reasonsEl.className = "photo-reasons";
|
||||||
|
reasonsEl.textContent = entry.reasons.join(", ");
|
||||||
|
info.appendChild(reasonsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "keep-btn";
|
||||||
|
btn.textContent = entry.flagged ? "Behalten" : "Aussortieren";
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
entry.flagged = !entry.flagged;
|
||||||
|
badge.className = entry.flagged ? "badge-reject" : "badge-ok";
|
||||||
|
badge.textContent = entry.flagged ? "n.i.O." : "i.O.";
|
||||||
|
btn.textContent = entry.flagged ? "Behalten" : "Aussortieren";
|
||||||
|
// Sync mit analysisResults für doMove
|
||||||
|
if (entry.idx !== null) {
|
||||||
|
row.classList.toggle("kept", !entry.flagged);
|
||||||
|
} else {
|
||||||
|
// Manuell zum Aussortieren markiert
|
||||||
|
row.id = "manual-" + entry.path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(makeThumb(entry.path, name));
|
||||||
|
row.appendChild(info);
|
||||||
|
row.appendChild(btn);
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Für doMove: manuelle Auswahl über allEntries tracken
|
||||||
|
list._allEntries = allEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Confirm & Move ---
|
||||||
|
el("confirm-btn").addEventListener("click", () => doMove(false));
|
||||||
|
|
||||||
|
async function doMove(skipReview = true) {
|
||||||
|
let toMove;
|
||||||
|
if (skipReview) {
|
||||||
|
toMove = analysisResults.map(r => r.path);
|
||||||
|
} else {
|
||||||
|
const entries = el("review-list")._allEntries || [];
|
||||||
|
toMove = entries.filter(e => e.flagged).map(e => e.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toMove.length === 0) {
|
||||||
|
renderResult(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showView("view-progress");
|
||||||
|
el("progress-label").textContent = "Verschiebe " + toMove.length + " Fotos...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("move", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ paths: toMove, folder: currentFolder }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
renderResult(data.moved.length);
|
||||||
|
} catch (e) {
|
||||||
|
alert("Fehler beim Verschieben: " + e.message);
|
||||||
|
showView("view-review");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Result ---
|
||||||
|
function renderResult(movedCount) {
|
||||||
|
const stats = el("result-stats");
|
||||||
|
stats.textContent = "";
|
||||||
|
|
||||||
|
const byReason = {};
|
||||||
|
analysisResults.forEach(item => {
|
||||||
|
item.reasons.forEach(r => { byReason[r] = (byReason[r] || 0) + 1; });
|
||||||
|
});
|
||||||
|
|
||||||
|
function addStat(label, value) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "stat";
|
||||||
|
const l = document.createElement("span");
|
||||||
|
l.textContent = label;
|
||||||
|
const v = document.createElement("span");
|
||||||
|
v.className = "stat-value";
|
||||||
|
v.textContent = value;
|
||||||
|
row.appendChild(l);
|
||||||
|
row.appendChild(v);
|
||||||
|
stats.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
addStat("Analysierte Fotos", analysisResults.length + okPaths.length);
|
||||||
|
addStat("Behalten (i.O.)", okPaths.length);
|
||||||
|
addStat("Aussortiert", movedCount);
|
||||||
|
Object.entries(byReason).forEach(([reason, count]) => {
|
||||||
|
addStat(" davon: " + reason, count);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hint = document.createElement("p");
|
||||||
|
hint.className = "hint";
|
||||||
|
hint.textContent = "Aussortierte Fotos befinden sich im Unterordner _aussortiert/";
|
||||||
|
stats.appendChild(hint);
|
||||||
|
|
||||||
|
showView("view-result");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,6 +4,6 @@ pillow==12.2.0
|
|||||||
opencv-python-headless==4.13.0.92
|
opencv-python-headless==4.13.0.92
|
||||||
imagehash==4.3.1
|
imagehash==4.3.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
anthropic==0.25.0
|
anthropic==0.89.0
|
||||||
pytest==8.1.1
|
pytest==8.1.1
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
|||||||
45
server.py
45
server.py
@@ -19,7 +19,7 @@ app = FastAPI(title="Foto-Kurator")
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:8000"],
|
allow_origins=["*"],
|
||||||
allow_methods=["GET", "POST"],
|
allow_methods=["GET", "POST"],
|
||||||
allow_headers=["Content-Type"],
|
allow_headers=["Content-Type"],
|
||||||
)
|
)
|
||||||
@@ -44,11 +44,29 @@ def serve_frontend():
|
|||||||
return FileResponse("index.html")
|
return FileResponse("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/pick-folder")
|
||||||
|
def pick_folder():
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
["osascript", "-e", 'POSIX path of (choose folder with prompt "Ordner auswählen")'],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
folder = result.stdout.strip().rstrip("/")
|
||||||
|
if not folder:
|
||||||
|
raise HTTPException(status_code=204, detail="Kein Ordner ausgewählt")
|
||||||
|
return {"folder": folder}
|
||||||
|
|
||||||
|
|
||||||
|
PREVIEW_ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/preview")
|
@app.get("/preview")
|
||||||
def preview(path: str):
|
def preview(path: str):
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
if ext not in PREVIEW_ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(status_code=403, detail="Dateityp nicht erlaubt")
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
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"
|
media = "image/jpeg" if ext in (".jpg", ".jpeg") else "image/png"
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
return Response(content=f.read(), media_type=media)
|
return Response(content=f.read(), media_type=media)
|
||||||
@@ -68,19 +86,34 @@ def analyze(req: AnalyzeRequest):
|
|||||||
use_ai=req.use_ai,
|
use_ai=req.use_ai,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
)
|
)
|
||||||
return {"results": results}
|
from analyzer import SUPPORTED_EXTENSIONS
|
||||||
|
all_paths = {
|
||||||
|
os.path.join(req.folder, f)
|
||||||
|
for f in os.listdir(req.folder)
|
||||||
|
if os.path.splitext(f)[1].lower() in SUPPORTED_EXTENSIONS
|
||||||
|
}
|
||||||
|
flagged_paths = {item["path"] for item in results}
|
||||||
|
ok_paths = sorted(all_paths - flagged_paths)
|
||||||
|
return {"results": results, "ok_paths": ok_paths}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/move")
|
@app.post("/move")
|
||||||
def move_files(req: MoveRequest):
|
def move_files(req: MoveRequest):
|
||||||
target_dir = os.path.join(req.folder, "_aussortiert")
|
folder_abs = os.path.abspath(req.folder)
|
||||||
|
if not os.path.isdir(folder_abs):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Ordner nicht gefunden: {req.folder}")
|
||||||
|
target_dir = os.path.join(folder_abs, "_aussortiert")
|
||||||
os.makedirs(target_dir, exist_ok=True)
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
moved = []
|
moved = []
|
||||||
errors = []
|
errors = []
|
||||||
for path in req.paths:
|
for path in req.paths:
|
||||||
|
path_abs = os.path.abspath(path)
|
||||||
|
if not path_abs.startswith(folder_abs + os.sep):
|
||||||
|
errors.append({"path": path, "error": "Pfad liegt außerhalb des analysierten Ordners"})
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
dest = os.path.join(target_dir, os.path.basename(path))
|
dest = os.path.join(target_dir, os.path.basename(path_abs))
|
||||||
shutil.move(path, dest)
|
shutil.move(path_abs, dest)
|
||||||
moved.append(path)
|
moved.append(path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append({"path": path, "error": str(e)})
|
errors.append({"path": path, "error": str(e)})
|
||||||
|
|||||||
Reference in New Issue
Block a user