Arnel Gwen Nuqui commited on
Commit
f06ccae
Β·
1 Parent(s): b3db271

update classification

Browse files
app.py CHANGED
@@ -6,11 +6,10 @@ from flask_cors import CORS
6
  # =============================================================
7
  # βœ… Environment Configuration
8
  # =============================================================
9
- # Hugging Face allows writes only under /tmp, so create safe dirs
10
  os.environ["MODEL_DIR"] = "/tmp/model"
11
  os.environ["MPLCONFIGDIR"] = "/tmp/matplotlib"
12
- os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" # Suppress TensorFlow INFO/WARN
13
- os.environ["GLOG_minloglevel"] = "2" # Suppress Mediapipe logs
14
 
15
  os.makedirs(os.environ["MODEL_DIR"], exist_ok=True)
16
  os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True)
@@ -20,45 +19,29 @@ os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True)
20
  # =============================================================
21
  app = Flask(__name__)
22
 
23
- # CORS Configuration
24
  CORS(
25
  app,
26
- resources={
27
- r"/api/*": {
28
- "origins": [
29
- "http://localhost:3000",
30
- "http://127.0.0.1:3000",
31
- "https://proctorvision-client.vercel.app",
32
- "https://proctorvision-server-production.up.railway.app",
33
- ]
34
- }
35
- },
36
  supports_credentials=True,
37
  )
38
 
39
  # =============================================================
40
- # πŸ” Import Blueprints (with deep diagnostics)
41
  # =============================================================
42
  try:
43
- print("πŸ” Attempting to import blueprints...")
44
 
45
  from routes.classification_routes import classification_bp
46
- # from routes.webrtc_routes import webrtc_bp
47
-
48
- print("βœ… Successfully imported classification_bp from routes.classification_routes")
49
-
50
- # Inspect blueprint details
51
- if hasattr(classification_bp, "url_prefix"):
52
- print(f"πŸ“¦ Blueprint prefix: {getattr(classification_bp, 'url_prefix', 'None')}")
53
- if hasattr(classification_bp, "deferred_functions"):
54
- print(f"πŸ“‹ Blueprint has {len(classification_bp.deferred_functions)} deferred functions")
55
-
56
- # Register the blueprints
57
  app.register_blueprint(classification_bp, url_prefix="/api")
58
- print("βœ… classification_bp registered successfully.")
59
 
60
- # app.register_blueprint(webrtc_bp)
61
- # print("βœ… webrtc_bp registered successfully.")
62
 
63
  except Exception as e:
64
  print("⚠️ Failed to import or register blueprints.")
@@ -68,21 +51,21 @@ except Exception as e:
68
  traceback.print_exc()
69
 
70
  # =============================================================
71
- # πŸ”Ž Debug: Log All Registered Routes
72
  # =============================================================
73
  print("\nπŸ” Final Registered Routes:")
74
  for rule in app.url_map.iter_rules():
75
  print(f"➑ {rule.endpoint} β†’ {rule}")
76
 
77
  # =============================================================
78
- # 🌐 Root & Health Check Route
79
  # =============================================================
80
  @app.route("/")
81
  def home():
82
  routes = [str(rule) for rule in app.url_map.iter_rules()]
83
  return jsonify({
84
  "status": "ok",
85
- "message": "βœ… ProctorVision AI Backend Running",
86
  "available_routes": routes
87
  })
88
 
@@ -90,8 +73,7 @@ def home():
90
  # πŸš€ Main Entrypoint
91
  # =============================================================
92
  if __name__ == "__main__":
93
- port = int(os.environ.get("PORT", 7860)) # Default for Hugging Face
94
  debug = os.environ.get("DEBUG", "False").lower() == "true"
95
-
96
  print(f"\nπŸš€ Starting Flask server on port {port} (debug={debug})...")
97
  app.run(host="0.0.0.0", port=port, debug=debug)
 
6
  # =============================================================
7
  # βœ… Environment Configuration
8
  # =============================================================
 
9
  os.environ["MODEL_DIR"] = "/tmp/model"
10
  os.environ["MPLCONFIGDIR"] = "/tmp/matplotlib"
11
+ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
12
+ os.environ["GLOG_minloglevel"] = "2"
13
 
14
  os.makedirs(os.environ["MODEL_DIR"], exist_ok=True)
15
  os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True)
 
19
  # =============================================================
20
  app = Flask(__name__)
21
 
 
22
  CORS(
23
  app,
24
+ resources={r"/api/*": {
25
+ "origins": [
26
+ "http://localhost:3000",
27
+ "http://127.0.0.1:3000",
28
+ "https://proctorvision-client.vercel.app",
29
+ "https://proctorvision-server-production.up.railway.app",
30
+ ]
31
+ }},
 
 
32
  supports_credentials=True,
33
  )
