Fotos: Gesichtssuche FH-weit (Studierende + Lektoren) als Job mit Live-Progress, Person-Info-Seiten, Scope-Auswahl
This commit is contained in:
+141
-31
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user