Satyam S commited on
Commit
d35d0ca
Β·
1 Parent(s): f02f28b

Per-classroom rosters, self-enroll tab with guided camera recording, and three-tier attendance with teacher-reviewed reinforcement

Browse files

- Split attendance data per classroom (CSE 1-8), each with its own JSON store
- New standalone Enroll Student tab/page with upload, folder, and guided
audio-narrated camera recording (front/left/right) for self-enrollment
- Mark Attendance now classifies faces into Present/Suspicious/Absent tiers
instead of a flat match list, with an unknown-faces zoom viewer
- Teachers can confirm/reject Suspicious matches; confirming reinforces that
student's gallery with the new embedding and recomputes their prototype
- Fixed enrollment video frame sampling to always sequentially decode
instead of seeking, which was unreliable for live-recorded webm clips

activity_web/backend/app.py CHANGED
@@ -7,7 +7,7 @@ from pathlib import Path
7
  from flask import Flask, jsonify, render_template, request, send_file
8
  from werkzeug.utils import secure_filename
9
 
10
- from .attendance_service import get_attendance_service
11
  from .pipeline_loader import get_pipeline
12
  from .engagement_loader import get_engagement_pipeline
13
  from .cognitive_loader import get_cognitive_pipeline
@@ -32,6 +32,11 @@ app = Flask(
32
  )
33
  app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024 * 1024
34
 
 
 
 
 
 
35
 
36
  def ensure_runtime_dirs() -> None:
37
  # Create the configured runtime directories
@@ -72,11 +77,32 @@ def attendance_artifact_url(filename: str) -> str:
72
  return f"/api/attendance/artifacts/{filename}"
73
 
74
 
 
 
 
 
 
 
 
 
75
  @app.get("/")
76
  def index():
77
  return render_template("index.html")
78
 
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  @app.get("/api/health")
81
  def health():
82
  return jsonify({"ok": True})
@@ -84,8 +110,11 @@ def health():
84
 
85
  @app.get("/api/attendance/roster")
86
  def attendance_roster():
 
 
 
87
  try:
88
- service = get_attendance_service()
89
  return jsonify({"ok": True, "students": service.list_students(), "attendance": service.list_attendance(limit=20)})
90
  except Exception as exc:
91
  return jsonify({"ok": False, "error": str(exc)}), 500
@@ -93,7 +122,10 @@ def attendance_roster():
93
 
94
  @app.delete("/api/attendance/students/<student_id>")
95
  def attendance_delete_student(student_id: str):
96
- service = get_attendance_service()
 
 
 
97
  try:
98
  result = service.delete_student(student_id)
99
  except KeyError:
@@ -104,8 +136,35 @@ def attendance_delete_student(student_id: str):
104
  return jsonify({"ok": True, **result})
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  @app.post("/api/attendance/enroll")
108
  def attendance_enroll():
 
 
 
 
109
  uploaded_files = request.files.getlist("media")
110
  student_name = request.form.get("student_name", "").strip()
111
 
@@ -114,7 +173,7 @@ def attendance_enroll():
114
  if not uploaded_files:
115
  return jsonify({"ok": False, "error": "Upload at least one photo or video."}), 400
116
 
117
- service = get_attendance_service()
118
  saved_paths: list[Path] = []
119
  for uploaded_file in uploaded_files:
120
  if not uploaded_file or not uploaded_file.filename:
@@ -142,6 +201,10 @@ def attendance_enroll():
142
  def attendance_enroll_folder():
143
  """Enroll a student from a local folder of videos/images (no upload needed)."""
144
  data = request.get_json(silent=True) or {}
 
 
 
 
145
  student_name = data.get("student_name", "").strip()
146
  folder_path = data.get("folder_path", "").strip()
147
 
@@ -161,7 +224,7 @@ def attendance_enroll_folder():
161
  if not media_paths:
162
  return jsonify({"ok": False, "error": "No video or image files found in that folder."}), 400
163
 
164
- service = get_attendance_service()
165
  try:
166
  result = service.enroll_student(student_name=student_name, media_paths=media_paths)
167
  except Exception as exc:
@@ -174,6 +237,10 @@ def attendance_enroll_folder():
174
 
175
  @app.post("/api/attendance/mark")
176
  def attendance_mark():
 
 
 
 
177
  uploaded_file = request.files.get("photo")
178
  if uploaded_file is None or not uploaded_file.filename:
179
  return jsonify({"ok": False, "error": "Upload a classroom photo first."}), 400
@@ -181,7 +248,7 @@ def attendance_mark():
181
  if not allowed_media(uploaded_file.filename):
182
  return jsonify({"ok": False, "error": "Use an image or video file for attendance marking."}), 400
183
 
184
- service = get_attendance_service()
185
  photo_name = secure_filename(uploaded_file.filename)
186
  photo_path = ATTENDANCE_DIR / "uploads" / f"{uuid.uuid4().hex[:12]}_{photo_name}"
187
  photo_path.parent.mkdir(parents=True, exist_ok=True)
@@ -199,12 +266,16 @@ def attendance_mark():
199
 
200
  @app.post("/api/attendance/demo")
201
  def attendance_demo():
 
 
 
 
202
  import shutil
203
  demo_src = Path(__file__).parent / "static" / "demo_classroom.jpg"
204
  if not demo_src.exists():
205
  return jsonify({"ok": False, "error": "Demo image not found."}), 404
206
 
207
- service = get_attendance_service()
208
  photo_path = ATTENDANCE_DIR / "uploads" / "demo_classroom.jpg"
209
  photo_path.parent.mkdir(parents=True, exist_ok=True)
210
  shutil.copy2(demo_src, photo_path)
 
7
  from flask import Flask, jsonify, render_template, request, send_file
8
  from werkzeug.utils import secure_filename
9
 
10
+ from .attendance_service import get_attendance_service, migrate_legacy_flat_store, CLASSROOMS, CLASSROOM_LABELS
11
  from .pipeline_loader import get_pipeline
12
  from .engagement_loader import get_engagement_pipeline
13
  from .cognitive_loader import get_cognitive_pipeline
 
32
  )
33
  app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024 * 1024
34
 
35
+ try:
36
+ migrate_legacy_flat_store()
37
+ except Exception:
38
+ pass
39
+
40
 
41
  def ensure_runtime_dirs() -> None:
42
  # Create the configured runtime directories
 
77
  return f"/api/attendance/artifacts/{filename}"
78
 
79
 
80
+ def _require_classroom(classroom_id: str | None):
81
+ """Validate a classroom id against the fixed whitelist. Returns None on
82
+ success, or a (response, status) tuple to return immediately on failure."""
83
+ if classroom_id not in CLASSROOMS:
84
+ return jsonify({"ok": False, "error": "Select a valid classroom."}), 400
85
+ return None
86
+
87
+
88
  @app.get("/")
89
  def index():
90
  return render_template("index.html")
91
 
92
 
93
+ @app.get("/enroll")
94
+ def enroll_page():
95
+ return render_template("enroll.html")
96
+
97
+
98
+ @app.get("/api/attendance/classrooms")
99
+ def attendance_classrooms():
100
+ return jsonify({
101
+ "ok": True,
102
+ "classrooms": [{"id": c, "label": CLASSROOM_LABELS[c]} for c in CLASSROOMS],
103
+ })
104
+
105
+
106
  @app.get("/api/health")
107
  def health():
108
  return jsonify({"ok": True})
 
110
 
111
  @app.get("/api/attendance/roster")
112
  def attendance_roster():
113
+ classroom_id = request.args.get("classroom", "")
114
+ if (error := _require_classroom(classroom_id)) is not None:
115
+ return error
116
  try:
117
+ service = get_attendance_service(classroom_id)
118
  return jsonify({"ok": True, "students": service.list_students(), "attendance": service.list_attendance(limit=20)})
119
  except Exception as exc:
120
  return jsonify({"ok": False, "error": str(exc)}), 500
 
122
 
123
  @app.delete("/api/attendance/students/<student_id>")
124
  def attendance_delete_student(student_id: str):
125
+ classroom_id = request.args.get("classroom", "")
126
+ if (error := _require_classroom(classroom_id)) is not None:
127
+ return error
128
+ service = get_attendance_service(classroom_id)
129
  try:
130
  result = service.delete_student(student_id)
131
  except KeyError:
 
136
  return jsonify({"ok": True, **result})
137
 
138
 
139
+ @app.post("/api/attendance/suspicious/resolve")
140
+ def attendance_resolve_suspicious():
141
+ data = request.get_json(silent=True) or {}
142
+ classroom_id = data.get("classroom", "")
143
+ if (error := _require_classroom(classroom_id)) is not None:
144
+ return error
145
+
146
+ review_id = str(data.get("review_id", "")).strip()
147
+ confirmed = bool(data.get("confirmed"))
148
+ if not review_id:
149
+ return jsonify({"ok": False, "error": "Missing review_id."}), 400
150
+
151
+ service = get_attendance_service(classroom_id)
152
+ try:
153
+ result = service.resolve_suspicious_review(review_id, confirmed)
154
+ except KeyError:
155
+ return jsonify({"ok": False, "error": "That review no longer exists (already resolved?)."}), 404
156
+ except Exception as exc:
157
+ return jsonify({"ok": False, "error": str(exc)}), 500
158
+
159
+ return jsonify({"ok": True, **result})
160
+
161
+
162
  @app.post("/api/attendance/enroll")
163
  def attendance_enroll():
164
+ classroom_id = request.form.get("classroom", "")
165
+ if (error := _require_classroom(classroom_id)) is not None:
166
+ return error
167
+
168
  uploaded_files = request.files.getlist("media")
169
  student_name = request.form.get("student_name", "").strip()
170
 
 
173
  if not uploaded_files:
174
  return jsonify({"ok": False, "error": "Upload at least one photo or video."}), 400
175
 
176
+ service = get_attendance_service(classroom_id)
177
  saved_paths: list[Path] = []
178
  for uploaded_file in uploaded_files:
179
  if not uploaded_file or not uploaded_file.filename:
 
201
  def attendance_enroll_folder():
202
  """Enroll a student from a local folder of videos/images (no upload needed)."""
203
  data = request.get_json(silent=True) or {}
204
+ classroom_id = data.get("classroom", "")
205
+ if (error := _require_classroom(classroom_id)) is not None:
206
+ return error
207
+
208
  student_name = data.get("student_name", "").strip()
209
  folder_path = data.get("folder_path", "").strip()
210
 
 
224
  if not media_paths:
225
  return jsonify({"ok": False, "error": "No video or image files found in that folder."}), 400
226
 
227
+ service = get_attendance_service(classroom_id)
228
  try:
229
  result = service.enroll_student(student_name=student_name, media_paths=media_paths)
230
  except Exception as exc:
 
237
 
238
  @app.post("/api/attendance/mark")
239
  def attendance_mark():
240
+ classroom_id = request.form.get("classroom", "")
241
+ if (error := _require_classroom(classroom_id)) is not None:
242
+ return error
243
+
244
  uploaded_file = request.files.get("photo")
245
  if uploaded_file is None or not uploaded_file.filename:
246
  return jsonify({"ok": False, "error": "Upload a classroom photo first."}), 400
 
248
  if not allowed_media(uploaded_file.filename):
249
  return jsonify({"ok": False, "error": "Use an image or video file for attendance marking."}), 400
250
 
251
+ service = get_attendance_service(classroom_id)
252
  photo_name = secure_filename(uploaded_file.filename)
253
  photo_path = ATTENDANCE_DIR / "uploads" / f"{uuid.uuid4().hex[:12]}_{photo_name}"
254
  photo_path.parent.mkdir(parents=True, exist_ok=True)
 
266
 
267
  @app.post("/api/attendance/demo")
268
  def attendance_demo():
269
+ classroom_id = request.form.get("classroom") or request.args.get("classroom", "")
270
+ if (error := _require_classroom(classroom_id)) is not None:
271
+ return error
272
+
273
  import shutil
274
  demo_src = Path(__file__).parent / "static" / "demo_classroom.jpg"