34
 
35
  # =============================================================
36
+ # πŸ” Import Blueprint (Classification only)
37
  # =============================================================
38
  try:
39
+ print("πŸ” Attempting to import classification routes...")
40
 
41
  from routes.classification_routes import classification_bp
 
 
 
 
 
 
 
 
 
 
 
42
  app.register_blueprint(classification_bp, url_prefix="/api")
 
43
 
44
+ print("βœ… classification_bp registered successfully.")
 
45
 
46
  except Exception as e:
47
  print("⚠️ Failed to import or register blueprints.")
 
51
  traceback.print_exc()
52
 
53
  # =============================================================
54
+ # πŸ”Ž Debug: List Registered Routes
55
  # =============================================================
56
  print("\nπŸ” Final Registered Routes:")
57
  for rule in app.url_map.iter_rules():
58
  print(f"➑ {rule.endpoint} β†’ {rule}")
59
 
60
  # =============================================================
61
+ # 🌐 Root Route
62
  # =============================================================
63
  @app.route("/")
64
  def home():
65
  routes = [str(rule) for rule in app.url_map.iter_rules()]
66
  return jsonify({
67
  "status": "ok",
68
+ "message": "βœ… ProctorVision AI Classification Backend Running",
69
  "available_routes": routes
70
  })
71
 
 
73
  # πŸš€ Main Entrypoint
74
  # =============================================================
75
  if __name__ == "__main__":
76
+ port = int(os.environ.get("PORT", 7860))
77
  debug = os.environ.get("DEBUG", "False").lower() == "true"
 
78
  print(f"\nπŸš€ Starting Flask server on port {port} (debug={debug})...")
79
  app.run(host="0.0.0.0", port=port, debug=debug)
requirements.txt CHANGED
@@ -1,9 +1,7 @@
1
  Flask==3.0.3
2
  Flask-Cors==4.0.0
3
  numpy==1.26.4
4
- opencv-python-headless==4.9.0.80
5
- mediapipe==0.10.14
6
  Pillow==10.3.0
7
  tensorflow-cpu==2.15.0
 
8
  gunicorn==21.2.0
9
- aiortc==1.9.0
 
1
  Flask==3.0.3
2
  Flask-Cors==4.0.0
3
  numpy==1.26.4
 
 
4
  Pillow==10.3.0
5
  tensorflow-cpu==2.15.0
6
+ requests==2.31.0
7
  gunicorn==21.2.0
 
routes/__pycache__/video_routes.cpython-311.pyc DELETED
Binary file (11.5 kB)
 
routes/__pycache__/webrtc_routes.cpython-311.pyc DELETED
Binary file (21.5 kB)
 
routes/classification_routes.py CHANGED
@@ -1,9 +1,9 @@
1
  import os, io, base64, requests
2
  from pathlib import Path
3
  from flask import Blueprint, request, jsonify
4
- import tensorflow as tf
5
  import numpy as np
6
  from PIL import Image
 
7
 
8
  try:
9
  from tensorflow.keras.applications import mobilenet_v2 as _mv2
@@ -11,13 +11,13 @@ except Exception:
11
  from keras.applications import mobilenet_v2 as _mv2
12
 
13
  preprocess_input = _mv2.preprocess_input
14
- classification_bp = Blueprint('classification_bp', __name__)
15
 
16
- # ------------------------------------------------------------
17
- # Model setup and auto-download
18
- # ------------------------------------------------------------
19
  MODEL_DIR = Path(os.getenv("MODEL_DIR", "/tmp/model"))
20
- os.makedirs(MODEL_DIR, exist_ok=True)
21
 
