Fotos: Fotowand je Verband + Gesichtssuche (lokales SCRFD/MobileFaceNet via onnxruntime)
This commit is contained in:
@@ -4,3 +4,4 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
*.log
|
*.log
|
||||||
|
data/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="avatar-initials">' + 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 %}
|
||||||
@@ -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, ≥ 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 ≥ 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,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,3 +4,7 @@ requests
|
|||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
jinja2
|
jinja2
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
python-multipart
|
||||||
|
numpy
|
||||||
|
pillow
|
||||||
|
onnxruntime
|
||||||
|
|||||||
Reference in New Issue
Block a user