275
  if not demo_src.exists():
276
  return jsonify({"ok": False, "error": "Demo image not found."}), 404
277
 
278
+ service = get_attendance_service(classroom_id)
279
  photo_path = ATTENDANCE_DIR / "uploads" / "demo_classroom.jpg"
280
  photo_path.parent.mkdir(parents=True, exist_ok=True)
281
  shutil.copy2(demo_src, photo_path)
activity_web/backend/attendance_service.py CHANGED
@@ -19,12 +19,39 @@ from utils.adaface_backbone import AdaFaceWrapper, DEFAULT_CKPT_PATH as ADAFACE_
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
@@ -127,32 +154,45 @@ class FaceSample:
127
  score: float
128
 
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  class AttendanceService:
131
- def __init__(self) -> None:
 
 
 
 
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:
143
- if not STORE_PATH.exists():
144
- return {"students": [], "attendance": []}
145
  try:
146
- data = json.loads(STORE_PATH.read_text())
147
  except Exception:
148
- return {"students": [], "attendance": []}
149
  data.setdefault("students", [])
150
  data.setdefault("attendance", [])
 
151
  return data
152
 
153
  def _write_store(self, data: dict) -> None:
154
- ATTENDANCE_DIR.mkdir(parents=True, exist_ok=True)
155
- STORE_PATH.write_text(json.dumps(data, indent=2))
156
 
157
  def _load_image(self, media_path: Path) -> np.ndarray | None:
158
  image = cv2.imread(str(media_path))
@@ -192,9 +232,23 @@ class AttendanceService:
192
 
193
  def _sample_frames_for_enrollment(self, media_path: Path) -> list[np.ndarray]:
194
  """Sample up to MAX_ENROLLMENT_FRAMES frames, spread evenly across the
195
- whole clip. Deriving the step from total_frames (rather than a fixed
196
- per-second interval) means short clips still yield close to the target
197
- frame count instead of being starved by their length."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  suffix = media_path.suffix.lower()
199
  if suffix in {".jpg", ".jpeg", ".png", ".webp", ".bmp"}:
200
  image = self._load_image(media_path)
@@ -204,18 +258,24 @@ class AttendanceService:
204
  if not cap.isOpened():
205
  return []
206
 
207
- total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
208
- step = max(1, total_frames // MAX_ENROLLMENT_FRAMES)
209
- indices = list(range(0, total_frames, step))[:MAX_ENROLLMENT_FRAMES]
210
-
211
- frames: list[np.ndarray] = []
212
- for idx in indices:
213
- cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
214
  ok, frame = cap.read()
215
- if ok and frame is not None:
216
- frames.append(frame)
 
217
  cap.release()
218
- return frames
 
 
 
 
 
 
219
 
220
  def _detect_samples(self, frame: np.ndarray) -> list[FaceSample]:
221
  faces = self.face_analysis.get(frame)
@@ -522,6 +582,58 @@ class AttendanceService:
522
 
523
  return {"match": self._student_public(best_student), "similarity": best_similarity}
524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  def mark_attendance(self, media_path: Path) -> dict:
526
  if not media_path.exists():
527
  raise FileNotFoundError(f"File not found: {media_path}")
@@ -540,7 +652,8 @@ class AttendanceService:
540
 
541
  detections = self._detect_samples(frame)
542
  marked_frame = frame.copy()
543
- recognized: list[dict] = []
 
544
  unknown_faces = 0
545
  unknown_faces_detail: list[dict] = []
546
  store = self._read_store()
@@ -551,7 +664,7 @@ class AttendanceService:
551
 
552
  # Per student: keep only the single highest-scoring face
553
  # so if two faces both exceed the threshold for the same student,
554
- # only the best one gets the green box β€” the other stays Unknown.
555
  best_per_student: dict[str, tuple] = {}
556
  for det, match in all_matches:
557
  if match["match"] is not None:
@@ -560,6 +673,7 @@ class AttendanceService:
560
  best_per_student[name] = (det, match["similarity"], match)
561
 
562
  best_det_ids = {id(det) for det, _, _ in best_per_student.values()}
 
563
 
564
  for det, match in all_matches:
565
  x1, y1, x2, y2 = det.bbox
@@ -567,34 +681,47 @@ class AttendanceService:
567
 
568
  if is_best:
569
  student = match["match"]
570
- color = (0, 200, 0)
571
- label = f"{student['name']} {match['similarity']:.2f}"
572
- if student["name"] not in {r["student"]["name"] for r in recognized}:
573
- attendance_log.append({
574
- "student_name": student["name"],
575
- "recognized_at": _now_iso(),
576
- "source": "classroom_photo",
577
- "confidence": round(float(match["similarity"]), 4),
578
- })
579
- recognized.append({
580
- "student": student,
581
- "confidence": round(float(match["similarity"]), 4),
582
- "bbox": [x1, y1, x2, y2],
583
- })
584
- # Incremental gallery growth: high-confidence classroom embeddings
585
- # are added to the student's gallery so future matches improve.
586
- if match["similarity"] >= 0.60:
587
- all_students = store.get("students", [])
588
- for s in all_students:
589
- if s.get("student_id") == student.get("student_id"):
590
- stored = s.get("embeddings", []) or []
591
- new_emb = _normalize(det.embedding).tolist()
592
- stored = (stored + [new_emb])[-MAX_STORED_EMBEDDINGS:]
593
- s["embeddings"] = stored
594
- # Recompute prototype
595
- mat = np.asarray(stored, dtype=np.float32)
596
- s["prototype"] = _normalize(mat.mean(axis=0)).tolist()
597
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  else:
599
  unknown_faces += 1
600
  color = (0, 0, 255)
@@ -612,6 +739,13 @@ class AttendanceService:
612
  cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA,
613
  )
614
 
 
 
 
 
 
 
 
615
  store["attendance"] = attendance_log
616
  self._write_store(store)
617
 
@@ -620,21 +754,22 @@ class AttendanceService:
620
  cv2.imwrite(str(marked_path), marked_frame)
621
 
622
  return {
623
- "recognized": recognized,
 
 
624
  "unknown_faces": unknown_faces,
625
  "unknown_faces_detail": unknown_faces_detail,
626
  "marked_path": str(marked_path),
627
  "marked_url": f"/api/attendance/artifacts/{marked_name}",
628
  "roster": self.list_students(),
629
- "attendance_log": self.list_attendance(limit=20),
630
  }
631
 
632
 
633
- @lru_cache(maxsize=1)
634
- def get_attendance_service() -> AttendanceService:
635
  # Fix nested insightface model folders if present before initializing
636
  try:
637
  ensure_insightface_models_flat(["antelopev2"])
638
  except Exception:
639
  pass
640
- return AttendanceService()
 
19
 
20
  UPLOAD_DIR = ATTENDANCE_DIR / "uploads"
21
  MARKED_DIR = ATTENDANCE_DIR / "marked"
 
22
  PHOTOS_PER_VIDEO = 32
23
+
24
+ # Fixed whitelist of valid classrooms β€” every roster is scoped to one of these.
25
+ # Always validate a classroom id against this list before it touches a file path.
26
+ CLASSROOMS = [f"cse{i}" for i in range(1, 9)]
27
+ CLASSROOM_LABELS = {c: f"CSE {c[3:]}" for c in CLASSROOMS}
28
+
29
+
30
+ def _store_path(classroom_id: str) -> Path:
31
+ if classroom_id not in CLASSROOMS:
32
+ raise ValueError(f"Unknown classroom: {classroom_id!r}")
33
+ return ATTENDANCE_DIR / f"attendance_store_{classroom_id}.json"
34
+
35
+
36
+ def migrate_legacy_flat_store() -> None:
37
+ """One-time migration: this app used to have a single flat attendance_store.json
38
+ shared by everyone. If it still exists and CSE 8's file doesn't, copy it over β€”
39
+ the students originally enrolled there all belong to CSE 8."""
40
+ legacy_path = ATTENDANCE_DIR / "attendance_store.json"
41
+ cse8_path = _store_path("cse8")
42
+ if legacy_path.exists() and not cse8_path.exists():
43
+ cse8_path.parent.mkdir(parents=True, exist_ok=True)
44
+ cse8_path.write_text(legacy_path.read_text())
45
  # AdaFace IR-101's own p99 impostor threshold, derived from the human-labeled
46
  # clean eval set (eval/impostor_scope_eval.py) β€” NOT glintr100's 0.38, the two
47
+ # backbones' cosine distributions aren't comparable. Below this, a face gets no
48
+ # name candidate at all ("Unknown").
49
  FACE_SIMILARITY_THRESHOLD = 0.28
50
+ # At/above this, a match counts as confidently "Present". Between
51
+ # FACE_SIMILARITY_THRESHOLD and this, it's a real name candidate but not
52
+ # confident enough to auto-confirm β€” surfaced as "Suspicious" for a teacher
53
+ # to eyeball, rather than silently trusted or silently discarded.
54
+ PRESENT_SIMILARITY_THRESHOLD = 0.30
55
  EMBEDDING_MODEL_NAME = "adaface_ir101_webface12m"
56
  ENROLLMENT_MIN_DET_SCORE = 0.50
57
  # Unchanged from glintr100: eval/build_gallery.py mirrors production enrollment
 
154
  score: float
155
 
156
 
157
+ @lru_cache(maxsize=1)
158
+ def _get_face_models() -> tuple[FaceAnalysis, AdaFaceWrapper]:
159
+ """Load the shared, expensive ML models once β€” every classroom's
160
+ AttendanceService reuses the same instances, only the roster JSON differs."""
161
+ face_analysis = FaceAnalysis(
162
+ name="antelopev2",
163
+ allowed_modules=["detection"],
164
+ providers=["CPUExecutionProvider"],
165
+ )
166
+ face_analysis.prepare(ctx_id=0, det_size=(1280, 1280), det_thresh=0.5)
167
+ adaface = AdaFaceWrapper.load(ADAFACE_CKPT_PATH)
168
+ return face_analysis, adaface
169
+
170
+
171
  class AttendanceService:
172
+ def __init__(self, classroom_id: str) -> None:
173
+ if classroom_id not in CLASSROOMS:
174
+ raise ValueError(f"Unknown classroom: {classroom_id!r}")
175
+ self.classroom_id = classroom_id
176
+ self.store_path = _store_path(classroom_id)
177
  ensure_attendance_dirs()
178
+ self.face_analysis, self.adaface = _get_face_models()
 
 
 
 
 
 
179
  self._migrate_legacy_embeddings_if_needed()
180
 
181
  def _read_store(self) -> dict:
182
+ if not self.store_path.exists():
183
+ return {"students": [], "attendance": [], "pending_reviews": []}
184
  try:
185
+ data = json.loads(self.store_path.read_text())
186
  except Exception:
187
+ return {"students": [], "attendance": [], "pending_reviews": []}
188
  data.setdefault("students", [])
189
  data.setdefault("attendance", [])
190
+ data.setdefault("pending_reviews", [])
191
  return data
192
 
193
  def _write_store(self, data: dict) -> None:
194
+ self.store_path.parent.mkdir(parents=True, exist_ok=True)
195
+ self.store_path.write_text(json.dumps(data, indent=2))
196
 
197
  def _load_image(self, media_path: Path) -> np.ndarray | None:
198
  image = cv2.imread(str(media_path))
 
232
 
233
  def _sample_frames_for_enrollment(self, media_path: Path) -> list[np.ndarray]:
234
  """Sample up to MAX_ENROLLMENT_FRAMES frames, spread evenly across the
