Arnel Gwen Nuqui commited on
Commit
1c0878b
·
1 Parent(s): 34352ce

done fixing database

Browse files
app.py CHANGED
@@ -23,7 +23,7 @@ CORS(
23
  "http://localhost:3000",
24
  "http://127.0.0.1:3000",
25
  "https://proctorvision-client.vercel.app", # your frontend
26
- # if using Hugging Face
27
  ]
28
  }
29
  },
 
23
  "http://localhost:3000",
24
  "http://127.0.0.1:3000",
25
  "https://proctorvision-client.vercel.app", # your frontend
26
+ "https://proctorvision-server-production.up.railway.app",
27
  ]
28
  }
29
  },
routes/classification_routes.py CHANGED
@@ -1,9 +1,8 @@
1
- import os, io, base64
2
  from pathlib import Path
3
  from flask import Blueprint, request, jsonify
4
  import tensorflow as tf
5
  import numpy as np
6
- import cv2
7
  from PIL import Image
8
 
9
  try:
@@ -13,18 +12,18 @@ except Exception:
13
  # Standalone Keras 3
14
  from keras.applications import mobilenet_v2 as _mv2 # type: ignore
15
 
 
16
  preprocess_input = _mv2.preprocess_input
17
-
18
- from database.connection import get_db_connection
19
-
20
  classification_bp = Blueprint('classification_bp', __name__)
21
 
22
- # ---------- Model & threshold loading ----------
23
- BASE_DIR = Path(__file__).resolve().parent.parent # -> server/
 
 
24
  MODEL_DIR = BASE_DIR / "model"
25
 
