From d97164cddcc4cbef869e8f453215a4b7329ea5ef Mon Sep 17 00:00:00 2001 From: Nicolas Gruber Date: Mon, 19 Oct 2026 11:28:59 +0200 Subject: [PATCH] Fotos: Fotowand je Verband + Gesichtssuche (lokales SCRFD/MobileFaceNet via onnxruntime) --- .gitignore | 1 + Dockerfile | 3 + README.md | 10 ++ app/cis.py | 65 ++++++++++ app/face.py | 209 ++++++++++++++++++++++++++++++++ app/main.py | 157 +++++++++++++++++++++++- app/static/style.css | 38 ++++++ app/templates/base.html | 1 + app/templates/fotos.html | 75 ++++++++++++ app/templates/fotos_result.html | 74 +++++++++++ docker-compose.yml | 5 + requirements.txt | 4 + 12 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 app/face.py create mode 100644 app/templates/fotos.html create mode 100644 app/templates/fotos_result.html diff --git a/.gitignore b/.gitignore index dd54f30..934a1d9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .venv/ venv/ *.log +data/ diff --git a/Dockerfile b/Dockerfile index be9a881..d1c582b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install -r requirements.txt diff --git a/README.md b/README.md index d6be023..25b9765 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Bessere Ansichten für das FH-Burgenland-CIS: - **Studentenplan** - eigener oder fremder Plan (Matrikelnummer), Wochenansicht + Semesterübersicht, iCal/CSV-Export - **Studiengangs-Pläne** - nach Studiengang, Studien-Semester und Verband (Vollzeit/Berufsbegleitend), inkl. Gruppen-Filter - **Namenslisten** - je Studiengang/Semester/Verband mit Suche und CSV-Export +- **Fotos** - Fotowand je Verband + **Gesichtssuche**: eigenes Foto hochladen, die App findet die passenden Profile (lokaler SCRFD/MobileFaceNet, läuft ohne Internet-API) - **Studiennummer-Decoder** - Matrikelnummer wird in Startsemester, Studiengang und Nummer zerlegt ## Lokal starten @@ -31,6 +32,15 @@ OWN_UID= `.env` wird nicht in das Docker-Image kopiert (`.dockerignore`), sondern zur Laufzeit übergeben. +### Datenverzeichnis `data/` + +Das Gesichtsmatching lädt beim ersten Start die Modelle (~16 MB, +`insightface/buffalo_sc`) nach `data/models` und speichert Caches in +`data/` (Profilbilder, Person-IDs, Gesichtsembeddings). Alles wird +automatisch angelegt. Im Docker-Setup ist `data/` als Named Volume +(`cis-data`) gemountet, damit Modelle und Caches beim Rebuild erhalten +bleiben. + ## Docker ```bash diff --git a/app/cis.py b/app/cis.py index 583bd43..45621fa 100644 --- a/app/cis.py +++ b/app/cis.py @@ -1,14 +1,20 @@ """CIS API client for FH-Burgenland (basic auth, HTML/iCal scraping).""" +import json import re import time from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from functools import lru_cache +from pathlib import Path from typing import Optional import requests from bs4 import BeautifulSoup +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +PERSON_ID_CACHE = DATA_DIR / "person_ids.json" +PHOTO_DIR = DATA_DIR / "photos" + TZ = timezone(timedelta(hours=1)) # Europe/Vienna winter; events carry their own tz, we just parse naive SLOTS = [ @@ -206,6 +212,65 @@ class CISClient: return Person(uid=u, name=name.strip(), stg=stg.strip(), semester=sem.strip(), verband=ver.strip(), gruppe="") + # ---------- person_id + photos ---------- + def person_id_for(self, uid: str) -> str: + """Matrikel/username → CIS person_id (persistent JSON cache).""" + uid = (uid or "").strip() + if not uid: + return "" + cache: dict = {} + if PERSON_ID_CACHE.exists(): + try: + cache = json.loads(PERSON_ID_CACHE.read_text()) + except Exception: + cache = {} + if uid in cache: + return cache[uid] + try: + html = self._get(f"{self.base}/private/profile/index.php?uid={uid}") + except requests.HTTPError: + return "" + m = re.search(r"bild\.php\?src=person&person_id=(\d+)", html) + if not m: + return "" + pid = m.group(1) + cache[uid] = pid + try: + PERSON_ID_CACHE.parent.mkdir(parents=True, exist_ok=True) + PERSON_ID_CACHE.write_text(json.dumps(cache, indent=0)) + except OSError: + pass + return pid + + def photo_bytes(self, person_id: str, max_age: int = 7 * 86400) -> Optional[tuple[bytes, str]]: + """Profilfoto holen (7-Tage-Disk-Cache). (bytes, content_type) oder None.""" + person_id = (person_id or "").strip() + if not person_id.isdigit(): + return None + f = PHOTO_DIR / f"{person_id}.bin" + if f.exists() and time.time() - f.stat().st_mtime < max_age: + try: + return f.read_bytes(), "image/jpeg" + except OSError: + pass + try: + r = self.s.get(f"{self.base}/public/bild.php?src=person&person_id={person_id}", timeout=30) + r.raise_for_status() + except requests.RequestException: + return None + data = r.content + if not data: + return None + ct = (r.headers.get("Content-Type") or "image/jpeg").split(";")[0].strip() + if ct not in ("image/jpeg", "image/png", "image/gif"): + return None + try: + PHOTO_DIR.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + except OSError: + pass + return data, ct + # ---------- Namensliste ---------- def namensliste(self, kz: str, sem: str, ver: str) -> list[Student]: html = self._get(f"{self.base}/private/stud_in_grp.php?kz={kz}&sem={sem}&verband={ver}") diff --git a/app/face.py b/app/face.py new file mode 100644 index 0000000..d48b337 --- /dev/null +++ b/app/face.py @@ -0,0 +1,209 @@ +"""Face matching: SCRFD detection + MobileFaceNet embeddings (onnxruntime, insightface buffalo_sc).""" +import io +import zipfile +from pathlib import Path + +import numpy as np +import onnxruntime as ort +from PIL import Image + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +MODEL_DIR = DATA_DIR / "models" +ZIP_URL = "https://github.com/deepinsight/insightface/releases/download/v0.7/buffalo_sc.zip" +DET_FILE = MODEL_DIR / "det_500m.onnx" +REC_FILE = MODEL_DIR / "w600k_mbf.onnx" + +DET_SIZE = 640 +DET_STRIDES = (8, 16, 32) +NUM_ANCHORS = 2 +DET_SCORE_THRESH = 0.40 +DET_NMS_THRESH = 0.40 +DET_INPUT_MEAN = 127.5 +DET_INPUT_STD = 128.0 +REC_SIZE = 112 +REC_INPUT_MEAN = 127.5 +REC_INPUT_STD = 127.5 + +# ArcFace 5-point reference (le, re, nose, lm, rm) in 112x112 +REF5 = np.array([ + [38.2946, 51.6963], + [73.5318, 51.5014], + [56.0252, 71.7366], + [41.5493, 92.3655], + [70.7299, 92.2041], +], dtype=np.float64) + + +def _nms(boxes, scores, thresh): + x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] + areas = (x2 - x1 + 1) * (y2 - y1 + 1) + order = scores.argsort()[::-1] + keep = [] + while order.size > 0: + i = order[0] + keep.append(i) + rest = order[1:] + if rest.size == 0: + break + xx1 = np.maximum(x1[i], x1[rest]) + yy1 = np.maximum(y1[i], y1[rest]) + xx2 = np.minimum(x2[i], x2[rest]) + yy2 = np.minimum(y2[i], y2[rest]) + inter = np.maximum(0.0, xx2 - xx1 + 1) * np.maximum(0.0, yy2 - yy1 + 1) + iou = inter / (areas[i] + areas[rest] - inter) + order = rest[iou <= thresh] + return keep + + +class FaceEngine: + def __init__(self): + self._det = None + self._rec = None + self._det_in = "" + self._rec_in = "" + self._lock = None + + def _load(self): + if self._det is not None: + return + import threading + self._lock = threading.Lock() + with self._lock: + if self._det is not None: + return + if not DET_FILE.exists() or not REC_FILE.exists(): + MODEL_DIR.mkdir(parents=True, exist_ok=True) + import time as _time + import urllib.request + req = urllib.request.Request(ZIP_URL, headers={"User-Agent": "Mozilla/5.0"}) + for attempt in range(3): + try: + blob = urllib.request.urlopen(req, timeout=600).read() + break + except Exception: + if attempt == 2: + raise + _time.sleep(2 * (attempt + 1)) + with zipfile.ZipFile(io.BytesIO(blob)) as zf: + zf.extractall(MODEL_DIR) + opts = ort.SessionOptions() + opts.intra_op_num_threads = 2 + self._det = ort.InferenceSession(str(DET_FILE), opts, providers=["CPUExecutionProvider"]) + self._rec = ort.InferenceSession(str(REC_FILE), opts, providers=["CPUExecutionProvider"]) + self._det_in = self._det.get_inputs()[0].name + self._rec_in = self._rec.get_inputs()[0].name + + def _detect(self, img: Image.Image): + """SCRFD decode (insightface). Returns (box (x1,y1,x2,y2), kps (5,2)) in original coords, or None.""" + h, w = img.height, img.width + im_ratio = h / w + if im_ratio > 1.0: + new_h = DET_SIZE + new_w = int(new_h / im_ratio) + else: + new_w = DET_SIZE + new_h = int(new_w * im_ratio) + det_scale = new_h / h + det = np.zeros((DET_SIZE, DET_SIZE, 3), dtype=np.uint8) + det[:new_h, :new_w] = np.asarray(img.convert("RGB").resize((new_w, new_h))) + blob = ((det.astype(np.float32) - DET_INPUT_MEAN) / DET_INPUT_STD).transpose(2, 0, 1)[None] + outs = self._det.run(None, {self._det_in: blob}) + scores_list, bboxes_list, kpss_list = [], [], [] + for idx, stride in enumerate(DET_STRIDES): + scores = outs[idx][:, 0] + keep = scores >= DET_SCORE_THRESH + if not keep.any(): + continue + H = DET_SIZE // stride + W = DET_SIZE // stride + centers = (np.stack(np.mgrid[:H, :W][::-1], axis=-1).astype(np.float32) * stride).reshape(-1, 2) + centers = np.stack([centers] * NUM_ANCHORS, axis=1).reshape(-1, 2) + c = centers[keep] + b = outs[idx + 3][keep] * stride + boxes = np.stack([c[:, 0] - b[:, 0], c[:, 1] - b[:, 1], + c[:, 0] + b[:, 2], c[:, 1] + b[:, 3]], axis=-1) + k = outs[idx + 6][keep] * stride + kpss = np.stack([c[:, 0:1] + k[:, 0::2], c[:, 1:2] + k[:, 1::2]], axis=-1).reshape(-1, 5, 2) + scores_list.append(scores[keep]) + bboxes_list.append(boxes / det_scale) + kpss_list.append(kpss / det_scale) + if not scores_list: + return None + scores = np.concatenate(scores_list) + bboxes = np.concatenate(bboxes_list) + kpss = np.concatenate(kpss_list) + order = np.argsort(-scores, kind="stable") + keep = _nms(bboxes[order], scores[order], DET_NMS_THRESH) + if not keep: + return None + sel = [order[k] for k in keep] + areas = (bboxes[sel, 2] - bboxes[sel, 0]) * (bboxes[sel, 3] - bboxes[sel, 1]) + cx = (bboxes[sel, 0] + bboxes[sel, 2]) / 2 - w / 2 + cy = (bboxes[sel, 1] + bboxes[sel, 3]) / 2 - h / 2 + values = areas - 2.0 * (cx ** 2 + cy ** 2) + best = sel[int(np.argmax(values))] + return bboxes[best], kpss[best] + + @staticmethod + def _align(img: Image.Image, pts) -> Image.Image: + src = pts.astype(np.float64) + dst = REF5 + A = [] + bvec = [] + for (x, y), (X, Y) in zip(src, dst): + A.append([x, -y, 1, 0]) + A.append([y, x, 0, 1]) + bvec.append(X) + bvec.append(Y) + sol, *_ = np.linalg.lstsq(np.array(A), np.array(bvec), rcond=None) + a, b, c, d = sol + s = a * a + b * b + if s < 1e-6: + return img + ap = a / s + bp = b / s + cp = -(a * c + b * d) / s + dp = -b / s + ep = a / s + fp = (b * c - a * d) / s + return img.transform((REC_SIZE, REC_SIZE), Image.AFFINE, (ap, bp, cp, dp, ep, fp), + Image.BICUBIC, fillcolor=(128, 128, 128)) + + def embed(self, img_bytes: bytes) -> np.ndarray | None: + """512-d L2-normalized embedding of the most prominent face, or None.""" + self._load() + try: + img = Image.open(io.BytesIO(img_bytes)) + img.load() + except Exception: + return None + det = self._detect(img) + if not det: + return None + box, pts = det + x1, y1, x2, y2 = [int(v) for v in box] + x1 = max(0, x1) + y1 = max(0, y1) + x2 = min(img.width, x2) + y2 = min(img.height, y2) + if x2 - x1 < 20 or y2 - y1 < 20: + return None + if pts is not None: + face = self._align(img, pts) + else: + face = img.crop((x1, y1, x2, y2)).resize((REC_SIZE, REC_SIZE)) + arr = np.asarray(face.convert("RGB"), dtype=np.float32) + arr = (arr - REC_INPUT_MEAN) / REC_INPUT_STD + arr = arr.transpose(2, 0, 1)[None] + emb = self._rec.run(None, {self._rec_in: arr})[0][0] + n = np.linalg.norm(emb) + if n < 1e-6: + return None + return (emb / n).astype(np.float32) + + @staticmethod + def similarity(a: np.ndarray, b: np.ndarray) -> float: + return float(np.dot(a, b)) + + +engine = FaceEngine() diff --git a/app/main.py b/app/main.py index 1e52ea2..e866994 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,12 @@ """CIS Enhanced - FastAPI app.""" +import json import os +import time from datetime import datetime, timedelta from pathlib import Path from dotenv import load_dotenv -from fastapi import FastAPI, Query, Request +from fastapi import FastAPI, File, Form, Query, Request, UploadFile from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.templating import Jinja2Templates @@ -17,9 +19,12 @@ for _var in ("CIS_USER", "CIS_PASSWORD"): from fastapi.staticfiles import StaticFiles # noqa: E402 +import numpy as np # noqa: E402 + from .cis import CISClient, SLOTS # noqa: E402 +from .face import engine # noqa: E402 from .util import (current_semester, decode_matrikel, events_to_json, fmt_range, # noqa: E402 - lv_color, week_label, week_start) + lv_color, week_label, week_start) BASE = os.environ["CIS_BASE_URL"].rstrip("/") client = CISClient(os.environ["CIS_USER"], os.environ["CIS_PASSWORD"], BASE) @@ -158,6 +163,154 @@ def liste(request: Request, }) +# ---------- Fotos / Gesichter ---------- +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +EMB_CACHE = DATA_DIR / "embeddings.json" +MAX_UPLOAD = 8 * 1024 * 1024 + + +def _emb_cache() -> dict: + if EMB_CACHE.exists(): + try: + return json.loads(EMB_CACHE.read_text()) + except Exception: + pass + return {} + + +def _embed_person(pid: str, photo: bytes) -> np.ndarray | None: + cache = _emb_cache() + if pid in cache: + return np.asarray(cache[pid], dtype=np.float32) + v = engine.embed(photo) + if v is None: + return None + cache[pid] = v.tolist() + try: + DATA_DIR.mkdir(parents=True, exist_ok=True) + EMB_CACHE.write_text(json.dumps(cache)) + except OSError: + pass + return v + + +def _photo_wall(kz: str, sem: str, ver: str) -> list[dict]: + """Students of a verband with person_id + whether a photo exists (cached check).""" + students = client.namensliste(kz, sem, ver) + wall = [] + for s in students: + pid = client.person_id_for(s.matrikel) + wall.append({ + "nachname": s.nachname, "vorname": s.vorname, + "matrikel": s.matrikel, "person_id": pid, + "has_photo": bool(pid), + }) + return wall + + +@app.get("/fotos", response_class=HTMLResponse) +def fotos_page(request: Request, + kz: str = Query("640"), + sem: str = Query("1"), + ver: str = Query("V")): + meta = _meta() + stg = next((x for x in meta["stgs"] if x["kz"] == kz), None) + verbands = client.verbands_for(kz, sem) + wall = _photo_wall(kz, sem, ver) + return templates.TemplateResponse(request, "fotos.html", { + "request": request, + "meta": meta, + "kz": kz, + "sem": sem, + "ver": ver, + "verbands": verbands, + "stg": stg, + "wall": wall, + "wall_json": wall, + "own_uid": OWN_UID, + }) + + +@app.get("/fotos/img/{person_id}") +def fotos_img(person_id: str): + if not person_id.isdigit(): + return JSONResponse({"error": "Ungültige ID"}, status_code=400) + res = client.photo_bytes(person_id) + if not res: + return JSONResponse({"error": "Kein Foto vorhanden"}, status_code=404) + data, ct = res + return Response(data, media_type=ct) + + +@app.post("/fotos/suche", response_class=HTMLResponse) +def fotos_suche(request: Request, + kz: str = Form("640"), + sem: str = Form("1"), + ver: str = Form("V"), + file: UploadFile = File(...)): + data = file.file.read() + if not data: + return templates.TemplateResponse(request, "fotos_result.html", { + "request": request, "error": "Leere Datei hochgeladen.", + "kz": kz, "sem": sem, "ver": ver, "results": [], "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, + }, status_code=400) + if len(data) > MAX_UPLOAD: + return templates.TemplateResponse(request, "fotos_result.html", { + "request": request, "error": "Datei zu groß (max. 8 MB).", + "kz": kz, "sem": sem, "ver": ver, "results": [], "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, + }, status_code=400) + emb = engine.embed(data) + if emb is None: + return templates.TemplateResponse(request, "fotos_result.html", { + "request": request, "error": "Kein Gesicht im hochgeladenen Bild gefunden.", + "kz": kz, "sem": sem, "ver": ver, "results": [], "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, + }, 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) + meta = _meta() + return templates.TemplateResponse(request, "fotos_result.html", { + "request": request, + "error": None, + "took": round(time.time() - t0, 1), + "query": {"kz": kz, "sem": sem, "ver": ver}, + "kz": kz, "sem": sem, "ver": ver, + "results": results[:30], + "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, + }) + + +@app.get("/api/person_id") +def api_person_id(uid: str = Query(...)): + return {"uid": uid, "person_id": client.person_id_for(uid)} + + @app.get("/api/meta") def api_meta(): return _meta() diff --git a/app/static/style.css b/app/static/style.css index 84b204f..1a7a311 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -189,6 +189,44 @@ table.data .num { text-align: right; font-variant-numeric: tabular-nums; } .decoder-result { margin-top: 14px; display: none; } .decoder-result.show { display: block; } +/* ---------- fotos ---------- */ +.hint { color: var(--muted); font-size: 13.5px; margin: 0 0 14px; } +.form-stack { flex-direction: column; align-items: stretch; gap: 12px; } +.photo-wall { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; } +.photo-tile { text-align: center; } +.photo-frame { + aspect-ratio: 3 / 4; border-radius: var(--radius); overflow: hidden; + background: var(--bg); border: 1px solid var(--line); display: flex; + align-items: center; justify-content: center; margin-bottom: 8px; +} +.photo-frame img { width: 100%; height: 100%; object-fit: cover; display: block; } +.avatar-initials { + font-size: 30px; font-weight: 800; color: var(--blue-dark); + background: var(--blue-light); width: 100%; height: 100%; + display: flex; align-items: center; justify-content: center; +} +.photo-name { font-size: 13.5px; font-weight: 600; color: var(--ink); line-height: 1.3; } +.photo-own { font-size: 11.5px; color: var(--green); font-weight: 700; margin-top: 2px; } +.photo-tile.has-own { border: none; } + +.match-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; } +.match-tile { + background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); + overflow: hidden; padding: 0; +} +.match-tile img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; background: var(--bg); } +.match-name { padding: 8px 10px 4px; font-size: 13.5px; font-weight: 600; } +.match-name a { color: var(--ink); } +.match-sim { padding: 0 10px 12px; display: flex; align-items: center; gap: 8px; } +.sim-bar { flex: 1; height: 8px; background: #eef2f7; border-radius: 6px; overflow: hidden; } +.sim-bar > div { height: 100%; background: #94a3b8; border-radius: 6px; } +.sim-num { font-size: 12.5px; font-weight: 700; color: var(--muted); min-width: 42px; text-align: right; } +.match-tile.match-mid .sim-bar > div { background: #d97706; } +.match-tile.match-mid .sim-num { color: #d97706; } +.match-tile.match-strong { border-color: var(--green); box-shadow: 0 0 0 2px rgba(21,128,61,.15); } +.match-tile.match-strong .sim-bar > div { background: var(--green); } +.match-tile.match-strong .sim-num { color: var(--green); } + .footer { border-top: 1px solid var(--line); padding: 16px 0; color: var(--muted); font-size: 13px; background: #fff; } @media (max-width: 720px) { diff --git a/app/templates/base.html b/app/templates/base.html index 925cd09..1de0dab 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -18,6 +18,7 @@ Studentenplan Studiengänge Namenslisten + Fotos Start diff --git a/app/templates/fotos.html b/app/templates/fotos.html new file mode 100644 index 0000000..3379dda --- /dev/null +++ b/app/templates/fotos.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}Fotos{% endblock %} +{% block content %} +

