Satyam S commited on
Commit
2f152d3
·
1 Parent(s): 08ddd4b

Switch attendance recognition backbone from glintr100 to AdaFace IR-101

Browse files

Same 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 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
- FACE_SIMILARITY_THRESHOLD = 0.38
23
- EMBEDDING_MODEL_NAME = "antelopev2_v3"
 
 
 
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 _degrade_and_embed(fa: FaceAnalysis, frame: np.ndarray, bbox: tuple, scale: float) -> np.ndarray | None:
 
 
 
 
 
 
 
 
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 fa.get
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
- best_emb, best_iou = None, 0.0
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
- e = getattr(f, "normed_embedding", None)
87
- if e is None:
88
- e = getattr(f, "embedding", None)
89
- if e is not None:
90
- best_emb = _normalize(np.asarray(e, dtype=np.float32).flatten().copy())
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", "recognition"],
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
- embedding = getattr(face, "normed_embedding", None)
206
- if embedding is None:
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=_normalize(np.asarray(embedding, dtype=np.float32)),
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