22
  MODEL_URLS = {
23
  "model": "https://huggingface.co/Gwen01/ProctorVision-Models/resolve/main/cheating_mobilenetv2_final.keras",
@@ -36,80 +36,67 @@ for key, url in MODEL_URLS.items():
36
  f.write(r.content)
37
  print(f"βœ… Saved {key} β†’ {local_path}")
38
 
39
- # Candidate filenames for compatibility
40
  CANDIDATES = [
41
  "cheating_mobilenetv2_final.keras",
42
- "mnv2_clean_best.keras",
43
- "mnv2_continue.keras",
44
- "mnv2_finetune_best.keras",
45
  ]
46
 
47
-
48
  model_path = next((MODEL_DIR / f for f in CANDIDATES if (MODEL_DIR / f).exists()), None)
 
 
49
  if model_path and model_path.exists():
50
- model = tf.keras.models.load_model(model_path, compile=False)
51
- print(f"βœ… Model loaded: {model_path}")
 
 
 
52
  else:
53
- model = None
54
- print(f"⚠️ No model found in {MODEL_DIR}. Put one of: {CANDIDATES}")
55
 
56
  # --- Load threshold ---
57
  thr_file = MODEL_DIR / "best_threshold.npy"
58
  THRESHOLD = float(np.load(thr_file)[0]) if thr_file.exists() else 0.555
59
  print(f"πŸ“Š Using decision threshold: {THRESHOLD:.3f}")
60
 
61
- # --- Input shape ---
62
  if model is not None:
63
  H, W = model.input_shape[1:3]
64
  else:
65
- H, W = 224, 224 # fallback
66
 
67
  LABELS = ["Cheating", "Not Cheating"]
68
 
69
- # ------------------------------------------------------------
70
- # Helper Functions
71
- # ------------------------------------------------------------
72
  def preprocess_pil(pil_img: Image.Image) -> np.ndarray:
73
- img = pil_img.convert("RGB")
74
- if img.size != (W, H):
75
- img = img.resize((W, H), Image.BILINEAR)
76
  x = np.asarray(img, dtype=np.float32)
77
  x = preprocess_input(x)
78
  return np.expand_dims(x, 0)
79
 
80
  def predict_batch(batch_np: np.ndarray) -> np.ndarray:
81
- probs = model.predict(batch_np, verbose=0).ravel()
82
- if probs.ndim == 0:
83
- probs = np.array([probs])
84
- if len(probs) != batch_np.shape[0]:
85
- raw = model.predict(batch_np, verbose=0)
86
- if raw.ndim == 2 and raw.shape[1] == 2:
87
- probs = raw[:, 1] # probability of "Not Cheating"
88
- else:
89
- probs = raw.ravel()
90
  return probs
91
 
92
  def label_from_prob(prob_non_cheating: float) -> str:
93
  return LABELS[int(prob_non_cheating >= THRESHOLD)]
94
 
95
- # ------------------------------------------------------------
96
- # Environment Variables
97
- # ------------------------------------------------------------
98
- RAILWAY_API = os.getenv("RAILWAY_API", "").rstrip("/")
99
- if not RAILWAY_API:
100
- print("⚠️ WARNING: RAILWAY_API not set β€” backend sync will fail.")
101
-
102
- # ------------------------------------------------------------
103
- # Route 1 β€” Classify uploaded multiple files (manual)
104
- # ------------------------------------------------------------
105
- @classification_bp.route('/classify_multiple', methods=['POST'])
106
  def classify_multiple():
107
  if model is None:
108
  return jsonify({"error": "Model not loaded."}), 500
109
 
110
- files = request.files.getlist('files') if 'files' in request.files else []
111
  if not files:
112
- return jsonify({"error": "No files uploaded"}), 400
113
 
114
  batch = []
115
  for f in files:
@@ -117,9 +104,9 @@ def classify_multiple():
117
  pil = Image.open(io.BytesIO(f.read()))
118
  batch.append(preprocess_pil(pil)[0])
119
  except Exception as e:
120
- return jsonify({"error": f"Error reading image: {str(e)}"}), 400
121
 
122
- batch_np = np.stack(batch, axis=0)
123
  probs = predict_batch(batch_np)
124
  labels = [label_from_prob(p) for p in probs]
125
 
@@ -128,68 +115,53 @@ def classify_multiple():
128
  "results": [{"label": lbl, "prob_non_cheating": float(p)} for lbl, p in zip(labels, probs)]
129
  })
130
 
131
- # ------------------------------------------------------------
132
- # Route 2 β€” Auto-classify Behavior Logs (Backend-to-Backend)
133
- # ------------------------------------------------------------
134
- @classification_bp.route('/classify_behavior_logs', methods=['POST'])
135
  def classify_behavior_logs():
136
  if model is None:
137
  return jsonify({"error": "Model not loaded."}), 500
138
 
139
  data = request.get_json(silent=True) or {}
140
- user_id = data.get('user_id')
141
- exam_id = data.get('exam_id')
142
  if not user_id or not exam_id:
143
  return jsonify({"error": "Missing user_id or exam_id"}), 400
144
 
145
- # --- Fetch behavior logs from Railway ---
 
 
 
 
146
  try:
147
  fetch_url = f"{RAILWAY_API}/api/fetch_behavior_logs"
148
- response = requests.get(fetch_url, params={"user_id": user_id, "exam_id": exam_id})
149
- if response.status_code != 200:
150
- return jsonify({"error": f"Failed to fetch logs: {response.text}"}), 500
151
-
152
- logs = response.json().get("logs", [])
153
  if not logs:
154
  return jsonify({"message": "No logs to classify."}), 200
155
  except Exception as e:
156
- return jsonify({"error": f"Failed to reach Railway API: {str(e)}"}), 500
157
 
