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

QR self-enrollment, multi-photo attendance, reject-and-rematch reinforcement, and enrollment reliability fixes

Browse files

- Add start_enrollment_session.py: pairs the local Flask server (now
threaded, for concurrent enrollments) with a cloudflared tunnel so
students can self-enroll via a QR code over their own mobile data,
not just the teacher's Wi-Fi
- Mark attendance now accepts multiple photos in one submission; a
student counts present if confidently matched in any one of them
- Suspicious matches: "Show face" reveal, and rejecting a match now
re-checks the embedding against the rest of the roster (excluding
the rejected student) instead of just discarding it - it lands in
Present/Suspicious/Unknown depending on what that re-match finds,
and the wrongly-suggested student drops to Absent
- Unknown faces can be assigned directly to an enrolled student from
a dropdown, reinforcing their gallery
- Guided camera recording now speaks the "arm's length" framing
instruction (previously text-only) and requests a real capture
resolution instead of trusting the browser default - both were
found to be causing near-total face-detection failure on some
enrollment recordings
- Swap the demo classroom photo for a higher-resolution one and fix
the enrollment-QR/demo pipeline to use full resolution rather than
a downscaled copy
- Add scipy, deepface, onnxruntime, qrcode to requirements.txt (all
were either missing or only present transitively)
- Rewrite README to reflect the current AdaFace-based pipeline,
per-classroom rosters, and all of the above

README.md CHANGED
@@ -10,7 +10,7 @@ pinned: false
10
 
11
  # PRISM AI β€” Classroom Monitoring System
12
 
13
- An AI-powered classroom engagement and attendance monitoring system with a browser-based dashboard.
14
 
15
  ---
16
 
@@ -18,60 +18,71 @@ An AI-powered classroom engagement and attendance monitoring system with a brows
18
 
19
  | Tab | Function |
20
  |---|---|
21
- | **Classroom Monitoring** | Upload a classroom video β†’ detect and track every student β†’ classify engagement using EAR, MAR, YOLO-pose, gaze and emotion signals β†’ per-student timeline with clips |
22
- | **Attendance** | Enroll students from photos/videos β†’ mark attendance from a classroom photo using face recognition |
 
 
23
 
24
  ---
25
 
26
- ## Classroom Monitoring β€” Pipeline
27
 
28
- The **Classroom pipeline** (`CLASSROOM PIPELINE/classroom_pipeline.py`) uses the following signals per student per window:
 
29
 
30
- | Signal | How |
31
- |---|---|
32
- | **EAR** (Eye Aspect Ratio) | dlib 68-pt landmarks β†’ eyes open/closed, blink detection |
33
- | **MAR** (Mouth Aspect Ratio) | dlib 68-pt landmarks β†’ talking detection |
34
- | **Head pose / gaze** | solvePnP on dlib landmarks β†’ looking centre / left / right / up / down |
35
- | **Body keypoints** | YOLOv8-pose β†’ head state, posture, motion |
36
- | **Phone detection** | YOLO β†’ on phone β†’ immediately low engagement |
37
- | **Emotion** | DeepFace β†’ happy / neutral / surprise / sad / angry |
38
- | **Face re-ID** | InsightFace embeddings + seat-position tracking across windows |
39
 
40
- Actions classified per student: **Attentive / Writing / Talking / On Phone / Sleeping / Distracted**
 
 
 
 
 
41
 
42
- ---
 
43
 
44
- ## Attendance β€” Face Recognition System
 
45
 
46
- ### Detection & recognition model
47
- **InsightFace `antelopev2`** β€” SCRFD-10G face detector + GLinT-R100 recogniser (ResNet-100, Glint360K, 512-d L2-normalised embeddings), running at det_size=1280Γ—1280
 
48
 
49
- > `utils/retinaface_detector.py` also contains a **SAHI + MTCNN + buffalo_l** wrapper used by the classroom and cognitive pipelines for better small-face recall.
50
 
51
- ### How enrollment works
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- 1. Upload a close-up photo/video (or paste a local folder path for multiple clips)
54
- 2. Sample **1 frame per second** (up to 30 frames)
55
- 3. **Anchor-based tracking** β€” first detected face is the identity anchor; subsequent frames accepted only if cosine similarity β‰₯ 0.35 (prevents multi-person videos from mixing identities)
56
- 4. For each accepted frame, generate **4 embeddings**:
57
- - Original quality
58
- - Degraded to **28 px** (INTER_AREA β†’ Gaussian Οƒ=1 β†’ JPEG q50 β†’ bicubic up)
59
- - Degraded to **36 px**
60
- - Degraded to **44 px**
61
- 5. All embeddings stored + weighted-mean prototype computed
62
 
63
- > Degraded variants bridge the domain gap between close-up enrollment (140–230 px face) and distant classroom faces (14–50 px).
64
 
65
- ### How attendance marking works
66
 
67
- 1. Upload a classroom photo
68
- 2. Detect all faces (SCRFD 1280Γ—1280)
69
- 3. For each face: `similarity = max(cosine vs prototype, max cosine vs all stored embeddings)`
70
- 4. If best similarity β‰₯ **0.38** β†’ recognized
71
- 5. Only the highest-scoring face per student gets the name label
 
 
 
72
 
73
- ### Re-enrollment
74
- Re-enrolling the same name **adds** embeddings to the existing gallery β€” does not overwrite.
75
 
76
  ---
77
 
@@ -83,11 +94,18 @@ pip install -r requirements.txt
83
 
84
  # Download YOLO model weights (Git LFS pointers β€” run once after cloning)
85
  python download_models.py
 
 
 
86
 
87
- # macOS β€” always include OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
88
- OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES PORT=8080 \
89
- .venv/bin/gunicorn --bind 0.0.0.0:8080 --timeout 600 --workers 1 \
90
- activity_web.backend.app:app
 
 
 
 
91
  ```
92
 
93
  Open **http://localhost:8080**
@@ -98,17 +116,20 @@ Open **http://localhost:8080**
98
 
99
  See [`deployment/README.md`](deployment/README.md) for full Railway deployment instructions.
100
 
 
 
101
  ---
102
 
103
  ## Repository Structure
104
 
105
  ```
106
- CLASSROOM PIPELINE/ Main classroom analysis pipeline (EAR/MAR/YOLO/emotion)
107
  ENGAGEMENT PIPELINE/ YOLOv8-pose engagement signals
108
  COGNITIVE PIPELINE/ EAR / gaze / emotion (dlib + DeepFace)
109
  COMBINED PIPELINE/ Merged engagement + cognitive
110
- activity_web/ Flask web app (2 tabs: Classroom + Attendance)
111
- utils/ SAHI + MTCNN + buffalo_l face detection wrapper
112
  Activity monitoring/models/ Trained model weights
113
  deployment/ Self-contained Railway deployment build
 
114
  ```
 
10
 
11
  # PRISM AI β€” Classroom Monitoring System
12
 
13
+ An AI-powered classroom attendance and engagement monitoring system with a browser-based dashboard, designed to run on a teacher's own laptop.
14
 
15
  ---
16
 
 
18
 
19
  | Tab | Function |
20
  |---|---|
21
+ | **Attendance** | Mark attendance from one or more classroom photos using face recognition, per classroom (CSE 1–8) |
22
+ | **Enroll Student** | Students self-enroll (upload, folder path, or guided camera recording) β€” includes a QR-code flow so a whole class can enroll from their own phones |
23
+ | **Classroom Monitoring** | Upload a classroom video β†’ detect and track every student β†’ classify engagement per window β†’ per-student timeline with clips |
24
+ | **How it Works** | In-app explanation of both pipelines, kept in sync with the actual thresholds/behavior below |
25
 
26
  ---
27
 
28
+ ## Attendance β€” Face Recognition System
29
 
30
+ ### Detection & recognition model
31
+ **InsightFace SCRFD** (1280Γ—1280 detection) for face detection + landmarks, **AdaFace IR-101** (WebFace12M, 512-d L2-normalised embeddings) for recognition. AdaFace replaced the previous glintr100/antelopev2 backbone after an offline evaluation showed cleaner separation between genuine and impostor matches on real classroom photos.
32
 
33
+ ### Per-classroom rosters
34
+ Each of the 8 classrooms (`cse1`–`cse8`) has its own independent JSON store β€” enrolling into one classroom never affects another, and a teacher picks a classroom before enrolling or marking attendance.
 
 
 
 
 
 
 
35
 
36
+ ### How enrollment works
37
+ 1. Provide a face sample β€” upload photo(s)/video, point at a local folder of clips, or record directly in the browser (see below).
38
+ 2. Up to **30 frames** are sampled evenly across the clip (sequential decode, never seeking β€” seeking is unreliable for live-recorded webm from a browser's `MediaRecorder`).
39
+ 3. **Anchor-based tracking**: the first accepted frame is the identity anchor; later frames are kept only if cosine similarity β‰₯ **0.35** against it, so a multi-person video can't mix identities.
40
+ 4. Each accepted frame contributes its full-quality embedding plus **2 degraded copies** (downscaled to 50% / 30% then upscaled back) so the gallery also matches small, distant classroom faces, not just close-up enrollment shots.
41
+ 5. Embeddings are capped at **128 per student** (oldest evicted first) with a weighted-mean prototype recomputed on every change; outliers more than **0.50** cosine distance from the running centroid are dropped before the prototype is built.
42
 
43
+ ### Guided camera recording
44
+ The in-browser recorder walks a student through a ~37-second sequence with **spoken** (not just written) instructions β€” including holding the phone at arm's length so the whole face fits in the on-screen oval, since a face filling the whole frame is a common cause of the detector missing it entirely. It also requests a real capture resolution (1280Γ—720 ideal) rather than trusting the browser's default, which on some phones can be as low as 480Γ—640.
45
 
46
+ ### Self-enrollment via QR code
47
+ A teacher can generate a QR code (Attendance tab β†’ **Show enrollment QR**) that points students straight at their classroom's enroll page β€” no manual classroom picking, no need to be on the same Wi-Fi. This works by pairing the local server with a `cloudflared` quick tunnel:
48
 
49
+ ```bash
50
+ python start_enrollment_session.py
51
+ ```
52
 
53
+ This starts the Flask app (threaded, so a burst of students enrolling at once doesn't serialize into a queue) and the tunnel together, and prints the public URL once it's live. Requires `cloudflared` installed once (`brew install cloudflared` on macOS). The tunnel is ephemeral β€” a fresh random URL every time the script (re)starts, and it self-heals if the tunnel reconnects mid-session with a new hostname.
54
 
55
+ ### How attendance marking works
56
+ 1. Upload **one or more** classroom photos at once β€” a student only needs to be clearly caught in *any one* of them to count present, so someone missed or turned away in one shot can still be caught by another.
57
+ 2. Every detected face across all photos is matched by cosine similarity against every enrolled student's prototype and individual stored embeddings; each student's *best* match across all photos wins.
58
+ 3. Three-tier result:
59
+ - **< 0.28** similarity β†’ no name candidate at all β†’ **Unknown** (red box).
60
+ - **0.28–0.30** β†’ a real candidate, not confident enough to auto-confirm β†’ **Suspicious** (amber box, for a teacher to review).
61
+ - **β‰₯ 0.30** β†’ **Present** (green box). Confidence β‰₯ 0.60 also auto-grows that student's gallery.
62
+ - Anyone enrolled but not matched at either tier β†’ **Absent**.
63
+ 4. Face crops (Present, Suspicious, and Unknown) stay hidden by default and reveal on demand via a **"Show face"** button, cropped from the pre-annotation photo so the reveal isn't obscured by a box/label.
64
+
65
+ ### Reinforcement β€” teachers correcting the model
66
+ - **Confirming** a Suspicious match adds that embedding to the student's gallery and marks them present.
67
+ - **Rejecting** a Suspicious match doesn't just discard it β€” the same face gets re-matched against the roster *excluding* the rejected student, and lands wherever that turns up: a confident hit β†’ straight to Present, a borderline one β†’ a fresh Suspicious entry for the new candidate, nothing left β†’ into the Unknown pool. The wrongly-suggested student drops to Absent unless already seen elsewhere in the same result.
68
+ - **Unknown faces** can be directly assigned to any enrolled student via a dropdown on each face card β€” reinforces that student's gallery and marks them present, since a teacher pointing at a photo and naming someone is direct evidence they were there.
69
 
70
+ ---
 
 
 
 
 
 
 
 
71
 
72
+ ## Classroom Monitoring β€” Pipeline
73
 
74
+ The **Classroom pipeline** (`CLASSROOM PIPELINE/classroom_pipeline.py`) processes a lecture video in 30-second, 24-frame bursts rather than every frame:
75
 
76
+ | Step | How |
77
+ |---|---|
78
+ | **Detection & tracking** | YOLOv8-pose for body/keypoints, InsightFace (SCRFD) for faces + 106-point landmarks, linked into per-student tracks by IoU overlap within a burst |
79
+ | **Re-identification** | AdaFace IR-101 embeddings, resolved as a one-to-one assignment per burst (β‰₯ 0.35 similarity) β€” two different people in the same burst can't collapse into one identity |
80
+ | **Roster recognition** | The same embedding is checked read-only against the enrolled Attendance roster (β‰₯ 0.35, with a margin over the runner-up) β€” a confident match shows the real name instead of an anonymous `student_00N` label |
81
+ | **Signal extraction** | 106-point landmarks drive mouth open/closed + head yaw/pitch; YOLO for phone detection; optical flow for motion; DeepFace for dominant emotion |
82
+ | **Action classification** | Priority ruleset: On Phone β†’ Sleeping β†’ Writing β†’ Talking β†’ Attentive β†’ otherwise Distracted. "Attentive" is driven by mouth-closed percentage (β‰₯ 80%) β€” a calibration pass against a labeled reference dataset found eye-openness (EAR) had no measurable correlation with attentiveness, while mouth state separated attentive/non-attentive far more cleanly |
83
+ | **Engagement rollup** | Fraction of attentive windows β†’ High (β‰₯ 70%) / Medium (β‰₯ 40%) / Low, plus a short saved clip per window |
84
 
85
+ **Known limitations**: phone detection is a generic, un-fine-tuned COCO model and currently finds close to none of the real phones in testing β€” needs replacing, not re-tuning. Attentive/not-attentive classification runs at roughly 67–70% accuracy against a 156-clip labeled reference set β€” a real improvement over the previous EAR-based approach, but not a solved problem.
 
86
 
87
  ---
88
 
 
94
 
95
  # Download YOLO model weights (Git LFS pointers β€” run once after cloning)
96
  python download_models.py
97
+ ```
98
+
99
+ > `deepface` (emotion detection) pulls in `tensorflow`, which only ships wheels for Python 3.9–3.12 β€” create the venv with one of those versions, not whatever newest Python happens to be installed. The Docker image (below) already uses `python:3.10-slim`.
100
 
