Spaces:
Sleeping
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 +67 -46
- activity_web/backend/app.py +95 -12
- activity_web/backend/attendance_service.py +292 -11
- activity_web/backend/config.py +5 -0
- activity_web/backend/static/app.js +324 -50
- activity_web/backend/static/camera-recorder.js +9 -1
- activity_web/backend/static/enroll.js +13 -0
- activity_web/backend/static/styles.css +92 -0
- activity_web/backend/templates/index.html +9 -4
- requirements.txt +10 -1
- start_enrollment_session.py +172 -0
|
@@ -10,7 +10,7 @@ pinned: false
|
|
| 10 |
|
| 11 |
# PRISM AI β Classroom Monitoring System
|
| 12 |
|
| 13 |
-
An AI-powered classroom
|
| 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 |
-
| **
|
| 22 |
-
| **
|
|
|
|
|
|
|
| 23 |
|
| 24 |
---
|
| 25 |
|
| 26 |
-
##
|
| 27 |
|
| 28 |
-
|
|
|
|
| 29 |
|
| 30 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
|
|
|
| 43 |
|
| 44 |
-
##
|
|
|
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
|
| 51 |
-
### How
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
|
| 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 |
-
|
| 64 |
|
| 65 |
-
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
-
|
| 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 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 107 |
ENGAGEMENT PIPELINE/ YOLOv8-pose engagement signals
|
| 108 |
COGNITIVE PIPELINE/ EAR / gaze / emotion (dlib + DeepFace)
|
| 109 |
COMBINED PIPELINE/ Merged engagement + cognitive
|
| 110 |
-
activity_web/
|
| 111 |
-
utils/ SAHI + MTCNN + buffalo_l
|
| 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 |
```
|
|
@@ -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 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
-
|
| 249 |
-
|
|
|
|
| 250 |
|
| 251 |
service = get_attendance_service(classroom_id)
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
try:
|
| 258 |
-
result = service.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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.
|
| 610 |
-
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 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 |
|
|
@@ -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"}
|
|
@@ -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
|
| 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
|
| 293 |
const btn = markForm.querySelector("button[type='submit']");
|
| 294 |
const payload = new FormData();
|
| 295 |
payload.append("classroom", currentClassroomId);
|
| 296 |
-
payload.append("
|
| 297 |
markStatus.classList.remove("error");
|
| 298 |
-
markStatus.textContent =
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 369 |
-
if (!
|
| 370 |
markResult.classList.remove("hidden");
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) => `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 419 |
-
//
|
|
|
|
|
|
|
| 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 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
const source = document.createElement("canvas");
|
| 463 |
source.width = imgEl.naturalWidth;
|
| 464 |
source.height = imgEl.naturalHeight;
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
});
|
| 486 |
}
|
| 487 |
|
| 488 |
-
function
|
| 489 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 · ${formatNumber(t.similarity)}</figcaption>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
</figure>`
|
| 497 |
)
|
| 498 |
.join("");
|
| 499 |
}
|
| 500 |
|
| 501 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 · ${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"); });
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
@@ -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");
|
|
@@ -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;
|
|
@@ -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
|
| 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="
|
| 46 |
-
<span id="classroom-photo-label">Choose
|
| 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 |
-
<
|
| 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>
|
|
@@ -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.
|
|
@@ -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()
|