26
  CANDIDATES = [
27
- "cheating_mobilenetv2_final.keras", # your chosen deploy name
28
  "mnv2_clean_best.keras",
29
  "mnv2_continue.keras",
30
  "mnv2_finetune_best.keras",
@@ -33,56 +32,59 @@ CANDIDATES = [
33
  model_path = next((MODEL_DIR / f for f in CANDIDATES if (MODEL_DIR / f).exists()), None)
34
  if model_path and model_path.exists():
35
  model = tf.keras.models.load_model(model_path, compile=False)
36
- print(f" Model loaded: {model_path}")
37
  else:
38
  model = None
39
- print(f" No model file found in {MODEL_DIR}. Put one of: {CANDIDATES}")
40
 
41
  thr_file = MODEL_DIR / "best_threshold.npy"
42
  THRESHOLD = float(np.load(thr_file)[0]) if thr_file.exists() else 0.555
43
- print(f"Using decision threshold: {THRESHOLD:.3f}")
44
 
45
- # Input size
46
  if model is not None:
47
  H, W = model.input_shape[1:3]
48
  else:
49
  H, W = 224, 224 # fallback
50
 
51
- # Class mapping used in training:
52
- # class 0 -> "cheating", class 1 -> "non-cheating"
53
  LABELS = ["Cheating", "Not Cheating"]
54
 
 
 
 
55
  def preprocess_pil(pil_img: Image.Image) -> np.ndarray:
56
- """Convert PIL -> model-ready tensor (1, H, W, 3) using MobileNetV2 preprocessing."""
57
  img = pil_img.convert("RGB")
58
  if img.size != (W, H):
59
  img = img.resize((W, H), Image.BILINEAR)
60
  x = np.asarray(img, dtype=np.float32)
61
- x = preprocess_input(x) # <- IMPORTANT: same preprocessing as training
62
  return np.expand_dims(x, 0)
63
 
64
  def predict_batch(batch_np: np.ndarray) -> np.ndarray:
65
- """batch_np: (N, H, W, 3) already preprocessed; returns probs of class 1 (non-cheating)."""
66
  probs = model.predict(batch_np, verbose=0).ravel()
67
- # If model outputs 2 logits (softmax), convert to class-1 prob
68
- if probs.ndim == 0: # single item edge case
69
  probs = np.array([probs])
70
  if len(probs) != batch_np.shape[0]:
71
- # Handle (N,2) softmax output
72
  raw = model.predict(batch_np, verbose=0)
73
  if raw.ndim == 2 and raw.shape[1] == 2:
74
- probs = raw[:, 1] # prob for class index 1 => "Not Cheating"
75
  else:
76
  probs = raw.ravel()
77
  return probs
78
 
79
  def label_from_prob(prob_non_cheating: float) -> str:
80
- """prob = P(class 1 = 'Not Cheating')"""
81
  return LABELS[int(prob_non_cheating >= THRESHOLD)]
82
- # ------------------------------------------------
83
 
 
 
 
 
 
 
84
 
85
- # -------- Route 1: classify uploaded multiple files --------
 
 
86
  @classification_bp.route('/classify_multiple', methods=['POST'])
87
  def classify_multiple():
88
  if model is None:
@@ -92,17 +94,16 @@ def classify_multiple():
92
  if not files:
93
  return jsonify({"error": "No files uploaded"}), 400
94
 
95
- # Preprocess all, predict in one batch (faster & avoids TF retracing spam)
96
  batch = []
97
  for f in files:
98
  try:
99
  pil = Image.open(io.BytesIO(f.read()))
100
- batch.append(preprocess_pil(pil)[0]) # (H,W,3)
101
  except Exception as e:
102
- return jsonify({"error": f"Error reading an image: {str(e)}"}), 400
103
 
104
- batch_np = np.stack(batch, axis=0) # (N,H,W,3)
105
- probs = predict_batch(batch_np) # prob of class 1 (Not Cheating)
106
  labels = [label_from_prob(p) for p in probs]
107
 
108
  return jsonify({
@@ -110,8 +111,9 @@ def classify_multiple():
110
  "results": [{"label": lbl, "prob_non_cheating": float(p)} for lbl, p in zip(labels, probs)]
111
  })
112
 
113
-
114
- # -------- Route 2: auto-classify behavior logs --------
 
115
  @classification_bp.route('/classify_behavior_logs', methods=['POST'])
116
  def classify_behavior_logs():
117
  if model is None:
@@ -123,49 +125,56 @@ def classify_behavior_logs():
123
  if not user_id or not exam_id:
124
  return jsonify({"error": "Missing user_id or exam_id"}), 400
125
 
 
126
  try:
127
- conn = get_db_connection()
128
- cursor = conn.cursor(dictionary=True)
129
-
130
- cursor.execute("""
131
- SELECT id, image_base64 FROM suspicious_behavior_logs
132
- WHERE user_id = %s AND exam_id = %s AND image_base64 IS NOT NULL
133
- """, (user_id, exam_id))
134
- logs = cursor.fetchall()
135
-
136
- # Vectorized predict in chunks
137
- CHUNK = 64
138
- for i in range(0, len(logs), CHUNK):
139
- chunk = logs[i:i+CHUNK]
140
- batch = []
141
- ids = []
142
- for log in chunk:
143
- try:
144
- img_data = base64.b64decode(log['image_base64'])
145
- pil = Image.open(io.BytesIO(img_data))
146
- batch.append(preprocess_pil(pil)[0])
147
- ids.append(log['id'])
148
- except Exception as e:
149
- print(f" Failed to read image ID {log['id']}: {e}")
150
-
151
- if not batch:
152
- continue
153
-
154
- batch_np = np.stack(batch, axis=0)
155
- probs = predict_batch(batch_np)
156
- labels = [label_from_prob(p) for p in probs]
157
-
158
- # write back
159
- cur2 = conn.cursor()
160
- for _id, lbl in zip(ids, labels):
161
- cur2.execute(
162
- "UPDATE suspicious_behavior_logs SET classification_label=%s WHERE id=%s",
163
- (lbl, _id)
164
- )
165
- conn.commit()
166
-
167
- conn.close()
168
- return jsonify({"message": "Classification complete.", "threshold": THRESHOLD}), 200
169
-
170
  except Exception as e:
171
- return jsonify({"error": str(e)}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
 
12
  # Standalone Keras 3
13
  from keras.applications import mobilenet_v2 as _mv2 # type: ignore
14
 
15
+ # --- Model preprocessing ---
16
  preprocess_input = _mv2.preprocess_input
 
 
 
17
  classification_bp = Blueprint('classification_bp', __name__)
18
 
19
+ # ------------------------------------------------------------
20
+ # Model setup
21
+ # ------------------------------------------------------------
22
+ BASE_DIR = Path(__file__).resolve().parent.parent # -> /app/server/
23
  MODEL_DIR = BASE_DIR / "model"
24
 
25
  CANDIDATES = [
26
+ "cheating_mobilenetv2_final.keras",
27
  "mnv2_clean_best.keras",
28
  "mnv2_continue.keras",
29
  "mnv2_finetune_best.keras",
 
32
  model_path = next((MODEL_DIR / f for f in CANDIDATES if (MODEL_DIR / f).exists()), None)
33
  if model_path and model_path.exists():
34
  model = tf.keras.models.load_model(model_path, compile=False)
35
+ print(f"✅ Model loaded: {model_path}")
36
  else:
37
  model = None
38
+ print(f"⚠️ No model found in {MODEL_DIR}. Put one of: {CANDIDATES}")
39
 
40
  thr_file = MODEL_DIR / "best_threshold.npy"
41
  THRESHOLD = float(np.load(thr_file)[0]) if thr_file.exists() else 0.555
42
+ print(f"📊 Using decision threshold: {THRESHOLD:.3f}")
43
 
44
+ # Input shape
45
  if model is not None:
46
  H, W = model.input_shape[1:3]
47
  else:
48
  H, W = 224, 224 # fallback
49
 
 
 
50
  LABELS = ["Cheating", "Not Cheating"]
51
 
52
+ # ------------------------------------------------------------
53
+ # Helper Functions
54
+ # ------------------------------------------------------------
55
  def preprocess_pil(pil_img: Image.Image) -> np.ndarray:
 
56
  img = pil_img.convert("RGB")
57
  if img.size != (W, H):
58
  img = img.resize((W, H), Image.BILINEAR)
59
  x = np.asarray(img, dtype=np.float32)
60
+ x = preprocess_input(x)
61
  return np.expand_dims(x, 0)
62
 
63
  def predict_batch(batch_np: np.ndarray) -> np.ndarray:
 
64
  probs = model.predict(batch_np, verbose=0).ravel()
65
+ if probs.ndim == 0:
 
66
  probs = np.array([probs])
67
  if len(probs) != batch_np.shape[0]:
 
68
  raw = model.predict(batch_np, verbose=0)
69
  if raw.ndim == 2 and raw.shape[1] == 2:
70
+ probs = raw[:, 1] # probability of "Not Cheating"
71
  else:
72
  probs = raw.ravel()
73
  return probs
74
 
75
  def label_from_prob(prob_non_cheating: float) -> str:
 
76
  return LABELS[int(prob_non_cheating >= THRESHOLD)]
 
77
 
78
+ # ------------------------------------------------------------
79
+ # Environment Variables
80
+ # ------------------------------------------------------------
81
+ RAILWAY_API = os.getenv("RAILWAY_API", "").rstrip("/")
82
+ if not RAILWAY_API:
83
+ print("⚠️ WARNING: RAILWAY_API not set — backend sync will fail.")
84
 
85
+ # ------------------------------------------------------------
86
+ # Route 1 — Classify uploaded multiple files (manual)
87
+ # ------------------------------------------------------------
88
  @classification_bp.route('/classify_multiple', methods=['POST'])
89
  def classify_multiple():
90
  if model is None:
 
94
  if not files:
95
  return jsonify({"error": "No files uploaded"}), 400
96
 
 
97
  batch = []
98
  for f in files:
99
  try:
100
  pil = Image.open(io.BytesIO(f.read()))
101
+ batch.append(preprocess_pil(pil)[0])
102
  except Exception as e:
103
+ return jsonify({"error": f"Error reading image: {str(e)}"}), 400
104
 
105
+ batch_np = np.stack(batch, axis=0)
106
+ probs = predict_batch(batch_np)
107
  labels = [label_from_prob(p) for p in probs]
108
 
109
  return jsonify({
 
111
  "results": [{"label": lbl, "prob_non_cheating": float(p)} for lbl, p in zip(labels, probs)]
112
  })
113
 
114
+ # ------------------------------------------------------------
115
+ # Route 2 — Auto-classify Behavior Logs (Backend-to-Backend)
116
+ # ------------------------------------------------------------
117
  @classification_bp.route('/classify_behavior_logs', methods=['POST'])
118
  def classify_behavior_logs():
119
  if model is None:
 
125
  if not user_id or not exam_id:
126
  return jsonify({"error": "Missing user_id or exam_id"}), 400
127
 
128
+ # --- Fetch behavior logs from Railway ---
129
  try:
130
+ fetch_url = f"{RAILWAY_API}/api/fetch_behavior_logs"
131
+ response = requests.get(fetch_url, params={"user_id": user_id, "exam_id": exam_id})
132
+ if response.status_code != 200:
133
+ return jsonify({"error": f"Failed to fetch logs: {response.text}"}), 500
134
+
135
+ logs = response.json().get("logs", [])
136
+ if not logs:
137
+ return jsonify({"message": "No logs to classify."}), 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  except Exception as e:
139
+ return jsonify({"error": f"Failed to reach Railway API: {str(e)}"}), 500
140
+
141
+ # --- Process & Predict ---
142
+ updates = []
143
+ CHUNK = 64
144
+ for i in range(0, len(logs), CHUNK):
145
+ chunk = logs[i:i+CHUNK]
146
+ batch = []
147
+ ids = []
148
+
149
+ for log in chunk:
150
+ try:
151
+ img_data = base64.b64decode(log["image_base64"])
152
+ pil = Image.open(io.BytesIO(img_data))
153
+ batch.append(preprocess_pil(pil)[0])
154
+ ids.append(log["id"])
155
+ except Exception as e:
156
+ print(f"⚠️ Failed to read image ID {log['id']}: {e}")
157
+
158
+ if not batch:
159
+ continue
160
+
161
+ batch_np = np.stack(batch, axis=0)
162
+ probs = predict_batch(batch_np)
163
+ labels = [label_from_prob(p) for p in probs]
164
+
165
+ for log_id, lbl in zip(ids, labels):
166
+ updates.append({"id": log_id, "label": lbl})
167
+
168
+ # --- Send predictions back to Railway ---
169
+ try:
170
+ update_url = f"{RAILWAY_API}/api/update_classifications"
171
+ post_res = requests.post(update_url, json={"updates": updates})
172
+ if post_res.status_code != 200:
173
+ return jsonify({"error": f"Failed to update classifications: {post_res.text}"}), 500
174
+ except Exception as e:
175
+ return jsonify({"error": f"Failed to push updates: {str(e)}"}), 500
176
+
177
+ return jsonify({
178
+ "message": f"Classification complete for {len(updates)} logs.",
179
+ "threshold": THRESHOLD
180
+ }), 200
routes/webrtc_routes.py CHANGED
@@ -1,82 +1,82 @@
1
- # routes/webrtc_routes.py
2
- from flask import Blueprint, request, jsonify
3
- import asyncio, time, traceback, os, threading, base64
4
  from collections import defaultdict, deque
5
  from aiortc import RTCPeerConnection, RTCSessionDescription
6
  from aiortc.contrib.media import MediaBlackhole
7
- import cv2, numpy as np
8
- import mediapipe as mp
9
-
10
- from services.behavior_service import save_behavior_log_async # save image to DB (async)
11
- from services.instructor_services import increment_suspicious_for_student_async # bump counter (async)
12
 
 
 
 
13
  webrtc_bp = Blueprint("webrtc", __name__)
14
 
15
- # -------- logging / config --------
 
 
 
 
16
  SUMMARY_EVERY_S = float(os.getenv("PROCTOR_SUMMARY_EVERY_S", "1.0"))
17
- RECV_TIMEOUT_S = float(os.getenv("PROCTOR_RECV_TIMEOUT_S", "5.0")) # timeout for track.recv
18
- HEARTBEAT_S = float(os.getenv("PROCTOR_HEARTBEAT_S", "10.0")) # reader heartbeat log
19
 
 
 
 
20
  def log(event, sid="-", eid="-", **kv):
21
  tail = " ".join(f"{k}={v}" for k, v in kv.items())
22
  print(f"[{event}] sid={sid} eid={eid} {tail}".strip(), flush=True)
23
 
24
- # -------- persistent asyncio loop (aiortc needs this) --------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  _loop = asyncio.new_event_loop()
26
  threading.Thread(target=_loop.run_forever, daemon=True).start()
27
- def run_coro(coro):
28
- return asyncio.run_coroutine_threadsafe(coro, _loop).result()
29
-
30
- # -------- global state --------
31
  pcs = set()
32
  last_warning = defaultdict(lambda: {"warning": "Looking Forward", "at": 0})
33
- last_metrics = defaultdict(lambda: {"yaw": None, "pitch": None, "dx": None, "dy": None, "fps": None, "label": "n/a", "at": 0})
34
  last_capture = defaultdict(lambda: {"label": None, "at": 0})
 
 
35
 
36
- # -------- thresholds (env tunable) --------
37
- # head pose
38
- YAW_DEG_TRIG = float(os.getenv("PROCTOR_YAW_DEG", "12")) # left/right
39
- PITCH_DEG_TRIG_UP = float(os.getenv("PROCTOR_PITCH_UP_DEG", "10")) # up
40
- PITCH_DEG_TRIG_DOWN = float(os.getenv("PROCTOR_PITCH_DOWN_DEG", "16")) # down (less sensitive than before)
41
- DX_TRIG = float(os.getenv("PROCTOR_DX", "0.06"))
42
- DY_TRIG_UP = float(os.getenv("PROCTOR_DY_UP", "0.08"))
43
- DY_TRIG_DOWN = float(os.getenv("PROCTOR_DY_DOWN", "0.10")) # down (less sensitive)
44
- SMOOTH_N = int(os.getenv("PROCTOR_SMOOTH_N", "5"))
45
-
46
- # capture policy
47
- CAPTURE_MIN_MS = int(os.getenv("PROCTOR_CAPTURE_MIN_MS", "1200"))
48
- HOLD_FRAMES_HEAD = int(os.getenv("PROCTOR_HOLD_FRAMES_HEAD", "3"))
49
- HOLD_FRAMES_NOFACE = int(os.getenv("PROCTOR_HOLD_FRAMES_NOFACE", "3"))
50
- HOLD_FRAMES_HAND = int(os.getenv("PROCTOR_HOLD_FRAMES_HAND", "5")) # stricter for hands
51
-
52
- # hand filters (reduce sensitivity)
53
- HAND_MIN_BOX_FRAC = float(os.getenv("PROCTOR_HAND_MIN_BOX_FRAC", "0.025")) # 2.5% of frame area
54
- HAND_MAX_BOX_FRAC = float(os.getenv("PROCTOR_HAND_MAX_BOX_FRAC", "0.60")) # ignore near-full-frame noise
55
- HAND_REQUIRE_LANDMARK = int(os.getenv("PROCTOR_HAND_REQUIRE_LANDMARK", "7")) # at least N landmarks in-box
56
-
57
- # -------- MediaPipe --------
58
  mp_face_mesh = mp.solutions.face_mesh
 
 
59
  face_mesh = mp_face_mesh.FaceMesh(
60
  static_image_mode=False, max_num_faces=1, refine_landmarks=True,
61
  min_detection_confidence=0.6, min_tracking_confidence=0.6
62
  )
63
- mp_hands = mp.solutions.hands
64
  hands = mp_hands.Hands(
65
  static_image_mode=False, max_num_hands=2,
66
  min_detection_confidence=0.6, min_tracking_confidence=0.6
67
  )
68
 
69
- # FaceMesh indices + 3D model
70
- IDX_NOSE, IDX_CHIN = 1, 152
71
- IDX_LE, IDX_RE = 263, 33
72
- IDX_LM, IDX_RM = 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):
@@ -89,40 +89,27 @@ def _bbox_from_landmarks(lms, w, h, pad=0.03):
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
- # -------- detector --------
 
 
 
 
 
93
  class ProctorDetector:
94
  def __init__(self):
95
- self.yaw_hist = deque(maxlen=SMOOTH_N)
96
- self.pitch_hist = deque(maxlen=SMOOTH_N)
97
- self.dx_hist = deque(maxlen=SMOOTH_N)
98
- self.dy_hist = deque(maxlen=SMOOTH_N)
99
  self.last_print = 0.0
100
- self.base_yaw = None
101
- self.base_pitch = None
102
- self.last_capture_ms = 0
103
-
104
- # streaks for noface / hand capture holds
105
- self.noface_streak = 0
106
- self.hand_streak = 0
107
-
108
- def _update_baseline(self, yaw, pitch):
109
- alpha = 0.10
110
- if yaw is not None:
111
- self.base_yaw = yaw if self.base_yaw is None else (1-alpha)*self.base_yaw + alpha*yaw
112
- if pitch is not None:
113
- self.base_pitch = pitch if self.base_pitch is None else (1-alpha)*self.base_pitch + alpha*pitch
114
 
115
  def _pose_angles(self, lms, w, h):
116
  try:
117
  pts2d = _landmarks_to_pts(lms, w, h)
118
- cam = np.array([[w, 0, w/2],
119
- [0, w, h/2],
120
- [0, 0, 1 ]], dtype=np.float32)
121
- dist = np.zeros((4,1), dtype=np.float32)
122
- ok, rvec, _ = cv2.solvePnP(MODEL_3D, pts2d, cam, dist, flags=cv2.SOLVEPNP_ITERATIVE)
123
  if not ok: return None, None
124
  R, _ = cv2.Rodrigues(rvec)
125
- *_, euler = cv2.RQDecomp3x3(R)
126
  pitch, yaw, _ = map(float, euler)
127
  return yaw, pitch
128
  except Exception:
@@ -132,184 +119,36 @@ class ProctorDetector:
132
  h, w = bgr.shape[:2]
133
  rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
134
  res = face_mesh.process(rgb)
135
-
136
  if not res.multi_face_landmarks:
137
- now = time.time()
138
- if now - self.last_print >= SUMMARY_EVERY_S:
139
- log("FRAME", sid, eid, note="no_face")
140
- self.last_print = now
141
- return "No Face", None, rgb, None, None, None, None
142
-
143
  lms = res.multi_face_landmarks[0].landmark
144
- box = _bbox_from_landmarks(lms, w, h, pad=0.03)
145
-
146
- # pixel offsets
147
- x1, y1, x2, y2 = box
148
- fw, fh = max(1, x2-x1), max(1, y2-y1)
149
- cx, cy = x1 + fw/2.0, y1 + fh/2.0
150
- nose_x, nose_y = lms[IDX_NOSE].x * w, lms[IDX_NOSE].y * h
151
- dx = (nose_x - cx) / fw # + right
152
- dy = (nose_y - cy) / fh # + down
153
- self.dx_hist.append(dx); self.dy_hist.append(dy)
154
- dx_s = float(np.median(self.dx_hist))
155
- dy_s = float(np.median(self.dy_hist))
156
-
157
- # angles (may be None on some frames)
158
  yaw, pitch = self._pose_angles(lms, w, h)
159
- yaw_s = pitch_s = None
160
- if yaw is not None:
161
- self.yaw_hist.append(yaw); yaw_s = float(np.median(self.yaw_hist))
162
- if pitch is not None:
163
- self.pitch_hist.append(pitch); pitch_s = float(np.median(self.pitch_hist))
164
-
165
- # triggers
166
- yaw_trigger = (yaw_s is not None and abs(yaw_s) > YAW_DEG_TRIG) or (abs(dx_s) > DX_TRIG)
167
-
168
- # For "Down", require BOTH pitch and DY to exceed higher thresholds
169
- pitch_down_trigger = (
170
- (pitch_s is not None and pitch_s > PITCH_DEG_TRIG_DOWN) and
171
- (dy_s > DY_TRIG_DOWN)
172
- )
173
- # For "Up", keep original sensitivity (OR) unless you also want stricter:
174
- pitch_up_trigger = (
175
- (pitch_s is not None and -pitch_s > PITCH_DEG_TRIG_UP) or
176
- (dy_s < -DY_TRIG_UP)
177
- )
178
-
179
  label = "Looking Forward"
180
- if yaw_trigger and (abs(dx_s) >= abs(dy_s) or not (pitch_up_trigger or pitch_down_trigger)):
181
- label = "Looking Right" if dx_s > 0 else "Looking Left"
182
- elif pitch_down_trigger:
183
- label = "Looking Down"
184
- elif pitch_up_trigger:
185
- label = "Looking Up"
186
-
187
- if label == "Looking Forward":
188
- self._update_baseline(yaw_s, pitch_s)
189
-
190
- now = time.time()
191
- if now - self.last_print >= SUMMARY_EVERY_S:
192
- log("FRAME", sid, eid,
193
- yaw=f"{yaw_s:.1f}" if yaw_s is not None else "na",
194
- pitch=f"{pitch_s:.1f}" if pitch_s is not None else "na",
195
- dx=f"{dx_s:.3f}", dy=f"{dy_s:.3f}", choose=label)
196
- self.last_print = now
197
-
198
- return label, box, rgb, yaw_s, pitch_s, dx_s, dy_s
199
 
200
  def detect_hands_anywhere(self, rgb):
201
- """
202
- Less sensitive hand detection:
203
- - requires a minimum hand bounding box area vs frame (HAND_MIN_BOX_FRAC)
204
- - ignores absurdly large boxes (HAND_MAX_BOX_FRAC)
205
- - returns ('Hand Detected', count) only if at least one qualified hand is present
206
- """
207
- h, w = rgb.shape[:2]
208
- frame_area = float(h * w)
209
  res = hands.process(rgb)
210
  if not res.multi_hand_landmarks:
211
- return None, 0
212
-
213
- qualified = 0
214
- for hlms in res.multi_hand_landmarks:
215
- xs = [p.x for p in hlms.landmark]; ys = [p.y for p in hlms.landmark]
216
- x1 = max(0, int(min(xs) * w)); y1 = max(0, int(min(ys) * h))
217
- x2 = min(w, int(max(xs) * w)); y2 = min(h, int(max(ys) * h))
218
- bw, bh = max(1, x2 - x1), max(1, y2 - y1)
219
- box_area_frac = (bw * bh) / frame_area
220
-
221
- # crude landmark-count-in-box sanity (they all are, but keep option)
222
- lm_in_box = sum(
223
- 1 for p in hlms.landmark
224
- if (x1 <= int(p.x * w) <= x2 and y1 <= int(p.y * h) <= y2)
225
- )
226
-
227
- if HAND_MIN_BOX_FRAC <= box_area_frac <= HAND_MAX_BOX_FRAC and lm_in_box >= HAND_REQUIRE_LANDMARK:
228
- qualified += 1
229
-
230
- if qualified > 0:
231
- return "Hand Detected", qualified
232
- return None, 0
233
-
234
- # capture throttles (shared for all capture kinds)
235
  def _throttle_ok(self):
236
  return int(time.time()*1000) - self.last_capture_ms >= CAPTURE_MIN_MS
237
- def _mark_captured(self):
238
- self.last_capture_ms = int(time.time()*1000)
239
-
240
- # head (left/right/up/down)
241
- def should_capture_head(self, head_label, sid="-", eid="-"):
242
- if head_label in ("Looking Forward", "No Face"):
243
- return False
244
- if not self._throttle_ok():
245
- return False
246
-
247
- # Pixel hold
248
- pixel_hold_ok = False
249
- if len(self.dx_hist) >= HOLD_FRAMES_HEAD and len(self.dy_hist) >= HOLD_FRAMES_HEAD:
250
- dxs = list(self.dx_hist)[-HOLD_FRAMES_HEAD:]
251
- dys = list(self.dy_hist)[-HOLD_FRAMES_HEAD:]
252
- if head_label in ("Looking Left", "Looking Right"):
253
- pixel_hold_ok = all(abs(x) > DX_TRIG for x in dxs)
254
- elif head_label in ("Looking Up", "Looking Down"):
255
- # For "Down", mirror the stricter rule: require DY hold above down threshold
256
- if head_label == "Looking Down":
257
- pixel_hold_ok = all(y > DY_TRIG_DOWN for y in dys)
258
- else: # Looking Up
259
- pixel_hold_ok = all(-y > DY_TRIG_UP for y in dys)
260
- else:
261
- pixel_hold_ok = (all(abs(x) > DX_TRIG for x in dxs) or
262
- all(abs(y) > max(DY_TRIG_UP, DY_TRIG_DOWN) for y in dys))
263
-
264
- # Angle hold
265
- angle_hold_ok = False
266
- if len(self.yaw_hist) >= HOLD_FRAMES_HEAD and len(self.pitch_hist) >= HOLD_FRAMES_HEAD:
267
- by = self.base_yaw if self.base_yaw is not None else 0.0
268
- bp = self.base_pitch if self.base_pitch is not None else 0.0
269
- recent_yaw = list(self.yaw_hist)[-HOLD_FRAMES_HEAD:]
270
- recent_pitch = list(self.pitch_hist)[-HOLD_FRAMES_HEAD:]
271
- if head_label in ("Looking Left", "Looking Right"):
272
- angle_hold_ok = all(abs(y - by) > YAW_DEG_TRIG for y in recent_yaw)
273
- elif head_label == "Looking Down":
274
- angle_hold_ok = all((p - bp) > PITCH_DEG_TRIG_DOWN for p in recent_pitch)
275
- elif head_label == "Looking Up":
276
- angle_hold_ok = all((bp - p) > PITCH_DEG_TRIG_UP for p in recent_pitch)
277
-
278
- ok = pixel_hold_ok or angle_hold_ok
279
- if ok:
280
- log("CAPTURE_DECISION_HEAD", sid, eid, label=head_label,
281
- pixel_hold=pixel_hold_ok, angle_hold=angle_hold_ok,
282
- hold_frames=HOLD_FRAMES_HEAD, ok=True)
283
- self._mark_captured()
284
- return ok
285
-
286
- # no face
287
- def should_capture_noface(self, sid="-", eid="-"):
288
- if not self._throttle_ok():
289
- return False
290
- ok = self.noface_streak >= HOLD_FRAMES_NOFACE
291
- if ok:
292
- log("CAPTURE_DECISION_NOFACE", sid, eid,
293
- streak=self.noface_streak, hold_frames=HOLD_FRAMES_NOFACE, ok=True)
294
- self._mark_captured()
295
- return ok
296
-
297
- # hands
298
- def should_capture_hand(self, hand_label, sid="-", eid="-"):
299
- if not hand_label:
300
- return False
301
- if not self._throttle_ok():
302
- return False
303
- ok = self.hand_streak >= HOLD_FRAMES_HAND
304
- if ok:
305
- log("CAPTURE_DECISION_HAND", sid, eid,
306
- streak=self.hand_streak, hold_frames=HOLD_FRAMES_HAND, ok=True)
307
- self._mark_captured()
308
- return ok
309
 
310
  detectors = defaultdict(ProctorDetector)
311
 
312
- # -------- capture helper --------
 
 
313
  def _maybe_capture(student_id: str, exam_id: str, bgr, label: str):
314
  ok, buf = cv2.imencode(".jpg", bgr)
315
  if not ok:
@@ -319,193 +158,116 @@ def _maybe_capture(student_id: str, exam_id: str, bgr, label: str):
319
  img_b64 = base64.b64encode(buf).decode("utf-8")
320
  log("CAPTURE_ENQUEUE", student_id, exam_id, label=label, bytes=len(buf))
321
 
322
- # Save image to DB (async)
323
- save_behavior_log_async(
324
- int(student_id), int(exam_id), img_b64, label,
325
- on_error=lambda e: log("CAPTURE_ERR", student_id, exam_id, err=str(e))
326
- )
 
 
327
 
328
- # bump suspicious counter (async)
329
- increment_suspicious_for_student_async(int(student_id))
 
330
 
331
- # update in-memory cache for frontend beep
332
  ts = int(time.time() * 1000)
333
  last_capture[(student_id, exam_id)] = {"label": label, "at": ts}
334
  log("LAST_CAPTURE_SET", student_id, exam_id, label=label, at=ts)
335
 
336
- # -------- aiortc helpers --------
337
- async def _wait_ice_complete(pc: RTCPeerConnection):
338
- if pc.iceGatheringState == "complete":
339
- return
 
340
  done = asyncio.Event()
341
  @pc.on("icegatheringstatechange")
342
  def _(_ev=None):
343
- if pc.iceGatheringState == "complete":
344
- done.set()
345
  await asyncio.wait_for(done.wait(), timeout=5.0)
346
 
347
- async def handle_offer(data: dict):
348
- student_id = str(data.get("student_id", "0"))
349
- exam_id = str(data.get("exam_id", "0"))
350
- key = (student_id, exam_id)
351
- log("OFFER_HANDLE", student_id, exam_id)
352
-
353
  offer = RTCSessionDescription(sdp=data["sdp"], type=data["type"])
354
  pc = RTCPeerConnection()
355
  pcs.add(pc)
356
- log("PC_CREATED", student_id, exam_id, pc=id(pc))
357
 
358
  @pc.on("connectionstatechange")
359
  async def _():
360
- log("PC_STATE", student_id, exam_id, state=pc.connectionState)
361
  if pc.connectionState in ("failed", "closed", "disconnected"):
362
- try:
363
- await pc.close()
364
- finally:
365
- pcs.discard(pc)
366
- for d in (detectors, last_warning, last_metrics, last_capture):
367
- d.pop(key, None)
368
- log("PC_CLOSED", student_id, exam_id, pc=id(pc))
369
 
370
  @pc.on("track")
371
  def on_track(track):
372
- log("TRACK", student_id, exam_id, kind=track.kind)
373
- if track.kind == "video":
374
- async def reader():
375
- det = detectors[key]
376
- t_fps = time.time(); frames = 0
377
- last_heartbeat = time.time()
378
-
379
- while True:
380
- # --- receive with timeout ---
381
- try:
382
- frame = await asyncio.wait_for(track.recv(), timeout=RECV_TIMEOUT_S)
383
- except asyncio.TimeoutError:
384
- # lightweight heartbeat on timeouts
385
- now = time.time()
386
- if now - last_heartbeat >= HEARTBEAT_S:
387
- log("TRACK_RECV_TIMEOUT", student_id, exam_id, timeout_s=RECV_TIMEOUT_S)
388
- last_heartbeat = now
389
- continue
390
- except Exception as e:
391
- log("TRACK_RECV_END", student_id, exam_id, err=str(e))
392
- break
393
-
394
- # fps window
395
- frames += 1
396
- now = time.time()
397
- if now - t_fps >= 2.0:
398
- fps = frames / max(1e-6, (now - t_fps))
399
- lm = last_metrics.get(key, {})
400
- lm["fps"] = fps
401
- last_metrics[key] = lm
402
- log("FPS", student_id, exam_id, fps=f"{fps:.1f}")
403
- t_fps = now; frames = 0
404
-
405
- # --- per-frame detection protected ---
406
- try:
407
- bgr = frame.to_ndarray(format="bgr24")
408
- head_label, face_box, rgb, yaw_s, pitch_s, dx_s, dy_s = det.detect(bgr, sid=student_id, eid=exam_id)
409
- hand_label, _ = det.detect_hands_anywhere(rgb)
410
- except Exception as e:
411
- log("DETECT_ERR", student_id, exam_id, err=str(e))
412
- continue
413
-
414
- # maintain streaks with stricter holds for hand/noface
415
- det.noface_streak = det.noface_streak + 1 if head_label == "No Face" else 0
416
- det.hand_streak = det.hand_streak + 1 if hand_label else 0
417
-
418
- # choose UI label
419
- if head_label == "No Face":
420
- warn = "No Face (Hand Detected)" if hand_label else "No Face"
421
- elif head_label != "Looking Forward":
422
- warn = head_label
423
- elif hand_label:
424
- warn = hand_label
425
- else:
426
- warn = "Looking Forward"
427
-
428
- # --- capture priority with holds/throttle ---
429
- if head_label == "No Face" and det.should_capture_noface(sid=student_id, eid=exam_id):
430
- _maybe_capture(student_id, exam_id, bgr, "No Face")
431
- elif head_label not in ("Looking Forward", "No Face") and det.should_capture_head(head_label, sid=student_id, eid=exam_id):
432
- _maybe_capture(student_id, exam_id, bgr, head_label)
433
- elif hand_label and det.should_capture_hand(hand_label, sid=student_id, eid=exam_id):
434
- _maybe_capture(student_id, exam_id, bgr, "Hand Detected")
435
-
436
- ts = int(time.time() * 1000)
437
- last_warning[key] = {"warning": warn, "at": ts}
438
- last_metrics[key] = {
439
- "yaw": yaw_s, "pitch": pitch_s, "dx": dx_s, "dy": dy_s,
440
- "fps": last_metrics.get(key, {}).get("fps"), "label": warn, "at": ts
441
- }
442
-
443
- # reader heartbeat (when frames flow)
444
- if now - last_heartbeat >= HEARTBEAT_S:
445
- log("READER_ALIVE", student_id, exam_id, label=warn)
446
- last_heartbeat = now
447
-
448
- asyncio.ensure_future(reader(), loop=_loop)
449
- else:
450
  MediaBlackhole().addTrack(track)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
 
452
  await pc.setRemoteDescription(offer)
453
  answer = await pc.createAnswer()
454
  await pc.setLocalDescription(answer)
455
  await _wait_ice_complete(pc)
456
- log("ICE_DONE", student_id, exam_id)
457
  return pc.localDescription
458
 
459
- # -------- routes --------
 
 
460
  @webrtc_bp.route("/webrtc/offer", methods=["POST"])
461
  def webrtc_offer():
462
  try:
463
  data = request.get_json(force=True)
464
- except Exception as e:
465
- return jsonify({"error": f"invalid json: {e}"}), 400
466
- sid = str(data.get("student_id", "0")); eid = str(data.get("exam_id", "0"))
467
- log("OFFER_RX", sid, eid)
468
- try:
469
- desc = run_coro(handle_offer(data))
470
  except Exception as e:
471
  traceback.print_exc()
472
- log("OFFER_ERR", sid, eid, err=str(e))
473
  return jsonify({"error": str(e)}), 500
474
- log("ANSWER_TX", sid, eid)
475
- return jsonify({"sdp": desc.sdp, "type": desc.type})
476
 
477
  @webrtc_bp.route("/webrtc/cleanup", methods=["POST"])
478
  def webrtc_cleanup():
479
  async def _close_all():
480
  for pc in list(pcs):
481
- try:
482
- await pc.close()
483
- finally:
484
- pcs.discard(pc)
485
- run_coro(_close_all())
486
- print("[CLEANUP] closed all RTCPeerConnections", flush=True)
487
  return jsonify({"ok": True})
488
 
489
  @webrtc_bp.route("/proctor/last_warning")
490
  def proctor_last_warning():
491
- student_id = request.args.get("student_id")
492
- exam_id = request.args.get("exam_id")
493
- if not student_id or not exam_id:
494
  return jsonify(error="missing student_id or exam_id"), 400
495
- return jsonify(last_warning.get((student_id, exam_id), {"warning": "Looking Forward", "at": 0}))
496
 
497
  @webrtc_bp.route("/proctor/last_capture")
498
  def proctor_last_capture():
499
- student_id = request.args.get("student_id")
500
- exam_id = request.args.get("exam_id")
501
- if not student_id or not exam_id:
502
- return jsonify(error="missing student_id or exam_id"), 400
503
- return jsonify(last_capture.get((student_id, exam_id), {"label": None, "at": 0}))
504
-
505
- @webrtc_bp.route("/proctor/metrics")
506
- def proctor_metrics():
507
- student_id = request.args.get("student_id")
508
- exam_id = request.args.get("exam_id")
509
- if not student_id or not exam_id:
510
  return jsonify(error="missing student_id or exam_id"), 400
511
- return jsonify(last_metrics.get((student_id, exam_id), {}))
 
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.6, min_tracking_confidence=0.6
63
  )
 
64
  hands = mp_hands.Hands(
65
  static_image_mode=False, max_num_hands=2,
66
  min_detection_confidence=0.6, min_tracking_confidence=0.6
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):
 
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
+ # Thresholds
93
+ YAW_DEG_TRIG, PITCH_UP, PITCH_DOWN = 12, 10, 16
94
+ DX_TRIG, DY_UP, DY_DOWN = 0.06, 0.08, 0.10
95
+ SMOOTH_N, CAPTURE_MIN_MS = 5, 1200
96
+ HOLD_FRAMES_HEAD, HOLD_FRAMES_NOFACE, HOLD_FRAMES_HAND = 3, 3, 5
97
+
98
  class ProctorDetector:
99
  def __init__(self):
100
+ self.yaw_hist, self.pitch_hist, self.dx_hist, self.dy_hist = deque(maxlen=SMOOTH_N), deque(maxlen=SMOOTH_N), deque(maxlen=SMOOTH_N), deque(maxlen=SMOOTH_N)
101
+ self.base_yaw = self.base_pitch = None
102
+ self.last_capture_ms, self.noface_streak, self.hand_streak = 0, 0, 0
 
103
  self.last_print = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  def _pose_angles(self, lms, w, h):
106
  try:
107
  pts2d = _landmarks_to_pts(lms, w, h)
108
+ cam = np.array([[w, 0, w/2], [0, w, h/2], [0, 0, 1]], dtype=np.float32)
109
+ ok, rvec, _ = cv2.solvePnP(MODEL_3D, pts2d, cam, np.zeros((4,1)))
 
 
 
110
  if not ok: return None, None
111
  R, _ = cv2.Rodrigues(rvec)
112
+ _, _, euler = cv2.RQDecomp3x3(R)
113
  pitch, yaw, _ = map(float, euler)
114
  return yaw, pitch
115
  except Exception:
 
119
  h, w = bgr.shape[:2]
120
  rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
121
  res = face_mesh.process(rgb)
 
122
  if not res.multi_face_landmarks:
123
+ log("FRAME", sid, eid, note="no_face")
124
+ self.noface_streak += 1
125
+ return "No Face", None, rgb
126
+ self.noface_streak = 0
 
 
127
  lms = res.multi_face_landmarks[0].landmark
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  yaw, pitch = self._pose_angles(lms, w, h)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  label = "Looking Forward"
130
+ if yaw and abs(yaw) > YAW_DEG_TRIG: label = "Looking Left" if yaw < 0 else "Looking Right"
131
+ if pitch and pitch > PITCH_DOWN: label = "Looking Down"
132
+ if pitch and -pitch > PITCH_UP: label = "Looking Up"
133
+ return label, _bbox_from_landmarks(lms, w, h), rgb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
  def detect_hands_anywhere(self, rgb):
 
 
 
 
 
 
 
 
136
  res = hands.process(rgb)
137
  if not res.multi_hand_landmarks:
138
+ self.hand_streak = 0
139
+ return None
140
+ self.hand_streak += 1
141
+ return "Hand Detected"
142
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  def _throttle_ok(self):
144
  return int(time.time()*1000) - self.last_capture_ms >= CAPTURE_MIN_MS
145
+ def _mark_captured(self): self.last_capture_ms = int(time.time()*1000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  detectors = defaultdict(ProctorDetector)
148
 
149
+ # ----------------------------------------------------------------------
150
+ # CAPTURE HANDLER — NOW CALLS RAILWAY API
151
+ # ----------------------------------------------------------------------
152
  def _maybe_capture(student_id: str, exam_id: str, bgr, label: str):
153
  ok, buf = cv2.imencode(".jpg", bgr)
154
  if not ok:
 
158
  img_b64 = base64.b64encode(buf).decode("utf-8")
159
  log("CAPTURE_ENQUEUE", student_id, exam_id, label=label, bytes=len(buf))
160
 
161
+ # 👉 send to Railway backend instead of local DB
162
+ _send_to_railway("/api/save_behavior_log", {
163
+ "user_id": int(student_id),
164
+ "exam_id": int(exam_id),
165
+ "image_base64": img_b64,
166
+ "warning_type": label
167
+ }, student_id, exam_id)
168
 
169
+ _send_to_railway("/api/increment_suspicious", {
170
+ "student_id": int(student_id)
171
+ }, student_id, exam_id)
172
 
 
173
  ts = int(time.time() * 1000)
174
  last_capture[(student_id, exam_id)] = {"label": label, "at": ts}
175
  log("LAST_CAPTURE_SET", student_id, exam_id, label=label, at=ts)
176
 
177
+ # ----------------------------------------------------------------------
178
+ # WEBRTC OFFER HANDLER
179
+ # ----------------------------------------------------------------------
180
+ async def _wait_ice_complete(pc):
181
+ if pc.iceGatheringState == "complete": return
182
  done = asyncio.Event()
183
  @pc.on("icegatheringstatechange")
184
  def _(_ev=None):
185
+ if pc.iceGatheringState == "complete": done.set()
 
186
  await asyncio.wait_for(done.wait(), timeout=5.0)
187
 
188
+ async def handle_offer(data):
189
+ sid, eid = str(data.get("student_id", "0")), str(data.get("exam_id", "0"))
190
+ log("OFFER_HANDLE", sid, eid)
 
 
 
191
  offer = RTCSessionDescription(sdp=data["sdp"], type=data["type"])
192
  pc = RTCPeerConnection()
193
  pcs.add(pc)
 
194
 
195
  @pc.on("connectionstatechange")
196
  async def _():
 
197
  if pc.connectionState in ("failed", "closed", "disconnected"):
198
+ await pc.close()
199
+ pcs.discard(pc)
200
+ for d in (detectors, last_warning, last_metrics, last_capture):
201
+ d.pop((sid, eid), None)
202
+ log("PC_CLOSED", sid, eid)
 
 
203
 
204
  @pc.on("track")
205
  def on_track(track):
206
+ log("TRACK", sid, eid, kind=track.kind)
207
+ if track.kind != "video":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  MediaBlackhole().addTrack(track)
209
+ return
210
+ async def reader():
211
+ det = detectors[(sid, eid)]
212
+ while True:
213
+ try:
214
+ frame = await asyncio.wait_for(track.recv(), timeout=RECV_TIMEOUT_S)
215
+ except Exception as e:
216
+ log("TRACK_RECV_ERR", sid, eid, err=str(e))
217
+ break
218
+ try:
219
+ bgr = frame.to_ndarray(format="bgr24")
220
+ head_label, _, rgb = det.detect(bgr, sid, eid)
221
+ hand_label = det.detect_hands_anywhere(rgb)
222
+ warn = hand_label or head_label
223
+ ts = int(time.time() * 1000)
224
+ last_warning[(sid, eid)] = {"warning": warn, "at": ts}
225
+ if det._throttle_ok() and warn not in ("Looking Forward", None):
226
+ _maybe_capture(sid, eid, bgr, warn)
227
+ det._mark_captured()
228
+ except Exception as e:
229
+ log("DETECT_ERR", sid, eid, err=str(e))
230
+ continue
231
+ asyncio.ensure_future(reader(), loop=_loop)
232
 
233
  await pc.setRemoteDescription(offer)
234
  answer = await pc.createAnswer()
235
  await pc.setLocalDescription(answer)
236
  await _wait_ice_complete(pc)
 
237
  return pc.localDescription
238
 
239
+ # ----------------------------------------------------------------------
240
+ # ROUTES
241
+ # ----------------------------------------------------------------------
242
  @webrtc_bp.route("/webrtc/offer", methods=["POST"])
243
  def webrtc_offer():
244
  try:
245
  data = request.get_json(force=True)
246
+ desc = asyncio.run_coroutine_threadsafe(handle_offer(data), _loop).result()
247
+ return jsonify({"sdp": desc.sdp, "type": desc.type})
 
 
 
 
248
  except Exception as e:
249
  traceback.print_exc()
 
250
  return jsonify({"error": str(e)}), 500
 
 
251
 
252
  @webrtc_bp.route("/webrtc/cleanup", methods=["POST"])
253
  def webrtc_cleanup():
254
  async def _close_all():
255
  for pc in list(pcs):
256
+ await pc.close()
257
+ pcs.discard(pc)
258
+ asyncio.run_coroutine_threadsafe(_close_all(), _loop)
 
 
 
259
  return jsonify({"ok": True})
260
 
261
  @webrtc_bp.route("/proctor/last_warning")
262
  def proctor_last_warning():
263
+ sid, eid = request.args.get("student_id"), request.args.get("exam_id")
264
+ if not sid or not eid:
 
265
  return jsonify(error="missing student_id or exam_id"), 400
266
+ return jsonify(last_warning.get((sid, eid), {"warning": "Looking Forward", "at": 0}))
267
 
268
  @webrtc_bp.route("/proctor/last_capture")
269
  def proctor_last_capture():
270
+ sid, eid = request.args.get("student_id"), request.args.get("exam_id")
271
+ if not sid or not eid:
 
 
 
 
 
 
 
 
 
272
  return jsonify(error="missing student_id or exam_id"), 400
273
+ return jsonify(last_capture.get((sid, eid), {"label": None, "at": 0}))
services/__init__.py DELETED
File without changes
services/behavior_service.py DELETED
@@ -1,59 +0,0 @@
1
- # services/behavior_service.py
2
- from database.connection import get_db_connection
3
- import threading
4
-
5
- def save_behavior_log(user_id: int, exam_id: int, image_base64: str, warning_type: str):
6
- """Persist a behavior log. Returns inserted row id when available."""
7
- conn = get_db_connection()
8
- cur = None
9
- try:
10
- cur = conn.cursor()
11
- row_id = None
12
- try:
13
- # Postgres path (supports RETURNING)
14
- cur.execute(
15
- """
16
- INSERT INTO suspicious_behavior_logs (user_id, exam_id, image_base64, warning_type)
17
- VALUES (%s, %s, %s, %s)
18
- RETURNING id
19
- """,
20
- (user_id, exam_id, image_base64, warning_type),
21
- )
22
- row = cur.fetchone()
23
- if row:
24
- row_id = row[0]
25
- except Exception:
26
- # Fallback for MySQL/MariaDB (no RETURNING)
27
- cur.execute(
28
- """
29
- INSERT INTO suspicious_behavior_logs (user_id, exam_id, image_base64, warning_type)
30
- VALUES (%s, %s, %s, %s)
31
- """,
32
- (user_id, exam_id, image_base64, warning_type),
33
- )
34
- try:
35
- row_id = getattr(cur, "lastrowid", None)
36
- except Exception:
37
- row_id = None
38
-
39
- conn.commit()
40
- return row_id
41
- except Exception:
42
- conn.rollback()
43
- raise
44
- finally:
45
- try:
46
- cur.close()
47
- except Exception:
48
- pass
49
- conn.close()
50
-
51
- def save_behavior_log_async(user_id: int, exam_id: int, image_base64: str, warning_type: str, on_error=None):
52
- """Fire-and-forget background insert so the WebRTC loop never blocks."""
53
- def _worker():
54
- try:
55
- save_behavior_log(user_id, exam_id, image_base64, warning_type)
56
- except Exception as e:
57
- if on_error:
58
- on_error(e)
59
- threading.Thread(target=_worker, daemon=True).start()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/instructor_services.py DELETED
@@ -1,93 +0,0 @@
1
- # services/instructor_service.py increment if the suspicous capture
2
- from database.connection import get_db_connection
3
- import threading
4
- import sys
5
-
6
- def _find_instructor_id_for_student(conn, student_id: int):
7
- """
8
- Return an instructor_id for this student.
9
- Adjust the query if your schema is different (e.g., join to exams).
10
- """
11
- cur = conn.cursor()
12
- try:
13
- # If you can have multiple assignments, pick the latest.
14
- cur.execute(
15
- """
16
- SELECT instructor_id
17
- FROM instructor_assignments
18
- WHERE student_id = %s
19
- ORDER BY id DESC
20
- LIMIT 1
21
- """,
22
- (student_id,),
23
- )
24
- row = cur.fetchone()
25
- return row[0] if row else None
26
- finally:
27
- cur.close()
28
-
29
- def increment_suspicious(student_id: int, instructor_id: int) -> int:
30
- """Direct bump: requires instructor_id; returns affected rows."""
31
- conn = get_db_connection()
32
- cur = None
33
- try:
34
- cur = conn.cursor()
35
- cur.execute(
36
- """
37
- UPDATE instructor_assignments
38
- SET suspicious_behavior_count = suspicious_behavior_count + 1
39
- WHERE student_id = %s AND instructor_id = %s
40
- """,
41
- (student_id, instructor_id),
42
- )
43
- conn.commit()
44
- return cur.rowcount
45
- except Exception:
46
- try: conn.rollback()
47
- except Exception: pass
48
- raise
49
- finally:
50
- try: cur.close()
51
- except Exception: pass
52
- conn.close()
53
-
54
- def increment_suspicious_for_student(student_id: int) -> int:
55
- """
56
- Convenience: look up instructor_id for the student and bump the count.
57
- Returns affected rows (0 if no assignment found).
58
- """
59
- conn = get_db_connection()
60
- cur = None
61
- try:
62
- iid = _find_instructor_id_for_student(conn, student_id)
63
- if iid is None:
64
- return 0
65
- cur = conn.cursor()
66
- cur.execute(
67
- """
68
- UPDATE instructor_assignments
69
- SET suspicious_behavior_count = suspicious_behavior_count + 1
70
- WHERE student_id = %s AND instructor_id = %s
71
- """,
72
- (student_id, iid),
73
- )
74
- conn.commit()
75
- return cur.rowcount
76
- except Exception:
77
- try: conn.rollback()
78
- except Exception: pass
79
- raise
80
- finally:
81
- try: cur.close()
82
- except Exception: pass
83
- conn.close()
84
-
85
- def increment_suspicious_for_student_async(student_id: int, on_error=None):
86
- """Fire-and-forget background increment."""
87
- def _worker():
88
- try:
89
- increment_suspicious_for_student(student_id)
90
- except Exception as e:
91
- if on_error: on_error(e)
92
- else: print(f"[INCR_SUSPICIOUS_ERR] {e}", file=sys.stderr, flush=True)
93
- threading.Thread(target=_worker, daemon=True).start()