101
+ **Just the dashboard**, no QR/tunnel:
102
+ ```bash
103
+ OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES python -m activity_web.backend.app
104
+ ```
105
+
106
+ **Dashboard + QR self-enrollment** (starts the tunnel too):
107
+ ```bash
108
+ python start_enrollment_session.py
109
  ```
110
 
111
  Open **http://localhost:8080**
 
116
 
117
  See [`deployment/README.md`](deployment/README.md) for full Railway deployment instructions.
118
 
119
+ > The Hugging Face Space build (this repo's `Dockerfile`) has no persistent storage β€” enrolled rosters don't survive a rebuild there. For a real classroom, run this locally on the teacher's own machine (see above) so attendance data persists between sessions; the Space is a demo/showcase deployment, not where you'd actually enroll students.
120
+
121
  ---
122
 
123
  ## Repository Structure
124
 
125
  ```
126
+ CLASSROOM PIPELINE/ Main classroom analysis pipeline (mouth/gaze/YOLO/emotion, AdaFace re-ID)
127
  ENGAGEMENT PIPELINE/ YOLOv8-pose engagement signals
128
  COGNITIVE PIPELINE/ EAR / gaze / emotion (dlib + DeepFace)
129
  COMBINED PIPELINE/ Merged engagement + cognitive
130
+ activity_web/backend/ Flask web app β€” app.py, attendance_service.py, templates, static (incl. camera-recorder.js, enroll.js)
131
+ utils/ adaface_backbone.py (recognition), roster_match.py, retinaface_detector.py (SAHI + MTCNN + buffalo_l)
132
  Activity monitoring/models/ Trained model weights
133
  deployment/ Self-contained Railway deployment build
134
+ start_enrollment_session.py One-command launcher: threaded Flask server + cloudflared tunnel for QR self-enrollment
135
  ```
activity_web/backend/app.py CHANGED
@@ -21,6 +21,7 @@ from .config import (
21
  ATTENDANCE_DIR,
22
  ALLOWED_EXTENSIONS,
23
  ALLOWED_IMAGE_EXTENSIONS,
 
24
  )
25
  from .startup import check_ffmpeg_available
26
  from .transcode import transcode_clips_async
@@ -103,6 +104,50 @@ def attendance_classrooms():
103
  })
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  @app.get("/api/health")
107
  def health():
108
  return jsonify({"ok": True})
@@ -159,6 +204,29 @@ def attendance_resolve_suspicious():
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", "")
@@ -241,26 +309,34 @@ def attendance_mark():
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
 
 
 
 
247
 
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)
255
- uploaded_file.save(photo_path)
 
 
 
 
256
 
257
  try:
258
- result = service.mark_attendance(photo_path)
259
  except Exception as exc:
260
  traceback.print_exc()
261
  return jsonify({"ok": False, "error": str(exc)}), 500
262
 
263
- result["marked_url"] = attendance_artifact_url(Path(result["marked_url"]).name)
264
  return jsonify({"ok": True, **result})
265
 
266
 
@@ -287,6 +363,7 @@ def attendance_demo():
287
  return jsonify({"ok": False, "error": str(exc)}), 500
288
 
289
  result["marked_url"] = attendance_artifact_url(Path(result["marked_url"]).name)
 
290
  return jsonify({"ok": True, **result})
291
 
292
 
@@ -699,4 +776,10 @@ def handle_unhandled_exception(exc):
699
 
700
 
701
  if __name__ == "__main__":
702
- app.run(host="0.0.0.0", port=8080, debug=True, use_reloader=False)
 
 
 
 
 
 
 
21
  ATTENDANCE_DIR,
22
  ALLOWED_EXTENSIONS,
23
  ALLOWED_IMAGE_EXTENSIONS,
24
+ TUNNEL_URL_PATH,
25
  )
26
  from .startup import check_ffmpeg_available
27
  from .transcode import transcode_clips_async
 
104
  })
105
 
106
 
107
+ def _read_tunnel_url() -> str | None:
108
+ """The public tunnel URL, written by start_enrollment_session.py once
109
+ cloudflared reports it's connected. None if no session is running."""
110
+ try:
111
+ url = TUNNEL_URL_PATH.read_text(encoding="utf-8").strip()
112
+ except FileNotFoundError:
113
+ return None
114
+ return url or None
115
+
116
+
117
+ @app.get("/api/enroll-url")
118
+ def enroll_url():
119
+ classroom_id = request.args.get("classroom", "")
120
+ if (error := _require_classroom(classroom_id)) is not None:
121
+ return error
122
+ tunnel_url = _read_tunnel_url()
123
+ if not tunnel_url:
124
+ return jsonify({
125
+ "ok": False,
126
+ "error": "No enrollment session is running. Start it with start_enrollment_session.py on the teacher's laptop.",
127
+ }), 404
128
+ return jsonify({"ok": True, "url": f"{tunnel_url}/enroll?classroom={classroom_id}"})
129
+
130
+
131
+ @app.get("/api/enroll-qr")
132
+ def enroll_qr():
133
+ classroom_id = request.args.get("classroom", "")
134
+ if (error := _require_classroom(classroom_id)) is not None:
135
+ return error
136
+ tunnel_url = _read_tunnel_url()
137
+ if not tunnel_url:
138
+ return jsonify({"ok": False, "error": "No enrollment session is running."}), 404
139
+
140
+ import io
141
+ import qrcode
142
+
143
+ target = f"{tunnel_url}/enroll?classroom={classroom_id}"
144
+ img = qrcode.make(target, box_size=8, border=2)
145
+ buf = io.BytesIO()
146
+ img.save(buf, format="PNG")
147
+ buf.seek(0)
148
+ return send_file(buf, mimetype="image/png")
149
+
150
+
151
  @app.get("/api/health")
152
  def health():
153
  return jsonify({"ok": True})
 
204
  return jsonify({"ok": True, **result})
205
 
206
 
207
+ @app.post("/api/attendance/unknown/assign")
208
+ def attendance_assign_unknown():
209
+ data = request.get_json(silent=True) or {}
210
+ classroom_id = data.get("classroom", "")
211
+ if (error := _require_classroom(classroom_id)) is not None:
212
+ return error
213
+
214
+ review_id = str(data.get("review_id", "")).strip()
215
+ student_id = str(data.get("student_id", "")).strip()
216
+ if not review_id or not student_id:
217
+ return jsonify({"ok": False, "error": "Missing review_id or student_id."}), 400
218
+
219
+ service = get_attendance_service(classroom_id)
220
+ try:
221
+ result = service.assign_unknown_face(review_id, student_id)
222
+ except KeyError as exc:
223
+ return jsonify({"ok": False, "error": str(exc)}), 404
224
+ except Exception as exc:
225
+ return jsonify({"ok": False, "error": str(exc)}), 500
226
+
227
+ return jsonify({"ok": True, **result})
228
+
229
+
230
  @app.post("/api/attendance/enroll")
231
  def attendance_enroll():
232
  classroom_id = request.form.get("classroom", "")
 
309
  if (error := _require_classroom(classroom_id)) is not None:
310
  return error
311
 
312
+ # "photos" (plural) lets a teacher mark attendance from several photos of
313
+ # the same classroom in one go β€” someone missed or turned away in one
314
+ # shot may be clearly caught in another, so a student only needs to be
315
+ # confidently matched in ANY one of them to count as present.
316
+ uploaded_files = request.files.getlist("photos")
317
+ if not uploaded_files or not uploaded_files[0].filename:
318
+ return jsonify({"ok": False, "error": "Upload at least one classroom photo first."}), 400
319
 
320
+ for f in uploaded_files:
321
+ if not allowed_media(f.filename):
322
+ return jsonify({"ok": False, "error": f"'{f.filename}' isn't a supported image/video file."}), 400
323
 
324
  service = get_attendance_service(classroom_id)