235
+ whole clip, by decoding sequentially and subsampling afterward β€”
236
+ never by seeking.
237
+
238
+ This used to seek to computed positions (via CAP_PROP_POS_FRAMES),
239
+ trusting CAP_PROP_FRAME_COUNT to know where those positions were. Both
240
+ turned out to be unreliable for webm/matroska files recorded live by a
241
+ browser's MediaRecorder, which streams encoded clusters via
242
+ ondataavailable and never goes back to write a finalized duration/seek
243
+ index: the reported frame count can be garbage (even negative), and β€”
244
+ the harder-to-catch failure β€” even when seeking *looks* like it
245
+ succeeded (returns frames, no error), sparse keyframes in a live
246
+ VP8/VP9 stream mean it can silently keep landing on the same handful
247
+ of early frames instead of actually spreading across the clip. A short
248
+ enrollment clip is cheap to fully decode, so there's no real reason to
249
+ seek at all here β€” only mark_attendance's _sample_video_frames (much
250
+ less frequently hit, and normally handed a still photo anyway) keeps
251
+ the seek-based path."""
252
  suffix = media_path.suffix.lower()
253
  if suffix in {".jpg", ".jpeg", ".png", ".webp", ".bmp"}:
254
  image = self._load_image(media_path)
 
258
  if not cap.isOpened():
259
  return []
260
 
261
+ # Flat cap generous enough for any realistic enrollment recording
262
+ # (~100s at 30fps) regardless of what the container's own metadata
263
+ # claims about duration/frame count.
264
+ all_frames: list[np.ndarray] = []
265
+ read_cap = 3000
266
+ while len(all_frames) < read_cap:
 
267
  ok, frame = cap.read()
268
+ if not ok or frame is None:
269
+ break
270
+ all_frames.append(frame)
271
  cap.release()
272
+
273
+ if not all_frames:
274
+ return []
275
+ if len(all_frames) <= MAX_ENROLLMENT_FRAMES:
276
+ return all_frames
277
+ pick = np.linspace(0, len(all_frames) - 1, MAX_ENROLLMENT_FRAMES, dtype=int)
278
+ return [all_frames[i] for i in pick]
279
 
280
  def _detect_samples(self, frame: np.ndarray) -> list[FaceSample]:
281
  faces = self.face_analysis.get(frame)
 
582
 
583
  return {"match": self._student_public(best_student), "similarity": best_similarity}
584
 
585
+ def _add_embedding_to_gallery(self, store: dict, student_id: str, embedding: np.ndarray) -> None:
586
+ """Append an embedding to a student's stored gallery and recompute
587
+ their prototype. Shared by mark_attendance's automatic high-confidence
588
+ growth and by a teacher manually confirming a suspicious match."""
589
+ for s in store.get("students", []):
590
+ if s.get("student_id") == student_id:
591
+ stored = s.get("embeddings", []) or []
592
+ new_emb = _normalize(embedding).tolist()
593
+ stored = (stored + [new_emb])[-MAX_STORED_EMBEDDINGS:]
594
+ s["embeddings"] = stored
595
+ mat = np.asarray(stored, dtype=np.float32)
596
+ s["prototype"] = _normalize(mat.mean(axis=0)).tolist()
597
+ # observations is a lifetime counter (unlike embeddings, which is
598
+ # capped and can evict old entries) β€” keep it moving so the
599
+ # roster UI visibly reflects that this reinforced the gallery.
600
+ s["observations"] = int(s.get("observations", 0)) + 1
601
+ s["updated_at"] = _now_iso()
602
+ break
603
+
604
+ def resolve_suspicious_review(self, review_id: str, confirmed: bool) -> dict:
605
+ """A teacher confirming or rejecting a 'suspicious' match from
606
+ mark_attendance. Confirming reinforces the model β€” the embedding that
607
+ triggered the suspicious match gets added to that student's gallery,
608
+ the same way a high-confidence classroom match already does β€” and
609
+ records the student as present. Rejecting just discards the pending
610
+ review; nothing is added anywhere, since a wrong name is worse to
611
+ learn from than a merely uncertain one."""
612
+ store = self._read_store()
613
+ pending = store.get("pending_reviews", [])
614
+
615
+ review = next((r for r in pending if r.get("review_id") == review_id), None)
616
+ if review is None:
617
+ raise KeyError(f"No pending review: {review_id}")
618
+ store["pending_reviews"] = [r for r in pending if r.get("review_id") != review_id]
619
+
620
+ if confirmed:
621
+ embedding = np.asarray(review["embedding"], dtype=np.float32)
622
+ self._add_embedding_to_gallery(store, review["student_id"], embedding)
623
+ store["attendance"].append({
624
+ "student_name": review["student_name"],
625
+ "recognized_at": _now_iso(),
626
+ "source": "classroom_photo_confirmed",
627
+ "confidence": review["similarity"],
628
+ })
629
+
630
+ self._write_store(store)
631
+ return {
632
+ "confirmed": confirmed,
633
+ "student_name": review["student_name"],
634
+ "roster": self.list_students(),
635
+ }
636
+
637
  def mark_attendance(self, media_path: Path) -> dict:
638
  if not media_path.exists():
639
  raise FileNotFoundError(f"File not found: {media_path}")
 
652
 
653
  detections = self._detect_samples(frame)
654
  marked_frame = frame.copy()
655
+ present: list[dict] = []
656
+ suspicious: list[dict] = []
657
  unknown_faces = 0
658
  unknown_faces_detail: list[dict] = []
659
  store = self._read_store()
 
664
 
665
  # Per student: keep only the single highest-scoring face
666
  # so if two faces both exceed the threshold for the same student,
667
+ # only the best one gets boxed β€” the other stays Unknown.
668
  best_per_student: dict[str, tuple] = {}
669
  for det, match in all_matches:
670
  if match["match"] is not None:
 
673
  best_per_student[name] = (det, match["similarity"], match)
674
 
675
  best_det_ids = {id(det) for det, _, _ in best_per_student.values()}
676
+ seen_student_ids: set[str] = set()
677
 
678
  for det, match in all_matches:
679
  x1, y1, x2, y2 = det.bbox
 
681
 
682
  if is_best:
683
  student = match["match"]
684
+ similarity = match["similarity"]
685
+ is_present = similarity >= PRESENT_SIMILARITY_THRESHOLD
686
+ color = (0, 200, 0) if is_present else (0, 165, 255) # green vs. amber (BGR)
687
+ tag = "" if is_present else " (suspicious)"
688
+ label = f"{student['name']} {similarity:.2f}{tag}"
689
+
690
+ if student["student_id"] not in seen_student_ids:
691
+ seen_student_ids.add(student["student_id"])
692
+ entry = {"student": student, "confidence": round(float(similarity), 4), "bbox": [x1, y1, x2, y2]}
693
+ if is_present:
694
+ present.append(entry)
695
+ # Only confident matches get written to the attendance record β€”
696
+ # a "suspicious" match is a candidate for a teacher to review,
697
+ # not something to silently record as confirmed attendance.
698
+ attendance_log.append({
699
+ "student_name": student["name"],
700
+ "recognized_at": _now_iso(),
701
+ "source": "classroom_photo",
702
+ "confidence": round(float(similarity), 4),
703
+ })
704
+ # Incremental gallery growth: high-confidence classroom embeddings
705
+ # are added to the student's gallery so future matches improve.
706
+ if similarity >= 0.60:
707
+ self._add_embedding_to_gallery(store, student["student_id"], det.embedding)
708
+ else:
709
+ # Hold the embedding behind a review_id rather than acting on
710
+ # it now β€” a teacher confirming/rejecting it is what decides
711
+ # whether it reinforces this student's gallery (see
712
+ # resolve_suspicious_review). Not returned in the API
713
+ # response itself; only the review_id is.
714
+ review_id = uuid.uuid4().hex
715
+ store.setdefault("pending_reviews", []).append({
716
+ "review_id": review_id,
717
+ "student_id": student["student_id"],
718
+ "student_name": student["name"],
719
+ "similarity": round(float(similarity), 4),
720
+ "embedding": _normalize(det.embedding).tolist(),
721
+ "created_at": _now_iso(),
722
+ })
723
+ entry["review_id"] = review_id
724
+ suspicious.append(entry)
725
  else:
726
  unknown_faces += 1
727
  color = (0, 0, 255)
 
739
  cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA,
740
  )
741
 
742
+ # Everyone enrolled in this classroom who wasn't matched at all (present
743
+ # or suspicious) in this photo.
744
+ absent = [
745
+ self._student_public(s) for s in store.get("students", [])
746
+ if s.get("student_id") not in seen_student_ids
747
+ ]
748
+
749
  store["attendance"] = attendance_log
750
  self._write_store(store)
751
 
 
754
  cv2.imwrite(str(marked_path), marked_frame)
755
 
756
  return {
757
+ "present": present,
758
+ "suspicious": suspicious,
759
+ "absent": absent,
760
  "unknown_faces": unknown_faces,
761
  "unknown_faces_detail": unknown_faces_detail,
762
  "marked_path": str(marked_path),
763
  "marked_url": f"/api/attendance/artifacts/{marked_name}",
764
  "roster": self.list_students(),
 
765
  }
766
 
767
 
768
+ @lru_cache(maxsize=None)
769
+ def get_attendance_service(classroom_id: str) -> AttendanceService:
770
  # Fix nested insightface model folders if present before initializing
771
  try:
772
  ensure_insightface_models_flat(["antelopev2"])
773
  except Exception:
774
  pass
775
+ return AttendanceService(classroom_id)
activity_web/backend/static/app.js CHANGED
@@ -9,6 +9,8 @@ function activateTab(tabId) {
9
  btn.setAttribute("aria-selected", String(active));
10
  });
11
  tabPanels.forEach((panel) => panel.classList.toggle("hidden", panel.id !== tabId));
 
 
12
  }
13
  tabButtons.forEach((btn) => btn.addEventListener("click", () => activateTab(btn.dataset.tabTarget)));
14
 
@@ -130,7 +132,64 @@ function renderClassroomStudents(students) {
130
  }).join("");
131
  }
132
 
133
- // ── Attendance tab ─────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  const enrollForm = document.getElementById("enroll-form");
135
  const studentNameInput = document.getElementById("student-name-input");
136
  const enrollMediaInput = document.getElementById("enroll-media-input");
@@ -144,10 +203,10 @@ const classroomPhotoLabel = document.getElementById("classroom-photo-label");
144
  const markStatus = document.getElementById("mark-status");
145
  const markResult = document.getElementById("mark-result");
146
  const markedPhotoPreview= document.getElementById("marked-photo-preview");
147
- const recognizedList = document.getElementById("recognized-list");
148
- const attendanceLogList = document.getElementById("attendance-log-list");
 
149
  const rosterList = document.getElementById("roster-list");
150
- const attendanceLogSummary = document.getElementById("attendance-log-summary");
151
 
152
  enrollMediaInput.addEventListener("change", () => {
153
  enrollMediaLabel.textContent = selectedFileText(enrollMediaInput.files, "Choose photos or videos for enrollment");
@@ -158,12 +217,23 @@ classroomPhotoInput.addEventListener("change", () => {
158
 
159
  // Enrollment tab toggle
160
  let enrollTab = "files";
 
161
  function switchEnrollTab(tab) {
 
162
  enrollTab = tab;
163
  document.getElementById("enroll-tab-files").style.display = tab === "files" ? "" : "none";
164
  document.getElementById("enroll-tab-folder").style.display = tab === "folder" ? "" : "none";
 
165
  document.getElementById("tab-files").classList.toggle("tab-active", tab === "files");
166
  document.getElementById("tab-folder").classList.toggle("tab-active", tab === "folder");
 
 
 
 
 
 
 
 
167
  }
168
 
169
  enrollForm.addEventListener("submit", async (event) => {
@@ -184,13 +254,24 @@ enrollForm.addEventListener("submit", async (event) => {
184
  response = await fetch("/api/attendance/enroll-folder", {
185
  method: "POST",
186
  headers: { "Content-Type": "application/json" },
187
- body: JSON.stringify({ student_name: studentName, folder_path: folderPath }),
188
  });
189
  data = await response.json();
190
  if (data.ok) enrollStatus.textContent = `Enrolled ${data.student.name} from ${data.files_used} file(s).`;
 
 
 
 
 
 
 
 
 
 
191
  } else {
192
  if (!enrollMediaInput.files.length) { enrollStatus.textContent = "Upload at least one photo or video."; enrollStatus.classList.add("error"); btn.disabled = false; return; }
193
  const payload = new FormData();
 
194
  payload.append("student_name", studentName);
195
  Array.from(enrollMediaInput.files).forEach((f) => payload.append("media", f));
196
  response = await fetch("/api/attendance/enroll", { method: "POST", body: payload });
@@ -198,9 +279,8 @@ enrollForm.addEventListener("submit", async (event) => {
198
  if (data.ok) enrollStatus.textContent = `Enrolled ${data.student.name} successfully.`;
199
  }
200
  if (!response.ok || !data.ok) throw new Error(data.error || "Enrollment failed.");
201
- renderRoster(data.students || []);
202
  renderEnrollmentResult(data.student, data.media_samples || []);
203
- await refreshAttendanceSummary();
204
  } catch (err) {
205
  enrollStatus.textContent = err.message;
206
  enrollStatus.classList.add("error");
@@ -212,6 +292,7 @@ markForm.addEventListener("submit", async (event) => {
212
  if (!classroomPhotoInput.files.length) { markStatus.textContent = "Upload a classroom photo first."; markStatus.classList.add("error"); return; }
213
  const btn = markForm.querySelector("button[type='submit']");
214
  const payload = new FormData();
 
215
  payload.append("photo", classroomPhotoInput.files[0]);
216
  markStatus.classList.remove("error");
217
  markStatus.textContent = "Detecting faces and marking attendance...";
@@ -221,12 +302,10 @@ markForm.addEventListener("submit", async (event) => {
221
  const data = await response.json();
222
  if (!response.ok || !data.ok) throw new Error(data.error || "Attendance marking failed.");
223
  renderMarkedPhoto(data.marked_url);
224
- renderRecognizedFaces(data.recognized || [], data.unknown_faces || 0, data.unknown_faces_detail || []);
225
- renderAttendanceLog(data.attendance_log || []);
226
  renderRoster(data.roster || []);
227
- markStatus.textContent = `Marked ${data.recognized.length} student${data.recognized.length === 1 ? "" : "s"}.`;
228
  markResult.classList.remove("hidden");
229
- await refreshAttendanceSummary();
230
  } catch (err) {
231
  markStatus.textContent = err.message;
232
  markStatus.classList.add("error");
@@ -238,8 +317,9 @@ document.getElementById("demo-preview-btn").addEventListener("click", () => {
238
  markStatus.textContent = "Demo classroom photo β€” original, no annotations.";
239
  markedPhotoPreview.src = "/static/demo_classroom.jpg";
240
  markResult.classList.remove("hidden");
241
- recognizedList.innerHTML = "";
242
- attendanceLogList.innerHTML = "";
 
243
  hideUnknownFacesUI();
244
  });
245
 
@@ -255,15 +335,13 @@ document.getElementById("demo-btn").addEventListener("click", async () => {
255
 
256
  // Step 2 β€” run the pipeline
257
  try {
258
- const response = await fetch("/api/attendance/demo", { method: "POST" });
259
  const data = await response.json();
260
  if (!response.ok || !data.ok) throw new Error(data.error || "Demo failed.");
261
  renderMarkedPhoto(data.marked_url);
262
- renderRecognizedFaces(data.recognized || [], data.unknown_faces || 0, data.unknown_faces_detail || []);
263
- renderAttendanceLog(data.attendance_log || []);
264
  renderRoster(data.roster || []);
265
- markStatus.textContent = `Demo complete β€” ${data.recognized.length} student${data.recognized.length === 1 ? "" : "s"} recognized.`;
266
- await refreshAttendanceSummary();
267
  } catch (err) {
268
  markStatus.textContent = err.message;
269
  markStatus.classList.add("error");
@@ -271,10 +349,11 @@ document.getElementById("demo-btn").addEventListener("click", async () => {
271
  });
272
 
273
  async function refreshAttendanceSummary() {
 
274
  try {
275
- const response = await fetch("/api/attendance/roster");
276
  const data = await response.json();
277
- if (response.ok && data.ok) { renderRoster(data.students || []); renderAttendanceSummary(data.attendance || []); }
278
  } catch (e) { console.error(e); }
279
  }
280
 
@@ -297,17 +376,29 @@ const unknownFacesGrid = document.getElementById("unknown-faces-grid");
297
  let currentUnknownFaces = [];
298
  let unknownFacesExpanded = false;
299
 
300
- function renderRecognizedFaces(recognized, unknownFaces, unknownFacesDetail) {
301
- const names = recognized.map((e) => e.student.name);
302
- const summary = names.length
303
- ? `<div class="result-summary">${names.length} present: ${names.join(", ")}</div>`
304
- : `<div class="result-summary muted">0 present</div>`;
305
-
306
- recognizedList.innerHTML = `<h3>Recognized faces</h3>
307
- ${summary}
308
- ${recognized.length
309
- ? recognized.map((e) => `<div class="result-item"><strong>${e.student.name}</strong><span>Confidence ${formatNumber(e.confidence)}</span></div>`).join("")
310
- : '<div class="result-item muted">No enrolled students recognized.</div>'}`;
 
 
 
 
 
 
 
 
 
 
 
 
311
 
312
  currentUnknownFaces = unknownFacesDetail || [];
313
  unknownFacesExpanded = false;
@@ -322,6 +413,43 @@ function renderRecognizedFaces(recognized, unknownFaces, unknownFacesDetail) {
322
  }
323
  }
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  function hideUnknownFacesUI() {
326
  currentUnknownFaces = [];
327
  unknownFacesExpanded = false;
@@ -384,16 +512,6 @@ unknownFacesToggle.addEventListener("click", () => {
384
  }
385
  });
386
 
387
- function renderAttendanceLog(log) {
388
- attendanceLogList.innerHTML = `<h3>Attendance log</h3>
389
- ${log.map((e) => `<div class="result-item"><strong>${e.student_name}</strong><span>${e.recognized_at} Β· ${formatNumber(e.confidence)}</span></div>`).join("")}`;
390
- }
391
-
392
- function renderAttendanceSummary(log) {
393
- attendanceLogSummary.innerHTML = `<h3>Recent attendance</h3>
394
- ${log.map((e) => `<div class="result-item"><strong>${e.student_name}</strong><span>${e.recognized_at} Β· ${formatNumber(e.confidence)}</span></div>`).join("")}`;
395
- }
396
-
397
  function renderRoster(students) {
398
  if (!students.length) { rosterList.innerHTML = '<div class="result-item muted">No students enrolled yet.</div>'; return; }
399
  rosterList.innerHTML = students.map((s) => `
@@ -414,12 +532,10 @@ rosterList.addEventListener("click", async (event) => {
414
  if (!confirm(`Delete ${name}? This removes the student and their attendance records.`)) return;
415
  btn.disabled = true; btn.textContent = "Deleting...";
416
  try {
417
- const response = await fetch(`/api/attendance/students/${encodeURIComponent(studentId)}`, { method: "DELETE" });
418
  const data = await response.json();
419
  if (!response.ok || !data.ok) throw new Error(data.error || "Delete failed.");
420
  renderRoster(data.students || []);
421
- renderAttendanceSummary(data.attendance || []);
422
- await refreshAttendanceSummary();
423
  } catch (err) { alert(err.message); }
424
  finally { btn.disabled = false; btn.textContent = "Delete"; }
425
  });
@@ -437,4 +553,5 @@ document.addEventListener("keydown", (e) => { if (e.key === "Escape") lightbox.c
437
  function formatWindow(s) { const n = Number(s); return isNaN(n) ? "-" : `${n.toFixed(2)}s`; }
438
  function formatNumber(v) { if (v == null || isNaN(Number(v))) return "-"; return Number(v).toFixed(4); }
439
 
440
- refreshAttendanceSummary();
 
 
9
  btn.setAttribute("aria-selected", String(active));
10
  });
11
  tabPanels.forEach((panel) => panel.classList.toggle("hidden", panel.id !== tabId));
12
+ // Refresh the roster in case a student was just enrolled from the other tab.
13
+ if (tabId === "attendance-tab") refreshAttendanceSummary();
14
  }
15
  tabButtons.forEach((btn) => btn.addEventListener("click", () => activateTab(btn.dataset.tabTarget)));
16
 
 
132
  }).join("");
133
  }
134
 
135
+ // ── Attendance tab: classroom picker ─────────────────────────────────────────
136
+ const classroomSelect = document.getElementById("classroom-select");
137
+ const rosterClassroomLabel = document.getElementById("roster-classroom-label");
138
+ let currentClassroomId = null;
139
+
140
+ function updateRosterClassroomLabel() {
141
+ const label = classroomSelect.options[classroomSelect.selectedIndex]?.textContent || "";
142
+ rosterClassroomLabel.textContent = label;
143
+ }
144
+
145
+ async function loadClassrooms() {
146
+ try {
147
+ const response = await fetch("/api/attendance/classrooms");
148
+ const data = await response.json();
149
+ if (!data.ok || !data.classrooms.length) throw new Error(data.error || "No classrooms available.");
150
+ classroomSelect.innerHTML = data.classrooms.map((c) => `<option value="${c.id}">${c.label}</option>`).join("");
151
+ const saved = localStorage.getItem("prism_classroom");
152
+ currentClassroomId = (saved && data.classrooms.some((c) => c.id === saved)) ? saved : data.classrooms[0].id;
153
+ classroomSelect.value = currentClassroomId;
154
+ updateRosterClassroomLabel();
155
+ await refreshAttendanceSummary();
156
+ } catch (err) {
157
+ console.error("Failed to load classrooms:", err);
158
+ }
159
+ }
160
+
161
+ classroomSelect.addEventListener("change", async () => {
162
+ currentClassroomId = classroomSelect.value;
163
+ localStorage.setItem("prism_classroom", currentClassroomId);
164
+ updateRosterClassroomLabel();
165
+ markResult.classList.add("hidden");
166
+ await refreshAttendanceSummary();
167
+ });
168
+
169
+ // ── Enroll Student tab: its own independent classroom picker ────────────────
170
+ const enrollClassroomSelect = document.getElementById("enroll-classroom-select");
171
+ let currentEnrollClassroomId = null;
172
+
173
+ async function loadEnrollClassrooms() {
174
+ try {
175
+ const response = await fetch("/api/attendance/classrooms");
176
+ const data = await response.json();
177
+ if (!data.ok || !data.classrooms.length) throw new Error(data.error || "No classrooms available.");
178
+ enrollClassroomSelect.innerHTML = data.classrooms.map((c) => `<option value="${c.id}">${c.label}</option>`).join("");
179
+ const saved = localStorage.getItem("prism_classroom");
180
+ currentEnrollClassroomId = (saved && data.classrooms.some((c) => c.id === saved)) ? saved : data.classrooms[0].id;
181
+ enrollClassroomSelect.value = currentEnrollClassroomId;
182
+ } catch (err) {
183
+ console.error("Failed to load classrooms:", err);
184
+ }
185
+ }
186
+
187
+ enrollClassroomSelect.addEventListener("change", () => {
188
+ currentEnrollClassroomId = enrollClassroomSelect.value;
189
+ localStorage.setItem("prism_classroom", currentEnrollClassroomId);
190
+ });
191
+
192
+ // ── Enroll Student tab ────────────────────────────────────────────────────────
193
  const enrollForm = document.getElementById("enroll-form");
194
  const studentNameInput = document.getElementById("student-name-input");
195
  const enrollMediaInput = document.getElementById("enroll-media-input");
 
203
  const markStatus = document.getElementById("mark-status");
204
  const markResult = document.getElementById("mark-result");
205
  const markedPhotoPreview= document.getElementById("marked-photo-preview");
206
+ const presentList = document.getElementById("present-list");
207
+ const suspiciousList = document.getElementById("suspicious-list");
208
+ const absentList = document.getElementById("absent-list");
209
  const rosterList = document.getElementById("roster-list");
 
210
 
211
  enrollMediaInput.addEventListener("change", () => {
212
  enrollMediaLabel.textContent = selectedFileText(enrollMediaInput.files, "Choose photos or videos for enrollment");
 
217
 
218
  // Enrollment tab toggle
219
  let enrollTab = "files";
220
+ let enrollCameraRecorder = null;
221
  function switchEnrollTab(tab) {
222
+ const previousTab = enrollTab;
223
  enrollTab = tab;
224
  document.getElementById("enroll-tab-files").style.display = tab === "files" ? "" : "none";
225
  document.getElementById("enroll-tab-folder").style.display = tab === "folder" ? "" : "none";
226
+ document.getElementById("enroll-tab-camera").style.display = tab === "camera" ? "" : "none";
227
  document.getElementById("tab-files").classList.toggle("tab-active", tab === "files");
228
  document.getElementById("tab-folder").classList.toggle("tab-active", tab === "folder");
229
+ document.getElementById("tab-camera").classList.toggle("tab-active", tab === "camera");
230
+
231
+ if (tab === "camera" && !enrollCameraRecorder) {
232
+ enrollCameraRecorder = CameraRecorder.create(document.getElementById("enroll-camera-recorder"));
233
+ }
234
+ if (previousTab === "camera" && tab !== "camera" && enrollCameraRecorder) {
235
+ enrollCameraRecorder.stopStream();
236
+ }
237
  }
238
 
239
  enrollForm.addEventListener("submit", async (event) => {
 
254
  response = await fetch("/api/attendance/enroll-folder", {
255
  method: "POST",
256
  headers: { "Content-Type": "application/json" },
257
+ body: JSON.stringify({ classroom: currentEnrollClassroomId, student_name: studentName, folder_path: folderPath }),
258
  });
259
  data = await response.json();
260
  if (data.ok) enrollStatus.textContent = `Enrolled ${data.student.name} from ${data.files_used} file(s).`;
261
+ } else if (enrollTab === "camera") {
262
+ const blob = enrollCameraRecorder && enrollCameraRecorder.getBlob();
263
+ if (!blob) { enrollStatus.textContent = "Record a video first."; enrollStatus.classList.add("error"); btn.disabled = false; return; }
264
+ const payload = new FormData();
265
+ payload.append("classroom", currentEnrollClassroomId);
266
+ payload.append("student_name", studentName);
267
+ payload.append("media", blob, "recording.webm");
268
+ response = await fetch("/api/attendance/enroll", { method: "POST", body: payload });
269
+ data = await response.json();
270
+ if (data.ok) { enrollStatus.textContent = `Enrolled ${data.student.name} successfully.`; enrollCameraRecorder.reset(); }
271
  } else {
272
  if (!enrollMediaInput.files.length) { enrollStatus.textContent = "Upload at least one photo or video."; enrollStatus.classList.add("error"); btn.disabled = false; return; }
273
  const payload = new FormData();
274
+ payload.append("classroom", currentEnrollClassroomId);
275
  payload.append("student_name", studentName);
276
  Array.from(enrollMediaInput.files).forEach((f) => payload.append("media", f));
277
  response = await fetch("/api/attendance/enroll", { method: "POST", body: payload });
 
279
  if (data.ok) enrollStatus.textContent = `Enrolled ${data.student.name} successfully.`;
280
  }
281
  if (!response.ok || !data.ok) throw new Error(data.error || "Enrollment failed.");
 
282
  renderEnrollmentResult(data.student, data.media_samples || []);
283
+ if (currentEnrollClassroomId === currentClassroomId) await refreshAttendanceSummary();
284
  } catch (err) {
285
  enrollStatus.textContent = err.message;
286
  enrollStatus.classList.add("error");
 
292
  if (!classroomPhotoInput.files.length) { markStatus.textContent = "Upload a classroom photo first."; markStatus.classList.add("error"); return; }
293
  const btn = markForm.querySelector("button[type='submit']");
294
  const payload = new FormData();
295
+ payload.append("classroom", currentClassroomId);
296
  payload.append("photo", classroomPhotoInput.files[0]);
297
  markStatus.classList.remove("error");
298
  markStatus.textContent = "Detecting faces and marking attendance...";
 
302
  const data = await response.json();
303
  if (!response.ok || !data.ok) throw new Error(data.error || "Attendance marking failed.");
304
  renderMarkedPhoto(data.marked_url);
305
+ renderAttendanceBuckets(data.present || [], data.suspicious || [], data.absent || [], data.unknown_faces || 0, data.unknown_faces_detail || []);
 
306
  renderRoster(data.roster || []);
307
+ markStatus.textContent = `${data.present.length} present, ${data.suspicious.length} suspicious, ${data.absent.length} absent.`;
308
  markResult.classList.remove("hidden");
 
309
  } catch (err) {
310
  markStatus.textContent = err.message;
311
  markStatus.classList.add("error");
 
317
  markStatus.textContent = "Demo classroom photo β€” original, no annotations.";
318
  markedPhotoPreview.src = "/static/demo_classroom.jpg";
319
  markResult.classList.remove("hidden");
320
+ presentList.innerHTML = "";
321
+ suspiciousList.innerHTML = "";
322
+ absentList.innerHTML = "";
323
  hideUnknownFacesUI();
324
  });
325
 
 
335
 
336
  // Step 2 β€” run the pipeline
337
  try {
338
+ const response = await fetch(`/api/attendance/demo?classroom=${encodeURIComponent(currentClassroomId)}`, { method: "POST" });
339
  const data = await response.json();
340
  if (!response.ok || !data.ok) throw new Error(data.error || "Demo failed.");
341
  renderMarkedPhoto(data.marked_url);
342
+ renderAttendanceBuckets(data.present || [], data.suspicious || [], data.absent || [], data.unknown_faces || 0, data.unknown_faces_detail || []);
 
343
  renderRoster(data.roster || []);
344
+ markStatus.textContent = `Demo complete β€” ${data.present.length} present, ${data.suspicious.length} suspicious, ${data.absent.length} absent.`;
 
345
  } catch (err) {
346
  markStatus.textContent = err.message;
347
  markStatus.classList.add("error");
 
349
  });
350
 
351
  async function refreshAttendanceSummary() {
352
+ if (!currentClassroomId) return;
353
  try {
354
+ const response = await fetch(`/api/attendance/roster?classroom=${encodeURIComponent(currentClassroomId)}`);
355
  const data = await response.json();
356
+ if (response.ok && data.ok) renderRoster(data.students || []);
357
  } catch (e) { console.error(e); }
358
  }
359
 
 
376
  let currentUnknownFaces = [];
377
  let unknownFacesExpanded = false;
378
 
379
+ function renderAttendanceBuckets(present, suspicious, absent, unknownFaces, unknownFacesDetail) {
380
+ presentList.innerHTML = `<h3>Present (${present.length})</h3>
381
+ ${present.length
382
+ ? present.map((e) => `<div class="result-item present-item"><strong>${e.student.name}</strong><span>Confidence ${formatNumber(e.confidence)}</span></div>`).join("")
383
+ : '<div class="result-item muted">No students confidently recognized.</div>'}`;
384
+
385
+ suspiciousList.innerHTML = `<h3>Suspicious (${suspicious.length})</h3>
386
+ ${suspicious.length
387
+ ? suspicious.map((e) => `
388
+ <div class="result-item suspicious-item" data-review-id="${e.review_id}">
389
+ <strong>${e.student.name}</strong>
390
+ <span>Confidence ${formatNumber(e.confidence)} β€” please verify</span>
391
+ <div class="suspicious-actions">
392
+ <button type="button" class="suspicious-btn suspicious-confirm-btn" data-review-id="${e.review_id}">Yes, it's them</button>
393
+ <button type="button" class="suspicious-btn suspicious-reject-btn" data-review-id="${e.review_id}">Not them</button>
394
+ </div>
395
+ </div>`).join("")
396
+ : '<div class="result-item muted">None.</div>'}`;
397
+
398
+ absentList.innerHTML = `<h3>Absent (${absent.length})</h3>
399
+ ${absent.length
400
+ ? absent.map((s) => `<div class="result-item absent-item"><strong>${s.name}</strong></div>`).join("")
401
+ : '<div class="result-item muted">Everyone enrolled was seen.</div>'}`;
402
 
403
  currentUnknownFaces = unknownFacesDetail || [];
404
  unknownFacesExpanded = false;
 
413
  }
414
  }
415
 
416
+ // Confirming reinforces the model: the embedding that triggered the suspicious
417
+ // match gets added to that student's gallery (same as an automatic high-
418
+ // confidence match would). Rejecting just discards it β€” nothing is learned
419
+ // from a match the teacher says is wrong.
420
+ suspiciousList.addEventListener("click", async (event) => {
421
+ const confirmBtn = event.target.closest(".suspicious-confirm-btn");
422
+ const rejectBtn = event.target.closest(".suspicious-reject-btn");
423
+ const btn = confirmBtn || rejectBtn;
424
+ if (!btn) return;
425
+
426
+ const confirmed = !!confirmBtn;
427
+ const item = btn.closest(".suspicious-item");
428
+ const reviewId = btn.dataset.reviewId;
429
+ const name = item.querySelector("strong")?.textContent || "this student";
430
+ item.querySelectorAll("button").forEach((b) => (b.disabled = true));
431
+
432
+ try {
433
+ const response = await fetch("/api/attendance/suspicious/resolve", {
434
+ method: "POST",
435
+ headers: { "Content-Type": "application/json" },
436
+ body: JSON.stringify({ classroom: currentClassroomId, review_id: reviewId, confirmed }),
437
+ });
438
+ const data = await response.json();
439
+ if (!response.ok || !data.ok) throw new Error(data.error || "Failed to resolve.");
440
+
441
+ item.classList.remove("suspicious-item");
442
+ item.classList.add(confirmed ? "present-item" : "absent-item");
443
+ item.innerHTML = confirmed
444
+ ? `<strong>${name}</strong><span>Confirmed β€” added to their gallery.</span>`
445
+ : `<strong>${name}</strong><span>Marked as not them.</span>`;
446
+ if (confirmed) await refreshAttendanceSummary();
447
+ } catch (err) {
448
+ alert(err.message);
449
+ item.querySelectorAll("button").forEach((b) => (b.disabled = false));
450
+ }
451
+ });
452
+
453
  function hideUnknownFacesUI() {
454
  currentUnknownFaces = [];
455
  unknownFacesExpanded = false;
 
512
  }
513
  });
514
 
 
 
 
 
 
 
 
 
 
 
515
  function renderRoster(students) {
516
  if (!students.length) { rosterList.innerHTML = '<div class="result-item muted">No students enrolled yet.</div>'; return; }
517
  rosterList.innerHTML = students.map((s) => `
 
532
  if (!confirm(`Delete ${name}? This removes the student and their attendance records.`)) return;
533
  btn.disabled = true; btn.textContent = "Deleting...";
534
  try {
535
+ const response = await fetch(`/api/attendance/students/${encodeURIComponent(studentId)}?classroom=${encodeURIComponent(currentClassroomId)}`, { method: "DELETE" });
536
  const data = await response.json();
537
  if (!response.ok || !data.ok) throw new Error(data.error || "Delete failed.");
538
  renderRoster(data.students || []);
 
 
539
  } catch (err) { alert(err.message); }
540
  finally { btn.disabled = false; btn.textContent = "Delete"; }
541
  });
 
553
  function formatWindow(s) { const n = Number(s); return isNaN(n) ? "-" : `${n.toFixed(2)}s`; }
554
  function formatNumber(v) { if (v == null || isNaN(Number(v))) return "-"; return Number(v).toFixed(4); }
555
 
556
+ loadClassrooms();
557
+ loadEnrollClassrooms();
activity_web/backend/static/camera-recorder.js ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared camera-recording widget, used by both the teacher dashboard's enroll
2
+ // tab and the student self-enrollment page. Records a short webm clip via
3
+ // MediaRecorder and hands the Blob back to the caller β€” it never touches a
4
+ // file <input>, the caller just asks for the current Blob on submit.
5
+ //
6
+ // Walks the person through a short guided sequence (look center, turn left,
7
+ // turn right, ...) while recording, and overlays a face-position guide, so
8
+ // the resulting clip has the pose variety the enrollment pipeline wants
9
+ // (recall ANCHOR_CONSISTENCY_THRESHOLD tolerates moderate turns, and the
10
+ // degraded/distance copies it generates work better from a clear, centered,
11
+ // well-lit face). The sequence is just a default β€” pass `script` to override.
12
+ //
13
+ // Usage:
14
+ // const recorder = CameraRecorder.create(containerEl);
15
+ // ...
16
+ // const blob = recorder.getBlob(); // null if nothing recorded yet
17
+ // recorder.reset(); // clear after a successful submit
18
+
19
+ const CameraRecorder = (() => {
20
+ const DEFAULT_SCRIPT = [
21
+ { text: "Look straight at the camera", seconds: 10 },
22
+ { text: "Slowly turn your head to the left", seconds: 8 },
23
+ { text: "Slowly turn your head to the right", seconds: 8 },
24
+ { text: "Back to center. Almost done", seconds: 6 },
25
+ ];
26
+
27
+ function speak(text) {
28
+ if (!("speechSynthesis" in window)) return;
29
+ try {
30
+ window.speechSynthesis.cancel(); // don't queue/overlap with a prior step
31
+ const utter = new SpeechSynthesisUtterance(text);
32
+ utter.rate = 0.95;
33
+ window.speechSynthesis.speak(utter);
34
+ } catch (err) {
35
+ console.warn("Speech synthesis unavailable:", err);
36
+ }
37
+ }
38
+
39
+ function create(container, options = {}) {
40
+ const script = options.script && options.script.length ? options.script : DEFAULT_SCRIPT;
41
+
42
+ container.innerHTML = `
43
+ <div class="camera-recorder">
44
+ <div class="camera-video-wrap">
45
+ <video class="camera-preview" autoplay muted playsinline></video>
46
+ <div class="camera-face-guide" hidden></div>
47
+ <div class="camera-instruction" hidden></div>
48
+ </div>
49
+ <p class="camera-hint">Center your face in the oval, in good light, at arm's length from the camera.</p>
50
+ <div class="camera-controls">
51
+ <button type="button" class="camera-btn" data-action="start-camera">Start camera</button>
52
+ <button type="button" class="camera-btn" data-action="record" disabled>Start recording</button>
53
+ <button type="button" class="camera-btn" data-action="retake" hidden>Retake</button>
54
+ </div>
55
+ <div class="camera-status">Camera off.</div>
56
+ </div>
57
+ `;
58
+
59
+ const videoEl = container.querySelector(".camera-preview");
60
+ const faceGuideEl = container.querySelector(".camera-face-guide");
61
+ const instructionEl = container.querySelector(".camera-instruction");
62
+ const statusEl = container.querySelector(".camera-status");
63
+ const startBtn = container.querySelector('[data-action="start-camera"]');
64
+ const recordBtn = container.querySelector('[data-action="record"]');
65
+ const retakeBtn = container.querySelector('[data-action="retake"]');
66
+
67
+ let stream = null;
68
+ let mediaRecorder = null;
69
+ let chunks = [];
70
+ let recordedBlob = null;
71
+ let recording = false;
72
+ let scriptTimer = null;
73
+
74
+ async function startCamera() {
75
+ try {
76
+ stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
77
+ } catch (err) {
78
+ statusEl.textContent = "Couldn't access the camera: " + err.message;
79
+ return;
80
+ }
81
+ videoEl.srcObject = stream;
82
+ faceGuideEl.hidden = false;
83
+ startBtn.disabled = true;
84
+ recordBtn.disabled = false;
85
+ statusEl.textContent = "Camera on. Center your face in the oval, then start recording.";
86
+ }
87
+
88
+ function runGuidedScript() {
89
+ let stepIndex = 0;
90
+ const showStep = () => {
91
+ if (!recording || stepIndex >= script.length) {
92
+ instructionEl.hidden = true;
93
+ if (recording) stopRecording();
94
+ return;
95
+ }
96
+ const step = script[stepIndex];
97
+ instructionEl.hidden = false;
98
+ instructionEl.textContent = `${step.text} (${stepIndex + 1}/${script.length})`;
99
+ speak(step.text);
100
+ stepIndex++;
101
+ scriptTimer = setTimeout(showStep, step.seconds * 1000);
102
+ };
103
+ showStep();
104
+ }
105
+
106
+ function startRecording() {
107
+ if (!stream) return;
108
+ chunks = [];
109
+ const mimeType = MediaRecorder.isTypeSupported("video/webm;codecs=vp8")
110
+ ? "video/webm;codecs=vp8"
111
+ : "video/webm";
112
+ mediaRecorder = new MediaRecorder(stream, { mimeType });
113
+ mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
114
+ mediaRecorder.onstop = () => {
115
+ recordedBlob = new Blob(chunks, { type: mimeType });
116
+ videoEl.srcObject = null;
117
+ videoEl.src = URL.createObjectURL(recordedBlob);
118
+ videoEl.muted = false;
119
+ videoEl.controls = true;
120
+ videoEl.play().catch(() => {});
121
+ stream.getTracks().forEach((t) => t.stop());
122
+ stream = null;
123
+ recording = false;
124
+ faceGuideEl.hidden = true;
125
+ instructionEl.hidden = true;
126
+ recordBtn.hidden = true;
127
+ retakeBtn.hidden = false;
128
+ statusEl.textContent = "Recorded. Review it, or retake.";
129
+ };
130
+ mediaRecorder.start();
131
+ recording = true;
132
+ recordBtn.textContent = "Stop recording";
133
+ statusEl.textContent = "Recording…";
134
+ runGuidedScript();
135
+ }
136
+
137
+ function stopRecording() {
138
+ if (scriptTimer) { clearTimeout(scriptTimer); scriptTimer = null; }
139
+ if ("speechSynthesis" in window) window.speechSynthesis.cancel();
140
+ if (mediaRecorder && recording) mediaRecorder.stop();
141
+ }
142
+
143
+ function retake() {
144
+ if (scriptTimer) { clearTimeout(scriptTimer); scriptTimer = null; }
145
+ if ("speechSynthesis" in window) window.speechSynthesis.cancel();
146
+ recordedBlob = null;
147
+ videoEl.controls = false;
148
+ videoEl.muted = true;
149
+ videoEl.removeAttribute("src");
150
+ videoEl.load();
151
+ faceGuideEl.hidden = true;
152
+ instructionEl.hidden = true;
153
+ recordBtn.hidden = false;
154
+ recordBtn.textContent = "Start recording";
155
+ retakeBtn.hidden = true;
156
+ startBtn.disabled = false;
157
+ recordBtn.disabled = true;
158
+ statusEl.textContent = "Camera off.";
159
+ }
160
+
161
+ startBtn.addEventListener("click", startCamera);
162
+ recordBtn.addEventListener("click", () => (recording ? stopRecording() : startRecording()));
163
+ retakeBtn.addEventListener("click", retake);
164
+
165
+ return {
166
+ getBlob: () => recordedBlob,
167
+ reset: retake,
168
+ stopStream: () => {
169
+ if (scriptTimer) { clearTimeout(scriptTimer); scriptTimer = null; }
170
+ if ("speechSynthesis" in window) window.speechSynthesis.cancel();
171
+ if (stream) stream.getTracks().forEach((t) => t.stop());
172
+ },
173
+ };
174
+ }
175
+
176
+ return { create, DEFAULT_SCRIPT };
177
+ })();
activity_web/backend/static/enroll.js ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Student self-enrollment page. Posts to the same /api/attendance/enroll
2
+ // endpoint the teacher dashboard uses β€” just a lighter-weight form around it.
3
+
4
+ const classroomSelect = document.getElementById("self-enroll-classroom");
5
+ const nameInput = document.getElementById("self-enroll-name");
6
+ const mediaInput = document.getElementById("self-enroll-media-input");
7
+ const mediaLabel = document.getElementById("self-enroll-media-label");
8
+ const form = document.getElementById("self-enroll-form");
9
+ const statusEl = document.getElementById("self-enroll-status");
10
+ const resultEl = document.getElementById("self-enroll-result");
11
+
12
+ let enrollTab = "files";
13
+ let cameraRecorder = null;
14
+
15
+ function selectedFileText(files, fallback) {
16
+ if (!files || !files.length) return fallback;
17
+ return files.length === 1 ? files[0].name : `${files[0].name} + ${files.length - 1} more`;
18
+ }
19
+
20
+ mediaInput.addEventListener("change", () => {
21
+ mediaLabel.textContent = selectedFileText(mediaInput.files, "Choose photos or a short video");
22
+ });
23
+
24
+ function switchSelfEnrollTab(tab) {
25
+ const previousTab = enrollTab;
26
+ enrollTab = tab;
27
+ document.getElementById("self-enroll-tab-files").style.display = tab === "files" ? "" : "none";
28
+ document.getElementById("self-enroll-tab-camera").style.display = tab === "camera" ? "" : "none";
29
+ document.getElementById("self-tab-files").classList.toggle("tab-active", tab === "files");
30
+ document.getElementById("self-tab-camera").classList.toggle("tab-active", tab === "camera");
31
+
32
+ if (tab === "camera" && !cameraRecorder) {
33
+ cameraRecorder = CameraRecorder.create(document.getElementById("self-enroll-camera-recorder"));
34
+ }
35
+ if (previousTab === "camera" && tab !== "camera" && cameraRecorder) {
36
+ cameraRecorder.stopStream();
37
+ }
38
+ }
39
+
40
+ async function loadClassrooms() {
41
+ try {
42
+ const response = await fetch("/api/attendance/classrooms");
43
+ const data = await response.json();
44
+ if (!data.ok || !data.classrooms.length) throw new Error(data.error || "No classrooms available.");
45
+ classroomSelect.innerHTML = data.classrooms.map((c) => `<option value="${c.id}">${c.label}</option>`).join("");
46
+ } catch (err) {
47
+ statusEl.textContent = "Couldn't load classrooms: " + err.message;
48
+ statusEl.classList.add("error");
49
+ }
50
+ }
51
+
52
+ form.addEventListener("submit", async (event) => {
53
+ event.preventDefault();
54
+ const name = nameInput.value.trim();
55
+ if (!name) { statusEl.textContent = "Enter your name."; statusEl.classList.add("error"); return; }
56
+
57
+ const btn = form.querySelector("button[type='submit']");
58
+ statusEl.classList.remove("error");
59
+ resultEl.classList.add("hidden");
60
+ btn.disabled = true;
61
+
62
+ try {
63
+ const payload = new FormData();
64
+ payload.append("classroom", classroomSelect.value);
65
+ payload.append("student_name", name);
66
+
67
+ if (enrollTab === "camera") {
68
+ const blob = cameraRecorder && cameraRecorder.getBlob();
69
+ if (!blob) { statusEl.textContent = "Record a video first."; statusEl.classList.add("error"); btn.disabled = false; return; }
70
+ payload.append("media", blob, "recording.webm");
71
+ } else {
72
+ if (!mediaInput.files.length) { statusEl.textContent = "Upload at least one photo or video."; statusEl.classList.add("error"); btn.disabled = false; return; }
73
+ Array.from(mediaInput.files).forEach((f) => payload.append("media", f));
74
+ }
75
+
76
+ statusEl.textContent = "Extracting your face profile...";
77
+ const response = await fetch("/api/attendance/enroll", { method: "POST", body: payload });
78
+ const data = await response.json();
79
+ if (!response.ok || !data.ok) throw new Error(data.error || "Enrollment failed.");
80
+
81
+ statusEl.textContent = `You're enrolled, ${data.student.name}!`;
82
+ resultEl.classList.remove("hidden");
83
+ resultEl.innerHTML = `<div class="result-summary">${data.student.name} β€” ${data.student.observations ?? 0} embeddings captured</div>`;
84
+ if (cameraRecorder) cameraRecorder.reset();
85
+ form.reset();
86
+ mediaLabel.textContent = "Choose photos or a short video";
87
+ } catch (err) {
88
+ statusEl.textContent = err.message;
89
+ statusEl.classList.add("error");
90
+ } finally {
91
+ btn.disabled = false;
92
+ }
93
+ });
94
+
95
+ loadClassrooms();
activity_web/backend/static/styles.css CHANGED
@@ -32,6 +32,10 @@ body {
32
  padding: 40px 0 56px;
33
  }
34
 
 
 
 
 
35
  .hero {
36
  max-width: 720px;
37
  margin-bottom: 24px;
@@ -149,7 +153,8 @@ h1 {
149
  color: var(--text);
150
  }
151
 
152
- .field-label input {
 
153
  width: 100%;
154
  border: 1px solid rgba(31, 35, 43, 0.16);
155
  border-radius: 16px;
@@ -159,6 +164,47 @@ h1 {
159
  color: var(--text);
160
  }
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  .file-drop-compact {
163
  min-height: 74px;
164
  }
@@ -204,6 +250,64 @@ h1 {
204
  font-weight: 800;
205
  }
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  .result-detail,
208
  .result-item,
209
  .roster-item {
@@ -266,6 +370,93 @@ h1 {
266
  .demo-btn:hover { background: rgba(99,102,241,0.07); }
267
  .demo-btn:disabled { opacity: 0.5; cursor: not-allowed; }
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  .unknown-faces-toggle {
270
  width: 100%;
271
  background: rgba(220, 53, 69, 0.08);
@@ -620,6 +811,10 @@ tbody tr:hover {
620
  grid-template-columns: 1fr;
621
  }
622
 
 
 
 
 
623
  button {
624
  width: 100%;
625
  }
 
32
  padding: 40px 0 56px;
33
  }
34
 
35
+ .shell-narrow {
36
+ width: min(640px, calc(100% - 32px));
37
+ }
38
+
39
  .hero {
40
  max-width: 720px;
41
  margin-bottom: 24px;
 
153
  color: var(--text);
154
  }
155
 
156
+ .field-label input,
157
+ .field-label select {
158
  width: 100%;
159
  border: 1px solid rgba(31, 35, 43, 0.16);
160
  border-radius: 16px;
 
164
  color: var(--text);
165
  }
166
 
167
+ .classroom-picker {
168
+ display: flex;
169
+ align-items: baseline;
170
+ gap: 14px;
171
+ flex-wrap: wrap;
172
+ margin-bottom: 18px;
173
+ padding: 14px 18px;
174
+ border-radius: 18px;
175
+ border: 1px solid var(--border);
176
+ background: rgba(255, 255, 255, 0.72);
177
+ }
178
+
179
+ .classroom-picker .field-label {
180
+ flex-direction: row;
181
+ align-items: center;
182
+ gap: 10px;
183
+ }
184
+
185
+ .classroom-picker select {
186
+ width: auto;
187
+ min-width: 160px;
188
+ padding: 8px 12px;
189
+ }
190
+
191
+ .classroom-picker-hint {
192
+ margin: 0;
193
+ font-size: 0.82rem;
194
+ color: var(--muted);
195
+ }
196
+
197
+ .roster-classroom-label {
198
+ font-size: 0.8rem;
199
+ font-weight: 700;
200
+ color: var(--accent-strong);
201
+ background: rgba(11, 94, 215, 0.1);
202
+ padding: 3px 10px;
203
+ border-radius: 999px;
204
+ vertical-align: middle;
205
+ margin-left: 6px;
206
+ }
207
+
208
  .file-drop-compact {
209
  min-height: 74px;
210
  }
 
250
  font-weight: 800;
251
  }
252
 
253
+ .attendance-buckets {
254
+ display: grid;
255
+ grid-template-columns: repeat(3, minmax(0, 1fr));
256
+ gap: 14px;
257
+ }
258
+
259
+ .attendance-bucket {
260
+ display: grid;
261
+ gap: 8px;
262
+ align-content: start;
263
+ }
264
+
265
+ .result-item.present-item {
266
+ border-color: rgba(34, 197, 94, 0.3);
267
+ background: rgba(34, 197, 94, 0.08);
268
+ }
269
+
270
+ .result-item.suspicious-item {
271
+ border-color: rgba(245, 158, 11, 0.35);
272
+ background: rgba(245, 158, 11, 0.1);
273
+ }
274
+
275
+ .result-item.absent-item {
276
+ border-color: rgba(120, 120, 120, 0.25);
277
+ background: rgba(120, 120, 120, 0.06);
278
+ }
279
+
280
+ .suspicious-actions {
281
+ display: flex;
282
+ gap: 6px;
283
+ margin-top: 6px;
284
+ }
285
+
286
+ .suspicious-btn {
287
+ flex: 1;
288
+ padding: 6px 10px;
289
+ border-radius: 10px;
290
+ font-size: 0.78rem;
291
+ font-weight: 700;
292
+ cursor: pointer;
293
+ box-shadow: none;
294
+ }
295
+ .suspicious-btn:disabled { opacity: 0.5; cursor: not-allowed; }
296
+
297
+ .suspicious-confirm-btn {
298
+ border: 1.5px solid rgba(34, 197, 94, 0.5);
299
+ background: rgba(34, 197, 94, 0.12);
300
+ color: #15803d;
301
+ }
302
+ .suspicious-confirm-btn:hover { background: rgba(34, 197, 94, 0.2); }
303
+
304
+ .suspicious-reject-btn {
305
+ border: 1.5px solid rgba(220, 53, 69, 0.4);
306
+ background: rgba(220, 53, 69, 0.1);
307
+ color: #b42318;
308
+ }
309
+ .suspicious-reject-btn:hover { background: rgba(220, 53, 69, 0.18); }
310
+
311
  .result-detail,
312
  .result-item,
313
  .roster-item {
 
370
  .demo-btn:hover { background: rgba(99,102,241,0.07); }
371
  .demo-btn:disabled { opacity: 0.5; cursor: not-allowed; }
372
 
373
+ .camera-recorder {
374
+ display: grid;
375
+ gap: 10px;
376
+ padding: 14px;
377
+ border-radius: 16px;
378
+ border: 1px solid var(--border);
379
+ background: rgba(255, 255, 255, 0.6);
380
+ }
381
+
382
+ .camera-video-wrap {
383
+ position: relative;
384
+ border-radius: 12px;
385
+ overflow: hidden;
386
+ background: #111;
387
+ }
388
+
389
+ .camera-preview {
390
+ width: 100%;
391
+ max-height: 320px;
392
+ display: block;
393
+ background: #111;
394
+ object-fit: contain;
395
+ }
396
+
397
+ .camera-face-guide {
398
+ position: absolute;
399
+ top: 50%;
400
+ left: 50%;
401
+ transform: translate(-50%, -50%);
402
+ width: 44%;
403
+ height: 70%;
404
+ border: 3px dashed rgba(255, 255, 255, 0.75);
405
+ border-radius: 50% / 42%;
406
+ pointer-events: none;
407
+ box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.28);
408
+ }
409
+
410
+ .camera-instruction {
411
+ position: absolute;
412
+ left: 50%;
413
+ bottom: 14px;
414
+ transform: translateX(-50%);
415
+ max-width: 90%;
416
+ padding: 8px 16px;
417
+ border-radius: 999px;
418
+ background: rgba(17, 17, 17, 0.78);
419
+ color: #fff;
420
+ font-size: 0.88rem;
421
+ font-weight: 600;
422
+ text-align: center;
423
+ white-space: nowrap;
424
+ overflow: hidden;
425
+ text-overflow: ellipsis;
426
+ }
427
+
428
+ .camera-hint {
429
+ margin: 0;
430
+ font-size: 0.8rem;
431
+ color: var(--muted);
432
+ }
433
+
434
+ .camera-controls {
435
+ display: flex;
436
+ gap: 8px;
437
+ flex-wrap: wrap;
438
+ }
439
+
440
+ .camera-btn {
441
+ flex: 1;
442
+ min-width: 120px;
443
+ background: transparent;
444
+ border: 1.5px solid #6366f1;
445
+ color: #6366f1;
446
+ border-radius: 8px;
447
+ padding: 8px 14px;
448
+ cursor: pointer;
449
+ font-size: 0.88rem;
450
+ font-weight: 600;
451
+ }
452
+ .camera-btn:hover { background: rgba(99,102,241,0.07); }
453
+ .camera-btn:disabled { opacity: 0.4; cursor: not-allowed; }
454
+
455
+ .camera-status {
456
+ font-size: 0.82rem;
457
+ color: var(--muted);
458
+ }
459
+
460
  .unknown-faces-toggle {
461
  width: 100%;
462
  background: rgba(220, 53, 69, 0.08);
 
811
  grid-template-columns: 1fr;
812
  }
813
 
814
+ .attendance-buckets {
815
+ grid-template-columns: 1fr;
816
+ }
817
+
818
  button {
819
  width: 100%;
820
  }
activity_web/backend/templates/enroll.html ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Enroll β€” PRISM AI</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
8
+ </head>
9
+ <body>
10
+ <main class="shell shell-narrow">
11
+ <section class="hero">
12
+ <p class="eyebrow">PRISM AI</p>
13
+ <h1>Enroll for attendance</h1>
14
+ <p class="lede">
15
+ Pick your classroom, enter your name, and provide a short face video β€”
16
+ upload a clip or record one right here. This creates the face profile
17
+ your teacher's attendance photos will be matched against.
18
+ </p>
19
+ </section>
20
+
21
+ <section class="panel">
22
+ <article class="attendance-card">
23
+ <form id="self-enroll-form" class="stack-form">
24
+ <label class="field-label">
25
+ Classroom
26
+ <select id="self-enroll-classroom" required></select>
27
+ </label>
28
+
29
+ <label class="field-label">
30
+ Your name
31
+ <input id="self-enroll-name" type="text" placeholder="Jane Doe" required />
32
+ </label>
33
+
34
+ <div style="display:flex;gap:8px;margin-bottom:4px;">
35
+ <button type="button" id="self-tab-files" class="tab-btn tab-active" onclick="switchSelfEnrollTab('files')">Upload files</button>
36
+ <button type="button" id="self-tab-camera" class="tab-btn" onclick="switchSelfEnrollTab('camera')">Record video</button>
37
+ </div>
38
+
39
+ <div id="self-enroll-tab-files">
40
+ <label class="file-drop file-drop-compact">
41
+ <input id="self-enroll-media-input" type="file" accept="image/*,video/*" multiple />
42
+ <span id="self-enroll-media-label">Choose photos or a short video</span>
43
+ </label>
44
+ </div>
45
+
46
+ <div id="self-enroll-tab-camera" style="display:none;">
47
+ <div id="self-enroll-camera-recorder"></div>
48
+ </div>
49
+
50
+ <button type="submit">Enroll me</button>
51
+ </form>
52
+
53
+ <div id="self-enroll-status" class="status">Ready to enroll.</div>
54
+ <div id="self-enroll-result" class="attendance-result hidden"></div>
55
+ </article>
56
+ </section>
57
+ </main>
58
+
59
+ <script src="{{ url_for('static', filename='camera-recorder.js') }}"></script>
60
+ <script src="{{ url_for('static', filename='enroll.js') }}"></script>
61
+ </body>
62
+ </html>
activity_web/backend/templates/index.html CHANGED
@@ -20,19 +20,84 @@
20
  <section class="tabs-shell">
21
  <div class="tabs-bar" role="tablist" aria-label="Workspace tabs">
22
  <button class="tab-button active" type="button" data-tab-target="attendance-tab" aria-selected="true">Attendance</button>
 
23
  <button class="tab-button" type="button" data-tab-target="classroom-tab" aria-selected="false">Classroom Monitoring</button>
24
  <button class="tab-button" type="button" data-tab-target="how-it-works-tab" aria-selected="false">How it Works</button>
25
  </div>
26
 
27
  <section id="attendance-tab" class="panel tab-panel">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  <div class="attendance-grid">
29
- <article class="attendance-card">
30
  <div class="attendance-card-header">
31
  <h2>Enroll student</h2>
32
- <p>Upload one or more photos or a short video to create the student embedding profile.</p>
33
  </div>
34
 
35
  <form id="enroll-form" class="stack-form">
 
 
 
 
 
36
  <label class="field-label">
37
  Student name
38
  <input id="student-name-input" name="student_name" type="text" placeholder="Jane Doe" required />
@@ -41,6 +106,7 @@
41
  <div style="display:flex;gap:8px;margin-bottom:4px;">
42
  <button type="button" id="tab-files" class="tab-btn tab-active" onclick="switchEnrollTab('files')">Upload files</button>
43
  <button type="button" id="tab-folder" class="tab-btn" onclick="switchEnrollTab('folder')">From folder path</button>
 
44
  </div>
45
 
46
  <div id="enroll-tab-files">
@@ -58,52 +124,16 @@
58
  <small style="color:#888;">All .mp4 .mov .avi .mkv .webm .jpg .png files in the folder will be used.</small>
59
  </div>
60
 
 
 
 
 
61
  <button type="submit">Enroll student</button>
62
  </form>
63
 
64
  <div id="enroll-status" class="status">Ready to enroll.</div>
65
  <div id="enroll-result" class="attendance-result hidden"></div>
66
  </article>
67
-
68
- <article class="attendance-card">
69
- <div class="attendance-card-header">
70
- <h2>Mark attendance</h2>
71
- <p>Upload a classroom photo and match all detected faces against the enrolled roster.</p>
72
- </div>
73
-
74
- <form id="mark-form" class="stack-form">
75
- <label class="file-drop file-drop-compact">
76
- <input id="classroom-photo-input" name="photo" type="file" accept="image/*,video/*" required />
77
- <span id="classroom-photo-label">Choose a classroom photo</span>
78
- </label>
79
-
80
- <button type="submit">Mark attendance</button>
81
- </form>
82
-
83
- <div class="demo-btn-row">
84
- <button id="demo-preview-btn" class="demo-btn">Preview demo image</button>
85
- <button id="demo-btn" class="demo-btn">Run demo through pipeline</button>
86
- </div>
87
-
88
- <div id="mark-status" class="status">Ready to mark attendance.</div>
89
- <div id="mark-result" class="attendance-result hidden">
90
- <img id="marked-photo-preview" class="marked-photo-preview" alt="Marked classroom photo" />
91
- <div id="recognized-list" class="result-list"></div>
92
- <button id="unknown-faces-toggle" type="button" class="unknown-faces-toggle hidden"></button>
93
- <div id="unknown-faces-grid" class="unknown-faces-grid hidden"></div>
94
- <div id="attendance-log-list" class="result-list"></div>
95
- </div>
96
- </article>
97
-
98
- <article class="attendance-card attendance-card-wide">
99
- <div class="attendance-card-header">
100
- <h2>Enrolled students</h2>
101
- <p>Current roster loaded from the enrolled embeddings; face detection uses InsightFace (SCRFD) and recognition uses the AdaFace IR-101 backbone.</p>
102
- </div>
103
-
104
- <div id="roster-list" class="roster-list"></div>
105
- <div id="attendance-log-summary" class="result-list"></div>
106
- </article>
107
  </div>
108
  </section>
109
 
@@ -141,9 +171,9 @@
141
  <li><strong>Detection</strong> β€” InsightFace (SCRFD, antelopev2 detector) finds every face in the photo at 1280Γ—1280, returning a bounding box and 5-point landmarks per face.</li>
142
  <li><strong>Alignment</strong> β€” each face is aligned to a canonical 112Γ—112 crop using InsightFace's <code>norm_crop</code>, based on the 5-point landmarks.</li>
143
  <li><strong>Recognition</strong> β€” the aligned crop is embedded with <strong>AdaFace IR-101</strong> (WebFace12M), producing a 512-dimensional, L2-normalised face embedding.</li>
144
- <li><strong>Matching</strong> β€” the embedding is compared via cosine similarity against every enrolled student's stored prototype and individual embeddings. The best match above <strong>0.28</strong> similarity is marked present; everything else is labeled Unknown.</li>
145
  <li><strong>Enrollment</strong> β€” enrollment videos are sampled at up to 30 frames spread evenly across the clip, with an anchor-consistency check to keep only frames of the same person, plus degraded (shrunk-and-upscaled) copies of each frame so the gallery also matches small, distant classroom faces.</li>
146
- <li><strong>Unknown faces</strong> β€” anyone below the match threshold is boxed in red as "Unknown." The "Show unknown faces" button on the Mark attendance card zooms in on each one so a teacher can review and enroll or manually mark them.</li>
147
  </ol>
148
  <p class="how-it-works-note">
149
  AdaFace replaced the previous glintr100/antelopev2 recognition backbone after an offline evaluation showed
@@ -185,6 +215,7 @@
185
  <img id="lightbox-img" class="lightbox-img" alt="Full-screen marked photo" />
186
  </div>
187
 
 
188
  <script src="{{ url_for('static', filename='app.js') }}"></script>
189
  </body>
190
  </html>
 
20
  <section class="tabs-shell">
21
  <div class="tabs-bar" role="tablist" aria-label="Workspace tabs">
22
  <button class="tab-button active" type="button" data-tab-target="attendance-tab" aria-selected="true">Attendance</button>
23
+ <button class="tab-button" type="button" data-tab-target="enroll-tab" aria-selected="false">Enroll Student</button>
24
  <button class="tab-button" type="button" data-tab-target="classroom-tab" aria-selected="false">Classroom Monitoring</button>
25
  <button class="tab-button" type="button" data-tab-target="how-it-works-tab" aria-selected="false">How it Works</button>
26
  </div>
27
 
28
  <section id="attendance-tab" class="panel tab-panel">
29
+ <div class="classroom-picker">
30
+ <label class="field-label" for="classroom-select">
31
+ Classroom
32
+ <select id="classroom-select"></select>
33
+ </label>
34
+ </div>
35
+
36
+ <div class="attendance-grid">
37
+ <article class="attendance-card attendance-card-wide">
38
+ <div class="attendance-card-header">
39
+ <h2>Mark attendance</h2>
40
+ <p>Upload a classroom photo and match all detected faces against the enrolled roster.</p>
41
+ </div>
42
+
43
+ <form id="mark-form" class="stack-form">
44
+ <label class="file-drop file-drop-compact">
45
+ <input id="classroom-photo-input" name="photo" type="file" accept="image/*,video/*" required />
46
+ <span id="classroom-photo-label">Choose a classroom photo</span>
47
+ </label>
48
+
49
+ <button type="submit">Mark attendance</button>
50
+ </form>
51
+
52
+ <div class="demo-btn-row">
53
+ <button id="demo-preview-btn" class="demo-btn">Preview demo image</button>
54
+ <button id="demo-btn" class="demo-btn">Run demo through pipeline</button>
55
+ </div>
56
+
57
+ <div id="mark-status" class="status">Ready to mark attendance.</div>
58
+ <div id="mark-result" class="attendance-result hidden">
59
+ <img id="marked-photo-preview" class="marked-photo-preview" alt="Marked classroom photo" />
60
+ <div class="attendance-buckets">
61
+ <div class="attendance-bucket">
62
+ <div id="present-list" class="result-list"></div>
63
+ </div>
64
+ <div class="attendance-bucket">
65
+ <div id="suspicious-list" class="result-list"></div>
66
+ </div>
67
+ <div class="attendance-bucket">
68
+ <div id="absent-list" class="result-list"></div>
69
+ </div>
70
+ </div>
71
+ <button id="unknown-faces-toggle" type="button" class="unknown-faces-toggle hidden"></button>
72
+ <div id="unknown-faces-grid" class="unknown-faces-grid hidden"></div>
73
+ </div>
74
+ </article>
75
+
76
+ <article class="attendance-card attendance-card-wide">
77
+ <div class="attendance-card-header">
78
+ <h2>Enrolled students <span id="roster-classroom-label" class="roster-classroom-label"></span></h2>
79
+ <p>Current roster loaded from the enrolled embeddings; face detection uses InsightFace (SCRFD) and recognition uses the AdaFace IR-101 backbone.</p>
80
+ </div>
81
+
82
+ <div id="roster-list" class="roster-list"></div>
83
+ </article>
84
+ </div>
85
+ </section>
86
+
87
+ <section id="enroll-tab" class="panel tab-panel hidden">
88
  <div class="attendance-grid">
89
+ <article class="attendance-card attendance-card-wide">
90
  <div class="attendance-card-header">
91
  <h2>Enroll student</h2>
92
+ <p>Pick your classroom, enter a name, and upload a photo/video or record one now to create the face profile.</p>
93
  </div>
94
 
95
  <form id="enroll-form" class="stack-form">
96
+ <label class="field-label">
97
+ Classroom
98
+ <select id="enroll-classroom-select" required></select>
99
+ </label>
100
+
101
  <label class="field-label">
102
  Student name
103
  <input id="student-name-input" name="student_name" type="text" placeholder="Jane Doe" required />
 
106
  <div style="display:flex;gap:8px;margin-bottom:4px;">
107
  <button type="button" id="tab-files" class="tab-btn tab-active" onclick="switchEnrollTab('files')">Upload files</button>
108
  <button type="button" id="tab-folder" class="tab-btn" onclick="switchEnrollTab('folder')">From folder path</button>
109
+ <button type="button" id="tab-camera" class="tab-btn" onclick="switchEnrollTab('camera')">Record video</button>
110
  </div>
111
 
112
  <div id="enroll-tab-files">
 
124
  <small style="color:#888;">All .mp4 .mov .avi .mkv .webm .jpg .png files in the folder will be used.</small>
125
  </div>
126
 
127
+ <div id="enroll-tab-camera" style="display:none;">
128
+ <div id="enroll-camera-recorder"></div>
129
+ </div>
130
+
131
  <button type="submit">Enroll student</button>
132
  </form>
133
 
134
  <div id="enroll-status" class="status">Ready to enroll.</div>
135
  <div id="enroll-result" class="attendance-result hidden"></div>
136
  </article>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  </div>
138
  </section>
139
 
 
171
  <li><strong>Detection</strong> β€” InsightFace (SCRFD, antelopev2 detector) finds every face in the photo at 1280Γ—1280, returning a bounding box and 5-point landmarks per face.</li>
172
  <li><strong>Alignment</strong> β€” each face is aligned to a canonical 112Γ—112 crop using InsightFace's <code>norm_crop</code>, based on the 5-point landmarks.</li>
173
  <li><strong>Recognition</strong> β€” the aligned crop is embedded with <strong>AdaFace IR-101</strong> (WebFace12M), producing a 512-dimensional, L2-normalised face embedding.</li>
174
+ <li><strong>Matching</strong> β€” the embedding is compared via cosine similarity against every enrolled student's stored prototype and individual embeddings. The best match is bucketed into one of three tiers: below <strong>0.28</strong> similarity gets no name candidate at all ("Unknown"); <strong>0.28–0.30</strong> is a real candidate but not confident enough to auto-confirm ("Suspicious" β€” for a teacher to eyeball); at or above <strong>0.30</strong> is confidently "Present". Anyone enrolled in the classroom who wasn't matched at either tier is listed as "Absent".</li>
175
  <li><strong>Enrollment</strong> β€” enrollment videos are sampled at up to 30 frames spread evenly across the clip, with an anchor-consistency check to keep only frames of the same person, plus degraded (shrunk-and-upscaled) copies of each frame so the gallery also matches small, distant classroom faces.</li>
176
+ <li><strong>Unknown faces</strong> β€” anyone below 0.28 is boxed in red as "Unknown." The "Show unknown faces" button on the Mark attendance card zooms in on each one so a teacher can review and enroll or manually mark them.</li>
177
  </ol>
178
  <p class="how-it-works-note">
179
  AdaFace replaced the previous glintr100/antelopev2 recognition backbone after an offline evaluation showed
 
215
  <img id="lightbox-img" class="lightbox-img" alt="Full-screen marked photo" />
216
  </div>
217
 
218
+ <script src="{{ url_for('static', filename='camera-recorder.js') }}"></script>
219
  <script src="{{ url_for('static', filename='app.js') }}"></script>
220
  </body>
221
  </html>