Fotos: Fotowand je Verband + Gesichtssuche (lokales SCRFD/MobileFaceNet via onnxruntime)

This commit is contained in:
Nicolas Gruber
2026-10-19 11:28:59 +02:00
parent 32b4dc4823
commit d97164cddc
12 changed files with 640 additions and 2 deletions
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
.venv/ .venv/
venv/ venv/
*.log *.log
data/
+3
View File
@@ -6,6 +6,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
+10
View File
@@ -5,6 +5,7 @@ Bessere Ansichten für das FH-Burgenland-CIS:
- **Studentenplan** - eigener oder fremder Plan (Matrikelnummer), Wochenansicht + Semesterübersicht, iCal/CSV-Export - **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 - **Studiengangs-Pläne** - nach Studiengang, Studien-Semester und Verband (Vollzeit/Berufsbegleitend), inkl. Gruppen-Filter
- **Namenslisten** - je Studiengang/Semester/Verband mit Suche und CSV-Export - **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 - **Studiennummer-Decoder** - Matrikelnummer wird in Startsemester, Studiengang und Nummer zerlegt
## Lokal starten ## Lokal starten
@@ -31,6 +32,15 @@ OWN_UID=<eigene matrikelnummer>
`.env` wird nicht in das Docker-Image kopiert (`.dockerignore`), sondern zur Laufzeit übergeben. `.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 ## Docker
```bash ```bash
+65
View File
@@ -1,14 +1,20 @@
"""CIS API client for FH-Burgenland (basic auth, HTML/iCal scraping).""" """CIS API client for FH-Burgenland (basic auth, HTML/iCal scraping)."""
import json
import re import re
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import lru_cache from functools import lru_cache
from pathlib import Path
from typing import Optional from typing import Optional
import requests import requests
from bs4 import BeautifulSoup 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 TZ = timezone(timedelta(hours=1)) # Europe/Vienna winter; events carry their own tz, we just parse naive
SLOTS = [ SLOTS = [
@@ -206,6 +212,65 @@ class CISClient:
return Person(uid=u, name=name.strip(), stg=stg.strip(), return Person(uid=u, name=name.strip(), stg=stg.strip(),
semester=sem.strip(), verband=ver.strip(), gruppe="") 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 ---------- # ---------- Namensliste ----------
def namensliste(self, kz: str, sem: str, ver: str) -> list[Student]: 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}") html = self._get(f"{self.base}/private/stud_in_grp.php?kz={kz}&sem={sem}&verband={ver}")
+209
View File
@@ -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()
+154 -1
View File
@@ -1,10 +1,12 @@
"""CIS Enhanced - FastAPI app.""" """CIS Enhanced - FastAPI app."""
import json
import os import os
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv 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.responses import HTMLResponse, JSONResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -17,7 +19,10 @@ for _var in ("CIS_USER", "CIS_PASSWORD"):
from fastapi.staticfiles import StaticFiles # noqa: E402 from fastapi.staticfiles import StaticFiles # noqa: E402
import numpy as np # noqa: E402
from .cis import CISClient, SLOTS # 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 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)
@@ -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") @app.get("/api/meta")
def api_meta(): def api_meta():
return _meta() return _meta()
+38
View File
@@ -189,6 +189,44 @@ table.data .num { text-align: right; font-variant-numeric: tabular-nums; }
.decoder-result { margin-top: 14px; display: none; } .decoder-result { margin-top: 14px; display: none; }
.decoder-result.show { display: block; } .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; } .footer { border-top: 1px solid var(--line); padding: 16px 0; color: var(--muted); font-size: 13px; background: #fff; }
@media (max-width: 720px) { @media (max-width: 720px) {
+1
View File
@@ -18,6 +18,7 @@
<a href="/plan" class="{{ 'active' if path.startswith('/plan') }}">Studentenplan</a> <a href="/plan" class="{{ 'active' if path.startswith('/plan') }}">Studentenplan</a>
<a href="/stg" class="{{ 'active' if path.startswith('/stg') }}">Studiengänge</a> <a href="/stg" class="{{ 'active' if path.startswith('/stg') }}">Studiengänge</a>
<a href="/liste" class="{{ 'active' if path.startswith('/liste') }}">Namenslisten</a> <a href="/liste" class="{{ 'active' if path.startswith('/liste') }}">Namenslisten</a>
<a href="/fotos" class="{{ 'active' if path.startswith('/fotos') }}">Fotos</a>
<a href="/" class="{{ 'active' if path == '/' }}">Start</a> <a href="/" class="{{ 'active' if path == '/' }}">Start</a>
</nav> </nav>
</div> </div>
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Fotos{% endblock %}
{% block content %}
<h1 class="page-title">Fotos
{% if stg %}<span class="badge">{{ stg.code }} - {{ stg.name }}</span>{% endif %}
<span class="badge badge-muted">{{ ver == 'V' and 'Vollzeit' or ver }}</span>
</h1>
<div class="card">
<form class="form-row" method="get" action="/fotos">
<div class="field">
<label>Studiengang</label>
<select name="kz" required>
{% for s in meta.stgs %}
<option value="{{ s.kz }}" {{ 'selected' if s.kz == kz }}>{{ s.code }} - {{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Studien-Semester</label>
<select name="sem">
{% for n in range(1, 9) %}
<option value="{{ n }}" {{ 'selected' if (n | string) == sem }}>{{ n }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Verband</label>
<select name="ver">
{% for v in verbands %}
<option value="{{ v }}" {{ 'selected' if v == ver }}>{{ v }}{{ verband_label(v) }}</option>
{% endfor %}
</select>
</div>
<button class="btn btn-primary" type="submit">Laden</button>
</form>
</div>
<div class="card">
<h2>Fotowand <span class="count-pill">{{ wall | selectattr('has_photo') | list | length }}/{{ wall | length }}</span></h2>
<div class="photo-wall" id="photo-wall">
{% for p in wall %}
<div class="photo-tile" data-uid="{{ p.matrikel }}" data-initials="{{ (p.nachname[:1] ~ p.vorname[:1]) | upper }}">
<div class="photo-frame">
{% if p.person_id %}
<img loading="lazy" src="/fotos/img/{{ p.person_id }}" alt="{{ p.nachname }}"
onerror="this.closest('.photo-frame').innerHTML='<div class=&quot;avatar-initials&quot;>' + this.closest('.photo-tile').dataset.initials + '</div>'">
{% else %}
<div class="avatar-initials">{{ (p.nachname[:1] ~ p.vorname[:1]) | upper }}</div>
{% endif %}
</div>
<div class="photo-name">{{ p.nachname }}{% if p.vorname %}, {{ p.vorname }}{% endif %}</div>
{% if p.matrikel == own_uid %}<div class="photo-own">Das bist du</div>{% endif %}
</div>
{% else %}
<p class="empty">Keine Studierenden in diesem Verband/Semester.</p>
{% endfor %}
</div>
</div>
<div class="card">
<h2>Gesichtssuche</h2>
<p class="hint">Foto von dir hochladen - die App findet die passenden Profile in diesem Verband.</p>
<form class="form-row form-stack" method="post" action="/fotos/suche" enctype="multipart/form-data">
<input type="hidden" name="kz" value="{{ kz }}">
<input type="hidden" name="sem" value="{{ sem }}">
<input type="hidden" name="ver" value="{{ ver }}">
<div class="field">
<label>Foto</label>
<input type="file" name="file" accept="image/*" required>
</div>
<button class="btn btn-primary" type="submit">Suchen</button>
</form>
</div>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Gesichtssuche{% endblock %}
{% block content %}
<h1 class="page-title">Gesichtssuche
{% if stg %}<span class="badge">{{ stg.code }} - {{ stg.name }}</span>{% endif %}
</h1>
<div class="card">
<form class="form-row" method="get" action="/fotos">
<div class="field">
<label>Studiengang</label>
<select name="kz">
{% for s in meta.stgs %}
<option value="{{ s.kz }}" {{ 'selected' if s.kz == kz }}>{{ s.code }} - {{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Studien-Semester</label>
<select name="sem">
{% for n in range(1, 9) %}
<option value="{{ n }}" {{ 'selected' if (n | string) == sem }}>{{ n }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Verband</label>
<select name="ver">
{% for v in verbands %}
<option value="{{ v }}" {{ 'selected' if v == ver }}>{{ v }}{{ verband_label(v) }}</option>
{% endfor %}
</select>
</div>
<button class="btn btn-primary" type="submit">Zurück zur Fotowand</button>
</form>
</div>
{% if error %}
<div class="card">
<p class="empty" style="color:#b3261e">{{ error }}</p>
</div>
{% else %}
<div class="card">
<h2>
Ergebnisse
<span class="count-pill">{{ results | selectattr('sim', 'ge', 0.35) | list | length }} Treffer</span>
{% if took is defined %}<span class="badge badge-muted">{{ took }} s</span>{% endif %}
</h2>
{% if results | selectattr('sim', 'ge', 0.35) | list | length %}
<div class="match-grid">
{% for r in results[:30] %}
<div class="match-tile {{ 'match-strong' if r.sim >= 0.5 else ('match-mid' if r.sim >= 0.35 else '') }}">
<img src="{{ r.img }}" alt="{{ r.nachname }}">
<div class="match-name">
<a href="/plan?uid={{ r.matrikel }}">{{ r.nachname }}{% if r.vorname %}, {{ r.vorname }}{% endif %}</a>
</div>
<div class="match-sim">
<div class="sim-bar"><div style="width: {{ [r.sim * 100, 0] | max | round(0) }}%"></div></div>
<span class="sim-num">{{ [r.sim * 100, 0] | max | round(0) }} %</span>
</div>
</div>
{% endfor %}
</div>
<p class="hint" style="margin-top:12px">
<strong>Hinweis:</strong> Hohe Werte (grün, &ge; 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.
</p>
{% else %}
<p class="empty">Kein Treffer mit &ge; 35 % Ähnlichkeit gefunden.
Prüfe, ob das richtige Semester/Verband gewählt ist und das Foto ein klar sichtbares Gesicht zeigt.</p>
{% endif %}
</div>
{% endif %}
{% endblock %}
+5
View File
@@ -5,4 +5,9 @@ services:
env_file: .env env_file: .env
ports: ports:
- "8000:8000" - "8000:8000"
volumes:
- cis-data:/app/data
restart: unless-stopped restart: unless-stopped
volumes:
cis-data:
+4
View File
@@ -4,3 +4,7 @@ requests
beautifulsoup4 beautifulsoup4
jinja2 jinja2
python-dotenv python-dotenv
python-multipart
numpy
pillow
onnxruntime