325
+ photo_paths: list[Path] = []
326
+ upload_dir = ATTENDANCE_DIR / "uploads"
327
+ upload_dir.mkdir(parents=True, exist_ok=True)
328
+ for f in uploaded_files:
329
+ photo_name = secure_filename(f.filename)
330
+ photo_path = upload_dir / f"{uuid.uuid4().hex[:12]}_{photo_name}"
331
+ f.save(photo_path)
332
+ photo_paths.append(photo_path)
333
 
334
  try:
335
+ result = service.mark_attendance_multi(photo_paths)
336
  except Exception as exc:
337
  traceback.print_exc()
338
  return jsonify({"ok": False, "error": str(exc)}), 500
339
 
 
340
  return jsonify({"ok": True, **result})
341
 
342
 
 
363
  return jsonify({"ok": False, "error": str(exc)}), 500
364
 
365
  result["marked_url"] = attendance_artifact_url(Path(result["marked_url"]).name)
366
+ result["clean_url"] = attendance_artifact_url(Path(result["clean_url"]).name)
367
  return jsonify({"ok": True, **result})
368
 
369
 
 
776
 
777
 
778
  if __name__ == "__main__":
779
+ # threaded=True so a burst of concurrent enrollment uploads (e.g. a
780
+ # class scanning the QR code at once) get processed in parallel rather
781
+ # than queued one-at-a-time. debug=False because this is the entrypoint
782
+ # used when exposing the server via a public tunnel for QR enrollment β€”
783
+ # Werkzeug's interactive debugger is a real RCE risk on anything
784
+ # internet-reachable.
785
+ app.run(host="0.0.0.0", port=8080, debug=False, use_reloader=False, threaded=True)
activity_web/backend/attendance_service.py CHANGED
@@ -180,14 +180,15 @@ class AttendanceService:
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:
@@ -547,8 +548,10 @@ class AttendanceService:
547
  "updated_at": student.get("updated_at"),
548
  }
549
 
550
- def match_student(self, embedding: np.ndarray) -> dict:
551
  students = self.list_students()
 
 
552
  if not students:
553
  return {"match": None, "similarity": -1.0}
554
 
@@ -606,9 +609,16 @@ class AttendanceService:
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
 
@@ -617,6 +627,8 @@ class AttendanceService:
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)
@@ -626,13 +638,101 @@ class AttendanceService:
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():
@@ -718,6 +818,8 @@ class AttendanceService:
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
@@ -727,9 +829,16 @@ class AttendanceService:
727
  color = (0, 0, 255)
728
  sim = match["similarity"]
729
  label = f"Unknown {sim:.2f}"
 
 
 
 
 
 
730
  unknown_faces_detail.append({
731
  "bbox": [x1, y1, x2, y2],
732
  "similarity": round(float(sim), 4),
 
733
  })
734
 
735
  cv2.rectangle(marked_frame, (x1, y1), (x2, y2), color, 2)
@@ -753,6 +862,14 @@ class AttendanceService:
753
  marked_path = MARKED_DIR / marked_name
754
  cv2.imwrite(str(marked_path), marked_frame)
755
 
 
 
 
 
 
 
 
 
756
  return {
757
  "present": present,
758
  "suspicious": suspicious,
@@ -761,6 +878,170 @@ class AttendanceService:
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
 
 
180
 
181
  def _read_store(self) -> dict:
182
  if not self.store_path.exists():
183
+ return {"students": [], "attendance": [], "pending_reviews": [], "pending_unknown_faces": []}
184
  try:
185
  data = json.loads(self.store_path.read_text())
186
  except Exception:
187
+ return {"students": [], "attendance": [], "pending_reviews": [], "pending_unknown_faces": []}
188
  data.setdefault("students", [])
189
  data.setdefault("attendance", [])
190
  data.setdefault("pending_reviews", [])
191
+ data.setdefault("pending_unknown_faces", [])
192
  return data
193
 
194
  def _write_store(self, data: dict) -> None:
 
548
  "updated_at": student.get("updated_at"),
549
  }
550
 
551
+ def match_student(self, embedding: np.ndarray, exclude_student_id: str | None = None) -> dict:
552
  students = self.list_students()
553
+ if exclude_student_id is not None:
554
+ students = [s for s in students if s.get("student_id") != exclude_student_id]
555
  if not students:
556
  return {"match": None, "similarity": -1.0}
557
 
 
609
  mark_attendance. Confirming reinforces the model β€” the embedding that
610
  triggered the suspicious match gets added to that student's gallery,
611
  the same way a high-confidence classroom match already does β€” and