Fotos + {% if stg %}{{ stg.code }} - {{ stg.name }}{% endif %} + {{ ver == 'V' and 'Vollzeit' or ver }} +

+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Fotowand {{ wall | selectattr('has_photo') | list | length }}/{{ wall | length }}

+
+ {% for p in wall %} +
+
+ {% if p.person_id %} + {{ p.nachname }} + {% else %} +
{{ (p.nachname[:1] ~ p.vorname[:1]) | upper }}
+ {% endif %} +
+
{{ p.nachname }}{% if p.vorname %}, {{ p.vorname }}{% endif %}
+ {% if p.matrikel == own_uid %}
Das bist du
{% endif %} +
+ {% else %} +

Keine Studierenden in diesem Verband/Semester.

+ {% endfor %} +
+
+ +
+

Gesichtssuche

+

Foto von dir hochladen - die App findet die passenden Profile in diesem Verband.

+
+ + + +
+ + +
+ +
+
+{% endblock %} diff --git a/app/templates/fotos_result.html b/app/templates/fotos_result.html new file mode 100644 index 0000000..f8dc142 --- /dev/null +++ b/app/templates/fotos_result.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}Gesichtssuche{% endblock %} +{% block content %} +

Gesichtssuche + {% if stg %}{{ stg.code }} - {{ stg.name }}{% endif %} +