158
- # --- Process & Predict ---
159
  updates = []
160
- CHUNK = 64
161
- for i in range(0, len(logs), CHUNK):
162
- chunk = logs[i:i+CHUNK]
163
- batch = []
164
- ids = []
165
-
166
- for log in chunk:
167
- try:
168
- img_data = base64.b64decode(log["image_base64"])
169
- pil = Image.open(io.BytesIO(img_data))
170
- batch.append(preprocess_pil(pil)[0])
171
- ids.append(log["id"])
172
- except Exception as e:
173
- print(f"⚠️ Failed to read image ID {log['id']}: {e}")
174
-
175
- if not batch:
176
- continue
177
-
178
- batch_np = np.stack(batch, axis=0)
179
- probs = predict_batch(batch_np)
180
- labels = [label_from_prob(p) for p in probs]
181
-
182
- for log_id, lbl in zip(ids, labels):
183
- updates.append({"id": log_id, "label": lbl})
184
-
185
- # --- Send predictions back to Railway ---
186
  try:
187
  update_url = f"{RAILWAY_API}/api/update_classifications"
188
  post_res = requests.post(update_url, json={"updates": updates})
189
- if post_res.status_code != 200:
190
- return jsonify({"error": f"Failed to update classifications: {post_res.text}"}), 500
191
  except Exception as e:
192
- return jsonify({"error": f"Failed to push updates: {str(e)}"}), 500
193
 
