Fotos: Gesichtssuche FH-weit (Studierende + Lektoren) als Job mit Live-Progress, Person-Info-Seiten, Scope-Auswahl

This commit is contained in:
Nicolas Gruber
2026-10-19 14:43:09 +02:00
parent 567bcb40ee
commit 6763faf9c1
7 changed files with 405 additions and 72 deletions
+141 -31
View File
@@ -1,7 +1,9 @@
"""CIS Enhanced - FastAPI app."""
import json
import os
import threading
import time
import uuid
from datetime import datetime, timedelta
from pathlib import Path
@@ -271,61 +273,169 @@ def fotos_img(person_id: str):
return Response(data, media_type=ct)
# ---------- Gesichtssuche als Hintergrund-Job ----------
SCOPE_LABELS = {
"verband": "Nur Verband",
"stg": "Gesamter Studiengang",
"stud": "Alle Studierenden (FH-weit)",
"lektor": "Alle Lektoren",
"alle": "Alle Personen (Studierende + Lektoren)",
}
JOBS: dict[str, dict] = {}
JOBS_LOCK = threading.Lock()
MAX_JOBS = 8
def _scope_population(scope: str, kz: str, sem: str, ver: str) -> list[dict]:
"""Personen des Suchbereichs, dedupliziert nach uid."""
people: dict[str, dict] = {}
def add(uid, nachname, vorname, kind):
uid = (uid or "").strip()
if uid and uid not in people:
people[uid] = {"uid": uid, "nachname": nachname,
"vorname": vorname, "kind": kind}
if scope == "verband":
for s in client.namensliste(kz, sem, ver):
add(s.matrikel, s.nachname, s.vorname, "student")
elif scope == "stg":
sems = client.study_semesters(kz) or list(range(1, 7))
for s in sems:
for v in client.verbands_for(kz, str(s)):
for st in client.namensliste(kz, str(s), v):
add(st.matrikel, st.nachname, st.vorname, "student")
else:
for b in client.benutzer_all():
kind = "student" if b["uid"].isdigit() else "lektor"
if scope == "stud" and kind != "student":
continue
if scope == "lektor" and kind != "lektor":
continue
add(b["uid"], b["nachname"], b["vorname"], kind)
return list(people.values())
def _search_worker(job: dict, emb, population: list[dict]):
try:
for p in population:
pid = client.person_id_for(p["uid"])
if pid:
res = client.photo_bytes(pid)
if res:
e = _embed_person(pid, res[0])
if e is not None:
sim = engine.similarity(emb, e)
if sim >= 0.20:
entry = {**p, "sim": round(sim, 4),
"img": f"/fotos/img/{pid}"}
with JOBS_LOCK:
job["results"].append(entry)
job["results"].sort(key=lambda r: r["sim"], reverse=True)
del job["results"][60:]
job["done"] += 1
except Exception as ex:
job["error"] = str(ex)
job["status"] = "done"
job["took"] = round(time.time() - job["created"], 1)
def _start_job(emb, population: list[dict], scope: str) -> dict:
job = {
"id": uuid.uuid4().hex[:12],
"status": "running",
"done": 0,
"total": len(population),
"results": [],
"error": None,
"scope": scope,
"created": time.time(),
}
with JOBS_LOCK:
JOBS[job["id"]] = job
while len(JOBS) > MAX_JOBS:
oldest = min(JOBS, key=lambda k: JOBS[k]["created"])
JOBS.pop(oldest, None)
threading.Thread(target=_search_worker, args=(job, emb, population), daemon=True).start()
return job
@app.post("/fotos/suche", response_class=HTMLResponse)
def fotos_suche(request: Request,
kz: str = Form("640"),
sem: str = Form("1"),
ver: str = Form("V"),
scope: str = Form("verband"),
file: UploadFile = File(...)):
if scope not in SCOPE_LABELS:
scope = "verband"
meta = _meta()
sems = _study_sem_options(kz, current_semester(meta["semesters"]))
if sem not in [str(n) for n in sems]:
sem = str(sems[0])
base_ctx = {
ctx = {
"request": request,
"kz": kz, "sem": sem, "sems": sems, "ver": ver,
"results": [], "meta": meta,
"scope": scope,
"scope_label": SCOPE_LABELS[scope],
"meta": meta,
"stg": next((x for x in meta["stgs"] if x["kz"] == kz), None),
"verbands": client.verbands_for(kz, sem),
"own_uid": OWN_UID,
}
data = file.file.read()
if not data:
return templates.TemplateResponse(request, "fotos_result.html",
{**base_ctx, "error": "Leere Datei hochgeladen."},
return templates.TemplateResponse(request, "fotos_search.html",
{**ctx, "error": "Leere Datei hochgeladen.", "job": None},
status_code=400)
if len(data) > MAX_UPLOAD:
return templates.TemplateResponse(request, "fotos_result.html",
{**base_ctx, "error": "Datei zu groß (max. 8 MB)."},
return templates.TemplateResponse(request, "fotos_search.html",
{**ctx, "error": "Datei zu groß (max. 8 MB).", "job": None},
status_code=400)
emb = engine.embed(data)
if emb is None:
return templates.TemplateResponse(request, "fotos_result.html",
{**base_ctx, "error": "Kein Gesicht im hochgeladenen Bild gefunden."},
return templates.TemplateResponse(request, "fotos_search.html",
{**ctx, "error": "Kein Gesicht im hochgeladenen Bild gefunden.", "job": None},
status_code=422)
wall = _photo_wall(kz, sem, ver)
results = []
t0 = time.time()
for p in wall:
pid = p["person_id"]
if not pid:
continue
res = client.photo_bytes(pid)
if not res:
continue
e = _embed_person(pid, res[0])
if e is None:
continue
sim = engine.similarity(emb, e)
results.append({**p, "sim": round(sim, 4),
"img": f"/fotos/img/{pid}"})
results.sort(key=lambda r: r["sim"], reverse=True)
return templates.TemplateResponse(request, "fotos_result.html", {
**base_ctx,
"error": None,
"took": round(time.time() - t0, 1),
"query": {"kz": kz, "sem": sem, "ver": ver},
"results": results[:30],
population = _scope_population(scope, kz, sem, ver)
job = _start_job(emb, population, scope)
return templates.TemplateResponse(request, "fotos_search.html",
{**ctx, "error": None, "job": job})
@app.get("/api/suche/status/{job_id}")
def suche_status(job_id: str):
job = JOBS.get(job_id)
if not job:
return JSONResponse({"error": "Job unbekannt oder abgelaufen"}, status_code=404)
with JOBS_LOCK:
out = {
"status": job["status"],
"done": job["done"],
"total": job["total"],
"error": job["error"],
"took": job.get("took"),
"top": job["results"][:30],
}
return JSONResponse(out)
@app.get("/person/{uid}", response_class=HTMLResponse)
def person_page(request: Request, uid: str):
prof = client.profile(uid)
if not prof:
return templates.TemplateResponse(request, "person.html", {
"request": request, "prof": None, "uid": uid, "meta": _meta(),
}, status_code=404)
meta = _meta()
dec = decode_matrikel(uid, meta["stgs"]) if uid.isdigit() else None
return templates.TemplateResponse(request, "person.html", {
"request": request,
"prof": prof,
"uid": uid,
"dec": dec,
"meta": meta,
"own_uid": OWN_UID,
})