612
+ records the student as present.
613
+
614
+ Rejecting doesn't just discard the face β€” a wrong suggestion doesn't
615
+ mean the face has no identity, only that it isn't THIS student. The
616
+ embedding gets re-matched against the roster excluding the rejected
617
+ student, and lands wherever that re-match says: a confident hit
618
+ (>=PRESENT_SIMILARITY_THRESHOLD) becomes present, a borderline one
619
+ (still >=FACE_SIMILARITY_THRESHOLD) becomes a fresh suspicious entry
620
+ for the new candidate, and no remaining candidate at all drops it
621
+ into the unknown-faces pool (assignable from there)."""
622
  store = self._read_store()
623
  pending = store.get("pending_reviews", [])
624
 
 
627
  raise KeyError(f"No pending review: {review_id}")
628
  store["pending_reviews"] = [r for r in pending if r.get("review_id") != review_id]
629
 
630
+ result: dict = {"confirmed": confirmed, "student_name": review["student_name"]}
631
+
632
  if confirmed:
633
  embedding = np.asarray(review["embedding"], dtype=np.float32)
634
  self._add_embedding_to_gallery(store, review["student_id"], embedding)
 
638
  "source": "classroom_photo_confirmed",
639
  "confidence": review["similarity"],
640
  })
641
+ result["outcome"] = "present"
642
+ else:
643
+ embedding = np.asarray(review["embedding"], dtype=np.float32)
644
+ bbox = review.get("bbox")
645
+ photo_index = review.get("photo_index", 0)
646
+ rematch = self.match_student(embedding, exclude_student_id=review["student_id"])
647
+
648
+ if rematch["match"] is not None and rematch["similarity"] >= PRESENT_SIMILARITY_THRESHOLD:
649
+ new_student = rematch["match"]
650
+ if rematch["similarity"] >= 0.60:
651
+ self._add_embedding_to_gallery(store, new_student["student_id"], embedding)
652
+ store["attendance"].append({
653
+ "student_name": new_student["name"],
654
+ "recognized_at": _now_iso(),
655
+ "source": "classroom_photo_rematch",
656
+ "confidence": round(float(rematch["similarity"]), 4),
657
+ })
658
+ result["outcome"] = "present"
659
+ result["new_match"] = {
660
+ "student": new_student,
661
+ "confidence": round(float(rematch["similarity"]), 4),
662
+ "bbox": bbox,
663
+ "photo_index": photo_index,
664
+ }
665
+ elif rematch["match"] is not None and rematch["similarity"] >= FACE_SIMILARITY_THRESHOLD:
666
+ new_review_id = uuid.uuid4().hex
667
+ store.setdefault("pending_reviews", []).append({
668
+ "review_id": new_review_id,
669
+ "student_id": rematch["match"]["student_id"],
670
+ "student_name": rematch["match"]["name"],
671
+ "similarity": round(float(rematch["similarity"]), 4),
672
+ "embedding": review["embedding"],
673
+ "bbox": bbox,
674
+ "photo_index": photo_index,
675
+ "created_at": _now_iso(),
676
+ })
677
+ result["outcome"] = "suspicious"
678
+ result["new_suspicious"] = {
679
+ "review_id": new_review_id,
680
+ "student": rematch["match"],
681
+ "confidence": round(float(rematch["similarity"]), 4),
682
+ "bbox": bbox,
683
+ "photo_index": photo_index,
684
+ }
685
+ else:
686
+ new_review_id = uuid.uuid4().hex
687
+ store.setdefault("pending_unknown_faces", []).append({
688
+ "review_id": new_review_id,
689
+ "embedding": review["embedding"],
690
+ "bbox": bbox,
691
+ "photo_index": photo_index,
692
+ "created_at": _now_iso(),
693
+ })
694
+ result["outcome"] = "unknown"
695
+ result["new_unknown"] = {
696
+ "review_id": new_review_id,
697
+ "similarity": round(float(rematch["similarity"]), 4) if rematch["match"] else -1.0,
698
+ "bbox": bbox,
699
+ "photo_index": photo_index,
700
+ }
701
 
702
  self._write_store(store)
703
+ result["roster"] = self.list_students()
704
+ return result
705
+
706
+ def assign_unknown_face(self, review_id: str, student_id: str) -> dict:
707
+ """A teacher identifying an 'Unknown' face crop (below the match
708
+ threshold entirely) as a specific enrolled student. Unlike a
709
+ suspicious-match confirmation, there's no name attached yet β€” the
710
+ teacher is supplying it β€” so this both reinforces that student's
711
+ gallery with the embedding and marks them present, since a teacher
712
+ pointing at a photo and naming someone is direct evidence they were
713
+ there."""
714
+ store = self._read_store()
715
+ pending = store.get("pending_unknown_faces", [])
716
+
717
+ entry = next((f for f in pending if f.get("review_id") == review_id), None)
718
+ if entry is None:
719
+ raise KeyError(f"No pending unknown face: {review_id}")
720
+ store["pending_unknown_faces"] = [f for f in pending if f.get("review_id") != review_id]
721
+
722
+ student = next((s for s in store.get("students", []) if s.get("student_id") == student_id), None)
723
+ if student is None:
724
+ raise KeyError(f"No such student: {student_id}")
725
+
726
+ embedding = np.asarray(entry["embedding"], dtype=np.float32)
727
+ self._add_embedding_to_gallery(store, student_id, embedding)
728
+ store["attendance"].append({
729
+ "student_name": student["name"],
730
+ "recognized_at": _now_iso(),
731
+ "source": "unknown_face_assigned",
732
+ })
733
+
734
+ self._write_store(store)
735
+ return {"student_name": student["name"], "roster": self.list_students()}
736
 
737
  def mark_attendance(self, media_path: Path) -> dict:
738
  if not media_path.exists():
 
818
  "student_name": student["name"],
819
  "similarity": round(float(similarity), 4),
820
  "embedding": _normalize(det.embedding).tolist(),
821
+ "bbox": [x1, y1, x2, y2],
822
+ "photo_index": 0,
823
  "created_at": _now_iso(),
824
  })
825
  entry["review_id"] = review_id
 
829
  color = (0, 0, 255)
830
  sim = match["similarity"]
831
  label = f"Unknown {sim:.2f}"
832
+ review_id = uuid.uuid4().hex
833
+ store.setdefault("pending_unknown_faces", []).append({
834
+ "review_id": review_id,
835
+ "embedding": _normalize(det.embedding).tolist(),
836
+ "created_at": _now_iso(),
837
+ })
838
  unknown_faces_detail.append({
839
  "bbox": [x1, y1, x2, y2],
840
  "similarity": round(float(sim), 4),
841
+ "review_id": review_id,
842
  })
843
 
844
  cv2.rectangle(marked_frame, (x1, y1), (x2, y2), color, 2)
 
862
  marked_path = MARKED_DIR / marked_name
863
  cv2.imwrite(str(marked_path), marked_frame)
864
 
865
+ # Also save the pre-annotation frame β€” the frontend crops individual
866
+ # faces (Present "Show face", unknown-faces grid) from this instead
867
+ # of the boxed/labelled image, so the revealed face isn't covered by
868
+ # a bounding-box border or confidence text.
869
+ clean_name = f"{media_path.stem}_clean.jpg"
870
+ clean_path = MARKED_DIR / clean_name
871
+ cv2.imwrite(str(clean_path), frame)
872
+
873
  return {
874
  "present": present,
875
  "suspicious": suspicious,
 
878
  "unknown_faces_detail": unknown_faces_detail,
879
  "marked_path": str(marked_path),
880
  "marked_url": f"/api/attendance/artifacts/{marked_name}",
881
+ "clean_path": str(clean_path),
882
+ "clean_url": f"/api/attendance/artifacts/{clean_name}",
883
+ "roster": self.list_students(),
884
+ }
885
+
886
+ def mark_attendance_multi(self, media_paths: list[Path]) -> dict:
887
+ """Same idea as mark_attendance, but across several photos of the
888
+ same classroom taken back to back (different angles/moments catch
889
+ people a single photo misses β€” someone turned away or blocked in
890
+ one shot may be clearly visible in another). A student is Present/
891
+ Suspicious based on their single BEST match across all the photos,
892
+ not per-photo β€” so being missed in one photo doesn't hurt them if
893
+ they were caught clearly in another. Absent = never matched (at
894
+ either tier) in any of the photos."""
895
+ if not media_paths:
896
+ raise ValueError("No photos provided")
897
+
898
+ store = self._read_store()
899
+ attendance_log = store["attendance"]
900
+
901
+ best_by_student: dict[str, dict] = {}
902
+ seen_any_student_ids: set[str] = set()
903
+ unknown_faces = 0
904
+ unknown_faces_detail: list[dict] = []
905
+ photos_out: list[dict] = []
906
+
907
+ for photo_index, media_path in enumerate(media_paths):
908
+ if not media_path.exists():
909
+ raise FileNotFoundError(f"File not found: {media_path}")
910
+
911
+ suffix = media_path.suffix.lower()
912
+ if suffix in {".jpg", ".jpeg", ".png", ".webp", ".bmp"}:
913
+ frame = self._load_image(media_path)
914
+ if frame is None:
915
+ raise RuntimeError(f"Could not read classroom photo: {media_path.name}")
916
+ else:
917
+ frames = self._sample_video_frames(media_path)
918
+ if not frames:
919
+ raise RuntimeError(f"Could not read classroom video: {media_path.name}")
920
+ frame = frames[0]
921
+
922
+ detections = self._detect_samples(frame)
923
+ marked_frame = frame.copy()
924
+ all_matches = [(det, self.match_student(det.embedding)) for det in detections]
925
+
926
+ best_per_student_this_photo: dict[str, tuple] = {}
927
+ for det, match in all_matches:
928
+ if match["match"] is not None:
929
+ name = match["match"]["name"]
930
+ if name not in best_per_student_this_photo or match["similarity"] > best_per_student_this_photo[name][1]:
931
+ best_per_student_this_photo[name] = (det, match["similarity"], match)
932
+ best_det_ids = {id(det) for det, _, _ in best_per_student_this_photo.values()}
933
+
934
+ for det, match in all_matches:
935
+ x1, y1, x2, y2 = det.bbox
936
+ is_best = match["match"] is not None and id(det) in best_det_ids
937
+
938
+ if is_best:
939
+ student = match["match"]
940
+ similarity = float(match["similarity"])
941
+ is_present = similarity >= PRESENT_SIMILARITY_THRESHOLD
942
+ color = (0, 200, 0) if is_present else (0, 165, 255)
943
+ tag = "" if is_present else " (suspicious)"
944
+ label = f"{student['name']} {similarity:.2f}{tag}"
945
+
946
+ sid = student["student_id"]
947
+ seen_any_student_ids.add(sid)
948
+ if sid not in best_by_student or similarity > best_by_student[sid]["similarity"]:
949
+ best_by_student[sid] = {
950
+ "student": student,
951
+ "similarity": similarity,
952
+ "bbox": [x1, y1, x2, y2],
953
+ "photo_index": photo_index,
954
+ "embedding": det.embedding,
955
+ }
956
+ else:
957
+ unknown_faces += 1
958
+ color = (0, 0, 255)
959
+ sim = match["similarity"]
960
+ label = f"Unknown {sim:.2f}"
961
+ review_id = uuid.uuid4().hex
962
+ store.setdefault("pending_unknown_faces", []).append({
963
+ "review_id": review_id,
964
+ "embedding": _normalize(det.embedding).tolist(),
965
+ "created_at": _now_iso(),
966
+ })
967
+ unknown_faces_detail.append({
968
+ "bbox": [x1, y1, x2, y2],
969
+ "similarity": round(float(sim), 4),
970
+ "photo_index": photo_index,
971
+ "review_id": review_id,
972
+ })
973
+
974
+ cv2.rectangle(marked_frame, (x1, y1), (x2, y2), color, 2)
975
+ cv2.putText(
976
+ marked_frame, label,
977
+ (x1, max(20, y1 - 8)),
978
+ cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA,
979
+ )
980
+
981
+ marked_name = f"{media_path.stem}_marked.jpg"
982
+ marked_path = MARKED_DIR / marked_name
983
+ cv2.imwrite(str(marked_path), marked_frame)
984
+
985
+ clean_name = f"{media_path.stem}_clean.jpg"
986
+ clean_path = MARKED_DIR / clean_name
987
+ cv2.imwrite(str(clean_path), frame)
988
+
989
+ photos_out.append({
990
+ "marked_url": f"/api/attendance/artifacts/{marked_name}",
991
+ "clean_url": f"/api/attendance/artifacts/{clean_name}",
992
+ })
993
+
994
+ present: list[dict] = []
995
+ suspicious: list[dict] = []
996
+ for info in best_by_student.values():
997
+ student = info["student"]
998
+ similarity = info["similarity"]
999
+ entry = {
1000
+ "student": student,
1001
+ "confidence": round(similarity, 4),
1002
+ "bbox": info["bbox"],
1003
+ "photo_index": info["photo_index"],
1004
+ }
1005
+ if similarity >= PRESENT_SIMILARITY_THRESHOLD:
1006
+ present.append(entry)
1007
+ attendance_log.append({
1008
+ "student_name": student["name"],
1009
+ "recognized_at": _now_iso(),
1010
+ "source": "classroom_photo",
1011
+ "confidence": round(similarity, 4),
1012
+ })
1013
+ if similarity >= 0.60:
1014
+ self._add_embedding_to_gallery(store, student["student_id"], info["embedding"])
1015
+ else:
1016
+ review_id = uuid.uuid4().hex
1017
+ store.setdefault("pending_reviews", []).append({
1018
+ "review_id": review_id,
1019
+ "student_id": student["student_id"],
1020
+ "student_name": student["name"],
1021
+ "similarity": round(similarity, 4),
1022
+ "embedding": _normalize(info["embedding"]).tolist(),
1023
+ "bbox": info["bbox"],
1024
+ "photo_index": info["photo_index"],
1025
+ "created_at": _now_iso(),
1026
+ })
1027
+ entry["review_id"] = review_id
1028
+ suspicious.append(entry)
1029
+
1030
+ absent = [
1031
+ self._student_public(s) for s in store.get("students", [])
1032
+ if s.get("student_id") not in seen_any_student_ids
1033
+ ]
1034
+
1035
+ store["attendance"] = attendance_log
1036
+ self._write_store(store)
1037
+
1038
+ return {
1039
+ "present": present,
1040
+ "suspicious": suspicious,
1041
+ "absent": absent,
1042
+ "unknown_faces": unknown_faces,
1043
+ "unknown_faces_detail": unknown_faces_detail,
1044
+ "photos": photos_out,
1045
  "roster": self.list_students(),
1046
  }
1047
 
activity_web/backend/config.py CHANGED
@@ -12,6 +12,11 @@ UPLOAD_DIR = Path(os.environ.get("ACTIVITY_WEB_UPLOAD_DIR", RUNTIME_DIR / "uploa
12
  OUTPUT_DIR = Path(os.environ.get("ACTIVITY_WEB_OUTPUT_DIR", RUNTIME_DIR / "outputs"))
13
  ATTENDANCE_DIR = Path(os.environ.get("ACTIVITY_WEB_ATTENDANCE_DIR", RUNTIME_DIR / "attendance"))
14
 
 
 
 
 
 
15
  # Allowed file types
16
  ALLOWED_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
17
  ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
 
12
  OUTPUT_DIR = Path(os.environ.get("ACTIVITY_WEB_OUTPUT_DIR", RUNTIME_DIR / "outputs"))
13
  ATTENDANCE_DIR = Path(os.environ.get("ACTIVITY_WEB_ATTENDANCE_DIR", RUNTIME_DIR / "attendance"))
14
 
15
+ # Written by start_enrollment_session.py once the cloudflared tunnel is up,
16
+ # so the running Flask process (a separate subprocess) can pick up the
17
+ # public URL without a restart.
18
+ TUNNEL_URL_PATH = RUNTIME_DIR / "tunnel_url.txt"
19
+
20
  # Allowed file types
21
  ALLOWED_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
22
  ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
activity_web/backend/static/app.js CHANGED
@@ -164,6 +164,34 @@ classroomSelect.addEventListener("change", async () => {
164
  updateRosterClassroomLabel();
165
  markResult.classList.add("hidden");
166
  await refreshAttendanceSummary();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  });
168
 
169
  // ── Enroll Student tab: its own independent classroom picker ────────────────
@@ -202,7 +230,7 @@ const classroomPhotoInput = document.getElementById("classroom-photo-input");
202
  const classroomPhotoLabel = document.getElementById("classroom-photo-label");
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");
@@ -289,19 +317,21 @@ enrollForm.addEventListener("submit", async (event) => {
289
 
290
  markForm.addEventListener("submit", async (event) => {
291
  event.preventDefault();
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...";
 
 
299
  btn.disabled = true;
300
  try {
301
  const response = await fetch("/api/attendance/mark", { method: "POST", body: payload });
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.`;
@@ -315,7 +345,7 @@ markForm.addEventListener("submit", async (event) => {
315
  document.getElementById("demo-preview-btn").addEventListener("click", () => {
316
  markStatus.classList.remove("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 = "";
@@ -330,7 +360,7 @@ document.getElementById("demo-btn").addEventListener("click", async () => {
330
 
331
  // Step 1 β€” show original unannotated image immediately
332
  markResult.classList.remove("hidden");
333
- markedPhotoPreview.src = "/static/demo_classroom.jpg";
334
  markStatus.textContent = "Here's the demo classroom photo. Running attendance pipeline...";
335
 
336
  // Step 2 β€” run the pipeline
@@ -338,7 +368,7 @@ document.getElementById("demo-btn").addEventListener("click", async () => {
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.`;
@@ -365,10 +395,14 @@ function renderEnrollmentResult(student, mediaSamples) {
365
  <div class="result-detail">${mediaSamples.map((s) => `${s.file_name} (${s.frame_samples} frames)`).join(", ")}</div>`;
366
  }
367
 
368
- function renderMarkedPhoto(url) {
369
- if (!url) { markResult.classList.add("hidden"); return; }
370
  markResult.classList.remove("hidden");
371
- markedPhotoPreview.src = url;
 
 
 
 
372
  }
373
 
374
  const unknownFacesToggle = document.getElementById("unknown-faces-toggle");
@@ -376,18 +410,33 @@ const unknownFacesGrid = document.getElementById("unknown-faces-grid");
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>
@@ -413,11 +462,108 @@ function renderAttendanceBuckets(present, suspicious, absent, unknownFaces, unkn
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;
@@ -440,10 +586,28 @@ suspiciousList.addEventListener("click", async (event) => {
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));
@@ -458,61 +622,166 @@ function hideUnknownFacesUI() {
458
  unknownFacesGrid.innerHTML = "";
459
  }
460
 
461
- function cropUnknownFaceThumbnails(imgEl, faces, pad = 26, outSize = 220) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  const source = document.createElement("canvas");
463
  source.width = imgEl.naturalWidth;
464
  source.height = imgEl.naturalHeight;
465
- const sctx = source.getContext("2d");
466
- sctx.drawImage(imgEl, 0, 0);
467
-
468
- return faces.map(({ bbox, similarity }) => {
469
- const [x1, y1, x2, y2] = bbox;
470
- const px1 = Math.max(0, x1 - pad);
471
- const py1 = Math.max(0, y1 - pad);
472
- const px2 = Math.min(source.width, x2 + pad);
473
- const py2 = Math.min(source.height, y2 + pad);
474
- const pw = Math.max(1, px2 - px1);
475
- const ph = Math.max(1, py2 - py1);
476
-
477
- const out = document.createElement("canvas");
478
- const scale = Math.max(outSize / pw, outSize / ph);
479
- out.width = Math.round(pw * scale);
480
- out.height = Math.round(ph * scale);
481
- const octx = out.getContext("2d");
482
- octx.imageSmoothingQuality = "high";
483
- octx.drawImage(source, px1, py1, pw, ph, 0, 0, out.width, out.height);
484
- return { dataUrl: out.toDataURL("image/jpeg", 0.88), similarity };
 
 
 
 
 
 
 
 
 
485
  });
486
  }
487
 
488
- function renderUnknownFacesGrid() {
489
- const thumbs = cropUnknownFaceThumbnails(markedPhotoPreview, currentUnknownFaces);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  unknownFacesGrid.innerHTML = thumbs
491
  .map(
492
  (t) => `
493
- <figure class="unknown-face-card">
494
  <img src="${t.dataUrl}" alt="Unrecognized face, similarity ${formatNumber(t.similarity)}" />
495
  <figcaption>Unknown &middot; ${formatNumber(t.similarity)}</figcaption>
 
 
 
 
 
496
  </figure>`
497
  )
498
  .join("");
499
  }
500
 
501
- unknownFacesToggle.addEventListener("click", () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  unknownFacesExpanded = !unknownFacesExpanded;
503
  if (unknownFacesExpanded) {
504
- const build = () => renderUnknownFacesGrid();
505
- if (markedPhotoPreview.complete && markedPhotoPreview.naturalWidth) build();
506
- else markedPhotoPreview.addEventListener("load", build, { once: true });
507
  unknownFacesGrid.classList.remove("hidden");
508
  unknownFacesToggle.textContent = `Hide unknown faces (${currentUnknownFaces.length})`;
 
 
 
 
 
509
  } else {
510
  unknownFacesGrid.classList.add("hidden");
511
  unknownFacesToggle.textContent = `Show unknown faces (${currentUnknownFaces.length})`;
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) => `
518
  <div class="roster-item" data-student-id="${s.student_id}">
@@ -544,7 +813,12 @@ rosterList.addEventListener("click", async (event) => {
544
  const lightbox = document.getElementById("photo-lightbox");
545
  const lightboxImg = document.getElementById("lightbox-img");
546
  const lightboxClose = document.getElementById("lightbox-close");
547
- markedPhotoPreview.addEventListener("click", () => { lightboxImg.src = markedPhotoPreview.src; lightbox.classList.remove("hidden"); });
 
 
 
 
 
548
  lightboxClose.addEventListener("click", () => lightbox.classList.add("hidden"));
549
  lightbox.addEventListener("click", (e) => { if (e.target === lightbox) lightbox.classList.add("hidden"); });
550
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") lightbox.classList.add("hidden"); });
 
164
  updateRosterClassroomLabel();
165
  markResult.classList.add("hidden");
166
  await refreshAttendanceSummary();
167
+ if (!enrollQrPanel.classList.contains("hidden")) await loadEnrollQr();
168
+ });
169
+
170
+ // ── Attendance tab: enrollment QR (students join via cloudflared tunnel) ────
171
+ const enrollQrToggle = document.getElementById("enroll-qr-toggle");
172
+ const enrollQrPanel = document.getElementById("enroll-qr-panel");
173
+ const enrollQrBody = document.getElementById("enroll-qr-body");
174
+
175
+ async function loadEnrollQr() {
176
+ enrollQrBody.innerHTML = `<p class="muted">Loading...</p>`;
177
+ try {
178
+ const response = await fetch(`/api/enroll-url?classroom=${encodeURIComponent(currentClassroomId)}`);
179
+ const data = await response.json();
180
+ if (!response.ok || !data.ok) throw new Error(data.error || "No enrollment session running.");
181
+ enrollQrBody.innerHTML = `
182
+ <img class="enroll-qr-image" src="/api/enroll-qr?classroom=${encodeURIComponent(currentClassroomId)}&t=${Date.now()}" alt="Enrollment QR code" />
183
+ <p class="enroll-qr-link">${data.url}</p>
184
+ <p class="muted">Students scan this to enroll themselves into ${classroomSelect.options[classroomSelect.selectedIndex]?.textContent || "this classroom"} β€” no Wi-Fi match needed, works over their own mobile data.</p>`;
185
+ } catch (err) {
186
+ enrollQrBody.innerHTML = `<p class="muted">${err.message} Start it with <code>python start_enrollment_session.py</code> on this laptop, then reopen this panel.</p>`;
187
+ }
188
+ }
189
+
190
+ enrollQrToggle.addEventListener("click", async () => {
191
+ const opening = enrollQrPanel.classList.contains("hidden");
192
+ enrollQrPanel.classList.toggle("hidden", !opening);
193
+ enrollQrToggle.textContent = opening ? "Hide enrollment QR" : "Show enrollment QR";
194
+ if (opening) await loadEnrollQr();
195
  });
196
 
197
  // ── Enroll Student tab: its own independent classroom picker ────────────────
 
230
  const classroomPhotoLabel = document.getElementById("classroom-photo-label");
231
  const markStatus = document.getElementById("mark-status");
232
  const markResult = document.getElementById("mark-result");
233
+ const markedPhotoGallery = document.getElementById("marked-photo-gallery");
234
  const presentList = document.getElementById("present-list");
235
  const suspiciousList = document.getElementById("suspicious-list");
236
  const absentList = document.getElementById("absent-list");
 
317
 
318
  markForm.addEventListener("submit", async (event) => {
319
  event.preventDefault();
320
+ if (!classroomPhotoInput.files.length) { markStatus.textContent = "Upload at least one classroom photo first."; markStatus.classList.add("error"); return; }
321
  const btn = markForm.querySelector("button[type='submit']");
322
  const payload = new FormData();
323
  payload.append("classroom", currentClassroomId);
324
+ Array.from(classroomPhotoInput.files).forEach((f) => payload.append("photos", f));
325
  markStatus.classList.remove("error");
326
+ markStatus.textContent = classroomPhotoInput.files.length > 1
327
+ ? `Detecting faces across ${classroomPhotoInput.files.length} photos and marking attendance...`
328
+ : "Detecting faces and marking attendance...";
329
  btn.disabled = true;
330
  try {
331
  const response = await fetch("/api/attendance/mark", { method: "POST", body: payload });
332
  const data = await response.json();
333
  if (!response.ok || !data.ok) throw new Error(data.error || "Attendance marking failed.");
334
+ renderMarkedPhotos(data.photos || []);
335
  renderAttendanceBuckets(data.present || [], data.suspicious || [], data.absent || [], data.unknown_faces || 0, data.unknown_faces_detail || []);
336
  renderRoster(data.roster || []);
337
  markStatus.textContent = `${data.present.length} present, ${data.suspicious.length} suspicious, ${data.absent.length} absent.`;
 
345
  document.getElementById("demo-preview-btn").addEventListener("click", () => {
346
  markStatus.classList.remove("error");
347
  markStatus.textContent = "Demo classroom photo β€” original, no annotations.";
348
+ markedPhotoGallery.innerHTML = `<img class="marked-photo-preview" src="/static/demo_classroom.jpg" alt="Demo classroom photo" />`;
349
  markResult.classList.remove("hidden");
350
  presentList.innerHTML = "";
351
  suspiciousList.innerHTML = "";
 
360
 
361
  // Step 1 β€” show original unannotated image immediately
362
  markResult.classList.remove("hidden");
363
+ markedPhotoGallery.innerHTML = `<img class="marked-photo-preview" src="/static/demo_classroom.jpg" alt="Demo classroom photo" />`;
364
  markStatus.textContent = "Here's the demo classroom photo. Running attendance pipeline...";
365
 
366
  // Step 2 β€” run the pipeline
 
368
  const response = await fetch(`/api/attendance/demo?classroom=${encodeURIComponent(currentClassroomId)}`, { method: "POST" });
369
  const data = await response.json();
370
  if (!response.ok || !data.ok) throw new Error(data.error || "Demo failed.");
371
+ renderMarkedPhotos([{ marked_url: data.marked_url, clean_url: data.clean_url }]);
372
  renderAttendanceBuckets(data.present || [], data.suspicious || [], data.absent || [], data.unknown_faces || 0, data.unknown_faces_detail || []);
373
  renderRoster(data.roster || []);
374
  markStatus.textContent = `Demo complete β€” ${data.present.length} present, ${data.suspicious.length} suspicious, ${data.absent.length} absent.`;
 
395
  <div class="result-detail">${mediaSamples.map((s) => `${s.file_name} (${s.frame_samples} frames)`).join(", ")}</div>`;
396
  }
397
 
398
+ function renderMarkedPhotos(photos) {
399
+ if (!photos || !photos.length) { markResult.classList.add("hidden"); return; }
400
  markResult.classList.remove("hidden");
401
+ markedPhotoGallery.innerHTML = photos
402
+ .map((p, i) => `<img class="marked-photo-preview" data-photo-index="${i}" src="${p.marked_url}" alt="Marked classroom photo ${i + 1}" />`)
403
+ .join("");
404
+ currentCleanPhotoUrls = photos.map((p) => p.clean_url);
405
+ cleanPhotoImageCache = {};
406
  }
407
 
408
  const unknownFacesToggle = document.getElementById("unknown-faces-toggle");
 
410
  let currentUnknownFaces = [];
411
  let unknownFacesExpanded = false;
412
 
413
+ let currentPresentFaces = [];
414
+
415
  function renderAttendanceBuckets(present, suspicious, absent, unknownFaces, unknownFacesDetail) {
416
+ currentPresentFaces = present.map((e) => ({ bbox: e.bbox, photoIndex: e.photo_index ?? 0 }));
417
  presentList.innerHTML = `<h3>Present (${present.length})</h3>
418
  ${present.length
419
+ ? present.map((e, i) => `
420
+ <div class="result-item present-item">
421
+ <div class="present-item-row">
422
+ <strong>${e.student.name}</strong>
423
+ <span>Confidence ${formatNumber(e.confidence)}</span>
424
+ <button type="button" class="show-face-btn" data-face-index="${i}">Show face</button>
425
+ </div>
426
+ <div class="face-reveal hidden" data-face-slot="${i}"></div>
427
+ </div>`).join("")
428
  : '<div class="result-item muted">No students confidently recognized.</div>'}`;
429
 
430
  suspiciousList.innerHTML = `<h3>Suspicious (${suspicious.length})</h3>
431
  ${suspicious.length
432
  ? suspicious.map((e) => `
433
+ <div class="result-item suspicious-item" data-review-id="${e.review_id}" data-bbox='${JSON.stringify(e.bbox)}' data-photo-index="${e.photo_index ?? 0}">
434
+ <div class="present-item-row">
435
+ <strong>${e.student.name}</strong>
436
+ <span>Confidence ${formatNumber(e.confidence)} β€” please verify</span>
437
+ <button type="button" class="show-face-btn">Show face</button>
438
+ </div>
439
+ <div class="face-reveal hidden"></div>
440
  <div class="suspicious-actions">
441
  <button type="button" class="suspicious-btn suspicious-confirm-btn" data-review-id="${e.review_id}">Yes, it's them</button>
442
  <button type="button" class="suspicious-btn suspicious-reject-btn" data-review-id="${e.review_id}">Not them</button>
 
462
  }
463
  }
464
 
465
+ function addToAbsentList(name) {
466
+ if ([...absentList.querySelectorAll(".absent-item strong")].some((el) => el.textContent === name)) return;
467
+ const muted = absentList.querySelector(".muted");
468
+ if (muted) muted.remove();
469
+ absentList.insertAdjacentHTML("beforeend", `<div class="result-item absent-item"><strong>${name}</strong></div>`);
470
+ const h3 = absentList.querySelector("h3");
471
+ if (h3) h3.textContent = `Absent (${absentList.querySelectorAll(".absent-item").length})`;
472
+ }
473
+
474
+ function removeFromAbsentList(name) {
475
+ const match = [...absentList.querySelectorAll(".absent-item")].find((el) => el.querySelector("strong")?.textContent === name);
476
+ if (match) match.remove();
477
+ const count = absentList.querySelectorAll(".absent-item").length;
478
+ const h3 = absentList.querySelector("h3");
479
+ if (h3) h3.textContent = `Absent (${count})`;
480
+ if (count === 0 && !absentList.querySelector(".muted")) {
481
+ absentList.insertAdjacentHTML("beforeend", '<div class="result-item muted">Everyone enrolled was seen.</div>');
482
+ }
483
+ }
484
+
485
+ function addPresentEntry(student, confidence, bbox, photoIndex) {
486
+ const muted = presentList.querySelector(".muted");
487
+ if (muted) muted.remove();
488
+ const index = currentPresentFaces.length;
489
+ currentPresentFaces.push({ bbox, photoIndex: photoIndex ?? 0 });
490
+ presentList.insertAdjacentHTML("beforeend", `
491
+ <div class="result-item present-item">
492
+ <div class="present-item-row">
493
+ <strong>${student.name}</strong>
494
+ <span>Confidence ${formatNumber(confidence)}</span>
495
+ <button type="button" class="show-face-btn" data-face-index="${index}">Show face</button>
496
+ </div>
497
+ <div class="face-reveal hidden" data-face-slot="${index}"></div>
498
+ </div>`);
499
+ const h3 = presentList.querySelector("h3");
500
+ if (h3) h3.textContent = `Present (${presentList.querySelectorAll(".present-item").length})`;
501
+ }
502
+
503
+ function addSuspiciousEntry(newSuspicious) {
504
+ const muted = suspiciousList.querySelector(".muted");
505
+ if (muted) muted.remove();
506
+ suspiciousList.insertAdjacentHTML("beforeend", `
507
+ <div class="result-item suspicious-item" data-review-id="${newSuspicious.review_id}" data-bbox='${JSON.stringify(newSuspicious.bbox)}' data-photo-index="${newSuspicious.photo_index ?? 0}">
508
+ <div class="present-item-row">
509
+ <strong>${newSuspicious.student.name}</strong>
510
+ <span>Confidence ${formatNumber(newSuspicious.confidence)} β€” please verify</span>
511
+ <button type="button" class="show-face-btn">Show face</button>
512
+ </div>
513
+ <div class="face-reveal hidden"></div>
514
+ <div class="suspicious-actions">
515
+ <button type="button" class="suspicious-btn suspicious-confirm-btn" data-review-id="${newSuspicious.review_id}">Yes, it's them</button>
516
+ <button type="button" class="suspicious-btn suspicious-reject-btn" data-review-id="${newSuspicious.review_id}">Not them</button>
517
+ </div>
518
+ </div>`);
519
+ const h3 = suspiciousList.querySelector("h3");
520
+ if (h3) h3.textContent = `Suspicious (${suspiciousList.querySelectorAll(".suspicious-item").length})`;
521
+ }
522
+
523
+ async function addUnknownEntry(newUnknown) {
524
+ currentUnknownFaces.push({
525
+ bbox: newUnknown.bbox,
526
+ similarity: newUnknown.similarity,
527
+ photo_index: newUnknown.photo_index,
528
+ review_id: newUnknown.review_id,
529
+ });
530
+ unknownFacesToggle.classList.remove("hidden");
531
+ unknownFacesToggle.textContent = unknownFacesExpanded
532
+ ? `Hide unknown faces (${currentUnknownFaces.length})`
533
+ : `Show unknown faces (${currentUnknownFaces.length})`;
534
+ if (unknownFacesExpanded) await renderUnknownFacesGrid();
535
+ }
536
+
537
  // Confirming reinforces the model: the embedding that triggered the suspicious
538
  // match gets added to that student's gallery (same as an automatic high-
539
+ // confidence match would). Rejecting doesn't just discard the face β€” it gets
540
+ // re-matched against the roster excluding the rejected student, and lands in
541
+ // Present/Suspicious/Unknown depending on what that re-match finds, while the
542
+ // wrongly-suggested student drops to Absent (unless seen elsewhere).
543
  suspiciousList.addEventListener("click", async (event) => {
544
+ const showBtn = event.target.closest(".show-face-btn");
545
+ if (showBtn) {
546
+ const item = showBtn.closest(".suspicious-item");
547
+ const bbox = JSON.parse(item.dataset.bbox);
548
+ const photoIndex = Number(item.dataset.photoIndex || 0);
549
+ const slot = item.querySelector(".face-reveal");
550
+ if (!slot.querySelector("img")) {
551
+ try {
552
+ const dataUrl = cropFaceThumbnail(imageAsCanvas(await getCleanPhotoImage(photoIndex)), bbox);
553
+ slot.innerHTML = `<img src="${dataUrl}" alt="Face of suspicious match" />`;
554
+ } catch (err) {
555
+ slot.innerHTML = `<span class="muted">${err.message}</span>`;
556
+ slot.classList.remove("hidden");
557
+ showBtn.textContent = "Hide face";
558
+ return;
559
+ }
560
+ }
561
+ const wasVisible = !slot.classList.contains("hidden");
562
+ slot.classList.toggle("hidden", wasVisible);
563
+ showBtn.textContent = wasVisible ? "Show face" : "Hide face";
564
+ return;
565
+ }
566
+
567
  const confirmBtn = event.target.closest(".suspicious-confirm-btn");
568
  const rejectBtn = event.target.closest(".suspicious-reject-btn");
569
  const btn = confirmBtn || rejectBtn;
 
586
 
587
  item.classList.remove("suspicious-item");
588
  item.classList.add(confirmed ? "present-item" : "absent-item");
589
+
590
+ if (confirmed) {
591
+ item.innerHTML = `<strong>${name}</strong><span>Confirmed β€” added to their gallery.</span>`;
592
+ await refreshAttendanceSummary();
593
+ return;
594
+ }
595
+
596
+ item.innerHTML = `<strong>${name}</strong><span>Not them β€” re-checked against the rest of the roster.</span>`;
597
+
598
+ const stillPresent = [...presentList.querySelectorAll(".present-item strong")].some((el) => el.textContent === name);
599
+ if (!stillPresent) addToAbsentList(name);
600
+
601
+ if (data.outcome === "present" && data.new_match) {
602
+ addPresentEntry(data.new_match.student, data.new_match.confidence, data.new_match.bbox, data.new_match.photo_index);
603
+ removeFromAbsentList(data.new_match.student.name);
604
+ } else if (data.outcome === "suspicious" && data.new_suspicious) {
605
+ addSuspiciousEntry(data.new_suspicious);
606
+ } else if (data.outcome === "unknown" && data.new_unknown) {
607
+ await addUnknownEntry(data.new_unknown);
608
+ }
609
+
610
+ await refreshAttendanceSummary();
611
  } catch (err) {
612
  alert(err.message);
613
  item.querySelectorAll("button").forEach((b) => (b.disabled = false));
 
622
  unknownFacesGrid.innerHTML = "";
623
  }
624
 
625
+ function cropFaceThumbnail(sourceCanvas, bbox, pad = 26, outSize = 220) {
626
+ const [x1, y1, x2, y2] = bbox;
627
+ const px1 = Math.max(0, x1 - pad);
628
+ const py1 = Math.max(0, y1 - pad);
629
+ const px2 = Math.min(sourceCanvas.width, x2 + pad);
630
+ const py2 = Math.min(sourceCanvas.height, y2 + pad);
631
+ const pw = Math.max(1, px2 - px1);
632
+ const ph = Math.max(1, py2 - py1);
633
+
634
+ const out = document.createElement("canvas");
635
+ const scale = Math.max(outSize / pw, outSize / ph);
636
+ out.width = Math.round(pw * scale);
637
+ out.height = Math.round(ph * scale);
638
+ const octx = out.getContext("2d");
639
+ octx.imageSmoothingQuality = "high";
640
+ octx.drawImage(sourceCanvas, px1, py1, pw, ph, 0, 0, out.width, out.height);
641
+ return out.toDataURL("image/jpeg", 0.88);
642
+ }
643
+
644
+ function imageAsCanvas(imgEl) {
645
  const source = document.createElement("canvas");
646
  source.width = imgEl.naturalWidth;
647
  source.height = imgEl.naturalHeight;
648
+ source.getContext("2d").drawImage(imgEl, 0, 0);
649
+ return source;
650
+ }
651
+
652
+ // Face crops (Present "Show face", unknown-faces grid) are cropped from the
653
+ // pre-annotation photo, not the boxed/labelled preview β€” otherwise the
654
+ // revealed face would have a bounding-box border and confidence text drawn
655
+ // across it. With multiple photos per mark, each face crop has to come from
656
+ // the specific photo it was detected in β€” indexed by photo_index. Cached
657
+ // per index so repeated crops don't re-fetch/re-decode.
658
+ let currentCleanPhotoUrls = []; // clean_url per photo, indexed by photo_index
659
+ let cleanPhotoImageCache = {}; // { [photoIndex]: { url, img } }
660
+
661
+ function getCleanPhotoImage(photoIndex = 0) {
662
+ return new Promise((resolve, reject) => {
663
+ const url = currentCleanPhotoUrls[photoIndex];
664
+ if (!url) { reject(new Error("No photo loaded yet.")); return; }
665
+ const cached = cleanPhotoImageCache[photoIndex];
666
+ if (cached && cached.url === url) {
667
+ resolve(cached.img);
668
+ return;
669
+ }
670
+ const img = new Image();
671
+ img.onload = () => {
672
+ cleanPhotoImageCache[photoIndex] = { url, img };
673
+ resolve(img);
674
+ };
675
+ img.onerror = () => reject(new Error("Could not load photo for cropping."));
676
+ img.src = url;
677
  });
678
  }
679
 
680
+ async function cropUnknownFaceThumbnails(faces, pad = 26, outSize = 220) {
681
+ const results = [];
682
+ for (const { bbox, similarity, photo_index, review_id } of faces) {
683
+ const source = imageAsCanvas(await getCleanPhotoImage(photo_index ?? 0));
684
+ results.push({ dataUrl: cropFaceThumbnail(source, bbox, pad, outSize), similarity, reviewId: review_id });
685
+ }
686
+ return results;
687
+ }
688
+
689
+ // Present faces stay hidden by default (privacy) β€” a "Show face" button
690
+ // crops the face on demand from the already-rendered marked photo, the
691
+ // same technique used for the unknown-faces grid.
692
+ presentList.addEventListener("click", async (event) => {
693
+ const btn = event.target.closest(".show-face-btn");
694
+ if (!btn) return;
695
+ const index = Number(btn.dataset.faceIndex);
696
+ const face = currentPresentFaces[index];
697
+ const slot = presentList.querySelector(`.face-reveal[data-face-slot="${index}"]`);
698
+ if (!face || !slot) return;
699
+
700
+ if (!slot.querySelector("img")) {
701
+ try {
702
+ const dataUrl = cropFaceThumbnail(imageAsCanvas(await getCleanPhotoImage(face.photoIndex)), face.bbox);
703
+ slot.innerHTML = `<img src="${dataUrl}" alt="Face of recognized student" />`;
704
+ } catch (err) {
705
+ slot.innerHTML = `<span class="muted">${err.message}</span>`;
706
+ slot.classList.remove("hidden");
707
+ btn.textContent = "Hide face";
708
+ return;
709
+ }
710
+ }
711
+ const wasVisible = !slot.classList.contains("hidden");
712
+ slot.classList.toggle("hidden", wasVisible);
713
+ btn.textContent = wasVisible ? "Show face" : "Hide face";
714
+ });
715
+
716
+ async function renderUnknownFacesGrid() {
717
+ const thumbs = await cropUnknownFaceThumbnails(currentUnknownFaces);
718
  unknownFacesGrid.innerHTML = thumbs
719
  .map(
720
  (t) => `
721
+ <figure class="unknown-face-card" data-review-id="${t.reviewId ?? ""}">
722
  <img src="${t.dataUrl}" alt="Unrecognized face, similarity ${formatNumber(t.similarity)}" />
723
  <figcaption>Unknown &middot; ${formatNumber(t.similarity)}</figcaption>
724
+ ${t.reviewId ? `
725
+ <select class="assign-face-select">
726
+ <option value="">Assign to...</option>
727
+ ${currentRoster.map((s) => `<option value="${s.student_id}">${s.name}</option>`).join("")}
728
+ </select>` : ""}
729
  </figure>`
730
  )
731
  .join("");
732
  }
733
 
734
+ // A teacher identifying an unrecognized face as a specific enrolled student β€”
735
+ // reinforces that student's gallery with this embedding and marks them
736
+ // present, the same idea as confirming a suspicious match but for a face
737
+ // that had no name candidate at all.
738
+ unknownFacesGrid.addEventListener("change", async (event) => {
739
+ const select = event.target.closest(".assign-face-select");
740
+ if (!select) return;
741
+ const studentId = select.value;
742
+ if (!studentId) return;
743
+ const card = select.closest(".unknown-face-card");
744
+ const reviewId = card.dataset.reviewId;
745
+ const studentName = select.options[select.selectedIndex].textContent;
746
+ select.disabled = true;
747
+
748
+ try {
749
+ const response = await fetch("/api/attendance/unknown/assign", {
750
+ method: "POST",
751
+ headers: { "Content-Type": "application/json" },
752
+ body: JSON.stringify({ classroom: currentClassroomId, review_id: reviewId, student_id: studentId }),
753
+ });
754
+ const data = await response.json();
755
+ if (!response.ok || !data.ok) throw new Error(data.error || "Failed to assign.");
756
+ card.querySelector("figcaption").textContent = `Assigned to ${studentName}`;
757
+ select.remove();
758
+ await refreshAttendanceSummary();
759
+ } catch (err) {
760
+ alert(err.message);
761
+ select.disabled = false;
762
+ }
763
+ });
764
+
765
+ unknownFacesToggle.addEventListener("click", async () => {
766
  unknownFacesExpanded = !unknownFacesExpanded;
767
  if (unknownFacesExpanded) {
 
 
 
768
  unknownFacesGrid.classList.remove("hidden");
769
  unknownFacesToggle.textContent = `Hide unknown faces (${currentUnknownFaces.length})`;
770
+ try {
771
+ await renderUnknownFacesGrid();
772
+ } catch (err) {
773
+ unknownFacesGrid.innerHTML = `<div class="result-item muted">${err.message}</div>`;
774
+ }
775
  } else {
776
  unknownFacesGrid.classList.add("hidden");
777
  unknownFacesToggle.textContent = `Show unknown faces (${currentUnknownFaces.length})`;
778
  }
779
  });
780
 
781
+ let currentRoster = [];
782
+
783
  function renderRoster(students) {
784
+ currentRoster = students || [];
785
  if (!students.length) { rosterList.innerHTML = '<div class="result-item muted">No students enrolled yet.</div>'; return; }
786
  rosterList.innerHTML = students.map((s) => `
787
  <div class="roster-item" data-student-id="${s.student_id}">
 
813
  const lightbox = document.getElementById("photo-lightbox");
814
  const lightboxImg = document.getElementById("lightbox-img");
815
  const lightboxClose = document.getElementById("lightbox-close");
816
+ markedPhotoGallery.addEventListener("click", (event) => {
817
+ const img = event.target.closest(".marked-photo-preview");
818
+ if (!img) return;
819
+ lightboxImg.src = img.src;
820
+ lightbox.classList.remove("hidden");
821
+ });
822
  lightboxClose.addEventListener("click", () => lightbox.classList.add("hidden"));
823
  lightbox.addEventListener("click", (e) => { if (e.target === lightbox) lightbox.classList.add("hidden"); });
824
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") lightbox.classList.add("hidden"); });
activity_web/backend/static/camera-recorder.js CHANGED
@@ -18,6 +18,7 @@
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 },
@@ -73,7 +74,14 @@ const CameraRecorder = (() => {
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;
 
18
 
19
  const CameraRecorder = (() => {
20
  const DEFAULT_SCRIPT = [
21
+ { text: "Hold your phone at arm's length, so your whole face fits inside the oval", seconds: 5 },
22
  { text: "Look straight at the camera", seconds: 10 },
23
  { text: "Slowly turn your head to the left", seconds: 8 },
24
  { text: "Slowly turn your head to the right", seconds: 8 },
 
74
 
75
  async function startCamera() {
76
  try {
77
+ // Explicit resolution request β€” without this, some phones default to
78
+ // a low capture resolution (observed as low as 480x640), which combined
79
+ // with a face filling most of the frame gives the detector very little
80
+ // to work with. "ideal" degrades gracefully on cameras that can't hit it.
81
+ stream = await navigator.mediaDevices.getUserMedia({
82
+ video: { width: { ideal: 1280 }, height: { ideal: 720 } },
83
+ audio: false,
84
+ });
85
  } catch (err) {
86
  statusEl.textContent = "Couldn't access the camera: " + err.message;
87
  return;
activity_web/backend/static/enroll.js CHANGED
@@ -43,6 +43,19 @@ async function loadClassrooms() {
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");
 
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
+
47
+ // Arriving via a teacher's QR code β€” the link encodes the classroom so
48
+ // there's nothing to pick.
49
+ const qsClassroom = new URLSearchParams(window.location.search).get("classroom");
50
+ const match = data.classrooms.find((c) => c.id === qsClassroom);
51
+ if (match) {
52
+ classroomSelect.value = match.id;
53
+ classroomSelect.disabled = true;
54
+ const note = document.createElement("p");
55
+ note.className = "muted";
56
+ note.textContent = `Enrolling into ${match.label} (from your teacher's QR code).`;
57
+ classroomSelect.closest("label").after(note);
58
+ }
59
  } catch (err) {
60
  statusEl.textContent = "Couldn't load classrooms: " + err.message;
61
  statusEl.classList.add("error");
activity_web/backend/static/styles.css CHANGED
@@ -194,6 +194,46 @@ h1 {
194
  color: var(--muted);
195
  }
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  .roster-classroom-label {
198
  font-size: 0.8rem;
199
  font-weight: 700;
@@ -277,6 +317,37 @@ h1 {
277
  background: rgba(120, 120, 120, 0.06);
278
  }
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  .suspicious-actions {
281
  display: flex;
282
  gap: 6px;
@@ -494,12 +565,33 @@ h1 {
494
  display: block;
495
  }
496
 
 
 
 
 
 
 
 
 
 
 
497
  .unknown-face-card figcaption {
498
  font-size: 0.72rem;
499
  color: var(--muted);
500
  text-align: center;
501
  }
502
 
 
 
 
 
 
 
 
 
 
 
 
503
  .marked-photo-preview {
504
  width: 100%;
505
  max-height: 420px;
 
194
  color: var(--muted);
195
  }
196
 
197
+ .enroll-qr-toggle {
198
+ margin-left: auto;
199
+ padding: 8px 16px;
200
+ border-radius: 12px;
201
+ border: 1.5px dashed rgba(37, 99, 235, 0.5);
202
+ background: rgba(37, 99, 235, 0.08);
203
+ color: #1d4ed8;
204
+ font-weight: 700;
205
+ font-size: 0.85rem;
206
+ cursor: pointer;
207
+ box-shadow: none;
208
+ }
209
+ .enroll-qr-toggle:hover { background: rgba(37, 99, 235, 0.15); }
210
+
211
+ .enroll-qr-panel {
212
+ margin-bottom: 18px;
213
+ padding: 18px;
214
+ border-radius: 18px;
215
+ border: 1px solid var(--border);
216
+ background: rgba(255, 255, 255, 0.72);
217
+ text-align: center;
218
+ }
219
+
220
+ .enroll-qr-image {
221
+ width: 220px;
222
+ height: 220px;
223
+ image-rendering: pixelated;
224
+ border-radius: 12px;
225
+ background: #fff;
226
+ padding: 10px;
227
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
228
+ }
229
+
230
+ .enroll-qr-link {
231
+ margin: 12px 0 4px;
232
+ font-family: monospace;
233
+ font-size: 0.85rem;
234
+ word-break: break-all;
235
+ }
236
+
237
  .roster-classroom-label {
238
  font-size: 0.8rem;
239
  font-weight: 700;
 
317
  background: rgba(120, 120, 120, 0.06);
318
  }
319
 
320
+ .present-item-row {
321
+ display: flex;
322
+ align-items: center;
323
+ gap: 8px;
324
+ flex-wrap: wrap;
325
+ }
326
+
327
+ .show-face-btn {
328
+ margin-left: auto;
329
+ padding: 4px 10px;
330
+ border-radius: 10px;
331
+ font-size: 0.74rem;
332
+ font-weight: 700;
333
+ cursor: pointer;
334
+ border: 1.5px solid rgba(34, 197, 94, 0.4);
335
+ background: rgba(34, 197, 94, 0.1);
336
+ color: #15803d;
337
+ box-shadow: none;
338
+ }
339
+ .show-face-btn:hover { background: rgba(34, 197, 94, 0.18); }
340
+
341
+ .face-reveal { margin-top: 8px; }
342
+
343
+ .face-reveal img {
344
+ width: 96px;
345
+ height: 96px;
346
+ object-fit: cover;
347
+ border-radius: 12px;
348
+ display: block;
349
+ }
350
+
351
  .suspicious-actions {
352
  display: flex;
353
  gap: 6px;
 
565
  display: block;
566
  }
567
 
568
+ .assign-face-select {
569
+ width: 100%;
570
+ padding: 5px 6px;
571
+ border-radius: 8px;
572
+ border: 1px solid var(--border);
573
+ font-size: 0.72rem;
574
+ background: rgba(255, 255, 255, 0.9);
575
+ }
576
+ .assign-face-select:disabled { opacity: 0.5; }
577
+
578
  .unknown-face-card figcaption {
579
  font-size: 0.72rem;
580
  color: var(--muted);
581
  text-align: center;
582
  }
583
 
584
+ .marked-photo-gallery {
585
+ display: grid;
586
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
587
+ gap: 12px;
588
+ margin-bottom: 14px;
589
+ }
590
+
591
+ .marked-photo-gallery:has(.marked-photo-preview:only-child) {
592
+ grid-template-columns: 1fr;
593
+ }
594
+
595
  .marked-photo-preview {
596
  width: 100%;
597
  max-height: 420px;
activity_web/backend/templates/index.html CHANGED
@@ -31,19 +31,24 @@
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>
@@ -56,7 +61,7 @@
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>
 
31
  Classroom
32
  <select id="classroom-select"></select>
33
  </label>
34
+ <button id="enroll-qr-toggle" type="button" class="enroll-qr-toggle">Show enrollment QR</button>
35
+ </div>
36
+
37
+ <div id="enroll-qr-panel" class="enroll-qr-panel hidden">
38
+ <div id="enroll-qr-body"></div>
39
  </div>
40
 
41
  <div class="attendance-grid">
42
  <article class="attendance-card attendance-card-wide">
43
  <div class="attendance-card-header">
44
  <h2>Mark attendance</h2>
45
+ <p>Upload one or more classroom photos β€” a student only needs to be clearly caught in any one of them to count as present. Faces are matched against the enrolled roster.</p>
46
  </div>
47
 
48
  <form id="mark-form" class="stack-form">
49
  <label class="file-drop file-drop-compact">
50
+ <input id="classroom-photo-input" name="photos" type="file" accept="image/*,video/*" multiple required />
51
+ <span id="classroom-photo-label">Choose one or more classroom photos</span>
52
  </label>
53
 
54
  <button type="submit">Mark attendance</button>
 
61
 
62
  <div id="mark-status" class="status">Ready to mark attendance.</div>
63
  <div id="mark-result" class="attendance-result hidden">
64
+ <div id="marked-photo-gallery" class="marked-photo-gallery"></div>
65
  <div class="attendance-buckets">
66
  <div class="attendance-bucket">
67
  <div id="present-list" class="result-list"></div>
requirements.txt CHANGED
@@ -10,8 +10,17 @@ huggingface_hub
10
  torch
11
  torchvision
12
  imageio-ffmpeg
 
 
 
 
13
 
14
  # Note: heavy ML packages (torch, torchvision, insightface, ultralytics)
15
  # may require specific wheels or system libraries and can fail on
16
  # ephemeral platforms. Consider using a Dockerfile or deploying to
17
- # a VM for reliable builds.
 
 
 
 
 
 
10
  torch
11
  torchvision
12
  imageio-ffmpeg
13
+ qrcode[pil]
14
+ scipy
15
+ deepface
16
+ onnxruntime
17
 
18
  # Note: heavy ML packages (torch, torchvision, insightface, ultralytics)
19
  # may require specific wheels or system libraries and can fail on
20
  # ephemeral platforms. Consider using a Dockerfile or deploying to
21
+ # a VM for reliable builds.
22
+ #
23
+ # deepface pulls in tensorflow, which only ships wheels for Python 3.9-3.12 β€”
24
+ # it will fail to resolve on newer local interpreters (e.g. 3.13/3.14). The
25
+ # Dockerfile uses python:3.10-slim, where this installs cleanly; for a raw
26
+ # local venv, make sure it's created with Python 3.10-3.12.
start_enrollment_session.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-command launcher for a QR-based self-enrollment session.
2
+
3
+ Starts the Flask app (threaded, bound to 0.0.0.0 so it can also be reached
4
+ directly over LAN) and a cloudflared quick tunnel (so it's also reachable
5
+ over the *public* internet β€” students on mobile data don't need to be on
6
+ the teacher's Wi-Fi). Once the tunnel is up, its URL is written to
7
+ activity_web/runtime/tunnel_url.txt, which the running Flask app reads to
8
+ build the QR code shown on the Attendance tab ("Show enrollment QR").
9
+
10
+ Usage:
11
+ python start_enrollment_session.py [--classroom cse8]
12
+
13
+ Ctrl+C stops both processes and clears the tunnel URL.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import shutil
19
+ import socket
20
+ import subprocess
21
+ import sys
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+
26
+ REPO_ROOT = Path(__file__).resolve().parent
27
+ sys.path.insert(0, str(REPO_ROOT))
28
+
29
+ from activity_web.backend.config import RUNTIME_DIR, TUNNEL_URL_PATH # noqa: E402
30
+
31
+ TUNNEL_URL_RE = re.compile(r"https://[a-zA-Z0-9\-]+\.trycloudflare\.com")
32
+ FLASK_PORT = 8080
33
+
34
+
35
+ def check_cloudflared() -> None:
36
+ if shutil.which("cloudflared") is None:
37
+ print(
38
+ "cloudflared isn't installed. On macOS: brew install cloudflared\n"
39
+ "(see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ "
40
+ "for other platforms)"
41
+ )
42
+ sys.exit(1)
43
+
44
+
45
+ def port_in_use(port: int) -> bool:
46
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
47
+ return sock.connect_ex(("127.0.0.1", port)) == 0
48
+
49
+
50
+ def stream_output(pipe, prefix: str, on_line=None) -> None:
51
+ for line in iter(pipe.readline, ""):
52
+ if not line:
53
+ break
54
+ print(f"[{prefix}] {line.rstrip()}")
55
+ if on_line:
56
+ on_line(line)
57
+
58
+
59
+ def main() -> None:
60
+ sys.stdout.reconfigure(line_buffering=True)
61
+ check_cloudflared()
62
+ RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
63
+
64
+ if port_in_use(FLASK_PORT):
65
+ print(
66
+ f"Port {FLASK_PORT} is already in use β€” an enrollment session is probably already\n"
67
+ f"running (maybe in another terminal, or left over from earlier). Either use that\n"
68
+ f"one, or stop it first:\n"
69
+ f" lsof -ti:{FLASK_PORT} | xargs kill; pkill -f 'cloudflared tunnel'\n"
70
+ f"then re-run this script. (Starting a second instance on top of a live one leaves\n"
71
+ f"a stale tunnel URL behind, which is exactly what caused the Cloudflare 1033 error.)"
72
+ )
73
+ sys.exit(1)
74
+
75
+ # Only cleared past this point β€” starting a second instance while one is
76
+ # already live must NOT touch a working session's tunnel_url.txt.
77
+ TUNNEL_URL_PATH.unlink(missing_ok=True)
78
+
79
+ flask_proc: subprocess.Popen | None = None
80
+ tunnel_proc: subprocess.Popen | None = None
81
+
82
+ def cleanup() -> None:
83
+ TUNNEL_URL_PATH.unlink(missing_ok=True)
84
+ for proc in (tunnel_proc, flask_proc):
85
+ if proc is not None and proc.poll() is None:
86
+ proc.terminate()
87
+ for proc in (tunnel_proc, flask_proc):
88
+ if proc is None:
89
+ continue
90
+ try:
91
+ proc.wait(timeout=5)
92
+ except subprocess.TimeoutExpired:
93
+ proc.kill()
94
+
95
+ try:
96
+ print(f"Starting the local server on http://0.0.0.0:{FLASK_PORT} ...")
97
+ flask_proc = subprocess.Popen(
98
+ [sys.executable, "-m", "activity_web.backend.app"],
99
+ cwd=REPO_ROOT,
100
+ stdout=subprocess.PIPE,
101
+ stderr=subprocess.STDOUT,
102
+ text=True,
103
+ bufsize=1,
104
+ )
105
+ threading.Thread(target=stream_output, args=(flask_proc.stdout, "server"), daemon=True).start()
106
+
107
+ print("Starting the cloudflared tunnel ...")
108
+ tunnel_proc = subprocess.Popen(
109
+ ["cloudflared", "tunnel", "--url", f"http://localhost:{FLASK_PORT}"],
110
+ cwd=REPO_ROOT,
111
+ stdout=subprocess.PIPE,
112
+ stderr=subprocess.STDOUT,
113
+ text=True,
114
+ bufsize=1,
115
+ )
116
+
117
+ found_url: dict[str, str | None] = {"url": None}
118
+
119
+ def watch_for_url(line: str) -> None:
120
+ # Free quick tunnels have no uptime guarantee β€” if the connection
121
+ # drops and cloudflared reconnects, it can get reassigned a new
122
+ # hostname while this process keeps running. Always take the
123
+ # latest one (not just the first) so the QR panel self-heals
124
+ # instead of quietly serving a dead link.
125
+ match = TUNNEL_URL_RE.search(line)
126
+ if match and match.group(0) != found_url["url"]:
127
+ found_url["url"] = match.group(0)
128
+ TUNNEL_URL_PATH.write_text(found_url["url"], encoding="utf-8")
129
+ print(f"[tunnel] URL is now: {found_url['url']}")
130
+
131
+ threading.Thread(
132
+ target=stream_output, args=(tunnel_proc.stdout, "tunnel", watch_for_url), daemon=True
133
+ ).start()
134
+
135
+ print("Waiting for the tunnel URL ...")
136
+ waited = 0.0
137
+ while not found_url["url"] and waited < 30:
138
+ time.sleep(0.5)
139
+ waited += 0.5
140
+ if flask_proc.poll() is not None:
141
+ print("The local server exited unexpectedly β€” check the [server] log above.")
142
+ return
143
+ if tunnel_proc.poll() is not None:
144
+ print("cloudflared exited unexpectedly β€” check the [tunnel] log above.")
145
+ return
146
+
147
+ if not found_url["url"]:
148
+ print("Timed out waiting for the tunnel URL. Check the [tunnel] log above for errors.")
149
+ return
150
+
151
+ print("\n" + "=" * 60)
152
+ print("Enrollment session is live.")
153
+ print(f" Public URL: {found_url['url']}")
154
+ print(f" Dashboard: http://localhost:{FLASK_PORT}/")
155
+ print("Open the dashboard, pick a classroom on the Attendance tab, and")
156
+ print("click 'Show enrollment QR' to display the QR code for students.")
157
+ print("=" * 60 + "\n")
158
+ print("Press Ctrl+C to stop the session.")
159
+
160
+ while True:
161
+ time.sleep(1)
162
+ if flask_proc.poll() is not None or tunnel_proc.poll() is not None:
163
+ print("A subprocess exited β€” stopping the session.")
164
+ break
165
+ except KeyboardInterrupt:
166
+ print("\nStopping...")
167
+ finally:
168
+ cleanup()
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()