194
  return jsonify({
195
  "message": f"Classification complete for {len(updates)} logs.",
 
1
  import os, io, base64, requests
2
  from pathlib import Path
3
  from flask import Blueprint, request, jsonify
 
4
  import numpy as np
5
  from PIL import Image
6
+ import tensorflow as tf
7
 
8
  try:
9
  from tensorflow.keras.applications import mobilenet_v2 as _mv2
 
11
  from keras.applications import mobilenet_v2 as _mv2
12
 
13
  preprocess_input = _mv2.preprocess_input
14
+ classification_bp = Blueprint("classification_bp", __name__)
15
 
16
+ # =============================================================
17
+ # 🧠 Model Setup
18
+ # =============================================================
19
  MODEL_DIR = Path(os.getenv("MODEL_DIR", "/tmp/model"))
20
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
21
 
22
  MODEL_URLS = {
23
  "model": "https://huggingface.co/Gwen01/ProctorVision-Models/resolve/main/cheating_mobilenetv2_final.keras",
 
36
  f.write(r.content)
37
  print(f"βœ… Saved {key} β†’ {local_path}")
38
 
 
39
  CANDIDATES = [
40
  "cheating_mobilenetv2_final.keras",
41
+ "cheating_mobilenetv2_final.h5",
 
 
42
  ]
43
 
 
44
  model_path = next((MODEL_DIR / f for f in CANDIDATES if (MODEL_DIR / f).exists()), None)
45
+
46
+ model = None
47
  if model_path and model_path.exists():
48
+ try:
49
+ model = tf.keras.models.load_model(model_path, compile=False)
50
+ print(f"βœ… Model loaded successfully from {model_path}")
51
+ except Exception as e:
52
+ print(f"❌ Failed to load model: {e}")
53
  else:
54
+ print(f"⚠️ No valid model found in {MODEL_DIR}")
 
55
 
56
  # --- Load threshold ---
57
  thr_file = MODEL_DIR / "best_threshold.npy"
58
  THRESHOLD = float(np.load(thr_file)[0]) if thr_file.exists() else 0.555
59
  print(f"πŸ“Š Using decision threshold: {THRESHOLD:.3f}")
60
 
61
+ # --- Default Input Shape ---
62
  if model is not None:
63
  H, W = model.input_shape[1:3]
64
  else:
65
+ H, W = 224, 224
66
 
67
  LABELS = ["Cheating", "Not Cheating"]
68
 
69
+ # =============================================================
70
+ # 🧩 Helper Functions
71
+ # =============================================================
72
  def preprocess_pil(pil_img: Image.Image) -> np.ndarray:
73
+ img = pil_img.convert("RGB").resize((W, H))
 
 
74
  x = np.asarray(img, dtype=np.float32)
75
  x = preprocess_input(x)
76
  return np.expand_dims(x, 0)
77
 
78
  def predict_batch(batch_np: np.ndarray) -> np.ndarray:
79
+ raw = model.predict(batch_np, verbose=0)
80
+ if raw.ndim == 2 and raw.shape[1] == 2:
81
+ probs = raw[:, 1] # Probability of "Not Cheating"
82
+ else:
83
+ probs = raw.ravel()
 
 
 
 
84
  return probs
85
 
86
  def label_from_prob(prob_non_cheating: float) -> str:
87
  return LABELS[int(prob_non_cheating >= THRESHOLD)]
88
 
89
+ # =============================================================
90
+ # πŸ”Ή Route 1 β€” Classify Multiple Uploaded Files
91
+ # =============================================================
92
+ @classification_bp.route("/classify_multiple", methods=["POST"])
 
 
 
 
 
 
 
93
  def classify_multiple():
94
  if model is None:
95
  return jsonify({"error": "Model not loaded."}), 500
96
 
97
+ files = request.files.getlist("files")
98
  if not files:
99
+ return jsonify({"error": "No files uploaded."}), 400
100
 
101
  batch = []
102
  for f in files:
 
104
  pil = Image.open(io.BytesIO(f.read()))
105
  batch.append(preprocess_pil(pil)[0])
106
  except Exception as e:
107
+ return jsonify({"error": f"Error reading image: {e}"}), 400
108
 
109
+ batch_np = np.stack(batch)
110
  probs = predict_batch(batch_np)
111
  labels = [label_from_prob(p) for p in probs]
112
 
 
115
  "results": [{"label": lbl, "prob_non_cheating": float(p)} for lbl, p in zip(labels, probs)]
116
  })
117
 
118
+ # =============================================================
119
+ # πŸ”Ή Route 2 β€” Classify Behavior Logs (Backend-to-Backend)
120
+ # =============================================================
121
+ @classification_bp.route("/classify_behavior_logs", methods=["POST"])
122
  def classify_behavior_logs():
123
  if model is None:
124
  return jsonify({"error": "Model not loaded."}), 500
125
 
126
  data = request.get_json(silent=True) or {}
127
+ user_id = data.get("user_id")
128
+ exam_id = data.get("exam_id")
129
  if not user_id or not exam_id:
130
  return jsonify({"error": "Missing user_id or exam_id"}), 400
131
 
132
+ RAILWAY_API = os.getenv("RAILWAY_API", "").rstrip("/")
133
+ if not RAILWAY_API:
134
+ return jsonify({"error": "RAILWAY_API not configured."}), 500
135
+
136
+ # Fetch logs
137
  try:
138
  fetch_url = f"{RAILWAY_API}/api/fetch_behavior_logs"
139
+ res = requests.get(fetch_url, params={"user_id": user_id, "exam_id": exam_id})
140
+ res.raise_for_status()
141
+ logs = res.json().get("logs", [])
 
 
142
  if not logs:
143
  return jsonify({"message": "No logs to classify."}), 200
144
  except Exception as e:
145
+ return jsonify({"error": f"Failed to fetch logs: {e}"}), 500
146
 
 
147
  updates = []
148
+ for log in logs:
149
+ try:
150
+ img_data = base64.b64decode(log["image_base64"])
151
+ pil = Image.open(io.BytesIO(img_data))
152
+ batch = preprocess_pil(pil)
153
+ prob = predict_batch(batch)[0]
154
+ lbl = label_from_prob(prob)
155
+ updates.append({"id": log["id"], "label": lbl})
156
+ except Exception as e:
157
+ print(f"⚠️ Skipped log {log.get('id')}: {e}")
158
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  try:
160
  update_url = f"{RAILWAY_API}/api/update_classifications"
161
  post_res = requests.post(update_url, json={"updates": updates})
162
+ post_res.raise_for_status()
 
163
  except Exception as e:
164
+ return jsonify({"error": f"Failed to push updates: {e}"}), 500
165
 
166
  return jsonify({
167
  "message": f"Classification complete for {len(updates)} logs.",
routes/webrtc_routes.py DELETED
@@ -1,309 +0,0 @@
1
- import asyncio, time, traceback, os, threading, base64, cv2, numpy as np, mediapipe as mp, requests
2
- from collections import defaultdict, deque
3
- from aiortc import RTCPeerConnection, RTCSessionDescription
4
- from aiortc.contrib.media import MediaBlackhole
5
- from flask import Blueprint, request, jsonify
6
-
7
- # ----------------------------------------------------------------------
8
- # CONFIGURATION
9
- # ----------------------------------------------------------------------
10
- webrtc_bp = Blueprint("webrtc", __name__)
11
-
12
- # Base URL of your main (Railway) backend
13
- RAILWAY_API = os.getenv("RAILWAY_API", "").rstrip("/")
14
- if not RAILWAY_API:
15
- print("⚠️ WARNING: RAILWAY_API not set β€” backend communication may fail.")
16
-
17
- SUMMARY_EVERY_S = float(os.getenv("PROCTOR_SUMMARY_EVERY_S", "1.0"))
18
- RECV_TIMEOUT_S = float(os.getenv("PROCTOR_RECV_TIMEOUT_S", "5.0"))
19
- HEARTBEAT_S = float(os.getenv("PROCTOR_HEARTBEAT_S", "10.0"))
20
-
21
- # ----------------------------------------------------------------------
22
- # LOGGING UTIL
23
- # ----------------------------------------------------------------------
24
- def log(event, sid="-", eid="-", **kv):
25
- tail = " ".join(f"{k}={v}" for k, v in kv.items())
26
- print(f"[{event}] sid={sid} eid={eid} {tail}".strip(), flush=True)
27
-
28
- # ----------------------------------------------------------------------
29
- # HELPER: send background POST to Railway backend
30
- # ----------------------------------------------------------------------
31
- def _send_to_railway(endpoint, payload, sid, eid):
32
- """Send POST requests asynchronously to Railway backend."""
33
- def _worker():
34
- try:
35
- url = f"{RAILWAY_API}{endpoint}"
36
- r = requests.post(url, json=payload, timeout=10)
37
- if r.status_code != 200:
38
- log("RAILWAY_POST_FAIL", sid, eid, code=r.status_code, msg=r.text)
39
- except Exception as e:
40
- log("RAILWAY_POST_ERR", sid, eid, err=str(e))
41
- threading.Thread(target=_worker, daemon=True).start()
42
-
43
- # ----------------------------------------------------------------------
44
- # GLOBAL STATE
45
- # ----------------------------------------------------------------------
46
- _loop = asyncio.new_event_loop()
47
- threading.Thread(target=_loop.run_forever, daemon=True).start()
48
- pcs = set()
49
- last_warning = defaultdict(lambda: {"warning": "Looking Forward", "at": 0})
50
- last_capture = defaultdict(lambda: {"label": None, "at": 0})
51
- last_metrics = defaultdict(lambda: {"yaw": None, "pitch": None, "dx": None, "dy": None,
52
- "fps": None, "label": "n/a", "at": 0})
53
-
54
- # ----------------------------------------------------------------------
55
- # MEDIAPIPE SETUP
56
- # ----------------------------------------------------------------------
57
- mp_face_mesh = mp.solutions.face_mesh
58
- mp_hands = mp.solutions.hands
59
-
60
- face_mesh = mp_face_mesh.FaceMesh(
61
- static_image_mode=False, max_num_faces=1, refine_landmarks=True,
62
- min_detection_confidence=0.5, min_tracking_confidence=0.5 # relaxed threshold
63
- )
64
- hands = mp_hands.Hands(
65
- static_image_mode=False, max_num_hands=2,
66
- min_detection_confidence=0.5, min_tracking_confidence=0.5
67
- )
68
-
69
- # ----------------------------------------------------------------------
70
- # DETECTOR CLASS
71
- # ----------------------------------------------------------------------
72
- IDX_NOSE, IDX_CHIN, IDX_LE, IDX_RE, IDX_LM, IDX_RM = 1, 152, 263, 33, 291, 61
73
- MODEL_3D = np.array([
74
- [0.0, 0.0, 0.0],
75
- [0.0, -63.6, -12.5],
76
- [-43.3, 32.7, -26.0],
77
- [43.3, 32.7, -26.0],
78
- [-28.9, -28.9, -24.1],
79
- [28.9, -28.9, -24.1],
80
- ], dtype=np.float32)
81
-
82
- def _landmarks_to_pts(lms, w, h):
83
- ids = [IDX_NOSE, IDX_CHIN, IDX_LE, IDX_RE, IDX_LM, IDX_RM]
84
- return np.array([[lms[i].x * w, lms[i].y * h] for i in ids], dtype=np.float32)
85
-
86
- def _bbox_from_landmarks(lms, w, h, pad=0.03):
87
- xs = [p.x for p in lms]; ys = [p.y for p in lms]
88
- x1n, y1n = max(0.0, min(xs) - pad), max(0.0, min(ys) - pad)
89
- x2n, y2n = min(1.0, max(xs) + pad), min(1.0, max(ys) + pad)
90
- return (int(x1n*w), int(y1n*h), int(x2n*w), int(y2n*h))
91
-
92
- # Tuned thresholds
93
- YAW_DEG_TRIG, PITCH_UP, PITCH_DOWN = 6, 7, 11
94
- SMOOTH_N, CAPTURE_MIN_MS = 5, 1200
95
-
96
- class ProctorDetector:
97
- def __init__(self):
98
- self.yaw_hist, self.pitch_hist = deque(maxlen=SMOOTH_N), deque(maxlen=SMOOTH_N)
99
- self.last_capture_ms, self.noface_streak, self.hand_streak = 0, 0, 0
100
- self.last_print = 0.0
101
-
102
- def _pose_angles(self, lms, w, h):
103
- try:
104
- pts2d = _landmarks_to_pts(lms, w, h)
105
- cam = np.array([[w, 0, w/2], [0, h, h/2], [0, 0, 1]], dtype=np.float32)
106
- ok, rvec, _ = cv2.solvePnP(MODEL_3D, pts2d, cam, np.zeros((4,1)))
107
- if not ok: return None, None
108
- R, _ = cv2.Rodrigues(rvec)
109
- _, _, euler = cv2.RQDecomp3x3(R)
110
- pitch, yaw, _ = map(float, euler)
111
- return yaw, pitch
112
- except Exception as e:
113
- log("POSE_ERR", err=str(e))
114
- return None, None
115
-
116
- def detect(self, bgr, sid="-", eid="-"):
117
- try:
118
- h, w = bgr.shape[:2]
119
- rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
120
- rgb = cv2.flip(rgb, 1) # βœ… flip horizontally for mirrored webcam
121
- res = face_mesh.process(rgb)
122
-
123
- if not res.multi_face_landmarks:
124
- self.noface_streak += 1
125
- log("NO_FACE_FRAME", sid, eid, streak=self.noface_streak)
126
- return "No Face", None, rgb
127
-
128
- self.noface_streak = 0
129
- lms = res.multi_face_landmarks[0].landmark
130
- yaw, pitch = self._pose_angles(lms, w, h)
131
- label = "Looking Forward"
132
-
133
- if yaw is not None and pitch is not None:
134
- if abs(yaw) > YAW_DEG_TRIG:
135
- label = "Looking Left" if yaw < 0 else "Looking Right"
136
- elif pitch > PITCH_DOWN:
137
- label = "Looking Down"
138
- elif pitch < -PITCH_UP:
139
- label = "Looking Up"
140
- else:
141
- label = "Looking Forward"
142
-
143
- # Detailed angles log
144
- if time.time() - self.last_print > 1.5:
145
- log("ANGLES", sid, eid, yaw=round(yaw or 0, 2), pitch=round(pitch or 0, 2), label=label)
146
- self.last_print = time.time()
147
-
148
- log("FACE_DETECTED", sid, eid, label=label)
149
- return label, _bbox_from_landmarks(lms, w, h), rgb
150
-
151
- except Exception as e:
152
- log("DETECT_EXCEPTION", sid, eid, err=str(e))
153
- traceback.print_exc()
154
- return "Error", None, bgr
155
-
156
- def detect_hands_anywhere(self, rgb, sid="-", eid="-"):
157
- try:
158
- res = hands.process(rgb)
159
- if not res.multi_hand_landmarks:
160
- self.hand_streak = 0
161
- return None
162
- self.hand_streak += 1
163
- log("HAND_DETECTED", sid, eid, count=len(res.multi_hand_landmarks))
164
- return "Hand Detected"
165
- except Exception as e:
166
- log("HAND_ERR", sid, eid, err=str(e))
167
- return None
168
-
169
- def _throttle_ok(self):
170
- return int(time.time()*1000) - self.last_capture_ms >= CAPTURE_MIN_MS
171
- def _mark_captured(self): self.last_capture_ms = int(time.time()*1000)
172
-
173
- detectors = defaultdict(ProctorDetector)
174
-
175
- # ----------------------------------------------------------------------
176
- # CAPTURE HANDLER β€” SENDS TO RAILWAY
177
- # ----------------------------------------------------------------------
178
- def _maybe_capture(student_id: str, exam_id: str, bgr, label: str):
179
- try:
180
- ok, buf = cv2.imencode(".jpg", bgr)
181
- if not ok:
182
- log("CAPTURE_SKIP", student_id, exam_id, reason="encode_failed")
183
- return
184
-
185
- img_b64 = base64.b64encode(buf).decode("utf-8")
186
- log("CAPTURE_TRIGGERED", student_id, exam_id, label=label, bytes=len(buf))
187
-
188
- _send_to_railway("/api/save_behavior_log", {
189
- "user_id": int(student_id),
190
- "exam_id": int(exam_id),
191
- "image_base64": img_b64,
192
- "warning_type": label
193
- }, student_id, exam_id)
194
-
195
- _send_to_railway("/api/increment_suspicious", {
196
- "student_id": int(student_id)
197
- }, student_id, exam_id)
198
-
199
- ts = int(time.time() * 1000)
200
- last_capture[(student_id, exam_id)] = {"label": label, "at": ts}
201
- log("LAST_CAPTURE_SET", student_id, exam_id, label=label, at=ts)
202
- except Exception as e:
203
- log("CAPTURE_ERR", student_id, exam_id, err=str(e))
204
- traceback.print_exc()
205
-
206
- # ----------------------------------------------------------------------
207
- # WEBRTC OFFER HANDLER
208
- # ----------------------------------------------------------------------
209
- async def _wait_ice_complete(pc):
210
- if pc.iceGatheringState == "complete": return
211
- done = asyncio.Event()
212
- @pc.on("icegatheringstatechange")
213
- def _(_ev=None):
214
- if pc.iceGatheringState == "complete": done.set()
215
- await asyncio.wait_for(done.wait(), timeout=5.0)
216
-
217
- async def handle_offer(data):
218
- sid, eid = str(data.get("student_id", "0")), str(data.get("exam_id", "0"))
219
- log("OFFER_HANDLE", sid, eid)
220
- offer = RTCSessionDescription(sdp=data["sdp"], type=data["type"])
221
- pc = RTCPeerConnection()
222
- pcs.add(pc)
223
-
224
- @pc.on("connectionstatechange")
225
- async def _():
226
- log("CONN_STATE", sid, eid, state=pc.connectionState)
227
- if pc.connectionState in ("failed", "closed", "disconnected"):
228
- await pc.close()
229
- pcs.discard(pc)
230
- for d in (detectors, last_warning, last_metrics, last_capture):
231
- d.pop((sid, eid), None)
232
- log("PC_CLOSED", sid, eid)
233
-
234
- @pc.on("track")
235
- def on_track(track):
236
- log("TRACK", sid, eid, kind=track.kind)
237
- if track.kind != "video":
238
- MediaBlackhole().addTrack(track)
239
- return
240
- async def reader():
241
- det = detectors[(sid, eid)]
242
- while True:
243
- try:
244
- frame = await asyncio.wait_for(track.recv(), timeout=RECV_TIMEOUT_S)
245
- log("FRAME_RECV", sid, eid)
246
- except asyncio.TimeoutError:
247
- continue
248
- except Exception as e:
249
- log("TRACK_RECV_ERR", sid, eid, err=str(e))
250
- traceback.print_exc()
251
- break
252
- try:
253
- bgr = frame.to_ndarray(format="bgr24")
254
- head_label, _, rgb = det.detect(bgr, sid, eid)
255
- hand_label = det.detect_hands_anywhere(rgb, sid, eid)
256
- warn = hand_label or head_label
257
- ts = int(time.time() * 1000)
258
- last_warning[(sid, eid)] = {"warning": warn, "at": ts}
259
- log("DETECTION_RESULT", sid, eid, warn=warn)
260
- if det._throttle_ok() and warn not in ("Looking Forward", None, "No Face"):
261
- _maybe_capture(sid, eid, bgr, warn)
262
- det._mark_captured()
263
- except Exception as e:
264
- log("DETECT_ERR", sid, eid, err=str(e))
265
- traceback.print_exc()
266
- continue
267
- asyncio.ensure_future(reader(), loop=_loop)
268
-
269
- await pc.setRemoteDescription(offer)
270
- answer = await pc.createAnswer()
271
- await pc.setLocalDescription(answer)
272
- await _wait_ice_complete(pc)
273
- return pc.localDescription
274
-
275
- # ----------------------------------------------------------------------
276
- # ROUTES
277
- # ----------------------------------------------------------------------
278
- @webrtc_bp.route("/webrtc/offer", methods=["POST"])
279
- def webrtc_offer():
280
- try:
281
- data = request.get_json(force=True)
282
- desc = asyncio.run_coroutine_threadsafe(handle_offer(data), _loop).result()
283
- return jsonify({"sdp": desc.sdp, "type": desc.type})
284
- except Exception as e:
285
- traceback.print_exc()
286
- return jsonify({"error": str(e)}), 500
287
-
288
- @webrtc_bp.route("/webrtc/cleanup", methods=["POST"])
289
- def webrtc_cleanup():
290
- async def _close_all():
291
- for pc in list(pcs):
292
- await pc.close()
293
- pcs.discard(pc)
294
- asyncio.run_coroutine_threadsafe(_close_all(), _loop)
295
- return jsonify({"ok": True})
296
-
297
- @webrtc_bp.route("/proctor/last_warning")
298
- def proctor_last_warning():
299
- sid, eid = request.args.get("student_id"), request.args.get("exam_id")
300
- if not sid or not eid:
301
- return jsonify(error="missing student_id or exam_id"), 400
302
- return jsonify(last_warning.get((sid, eid), {"warning": "Looking Forward", "at": 0}))
303
-
304
- @webrtc_bp.route("/proctor/last_capture")
305
- def proctor_last_capture():
306
- sid, eid = request.args.get("student_id"), request.args.get("exam_id")
307
- if not sid or not eid:
308
- return jsonify(error="missing student_id or exam_id"), 400
309
- return jsonify(last_capture.get((sid, eid), {"label": None, "at": 0}))