+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +{% if error %} +
+

{{ error }}

+
+{% else %} +
+

+ Ergebnisse + {{ results | selectattr('sim', 'ge', 0.35) | list | length }} Treffer + {% if took is defined %}{{ took }} s{% endif %} +

+ {% if results | selectattr('sim', 'ge', 0.35) | list | length %} +
+ {% for r in results[:30] %} +
+ {{ r.nachname }} + +
+
+ {{ [r.sim * 100, 0] | max | round(0) }} % +
+
+ {% endfor %} +
+

+ Hinweis: Hohe Werte (grün, ≥ 50 %) deuten auf dieselbe Person hin. + Werte um 35–50 % (gelb) sind mögliche Treffer - bitte prüfen. Unter 35 % wird nicht als Treffer gezählt. +

+ {% else %} +

Kein Treffer mit ≥ 35 % Ähnlichkeit gefunden. + Prüfe, ob das richtige Semester/Verband gewählt ist und das Foto ein klar sichtbares Gesicht zeigt.

+ {% endif %} +
+{% endif %} +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index 882a47f..176afbb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,9 @@ services: env_file: .env ports: - "8000:8000" + volumes: + - cis-data:/app/data restart: unless-stopped + +volumes: + cis-data: diff --git a/requirements.txt b/requirements.txt index 1b3856e..1bfbd0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,7 @@ requests beautifulsoup4 jinja2 python-dotenv +python-multipart +numpy +pillow +onnxruntime