Spaces:
Running
Running
Satyam S commited on
Commit ·
2f152d3
1
Parent(s): 08ddd4b
Switch attendance recognition backbone from glintr100 to AdaFace IR-101
Browse filesSame content as origin/main's cd43f50, applied directly on top of this
Space's existing history (not origin's full history, to avoid dragging in
old already-removed binary blobs from origin's deep history).
- activity_web/backend/attendance_service.py +36 -21
- download_models.py +15 -0
- requirements.txt +1 -0
- utils/adaface_backbone.py +197 -0
activity_web/backend/attendance_service.py
CHANGED
|
@@ -11,21 +11,31 @@ import cv2
|
|
| 11 |
import numpy as np
|
| 12 |
|
| 13 |
from insightface.app import FaceAnalysis
|
|
|
|
| 14 |
from .startup import ensure_insightface_models_flat
|
| 15 |
from .config import ATTENDANCE_DIR
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
UPLOAD_DIR = ATTENDANCE_DIR / "uploads"
|
| 19 |
MARKED_DIR = ATTENDANCE_DIR / "marked"
|
| 20 |
STORE_PATH = ATTENDANCE_DIR / "attendance_store.json"
|
| 21 |
PHOTOS_PER_VIDEO = 32
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
| 24 |
ENROLLMENT_MIN_DET_SCORE = 0.50
|
|
|
|
|
|
|
|
|
|
| 25 |
ENROLLMENT_OUTLIER_SIM_THRESHOLD = 0.50
|
| 26 |
MAX_STORED_EMBEDDINGS = 128
|
| 27 |
ENROLLMENT_SAMPLE_INTERVAL_S = 1.0 # sample one frame every 1 second for enrollment
|
| 28 |
MAX_ENROLLMENT_FRAMES = 30 # cap to avoid overly long videos
|
|
|
|
|
|
|
| 29 |
ANCHOR_CONSISTENCY_THRESHOLD = 0.35
|
| 30 |
# Degradation scales applied during enrollment to bridge the gap between
|
| 31 |
# close-up enrollment faces and small distant classroom faces.
|
|
@@ -51,7 +61,15 @@ def _normalize(vector: np.ndarray) -> np.ndarray:
|
|
| 51 |
return vector / norm
|
| 52 |
|
| 53 |
|
| 54 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
"""Shrink a face crop to `scale` fraction then bicubic back to original size,
|
| 56 |
then re-run recognition on that blurry crop. Simulates how a distant classroom
|
| 57 |
face looks to the recognition model, so the enrollment gallery contains embeddings
|
|
@@ -68,13 +86,13 @@ def _degrade_and_embed(fa: FaceAnalysis, frame: np.ndarray, bbox: tuple, scale:
|
|
| 68 |
degraded = cv2.resize(crop, (small_w, small_h), interpolation=cv2.INTER_AREA)
|
| 69 |
degraded = cv2.resize(degraded, (cw, ch), interpolation=cv2.INTER_CUBIC)
|
| 70 |
|
| 71 |
-
# Paste degraded crop back into a copy of the frame and re-run
|
| 72 |
frame_copy = frame.copy()
|
| 73 |
frame_copy[y1:y2, x1:x2] = degraded
|
| 74 |
faces = fa.get(frame_copy)
|
| 75 |
|
| 76 |
# Pick the face closest to the original bbox
|
| 77 |
-
|
| 78 |
for f in faces:
|
| 79 |
fx1, fy1, fx2, fy2 = [int(v) for v in f.bbox]
|
| 80 |
ix1, iy1 = max(x1, fx1), max(y1, fy1)
|
|
@@ -82,14 +100,12 @@ def _degrade_and_embed(fa: FaceAnalysis, frame: np.ndarray, bbox: tuple, scale:
|
|
| 82 |
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
|
| 83 |
union = (x2-x1)*(y2-y1) + (fx2-fx1)*(fy2-fy1) - inter
|
| 84 |
iou = inter / union if union > 0 else 0.0
|
| 85 |
-
if iou > best_iou:
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
best_iou = iou
|
| 92 |
-
return best_emb
|
| 93 |
|
| 94 |
|
| 95 |
|
|
@@ -116,10 +132,11 @@ class AttendanceService:
|
|
| 116 |
ensure_attendance_dirs()
|
| 117 |
self.face_analysis = FaceAnalysis(
|
| 118 |
name="antelopev2",
|
| 119 |
-
allowed_modules=["detection"
|
| 120 |
providers=["CPUExecutionProvider"],
|
| 121 |
)
|
| 122 |
self.face_analysis.prepare(ctx_id=0, det_size=(1280, 1280), det_thresh=0.5)
|
|
|
|
| 123 |
self._migrate_legacy_embeddings_if_needed()
|
| 124 |
|
| 125 |
def _read_store(self) -> dict:
|
|
@@ -202,15 +219,13 @@ class AttendanceService:
|
|
| 202 |
faces = self.face_analysis.get(frame)
|
| 203 |
samples: list[FaceSample] = []
|
| 204 |
for face in faces:
|
| 205 |
-
|
| 206 |
-
if
|
| 207 |
-
embedding = getattr(face, "embedding", None)
|
| 208 |
-
if embedding is None:
|
| 209 |
continue
|
| 210 |
bbox = tuple(int(value) for value in face.bbox)
|
| 211 |
samples.append(
|
| 212 |
FaceSample(
|
| 213 |
-
embedding=
|
| 214 |
bbox=bbox,
|
| 215 |
score=float(getattr(face, "det_score", 0.0)),
|
| 216 |
)
|
|
@@ -252,7 +267,7 @@ class AttendanceService:
|
|
| 252 |
results.append((primary.embedding, float(primary.score)))
|
| 253 |
# Also enroll degraded versions to match distant classroom conditions
|
| 254 |
for scale in ENROLLMENT_DEGRADATION_SCALES:
|
| 255 |
-
deg = _degrade_and_embed(self.face_analysis, frame, primary.bbox, scale)
|
| 256 |
if deg is not None:
|
| 257 |
results.append((deg, float(primary.score) * 0.9))
|
| 258 |
else:
|
|
@@ -263,7 +278,7 @@ class AttendanceService:
|
|
| 263 |
results.append((best.embedding, float(best.score)))
|
| 264 |
# Also enroll degraded versions
|
| 265 |
for scale in ENROLLMENT_DEGRADATION_SCALES:
|
| 266 |
-
deg = _degrade_and_embed(self.face_analysis, frame, best.bbox, scale)
|
| 267 |
if deg is not None:
|
| 268 |
results.append((deg, float(best.score) * 0.9))
|
| 269 |
|
|
|
|
| 11 |
import numpy as np
|
| 12 |
|
| 13 |
from insightface.app import FaceAnalysis
|
| 14 |
+
from insightface.utils import face_align
|
| 15 |
from .startup import ensure_insightface_models_flat
|
| 16 |
from .config import ATTENDANCE_DIR
|
| 17 |
+
from utils.adaface_backbone import AdaFaceWrapper, DEFAULT_CKPT_PATH as ADAFACE_CKPT_PATH
|
| 18 |
|
| 19 |
|
| 20 |
UPLOAD_DIR = ATTENDANCE_DIR / "uploads"
|
| 21 |
MARKED_DIR = ATTENDANCE_DIR / "marked"
|
| 22 |
STORE_PATH = ATTENDANCE_DIR / "attendance_store.json"
|
| 23 |
PHOTOS_PER_VIDEO = 32
|
| 24 |
+
# AdaFace IR-101's own p99 impostor threshold, derived from the human-labeled
|
| 25 |
+
# clean eval set (eval/impostor_scope_eval.py) — NOT glintr100's 0.38, the two
|
| 26 |
+
# backbones' cosine distributions aren't comparable.
|
| 27 |
+
FACE_SIMILARITY_THRESHOLD = 0.28
|
| 28 |
+
EMBEDDING_MODEL_NAME = "adaface_ir101_webface12m"
|
| 29 |
ENROLLMENT_MIN_DET_SCORE = 0.50
|
| 30 |
+
# Unchanged from glintr100: eval/build_gallery.py mirrors production enrollment
|
| 31 |
+
# and kept this same value (0.50) when building the AdaFace gallery used in
|
| 32 |
+
# every comparison run, so no evidence it needs to move for the new backbone.
|
| 33 |
ENROLLMENT_OUTLIER_SIM_THRESHOLD = 0.50
|
| 34 |
MAX_STORED_EMBEDDINGS = 128
|
| 35 |
ENROLLMENT_SAMPLE_INTERVAL_S = 1.0 # sample one frame every 1 second for enrollment
|
| 36 |
MAX_ENROLLMENT_FRAMES = 30 # cap to avoid overly long videos
|
| 37 |
+
# Unchanged from glintr100 (see ENROLLMENT_OUTLIER_SIM_THRESHOLD note above) —
|
| 38 |
+
# eval/build_gallery.py's _ANCHOR_SIM_THRESH stayed at 0.35 for AdaFace too.
|
| 39 |
ANCHOR_CONSISTENCY_THRESHOLD = 0.35
|
| 40 |
# Degradation scales applied during enrollment to bridge the gap between
|
| 41 |
# close-up enrollment faces and small distant classroom faces.
|
|
|
|
| 61 |
return vector / norm
|
| 62 |
|
| 63 |
|
| 64 |
+
def _align_and_embed(adaface: AdaFaceWrapper, frame: np.ndarray, kps: np.ndarray) -> np.ndarray:
|
| 65 |
+
"""Align a face via InsightFace's norm_crop (same alignment AdaFace was
|
| 66 |
+
trained/calibrated against) and embed it with the AdaFace backbone."""
|
| 67 |
+
aligned = face_align.norm_crop(frame, landmark=np.asarray(kps, dtype=np.float32), image_size=112)
|
| 68 |
+
emb, _feat_norm = adaface.embed_aligned(aligned)
|
| 69 |
+
return _normalize(emb)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _degrade_and_embed(fa: FaceAnalysis, adaface: AdaFaceWrapper, frame: np.ndarray, bbox: tuple, scale: float) -> np.ndarray | None:
|
| 73 |
"""Shrink a face crop to `scale` fraction then bicubic back to original size,
|
| 74 |
then re-run recognition on that blurry crop. Simulates how a distant classroom
|
| 75 |
face looks to the recognition model, so the enrollment gallery contains embeddings
|
|
|
|
| 86 |
degraded = cv2.resize(crop, (small_w, small_h), interpolation=cv2.INTER_AREA)
|
| 87 |
degraded = cv2.resize(degraded, (cw, ch), interpolation=cv2.INTER_CUBIC)
|
| 88 |
|
| 89 |
+
# Paste degraded crop back into a copy of the frame and re-run detection
|
| 90 |
frame_copy = frame.copy()
|
| 91 |
frame_copy[y1:y2, x1:x2] = degraded
|
| 92 |
faces = fa.get(frame_copy)
|
| 93 |
|
| 94 |
# Pick the face closest to the original bbox
|
| 95 |
+
best_kps, best_iou = None, 0.0
|
| 96 |
for f in faces:
|
| 97 |
fx1, fy1, fx2, fy2 = [int(v) for v in f.bbox]
|
| 98 |
ix1, iy1 = max(x1, fx1), max(y1, fy1)
|
|
|
|
| 100 |
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
|
| 101 |
union = (x2-x1)*(y2-y1) + (fx2-fx1)*(fy2-fy1) - inter
|
| 102 |
iou = inter / union if union > 0 else 0.0
|
| 103 |
+
if iou > best_iou and getattr(f, "kps", None) is not None:
|
| 104 |
+
best_kps = f.kps
|
| 105 |
+
best_iou = iou
|
| 106 |
+
if best_kps is None:
|
| 107 |
+
return None
|
| 108 |
+
return _align_and_embed(adaface, frame_copy, best_kps)
|
|
|
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
|
|
|
|
| 132 |
ensure_attendance_dirs()
|
| 133 |
self.face_analysis = FaceAnalysis(
|
| 134 |
name="antelopev2",
|
| 135 |
+
allowed_modules=["detection"],
|
| 136 |
providers=["CPUExecutionProvider"],
|
| 137 |
)
|
| 138 |
self.face_analysis.prepare(ctx_id=0, det_size=(1280, 1280), det_thresh=0.5)
|
| 139 |
+
self.adaface = AdaFaceWrapper.load(ADAFACE_CKPT_PATH)
|
| 140 |
self._migrate_legacy_embeddings_if_needed()
|
| 141 |
|
| 142 |
def _read_store(self) -> dict:
|
|
|
|
| 219 |
faces = self.face_analysis.get(frame)
|
| 220 |
samples: list[FaceSample] = []
|
| 221 |
for face in faces:
|
| 222 |
+
kps = getattr(face, "kps", None)
|
| 223 |
+
if kps is None:
|
|
|
|
|
|
|
| 224 |
continue
|
| 225 |
bbox = tuple(int(value) for value in face.bbox)
|
| 226 |
samples.append(
|
| 227 |
FaceSample(
|
| 228 |
+
embedding=_align_and_embed(self.adaface, frame, kps),
|
| 229 |
bbox=bbox,
|
| 230 |
score=float(getattr(face, "det_score", 0.0)),
|
| 231 |
)
|
|
|
|
| 267 |
results.append((primary.embedding, float(primary.score)))
|
| 268 |
# Also enroll degraded versions to match distant classroom conditions
|
| 269 |
for scale in ENROLLMENT_DEGRADATION_SCALES:
|
| 270 |
+
deg = _degrade_and_embed(self.face_analysis, self.adaface, frame, primary.bbox, scale)
|
| 271 |
if deg is not None:
|
| 272 |
results.append((deg, float(primary.score) * 0.9))
|
| 273 |
else:
|
|
|
|
| 278 |
results.append((best.embedding, float(best.score)))
|
| 279 |
# Also enroll degraded versions
|
| 280 |
for scale in ENROLLMENT_DEGRADATION_SCALES:
|
| 281 |
+
deg = _degrade_and_embed(self.face_analysis, self.adaface, frame, best.bbox, scale)
|
| 282 |
if deg is not None:
|
| 283 |
results.append((deg, float(best.score) * 0.9))
|
| 284 |
|
download_models.py
CHANGED
|
@@ -62,6 +62,19 @@ def _download(url: str, dest: Path, retries: int = 5) -> None:
|
|
| 62 |
sys.exit(1)
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
def main() -> None:
|
| 66 |
any_downloaded = False
|
| 67 |
for rel, url, _ in MODELS:
|
|
@@ -72,6 +85,8 @@ def main() -> None:
|
|
| 72 |
_download(url, dest)
|
| 73 |
any_downloaded = True
|
| 74 |
|
|
|
|
|
|
|
| 75 |
if not any_downloaded:
|
| 76 |
print("All model weights are already in place.")
|
| 77 |
else:
|
|
|
|
| 62 |
sys.exit(1)
|
| 63 |
|
| 64 |
|
| 65 |
+
def _download_adaface() -> bool:
|
| 66 |
+
from utils.adaface_backbone import DEFAULT_CKPT_PATH, download_model
|
| 67 |
+
|
| 68 |
+
if DEFAULT_CKPT_PATH.exists() and not _is_lfs_pointer(DEFAULT_CKPT_PATH):
|
| 69 |
+
print(f" {DEFAULT_CKPT_PATH.name} already present, skipping.")
|
| 70 |
+
return False
|
| 71 |
+
print(f" downloading {DEFAULT_CKPT_PATH.name} from HuggingFace (minchul/cvlface_adaface_ir101_webface12m) …")
|
| 72 |
+
download_model(DEFAULT_CKPT_PATH)
|
| 73 |
+
size_mb = DEFAULT_CKPT_PATH.stat().st_size / 1e6
|
| 74 |
+
print(f" {DEFAULT_CKPT_PATH.name} — {size_mb:.1f} MB ✓")
|
| 75 |
+
return True
|
| 76 |
+
|
| 77 |
+
|
| 78 |
def main() -> None:
|
| 79 |
any_downloaded = False
|
| 80 |
for rel, url, _ in MODELS:
|
|
|
|
| 85 |
_download(url, dest)
|
| 86 |
any_downloaded = True
|
| 87 |
|
| 88 |
+
any_downloaded = _download_adaface() or any_downloaded
|
| 89 |
+
|
| 90 |
if not any_downloaded:
|
| 91 |
print("All model weights are already in place.")
|
| 92 |
else:
|
requirements.txt
CHANGED
|
@@ -6,6 +6,7 @@ Pillow
|
|
| 6 |
opencv-python-headless
|
| 7 |
ultralytics
|
| 8 |
insightface
|
|
|
|
| 9 |
torch
|
| 10 |
torchvision
|
| 11 |
imageio-ffmpeg
|
|
|
|
| 6 |
opencv-python-headless
|
| 7 |
ultralytics
|
| 8 |
insightface
|
| 9 |
+
huggingface_hub
|
| 10 |
torch
|
| 11 |
torchvision
|
| 12 |
imageio-ffmpeg
|
utils/adaface_backbone.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AdaFace IR-101 (CVLFace, minchul/cvlface_adaface_ir101_webface12m) recognition backbone.
|
| 3 |
+
|
| 4 |
+
Replaces glintr100/antelopev2 as the production embedding model. InsightFace's
|
| 5 |
+
`FaceAnalysis` is still used for detection (bbox + 5-pt landmarks); this module
|
| 6 |
+
only handles alignment + embedding.
|
| 7 |
+
|
| 8 |
+
Preprocessing:
|
| 9 |
+
- color_space: RGB → convert BGR→RGB before normalising
|
| 10 |
+
- normalisation: (pixel/255 - 0.5) / 0.5, applied after the BGR→RGB flip
|
| 11 |
+
- alignment: insightface.utils.face_align.norm_crop, 112x112 (same as glintr100)
|
| 12 |
+
|
| 13 |
+
Output: (512-d L2-normalised embedding, pre-BN feature norm — quality proxy).
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import NamedTuple
|
| 19 |
+
|
| 20 |
+
import cv2
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
import torch.nn as nn
|
| 24 |
+
import torch.nn.functional as F
|
| 25 |
+
from torch.nn import (BatchNorm1d, BatchNorm2d, Conv2d, Dropout, Flatten,
|
| 26 |
+
Linear, MaxPool2d, PReLU, Sequential)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
# Building blocks (CVLFace IR-101 architecture)
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
class BasicBlockIR(nn.Module):
|
| 34 |
+
def __init__(self, in_channel: int, depth: int, stride: int):
|
| 35 |
+
super().__init__()
|
| 36 |
+
if in_channel == depth:
|
| 37 |
+
self.shortcut_layer = MaxPool2d(1, stride)
|
| 38 |
+
else:
|
| 39 |
+
self.shortcut_layer = Sequential(
|
| 40 |
+
Conv2d(in_channel, depth, (1, 1), stride, bias=False),
|
| 41 |
+
BatchNorm2d(depth),
|
| 42 |
+
)
|
| 43 |
+
self.res_layer = Sequential(
|
| 44 |
+
BatchNorm2d(in_channel),
|
| 45 |
+
Conv2d(in_channel, depth, (3, 3), (1, 1), 1, bias=False),
|
| 46 |
+
BatchNorm2d(depth),
|
| 47 |
+
PReLU(depth),
|
| 48 |
+
Conv2d(depth, depth, (3, 3), stride, 1, bias=False),
|
| 49 |
+
BatchNorm2d(depth),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 53 |
+
return self.res_layer(x) + self.shortcut_layer(x)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class _BlockSpec(NamedTuple):
|
| 57 |
+
in_channel: int
|
| 58 |
+
depth: int
|
| 59 |
+
stride: int
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _get_blocks_ir101() -> list[list[_BlockSpec]]:
|
| 63 |
+
"""Block specs for IR-101: unit counts [3, 13, 30, 3]."""
|
| 64 |
+
def _block(in_c: int, depth: int, n: int) -> list[_BlockSpec]:
|
| 65 |
+
return [_BlockSpec(in_c, depth, 2)] + [_BlockSpec(depth, depth, 1)] * (n - 1)
|
| 66 |
+
|
| 67 |
+
return [
|
| 68 |
+
_block(64, 64, 3),
|
| 69 |
+
_block(64, 128, 13),
|
| 70 |
+
_block(128, 256, 30),
|
| 71 |
+
_block(256, 512, 3),
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class Backbone(nn.Module):
|
| 76 |
+
"""CVLFace Backbone (InsightFace-style IR). Forward returns (emb, feat_norm)."""
|
| 77 |
+
|
| 78 |
+
def __init__(self, blocks_spec: list[list[_BlockSpec]], output_dim: int = 512,
|
| 79 |
+
dropout: float = 0.4):
|
| 80 |
+
super().__init__()
|
| 81 |
+
self.input_layer = Sequential(
|
| 82 |
+
Conv2d(3, 64, (3, 3), 1, 1, bias=False),
|
| 83 |
+
BatchNorm2d(64),
|
| 84 |
+
PReLU(64),
|
| 85 |
+
)
|
| 86 |
+
units = [BasicBlockIR(b.in_channel, b.depth, b.stride)
|
| 87 |
+
for block in blocks_spec for b in block]
|
| 88 |
+
self.body = Sequential(*units)
|
| 89 |
+
|
| 90 |
+
self.output_layer = Sequential(
|
| 91 |
+
BatchNorm2d(512),
|
| 92 |
+
Dropout(p=dropout),
|
| 93 |
+
Flatten(),
|
| 94 |
+
Linear(512 * 7 * 7, output_dim, bias=True),
|
| 95 |
+
BatchNorm1d(output_dim, affine=False),
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
def forward(self, x: torch.Tensor):
|
| 99 |
+
x = self.input_layer(x)
|
| 100 |
+
x = self.body(x)
|
| 101 |
+
x = self.output_layer[0](x)
|
| 102 |
+
x = self.output_layer[1](x)
|
| 103 |
+
x = self.output_layer[2](x)
|
| 104 |
+
x = self.output_layer[3](x)
|
| 105 |
+
feat_norm = torch.norm(x, p=2, dim=1)
|
| 106 |
+
x = self.output_layer[4](x)
|
| 107 |
+
emb = F.normalize(x, p=2, dim=1)
|
| 108 |
+
return emb, feat_norm
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def ir101(output_dim: int = 512) -> Backbone:
|
| 112 |
+
return Backbone(_get_blocks_ir101(), output_dim=output_dim)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def load_cvlface_checkpoint(model: Backbone, ckpt_path: str) -> None:
|
| 116 |
+
"""Load CVLFace model.pt, stripping the 'net.' prefix from state-dict keys."""
|
| 117 |
+
raw = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 118 |
+
if isinstance(raw, dict) and not isinstance(raw, nn.Module):
|
| 119 |
+
sd = raw.get("state_dict", raw)
|
| 120 |
+
else:
|
| 121 |
+
sd = raw
|
| 122 |
+
if any(k.startswith("net.") for k in sd):
|
| 123 |
+
sd = {k[4:]: v for k, v in sd.items() if k.startswith("net.")}
|
| 124 |
+
model.load_state_dict(sd, strict=False)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
# Preprocessing + wrapper
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
|
| 131 |
+
def _to_tensor(aligned_bgr: np.ndarray) -> torch.Tensor:
|
| 132 |
+
"""112x112 BGR ndarray -> (1,3,112,112) float32 tensor, RGB, normalised to [-1,1]."""
|
| 133 |
+
arr = aligned_bgr[:, :, ::-1].copy() # BGR -> RGB
|
| 134 |
+
arr = arr.astype(np.float32)
|
| 135 |
+
arr = (arr / 255.0 - 0.5) / 0.5 # -> [-1, 1]
|
| 136 |
+
arr = arr.transpose(2, 0, 1)[np.newaxis] # (1, C, H, W)
|
| 137 |
+
return torch.from_numpy(arr)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class AdaFaceWrapper:
|
| 141 |
+
def __init__(self, model: torch.nn.Module):
|
| 142 |
+
self._model = model
|
| 143 |
+
self._model.eval()
|
| 144 |
+
|
| 145 |
+
@classmethod
|
| 146 |
+
def load(cls, ckpt_path: str | Path) -> "AdaFaceWrapper":
|
| 147 |
+
ckpt_path = Path(ckpt_path)
|
| 148 |
+
if not ckpt_path.exists():
|
| 149 |
+
raise FileNotFoundError(
|
| 150 |
+
f"AdaFace checkpoint not found: {ckpt_path}\n"
|
| 151 |
+
"Run `python download_models.py` to fetch it."
|
| 152 |
+
)
|
| 153 |
+
model = ir101()
|
| 154 |
+
load_cvlface_checkpoint(model, str(ckpt_path))
|
| 155 |
+
model.eval()
|
| 156 |
+
return cls(model)
|
| 157 |
+
|
| 158 |
+
def embed_aligned(self, aligned_bgr_112: np.ndarray) -> tuple[np.ndarray, float]:
|
| 159 |
+
"""
|
| 160 |
+
aligned_bgr_112 : 112x112 BGR ndarray (from insightface.utils.face_align.norm_crop)
|
| 161 |
+
Returns (emb (512,) float32 L2-normalised, feat_norm quality proxy).
|
| 162 |
+
"""
|
| 163 |
+
if aligned_bgr_112 is None or aligned_bgr_112.size == 0:
|
| 164 |
+
raise ValueError("aligned_bgr_112 cannot be empty")
|
| 165 |
+
crop = aligned_bgr_112
|
| 166 |
+
if crop.shape[:2] != (112, 112):
|
| 167 |
+
crop = cv2.resize(crop, (112, 112), interpolation=cv2.INTER_LINEAR)
|
| 168 |
+
tensor = _to_tensor(crop)
|
| 169 |
+
with torch.inference_mode():
|
| 170 |
+
emb_t, norm_t = self._model(tensor)
|
| 171 |
+
emb = emb_t[0].numpy().astype(np.float32)
|
| 172 |
+
norm = float(norm_t[0].item())
|
| 173 |
+
return emb, norm
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ---------------------------------------------------------------------------
|
| 177 |
+
# Download helper — mirrors the YOLO-weight pattern in download_models.py
|
| 178 |
+
# ---------------------------------------------------------------------------
|
| 179 |
+
|
| 180 |
+
_HF_REPO = "minchul/cvlface_adaface_ir101_webface12m"
|
| 181 |
+
_HF_FILE = "pretrained_model/model.pt"
|
| 182 |
+
|
| 183 |
+
DEFAULT_CKPT_PATH = Path(__file__).resolve().parent.parent / "models" / "adaface" / "adaface_ir101_webface12m.pt"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def download_model(dest: Path = DEFAULT_CKPT_PATH, force: bool = False) -> Path:
|
| 187 |
+
dest = Path(dest)
|
| 188 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 189 |
+
if dest.exists() and not force:
|
| 190 |
+
return dest
|
| 191 |
+
|
| 192 |
+
from huggingface_hub import hf_hub_download
|
| 193 |
+
tmp = hf_hub_download(repo_id=_HF_REPO, filename=_HF_FILE, local_dir=str(dest.parent))
|
| 194 |
+
tmp_p = Path(tmp)
|
| 195 |
+
if tmp_p != dest:
|
| 196 |
+
tmp_p.rename(dest)
|
| 197 |
+
return dest
|