devindia commited on
Commit
ea2f1f1
·
verified ·
1 Parent(s): f8bcd30

Upload 31 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ hf-space/static/images/logo.png filter=lfs diff=lfs merge=lfs -text
hf-space/.dockerignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies and build
2
+ node_modules
3
+ dist
4
+ __pycache__
5
+ *.pyc
6
+ .pytest_cache
7
+ *.egg-info
8
+ .eggs
9
+ venv
10
+ .venv
11
+ env
12
+
13
+ # Secrets and local config
14
+ .env
15
+ .env.*
16
+ *.local
17
+
18
+ # Local database (Space starts with a fresh DB)
19
+ database/*.db
20
+ database/*.faiss
21
+ database/face_meta.json
22
+
23
+ # Git and editor
24
+ .git
25
+ .gitignore
26
+ .vscode
27
+ .idea
28
+ *.md
29
+ !README.md
30
+
31
+ # Optional: uncomment to skip tests and dev files
32
+ # test_*.py
33
+ # vite.config.ts
34
+ # tsconfig.json
35
+ # package.json
hf-space/.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
hf-space/Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # One Step Greener – Face recognition attendance (Hugging Face Spaces)
2
+ # Spaces expect the app to listen on port 7860.
3
+
4
+ FROM python:3.10-slim
5
+
6
+ # OpenCV and other libs need these
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ libgl1-mesa-glx \
9
+ libglib2.0-0 \
10
+ libsm6 \
11
+ libxext6 \
12
+ libxrender-dev \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ WORKDIR /app
16
+
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt gunicorn
19
+
20
+ COPY . .
21
+
22
+ # Hugging Face Spaces use port 7860
23
+ ENV PORT=7860
24
+ EXPOSE 7860
25
+
26
+ # Single worker (ML models in memory); multiple threads for concurrent requests
27
+ CMD gunicorn --bind 0.0.0.0:7860 --workers 1 --threads 4 --timeout 120 app:app
hf-space/README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AttendanceFaceRecognition
3
+ emoji: 🐠
4
+ colorFrom: green
5
+ colorTo: pink
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: Face recognition attendance for One Step Greener
9
+ ---
10
+
11
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
hf-space/app.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ One Step Greener – Face recognition attendance (waste management).
3
+ Flask application entry point.
4
+ """
5
+
6
+ import os
7
+ import logging
8
+ from flask import Flask, render_template, request, jsonify
9
+
10
+ from database.db import init_db, add_employee, get_employee, get_all_employees, mark_attendance, get_today_attendance, delete_employee
11
+ from models.embeddings_store import EmbeddingStore
12
+ from models.face_engine import (
13
+ decode_image,
14
+ get_face_embedding,
15
+ get_embeddings_from_frames,
16
+ get_embeddings_and_crops_from_frames,
17
+ get_face_crops_from_frames,
18
+ )
19
+ from models.anti_spoof import check_liveness, check_liveness_sequence
20
+
21
+ logging.basicConfig(level=logging.INFO)
22
+ logger = logging.getLogger(__name__)
23
+
24
+ app = Flask(__name__)
25
+ app.secret_key = os.environ.get("SECRET_KEY", "constable-secret-2025")
26
+
27
+ REGISTER_PIN = os.environ.get("REGISTER_PIN", "3620")
28
+
29
+ # ── Initialise database and embedding store ─────────────────────────────────
30
+ init_db()
31
+ store = EmbeddingStore()
32
+
33
+ # ══════════════════════════════════════════════════════════════════════════════
34
+ # Page routes
35
+ # ══════════════════════════════════════════════════════════════════════════════
36
+
37
+ @app.route("/")
38
+ @app.route("/dashboard")
39
+ def dashboard():
40
+ return render_template("dashboard.html")
41
+
42
+
43
+ @app.route("/register")
44
+ def register_page():
45
+ return render_template("register.html")
46
+
47
+
48
+ @app.route("/manage")
49
+ def manage_page():
50
+ return render_template("manage.html")
51
+
52
+
53
+ @app.route("/attendance")
54
+ def attendance_page():
55
+ return render_template("attendance.html")
56
+
57
+
58
+ # ══════════════════════════════════════════════════════════════════════════════
59
+ # API routes
60
+ # ══════════════════════════════════════════════════════════════════════════════
61
+
62
+ @app.route("/api/register", methods=["POST"])
63
+ def api_register():
64
+ """
65
+ Body JSON:
66
+ { employee_id, name, frames: [base64DataUrl, ...] }
67
+ """
68
+ data = request.get_json(force=True)
69
+ employee_id = data.get("employee_id", "").strip()
70
+ name = data.get("name", "").strip()
71
+ frames = data.get("frames", [])
72
+
73
+ if not employee_id or not name:
74
+ return jsonify({"status": "error", "message": "Employee ID and name are required."}), 400
75
+
76
+ if not frames:
77
+ return jsonify({"status": "error", "message": "No frames provided."}), 400
78
+
79
+ logger.info(f"Registering {employee_id} ({name}) with {len(frames)} frames …")
80
+ embeddings, face_crops = get_embeddings_and_crops_from_frames(frames)
81
+
82
+ if not embeddings:
83
+ return jsonify({
84
+ "status": "error",
85
+ "message": "No face detected in the provided frames. "
86
+ "Please ensure good lighting and that your face is clearly visible."
87
+ }), 400
88
+
89
+ # Anti-spoofing: reject photo/screen/video (motion + blink + texture)
90
+ if len(face_crops) >= 2:
91
+ liveness = check_liveness_sequence([c for c in face_crops if c is not None and c.size > 0])
92
+ else:
93
+ liveness = check_liveness(face_crops[0]) if face_crops and face_crops[0] is not None else {"is_live": False}
94
+ if not liveness.get("is_live", True):
95
+ logger.warning(f"Registration rejected (spoof): {liveness.get('reason', 'liveness failed')}")
96
+ return jsonify({
97
+ "status": "spoof",
98
+ "message": liveness.get("reason", "Liveness check failed. Use a live face, not a photo or screen."),
99
+ "reason": liveness.get("reason", "Liveness check failed"),
100
+ "composite": liveness.get("score", 0.0),
101
+ }), 400
102
+
103
+ # Check for duplicate face registration
104
+ for emb in embeddings:
105
+ match_id, score = store.search(emb)
106
+ if match_id:
107
+ match_emp = get_employee(match_id)
108
+ match_name = match_emp["name"] if match_emp else match_id
109
+ logger.warning(f"Registration rejected: face already registered to {match_name} ({match_id})")
110
+ return jsonify({
111
+ "status": "error",
112
+ "message": f"This face is already registered to {match_name} ({match_id})."
113
+ }), 400
114
+
115
+ # Persist employee in DB and embeddings in FAISS
116
+ add_employee(employee_id, name)
117
+ store.add(employee_id, embeddings)
118
+
119
+ logger.info(f"Registered {employee_id} with {len(embeddings)} embedding(s).")
120
+ return jsonify({
121
+ "status": "registered",
122
+ "employee_id": employee_id,
123
+ "name": name,
124
+ "embeddings_stored": len(embeddings),
125
+ })
126
+
127
+
128
+ @app.route("/api/recognize", methods=["POST"])
129
+ def api_recognize():
130
+ """
131
+ Body JSON:
132
+ { frame: base64DataUrl } or { frames: [base64DataUrl, ...] }
133
+ When frames is provided, uses sequence liveness (motion + blink).
134
+
135
+ Response JSON (one of):
136
+ { status: 'success', name, timestamp }
137
+ { status: 'already_marked', name }
138
+ { status: 'spoof', reason, composite }
139
+ { status: 'unknown' }
140
+ { status: 'no_face' }
141
+ """
142
+ data = request.get_json(force=True)
143
+ frame = data.get("frame", "")
144
+ frames = data.get("frames", [])
145
+
146
+ # Prefer frames for sequence liveness when available
147
+ if frames and len(frames) >= 2:
148
+ try:
149
+ face_crops = get_face_crops_from_frames(frames)
150
+ except Exception:
151
+ face_crops = []
152
+ if not face_crops:
153
+ return jsonify({"status": "no_face"})
154
+ # Use latest frame for identity
155
+ try:
156
+ img = decode_image(frames[-1])
157
+ except Exception:
158
+ return jsonify({"status": "no_face"})
159
+ embedding, _ = get_face_embedding(img)
160
+ if embedding is None:
161
+ return jsonify({"status": "no_face"})
162
+ liveness = check_liveness_sequence(face_crops)
163
+ else:
164
+ if not frame:
165
+ return jsonify({"status": "no_face"})
166
+ try:
167
+ img = decode_image(frame)
168
+ except Exception:
169
+ return jsonify({"status": "no_face"})
170
+ embedding, face_crop = get_face_embedding(img)
171
+ if embedding is None:
172
+ return jsonify({"status": "no_face"})
173
+ if face_crop is not None:
174
+ liveness = check_liveness(face_crop)
175
+ else:
176
+ liveness = {"is_live": True}
177
+
178
+ if not liveness.get("is_live", True):
179
+ logger.info(f"Spoof detected (score={liveness.get('score', 0):.4f}, reason={liveness.get('reason', '')})")
180
+ return jsonify({
181
+ "status": "spoof",
182
+ "reason": liveness.get("reason", "Liveness check failed"),
183
+ "scores": liveness.get("scores", {}),
184
+ "composite": liveness.get("score", 0.0),
185
+ })
186
+
187
+ # Identity search
188
+ employee_id, score = store.search(embedding)
189
+ if employee_id is None:
190
+ return jsonify({"status": "unknown"})
191
+
192
+ employee = get_employee(employee_id)
193
+ name = employee["name"] if employee else employee_id
194
+
195
+ result = mark_attendance(employee_id)
196
+
197
+ if result["status"] == "already_marked":
198
+ return jsonify({"status": "already_marked", "name": name})
199
+
200
+ logger.info(f"Attendance marked: {employee_id} ({name}) at {result['timestamp']}")
201
+ return jsonify({
202
+ "status": "success",
203
+ "name": name,
204
+ "employee_id": employee_id,
205
+ "timestamp": result["timestamp"],
206
+ "confidence": round(score, 4),
207
+ })
208
+
209
+
210
+ @app.route("/api/verify-pin", methods=["POST"])
211
+ def api_verify_pin():
212
+ """Verify PIN to unlock Register form for this page. PIN must match REGISTER_PIN (default 3620)."""
213
+ data = request.get_json(force=True)
214
+ pin = (data.get("pin") or "").strip()
215
+ if pin == REGISTER_PIN:
216
+ return jsonify({"status": "ok", "message": "Verified"})
217
+ return jsonify({"status": "error", "message": "Incorrect PIN"}), 403
218
+
219
+
220
+ @app.route("/api/employees", methods=["GET"])
221
+ def api_employees_list():
222
+ employees = get_all_employees()
223
+ return jsonify({"status": "ok", "employees": employees, "count": len(employees)})
224
+
225
+
226
+ @app.route("/api/employees/<employee_id>", methods=["DELETE"])
227
+ def api_employee_delete(employee_id):
228
+ """Delete a registered employee (DB + face index)."""
229
+ if not get_employee(employee_id):
230
+ return jsonify({"status": "error", "message": "Employee not found"}), 404
231
+ store.remove_employee(employee_id)
232
+ if not delete_employee(employee_id):
233
+ return jsonify({"status": "error", "message": "Delete failed"}), 500
234
+ logger.info(f"Deleted employee {employee_id}")
235
+ return jsonify({"status": "ok", "message": "Deleted"})
236
+
237
+
238
+ @app.route("/api/attendance/today", methods=["GET"])
239
+ def api_today_attendance():
240
+ records = get_today_attendance()
241
+ return jsonify({"status": "ok", "records": records, "count": len(records)})
242
+
243
+
244
+ @app.route("/api/health", methods=["GET"])
245
+ def health():
246
+ return jsonify({
247
+ "status": "ok",
248
+ "total_employees_indexed": store.total_vectors,
249
+ })
250
+
251
+
252
+ # ══════════════════════════════════════════════════════════════════════════════
253
+
254
+ if __name__ == "__main__":
255
+ port = int(os.environ.get("PORT", 5000))
256
+ debug = os.environ.get("FLASK_DEBUG", "0") == "1"
257
+ logger.info(f"One Step Greener starting on http://localhost:{port}")
258
+ app.run(host="0.0.0.0", port=port, debug=debug, threaded=True)
hf-space/database/__init__.py ADDED
File without changes
hf-space/database/__pycache__/__init__.cpython-38.pyc ADDED
Binary file (150 Bytes). View file
 
hf-space/database/__pycache__/db.cpython-38.pyc ADDED
Binary file (3.99 kB). View file
 
hf-space/database/constable.db ADDED
Binary file (20.5 kB). View file
 
hf-space/database/db.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CONSTABLE – SQLite database helpers.
3
+ Tables:
4
+ employees – id (TEXT PK), name (TEXT), registered_at (TEXT)
5
+ attendance – id (INTEGER PK), employee_id (TEXT FK), timestamp (TEXT), date (TEXT)
6
+ """
7
+
8
+ import sqlite3
9
+ import os
10
+ from datetime import datetime, date
11
+
12
+ DB_DIR = os.path.join(os.path.dirname(__file__))
13
+ DB_PATH = os.path.join(DB_DIR, "constable.db")
14
+
15
+
16
+ def get_connection():
17
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
18
+ conn.row_factory = sqlite3.Row
19
+ return conn
20
+
21
+
22
+ def init_db():
23
+ """Create tables if they don't exist."""
24
+ os.makedirs(DB_DIR, exist_ok=True)
25
+ conn = get_connection()
26
+ cur = conn.cursor()
27
+ cur.executescript("""
28
+ CREATE TABLE IF NOT EXISTS employees (
29
+ id TEXT PRIMARY KEY,
30
+ name TEXT NOT NULL,
31
+ registered_at TEXT NOT NULL
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS attendance (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ employee_id TEXT NOT NULL,
37
+ timestamp TEXT NOT NULL,
38
+ date TEXT NOT NULL,
39
+ FOREIGN KEY (employee_id) REFERENCES employees(id)
40
+ );
41
+ """)
42
+ conn.commit()
43
+ conn.close()
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Employee helpers
48
+ # ---------------------------------------------------------------------------
49
+
50
+ def add_employee(employee_id: str, name: str) -> bool:
51
+ """Insert or replace an employee record. Returns True on success."""
52
+ conn = get_connection()
53
+ try:
54
+ conn.execute(
55
+ "INSERT OR REPLACE INTO employees (id, name, registered_at) VALUES (?, ?, ?)",
56
+ (employee_id, name, datetime.now().isoformat(timespec="seconds")),
57
+ )
58
+ conn.commit()
59
+ return True
60
+ except Exception as e:
61
+ print(f"[DB] add_employee error: {e}")
62
+ return False
63
+ finally:
64
+ conn.close()
65
+
66
+
67
+ def get_employee(employee_id: str):
68
+ """Return employee row or None."""
69
+ conn = get_connection()
70
+ try:
71
+ row = conn.execute(
72
+ "SELECT * FROM employees WHERE id = ?", (employee_id,)
73
+ ).fetchone()
74
+ return dict(row) if row else None
75
+ finally:
76
+ conn.close()
77
+
78
+
79
+ def get_all_employees():
80
+ conn = get_connection()
81
+ try:
82
+ rows = conn.execute("SELECT * FROM employees ORDER BY registered_at DESC").fetchall()
83
+ return [dict(r) for r in rows]
84
+ finally:
85
+ conn.close()
86
+
87
+
88
+ def delete_employee(employee_id: str) -> bool:
89
+ """Delete an employee and their attendance records. Returns True on success."""
90
+ conn = get_connection()
91
+ try:
92
+ conn.execute("DELETE FROM attendance WHERE employee_id = ?", (employee_id,))
93
+ conn.execute("DELETE FROM employees WHERE id = ?", (employee_id,))
94
+ conn.commit()
95
+ return True
96
+ except Exception as e:
97
+ print(f"[DB] delete_employee error: {e}")
98
+ return False
99
+ finally:
100
+ conn.close()
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Attendance helpers
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def mark_attendance(employee_id: str) -> dict:
108
+ """
109
+ Log attendance for today.
110
+ Returns {'status': 'success'|'already_marked', 'timestamp': ...}
111
+ """
112
+ today = date.today().isoformat()
113
+ conn = get_connection()
114
+ try:
115
+ existing = conn.execute(
116
+ "SELECT id FROM attendance WHERE employee_id = ? AND date = ?",
117
+ (employee_id, today),
118
+ ).fetchone()
119
+ if existing:
120
+ return {"status": "already_marked"}
121
+
122
+ ts = datetime.now().strftime("%I:%M %p")
123
+ conn.execute(
124
+ "INSERT INTO attendance (employee_id, timestamp, date) VALUES (?, ?, ?)",
125
+ (employee_id, ts, today),
126
+ )
127
+ conn.commit()
128
+ return {"status": "success", "timestamp": ts}
129
+ finally:
130
+ conn.close()
131
+
132
+
133
+ def get_today_attendance():
134
+ today = date.today().isoformat()
135
+ conn = get_connection()
136
+ try:
137
+ rows = conn.execute(
138
+ """SELECT a.timestamp, e.id, e.name
139
+ FROM attendance a
140
+ JOIN employees e ON a.employee_id = e.id
141
+ WHERE a.date = ?
142
+ ORDER BY a.id DESC""",
143
+ (today,),
144
+ ).fetchall()
145
+ return [dict(r) for r in rows]
146
+ finally:
147
+ conn.close()
hf-space/database/face_index.faiss ADDED
Binary file (32.8 kB). View file
 
hf-space/database/face_meta.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"0": "tanmay", "1": "tanmay", "2": "tanmay", "3": "tanmay", "4": "tanmay", "5": "sahil", "6": "sahil", "7": "sahil", "8": "sahil", "9": "sahil", "10": "tt", "11": "tt", "12": "tt", "13": "tt", "14": "tt", "15": "EMP-TEST"}
hf-space/models/__init__.py ADDED
File without changes
hf-space/models/__pycache__/__init__.cpython-38.pyc ADDED
Binary file (148 Bytes). View file
 
hf-space/models/__pycache__/anti_spoof.cpython-38.pyc ADDED
Binary file (13.5 kB). View file
 
hf-space/models/__pycache__/embeddings_store.cpython-38.pyc ADDED
Binary file (4.74 kB). View file
 
hf-space/models/__pycache__/face_engine.cpython-38.pyc ADDED
Binary file (4.18 kB). View file
 
hf-space/models/anti_spoof.py ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Multi-Layer Face Anti-Spoofing Engine (DeepFAS-inspired)
3
+ =========================================================
4
+ Design follows the taxonomy of "Deep Learning for Face Anti-Spoofing: A Survey"
5
+ (TPAMI 2022): https://github.com/ZitongYu/DeepFAS
6
+
7
+ Combines hybrid (handcrafted) cues + temporal (motion/blink) to detect
8
+ print, replay, and screen attacks. Each layer scores 0.0–1.0 (1.0 = live).
9
+
10
+ Static layers (single frame)
11
+ ----------------------------
12
+ 1. LBP Texture – Real skin has rich micro-texture; flat media does not.
13
+ 2. Moiré / FFT – Screens emit periodic grid patterns (frequency domain).
14
+ 3. Color Distribution – Real skin: warm HSV, broad hue spread; screens flatter.
15
+ 4. Edge Density – 3D faces yield strong edges; printed photos softer.
16
+ 5. Specular Highlights – Live faces: specular spots; flat media rarely.
17
+ 6. Central Difference – CDCN-inspired (CVPR'20): gradient structure; live skin
18
+ has richer central-difference response than flat prints/screens.
19
+ Ref: https://github.com/ZitongYu/CDCN
20
+
21
+ Temporal (multi-frame)
22
+ ----------------------
23
+ 7. Motion – Frame-to-frame variance (static image → spoof).
24
+ 8. Blink – Eye Aspect Ratio; no blink in sequence → likely photo/video.
25
+ """
26
+
27
+ import logging
28
+ import numpy as np
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # ─── Optional imports ────────────────────────────────────────────────────────
33
+ try:
34
+ from skimage.feature import local_binary_pattern
35
+ SKIMAGE_OK = True
36
+ except ImportError:
37
+ SKIMAGE_OK = False
38
+
39
+ try:
40
+ import cv2
41
+ CV2_OK = True
42
+ except ImportError:
43
+ CV2_OK = False
44
+
45
+ try:
46
+ import mediapipe as mp
47
+ MEDIAPIPE_OK = True
48
+ except (ImportError, TypeError, Exception):
49
+ MEDIAPIPE_OK = False
50
+ mp = None
51
+
52
+
53
+ # ═══════════════════════════════════════════════════════════════════════════════
54
+ # Tunable thresholds / weights
55
+ # ═══════════════════════════════════════════════════════════════════════════════
56
+ COMPOSITE_THRESHOLD = 0.52 # below this → spoof (stricter: block images/screens)
57
+
58
+ WEIGHTS = {
59
+ "lbp": 0.20,
60
+ "moire": 0.20,
61
+ "color": 0.18,
62
+ "edge": 0.12,
63
+ "specular": 0.10,
64
+ "cdc": 0.20, # Central Difference (CDCN-inspired)
65
+ }
66
+
67
+ # Per-layer knobs
68
+ LBP_RADIUS = 1
69
+ LBP_N_POINTS = 8
70
+ LBP_VAR_LIVE_MIN = 0.0025 # higher bar (photos are flatter)
71
+
72
+ MOIRE_HIGH_RATIO_MAX = 0.32 # stricter for screens # high-freq energy ratio above this → likely screen
73
+
74
+ COLOR_SAT_LIVE_MIN = 35.0 # real skin has more saturation
75
+ COLOR_HUE_STD_MIN = 14.0 # more hue spread for live skin
76
+
77
+ EDGE_RATIO_LIVE_MIN = 0.05 # printed photos often softer
78
+ EDGE_RATIO_MAX = 0.28
79
+
80
+ SPECULAR_BRIGHT_THRES = 228
81
+ SPECULAR_RATIO_MIN = 0.0025
82
+
83
+ # Central Difference (CDCN-inspired): gradient structure variance
84
+ CDC_VAR_LIVE_MIN = 8.0 # below this → flat → spoof (tuned for 64x64 diff map)
85
+
86
+ # Sequence: motion and blink
87
+ MOTION_VAR_MIN = 2.5e-5 # frame-to-frame variance below this → static → spoof
88
+ MIN_FRAMES_FOR_MOTION = 3
89
+ EAR_BLINK_THRESHOLD = 0.22 # EAR below this = blink
90
+ EAR_MIN_FRAMES = 4
91
+ BLINK_REQUIRED = True # require at least one blink in sequence
92
+
93
+
94
+ # ═══════════════════════════════════════════════════════════════════════════════
95
+ # Helpers
96
+ # ═══════════════════════════════════════════════════════════════════════════════
97
+
98
+ def _to_uint8(img: np.ndarray) -> np.ndarray:
99
+ if img.dtype != np.uint8:
100
+ return (img * 255).clip(0, 255).astype(np.uint8)
101
+ return img
102
+
103
+
104
+ def _to_gray(img: np.ndarray) -> np.ndarray:
105
+ img = _to_uint8(img)
106
+ if img.ndim == 3:
107
+ if CV2_OK:
108
+ code = cv2.COLOR_RGBA2GRAY if img.shape[2] == 4 else cv2.COLOR_RGB2GRAY
109
+ return cv2.cvtColor(img, code)
110
+ return (0.299 * img[..., 0] + 0.587 * img[..., 1] + 0.114 * img[..., 2]).astype(np.uint8)
111
+ return img
112
+
113
+
114
+ def _to_hsv(img: np.ndarray) -> np.ndarray:
115
+ img = _to_uint8(img)
116
+ if img.ndim == 2:
117
+ img = np.stack([img, img, img], axis=-1)
118
+ if img.shape[2] == 4:
119
+ img = img[..., :3]
120
+ if CV2_OK:
121
+ return cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
122
+ # Minimal fallback – enough for heuristic scoring
123
+ r, g, b = img[..., 0].astype(float), img[..., 1].astype(float), img[..., 2].astype(float)
124
+ mx = np.maximum(np.maximum(r, g), b)
125
+ mn = np.minimum(np.minimum(r, g), b)
126
+ diff = mx - mn + 1e-10
127
+ h = np.where(mx == r, 60 * ((g - b) / diff) % 360,
128
+ np.where(mx == g, 60 * ((b - r) / diff) + 120,
129
+ 60 * ((r - g) / diff) + 240))
130
+ s = np.where(mx == 0, 0, (diff / (mx + 1e-10)) * 255)
131
+ v = mx
132
+ return np.stack([h / 2, s, v], axis=-1).astype(np.uint8)
133
+
134
+
135
+ # ═══════════════════════════════════════════════════════════════════════════════
136
+ # Individual scoring layers (each returns 0.0 – 1.0, higher = more live-like)
137
+ # ═══════════════════════════════════════════════════════════════════════════════
138
+
139
+ def _score_lbp(gray: np.ndarray) -> float:
140
+ """LBP histogram variance — rich texture ⇒ high score."""
141
+ if not SKIMAGE_OK:
142
+ return 0.5 # neutral fallback
143
+ lbp = local_binary_pattern(gray, LBP_N_POINTS, LBP_RADIUS, method="uniform")
144
+ n_bins = LBP_N_POINTS + 2
145
+ hist, _ = np.histogram(lbp.ravel(), bins=n_bins, range=(0, n_bins), density=True)
146
+ var = float(np.var(hist))
147
+ # Map variance to 0-1. Anything ≥ 2× the threshold is fully live.
148
+ score = min(1.0, var / (LBP_VAR_LIVE_MIN * 2))
149
+ return score
150
+
151
+
152
+ def _score_moire(gray: np.ndarray) -> float:
153
+ """
154
+ FFT high-frequency energy ratio.
155
+ Screens produce periodic moiré patterns that concentrate energy at
156
+ specific high frequencies. A high ratio → likely screen → low score.
157
+ """
158
+ f = np.fft.fft2(gray.astype(np.float32))
159
+ fshift = np.fft.fftshift(f)
160
+ magnitude = np.abs(fshift)
161
+
162
+ rows, cols = gray.shape
163
+ crow, ccol = rows // 2, cols // 2
164
+ # Define "low frequency" as the central 30% of the spectrum
165
+ r = int(min(rows, cols) * 0.15)
166
+ mask_low = np.zeros_like(magnitude, dtype=bool)
167
+ y, x = np.ogrid[:rows, :cols]
168
+ mask_low[((y - crow)**2 + (x - ccol)**2) <= r**2] = True
169
+
170
+ total = magnitude.sum() + 1e-10
171
+ low_energy = magnitude[mask_low].sum()
172
+ high_ratio = 1.0 - (low_energy / total)
173
+
174
+ # high_ratio close to 1 means most energy is high-freq → moiré likely
175
+ if high_ratio >= MOIRE_HIGH_RATIO_MAX:
176
+ score = max(0.0, 1.0 - (high_ratio - MOIRE_HIGH_RATIO_MAX) / 0.3)
177
+ else:
178
+ score = 1.0
179
+ return float(score)
180
+
181
+
182
+ def _score_color(hsv: np.ndarray) -> float:
183
+ """
184
+ HSV colour analysis.
185
+ Real skin has warm hue, moderate-to-high saturation, and broad hue spread.
186
+ Screen reproductions tend to have shifted hue and flat saturation.
187
+ """
188
+ h, s, v = hsv[..., 0].astype(float), hsv[..., 1].astype(float), hsv[..., 2].astype(float)
189
+
190
+ mean_sat = float(np.mean(s))
191
+ hue_std = float(np.std(h))
192
+
193
+ sat_score = min(1.0, mean_sat / (COLOR_SAT_LIVE_MIN * 2.0))
194
+ hue_score = min(1.0, hue_std / (COLOR_HUE_STD_MIN * 2.0))
195
+
196
+ return 0.5 * sat_score + 0.5 * hue_score
197
+
198
+
199
+ def _score_edge(gray: np.ndarray) -> float:
200
+ """
201
+ Canny edge density.
202
+ 3-D faces yield strong depth/shadow edges; printed photos are softer.
203
+ """
204
+ if not CV2_OK:
205
+ return 0.5
206
+ edges = cv2.Canny(gray, 50, 150)
207
+ ratio = float(np.count_nonzero(edges)) / max(edges.size, 1)
208
+ ratio = min(ratio, EDGE_RATIO_MAX)
209
+ score = min(1.0, ratio / (EDGE_RATIO_LIVE_MIN * 2.0))
210
+ return score
211
+
212
+
213
+ def _score_specular(hsv: np.ndarray) -> float:
214
+ """
215
+ Specular highlight detection.
216
+ Real 3D faces reflect light → bright spots on nose / forehead.
217
+ Flat media rarely reproduces these.
218
+ """
219
+ v = hsv[..., 2]
220
+ bright = np.count_nonzero(v >= SPECULAR_BRIGHT_THRES)
221
+ total = max(v.size, 1)
222
+ ratio = bright / total
223
+ score = min(1.0, ratio / (SPECULAR_RATIO_MIN * 3.0))
224
+ return float(score)
225
+
226
+
227
+ def _score_central_difference(gray: np.ndarray) -> float:
228
+ """
229
+ Central-difference (CDCN-inspired) cue: gradient structure.
230
+ CDCN (CVPR'20) uses central difference convolution to capture fine-grained
231
+ structure; live skin has richer local gradient variance than flat prints.
232
+ We approximate with Laplacian response variance on the face crop.
233
+ Ref: https://github.com/ZitongYu/CDCN
234
+ """
235
+ if gray.size < 100:
236
+ return 0.5
237
+ g = _to_uint8(gray).astype(np.float32)
238
+ if CV2_OK:
239
+ # Laplacian: center-weighted difference from neighbors (CDCN-like)
240
+ lap = cv2.Laplacian(g, cv2.CV_32F, ksize=3)
241
+ else:
242
+ # 3x3 Laplacian via numpy: center - (L+R+U+D)
243
+ h, w = g.shape
244
+ c = g[1:-1, 1:-1]
245
+ lap = 4.0 * c - (g[:-2, 1:-1] + g[2:, 1:-1] + g[1:-1, :-2] + g[1:-1, 2:])
246
+ lap = np.pad(lap, 1, mode="edge").astype(np.float32)
247
+ var = float(np.var(lap))
248
+ score = min(1.0, var / (CDC_VAR_LIVE_MIN * 4.0)) if CDC_VAR_LIVE_MIN else 1.0
249
+ return score
250
+
251
+
252
+ # ═══════════════════════════════════════════════════════════════════════════════
253
+ # Public API
254
+ # ═══════════════════════════════════════════════════════════════════════════════
255
+
256
+ def check_liveness(face_array: np.ndarray) -> dict:
257
+ """
258
+ Parameters
259
+ ----------
260
+ face_array : np.ndarray
261
+ Cropped face region (RGB, uint8 or float32, any resolution).
262
+
263
+ Returns
264
+ -------
265
+ dict
266
+ is_live : bool
267
+ score : float (composite 0-1, higher = more live)
268
+ scores : dict (per-layer breakdown)
269
+ reason : str (human-readable reason if spoof)
270
+ method : str
271
+ """
272
+ if face_array is None or face_array.size == 0:
273
+ return {
274
+ "is_live": False, "score": 0.0,
275
+ "scores": {}, "reason": "Empty face input", "method": "empty",
276
+ }
277
+
278
+ gray = _to_gray(face_array)
279
+ hsv = _to_hsv(face_array)
280
+
281
+ # Run all layers (including CDCN-inspired central difference)
282
+ layer_scores = {
283
+ "lbp": _score_lbp(gray),
284
+ "moire": _score_moire(gray),
285
+ "color": _score_color(hsv),
286
+ "edge": _score_edge(gray),
287
+ "specular": _score_specular(hsv),
288
+ "cdc": _score_central_difference(gray),
289
+ }
290
+
291
+ # Weighted composite
292
+ composite = sum(WEIGHTS[k] * layer_scores[k] for k in WEIGHTS)
293
+ composite = round(composite, 4)
294
+
295
+ is_live = composite >= COMPOSITE_THRESHOLD
296
+
297
+ # Determine the weakest signal for the reason string
298
+ reason = ""
299
+ if not is_live:
300
+ weakest = min(layer_scores, key=lambda k: layer_scores[k])
301
+ reason_map = {
302
+ "lbp": "Flat texture — possible printed photo",
303
+ "moire": "Screen moiré pattern — possible video / phone replay",
304
+ "color": "Abnormal colour — possible screen reproduction",
305
+ "edge": "Low edge detail — possible printed photo",
306
+ "specular": "No specular highlights — possible flat surface",
307
+ "cdc": "Flat gradient structure — possible photo or screen (CDCN cue)",
308
+ }
309
+ reason = reason_map.get(weakest, "Liveness check failed")
310
+
311
+ logger.info(
312
+ f"[AntiSpoof] composite={composite:.3f} live={is_live} "
313
+ f"layers={{{', '.join(f'{k}={v:.3f}' for k, v in layer_scores.items())}}}"
314
+ )
315
+
316
+ return {
317
+ "is_live": is_live,
318
+ "score": composite,
319
+ "scores": {k: round(v, 4) for k, v in layer_scores.items()},
320
+ "reason": reason,
321
+ "method": "multi_layer_v1",
322
+ }
323
+
324
+
325
+ # ═══════════════════════════════════════════════════════════════════════════════
326
+ # Motion and blink (sequence liveness)
327
+ # ═══════════════════════════════════════════════════════════════════════════════
328
+
329
+ def _motion_score(face_arrays: list) -> float:
330
+ """
331
+ Frame-to-frame variance in face region. Static image → near-zero variance → 0.
332
+ Returns 0.0–1.0 (1.0 = enough motion).
333
+ """
334
+ if not face_arrays or len(face_arrays) < MIN_FRAMES_FOR_MOTION:
335
+ return 0.5 # neutral if too few frames
336
+ grays = []
337
+ for arr in face_arrays:
338
+ if arr is None or arr.size == 0:
339
+ continue
340
+ g = _to_gray(arr)
341
+ if g.size < 100:
342
+ continue
343
+ # Resize to fixed size for consistent variance
344
+ if CV2_OK:
345
+ g = cv2.resize(g, (64, 64), interpolation=cv2.INTER_AREA)
346
+ else:
347
+ from PIL import Image
348
+ g = np.array(Image.fromarray(g).resize((64, 64), Image.Resampling.LANCZOS))
349
+ grays.append(g.astype(np.float32))
350
+ if len(grays) < 2:
351
+ return 0.5
352
+ variances = []
353
+ for i in range(1, len(grays)):
354
+ diff = np.abs(grays[i] - grays[i - 1])
355
+ variances.append(float(np.mean(diff ** 2)))
356
+ mean_var = np.mean(variances) if variances else 0.0
357
+ score = min(1.0, mean_var / (MOTION_VAR_MIN * 10)) if MOTION_VAR_MIN else 1.0
358
+ return float(score)
359
+
360
+
361
+ def _ear_from_landmarks(landmarks, idx1, idx2, idx3, idx4, idx5, idx6):
362
+ """EAR = (||p2-p6|| + ||p3-p5||) / (2*||p1-p4||)."""
363
+ p1 = np.array([landmarks[idx1].x, landmarks[idx1].y])
364
+ p2 = np.array([landmarks[idx2].x, landmarks[idx2].y])
365
+ p3 = np.array([landmarks[idx3].x, landmarks[idx3].y])
366
+ p4 = np.array([landmarks[idx4].x, landmarks[idx4].y])
367
+ p5 = np.array([landmarks[idx5].x, landmarks[idx5].y])
368
+ p6 = np.array([landmarks[idx6].x, landmarks[idx6].y])
369
+ v1 = np.linalg.norm(p2 - p6)
370
+ v2 = np.linalg.norm(p3 - p5)
371
+ h = 2 * np.linalg.norm(p1 - p4)
372
+ if h < 1e-6:
373
+ return 0.3
374
+ return (v1 + v2) / h
375
+
376
+
377
+ # MediaPipe Face Mesh eye indices: left 33,133,160,158,153,144; right 362,263,385,387,373,380
378
+ _LEFT_EYE = (33, 133, 160, 158, 153, 144)
379
+ _RIGHT_EYE = (362, 263, 385, 387, 373, 380)
380
+
381
+ _face_mesh = None
382
+
383
+ def _get_face_mesh():
384
+ global _face_mesh
385
+ if _face_mesh is None and MEDIAPIPE_OK:
386
+ _face_mesh = mp.solutions.face_mesh.FaceMesh(
387
+ static_image_mode=True,
388
+ max_num_faces=1,
389
+ refine_landmarks=True,
390
+ min_detection_confidence=0.5,
391
+ )
392
+ return _face_mesh
393
+
394
+
395
+ def _blink_detected(face_arrays: list) -> tuple:
396
+ """
397
+ Returns (has_blink: bool, ear_scores: list). Uses EAR; below EAR_BLINK_THRESHOLD = blink.
398
+ """
399
+ if not MEDIAPIPE_OK or len(face_arrays) < EAR_MIN_FRAMES:
400
+ return True, [] # no blink required if we can't check
401
+ mesh = _get_face_mesh()
402
+ if mesh is None:
403
+ return True, []
404
+ ear_scores = []
405
+ for arr in face_arrays:
406
+ if arr is None or arr.size == 0:
407
+ continue
408
+ img = _to_uint8(arr)
409
+ if img.ndim == 2:
410
+ img = np.stack([img, img, img], axis=-1)
411
+ if img.shape[2] == 4:
412
+ img = img[..., :3]
413
+ results = mesh.process(img)
414
+ if not results.multi_face_landmarks:
415
+ continue
416
+ lm = results.multi_face_landmarks[0]
417
+ ear_left = _ear_from_landmarks(lm.landmark, *_LEFT_EYE)
418
+ ear_right = _ear_from_landmarks(lm.landmark, *_RIGHT_EYE)
419
+ ear = (ear_left + ear_right) / 2.0
420
+ ear_scores.append(ear)
421
+ if len(ear_scores) < EAR_MIN_FRAMES:
422
+ return True, ear_scores
423
+ has_blink = any(e < EAR_BLINK_THRESHOLD for e in ear_scores)
424
+ return has_blink, ear_scores
425
+
426
+
427
+ def check_liveness_sequence(face_arrays: list) -> dict:
428
+ """
429
+ Multi-frame liveness: single-frame composite + motion + blink.
430
+ face_arrays: list of cropped face numpy arrays (RGB).
431
+ Returns same shape as check_liveness; is_live False if any check fails.
432
+ """
433
+ if not face_arrays:
434
+ return {
435
+ "is_live": False, "score": 0.0,
436
+ "scores": {}, "reason": "No frames", "method": "sequence",
437
+ }
438
+ # Single-frame checks on the latest frame
439
+ latest = face_arrays[-1] if face_arrays else None
440
+ single = check_liveness(latest) if latest is not None and latest.size > 0 else {
441
+ "is_live": False, "score": 0.0, "scores": {}, "reason": "No face", "method": "single",
442
+ }
443
+ if not single["is_live"]:
444
+ return single
445
+
446
+ # Motion: require some frame-to-frame change (reject static photo)
447
+ motion = _motion_score(face_arrays)
448
+ if motion < 0.15: # very low motion → likely static image
449
+ logger.info(f"[AntiSpoof] sequence: motion too low ({motion:.4f}) → spoof")
450
+ return {
451
+ "is_live": False,
452
+ "score": round(single["score"] * 0.5, 4),
453
+ "scores": {**single.get("scores", {}), "motion": round(motion, 4)},
454
+ "reason": "No motion detected — possible photo or screen.",
455
+ "method": "sequence",
456
+ }
457
+
458
+ # Blink: require at least one blink in sequence (reject photo/video without blink)
459
+ has_blink, ear_scores = _blink_detected(face_arrays)
460
+ if BLINK_REQUIRED and len(ear_scores) >= EAR_MIN_FRAMES and not has_blink:
461
+ logger.info(f"[AntiSpoof] sequence: no blink in {len(ear_scores)} frames → spoof")
462
+ return {
463
+ "is_live": False,
464
+ "score": round(single["score"] * 0.6, 4),
465
+ "scores": {**single.get("scores", {}), "blink": 0.0},
466
+ "reason": "No blink detected — please look at the camera and blink naturally.",
467
+ "method": "sequence",
468
+ }
469
+
470
+ return {
471
+ "is_live": True,
472
+ "score": single["score"],
473
+ "scores": single.get("scores", {}),
474
+ "reason": "",
475
+ "method": "sequence",
476
+ }
hf-space/models/embeddings_store.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CONSTABLE – FAISS embedding store for face vectors.
3
+ Face embeddings (512-d float32 from FaceNet/InceptionResnetV1) are stored in a
4
+ flat L2 index. A parallel JSON sidecar maps FAISS integer IDs → employee IDs.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ import numpy as np
10
+
11
+ try:
12
+ import faiss
13
+ FAISS_AVAILABLE = True
14
+ except ImportError:
15
+ FAISS_AVAILABLE = False
16
+ print("[EmbeddingStore] faiss-cpu not installed – using brute-force fallback.")
17
+
18
+ DB_DIR = os.path.join(os.path.dirname(__file__), "..", "database")
19
+ INDEX_PATH = os.path.join(DB_DIR, "face_index.faiss")
20
+ META_PATH = os.path.join(DB_DIR, "face_meta.json")
21
+
22
+ EMBEDDING_DIM = 512
23
+ SIMILARITY_THRESHOLD = 0.85 # cosine similarity threshold (after L2-normalisation)
24
+
25
+
26
+ class EmbeddingStore:
27
+ def __init__(self):
28
+ os.makedirs(DB_DIR, exist_ok=True)
29
+ self._load()
30
+
31
+ # ------------------------------------------------------------------
32
+ # Internal helpers
33
+ # ------------------------------------------------------------------
34
+
35
+ def _load(self):
36
+ if FAISS_AVAILABLE and os.path.exists(INDEX_PATH) and os.path.exists(META_PATH):
37
+ self.index = faiss.read_index(INDEX_PATH)
38
+ with open(META_PATH) as f:
39
+ self.meta = json.load(f) # {str(faiss_id): employee_id}
40
+ else:
41
+ if FAISS_AVAILABLE:
42
+ self.index = faiss.IndexFlatIP(EMBEDDING_DIM) # inner product on L2-normed vecs = cosine
43
+ else:
44
+ self.index = None
45
+ self.meta = {}
46
+
47
+ def _save(self):
48
+ if FAISS_AVAILABLE and self.index is not None:
49
+ faiss.write_index(self.index, INDEX_PATH)
50
+ with open(META_PATH, "w") as f:
51
+ json.dump(self.meta, f)
52
+
53
+ @staticmethod
54
+ def _normalise(vec: np.ndarray) -> np.ndarray:
55
+ norm = np.linalg.norm(vec)
56
+ return vec / norm if norm > 1e-10 else vec
57
+
58
+ # ------------------------------------------------------------------
59
+ # Public API
60
+ # ------------------------------------------------------------------
61
+
62
+ def add(self, employee_id: str, embeddings: list):
63
+ """Add one or more embeddings for an employee."""
64
+ for emb in embeddings:
65
+ vec = self._normalise(np.array(emb, dtype=np.float32)).reshape(1, -1)
66
+ if FAISS_AVAILABLE and self.index is not None:
67
+ faiss_id = self.index.ntotal
68
+ self.index.add(vec)
69
+ self.meta[str(faiss_id)] = employee_id
70
+ else:
71
+ # Brute-force fallback: store as list in meta
72
+ faiss_id = len(self.meta)
73
+ self.meta[str(faiss_id)] = {"id": employee_id, "vec": vec.tolist()[0]}
74
+ self._save()
75
+
76
+ def search(self, embedding: np.ndarray, top_k: int = 1):
77
+ """
78
+ Returns (employee_id, similarity_score) or (None, 0.0) if no match.
79
+ """
80
+ vec = self._normalise(np.array(embedding, dtype=np.float32)).reshape(1, -1)
81
+
82
+ if FAISS_AVAILABLE and self.index is not None and self.index.ntotal > 0:
83
+ distances, indices = self.index.search(vec, top_k)
84
+ best_idx = int(indices[0][0])
85
+ best_score = float(distances[0][0])
86
+ if best_score >= SIMILARITY_THRESHOLD and best_idx != -1:
87
+ employee_id = self.meta.get(str(best_idx))
88
+ return employee_id, best_score
89
+ return None, best_score
90
+
91
+ # Brute-force fallback
92
+ best_score = -1.0
93
+ best_id = None
94
+ for key, val in self.meta.items():
95
+ if isinstance(val, dict):
96
+ stored_vec = np.array(val["vec"], dtype=np.float32)
97
+ score = float(np.dot(vec.flatten(), stored_vec))
98
+ if score > best_score:
99
+ best_score = score
100
+ best_id = val["id"]
101
+ if best_score >= SIMILARITY_THRESHOLD:
102
+ return best_id, best_score
103
+ return None, best_score
104
+
105
+ def remove_employee(self, employee_id: str):
106
+ """Remove all vectors for an employee (requires index rebuild)."""
107
+ if not FAISS_AVAILABLE or self.index is None:
108
+ self.meta = {k: v for k, v in self.meta.items()
109
+ if not (isinstance(v, dict) and v.get("id") == employee_id)}
110
+ self._save()
111
+ return
112
+
113
+ # Collect surviving entries
114
+ survivors = [(k, v) for k, v in self.meta.items() if v != employee_id]
115
+ new_index = faiss.IndexFlatIP(EMBEDDING_DIM)
116
+ new_meta = {}
117
+
118
+ # We can't retrieve raw vectors from IndexFlatIP after the fact,
119
+ # so we rebuild from scratch using stored reconstructed vectors.
120
+ # (IndexFlatIP supports reconstruct)
121
+ for old_key, emp_id in self.meta.items():
122
+ if emp_id == employee_id:
123
+ continue
124
+ vec = np.zeros((1, EMBEDDING_DIM), dtype=np.float32)
125
+ self.index.reconstruct(int(old_key), vec.reshape(-1))
126
+ new_id = new_index.ntotal
127
+ new_index.add(vec)
128
+ new_meta[str(new_id)] = emp_id
129
+
130
+ self.index = new_index
131
+ self.meta = new_meta
132
+ self._save()
133
+
134
+ @property
135
+ def total_vectors(self):
136
+ if FAISS_AVAILABLE and self.index is not None:
137
+ return self.index.ntotal
138
+ return sum(1 for v in self.meta.values() if isinstance(v, dict))
hf-space/models/face_engine.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CONSTABLE – Face detection and recognition engine.
3
+ Uses:
4
+ • MTCNN – fast face detection & alignment
5
+ • InceptionResnetV1 (pretrained='vggface2') – 512-d face embeddings
6
+ """
7
+
8
+ import io
9
+ import base64
10
+ import logging
11
+ import numpy as np
12
+ from PIL import Image
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # ─── Lazy imports so the app starts even if GPU is not available ───────────
17
+ try:
18
+ from facenet_pytorch import MTCNN, InceptionResnetV1
19
+ import torch
20
+ FACENET_OK = True
21
+ except ImportError:
22
+ FACENET_OK = False
23
+ logger.warning("facenet-pytorch not installed – face recognition disabled.")
24
+
25
+ try:
26
+ import cv2
27
+ CV2_OK = True
28
+ except ImportError:
29
+ CV2_OK = False
30
+
31
+
32
+ DEVICE = "cpu"
33
+ if FACENET_OK:
34
+ try:
35
+ import torch
36
+ if torch.cuda.is_available():
37
+ DEVICE = "cuda"
38
+ except Exception:
39
+ pass
40
+
41
+ _mtcnn = None
42
+ _resnet = None
43
+
44
+
45
+ def _get_models():
46
+ global _mtcnn, _resnet
47
+ if _mtcnn is None:
48
+ _mtcnn = MTCNN(
49
+ image_size=160,
50
+ margin=20,
51
+ min_face_size=40,
52
+ thresholds=[0.6, 0.7, 0.7],
53
+ factor=0.709,
54
+ post_process=True,
55
+ keep_all=False,
56
+ device=DEVICE,
57
+ )
58
+ if _resnet is None:
59
+ _resnet = InceptionResnetV1(pretrained="vggface2").eval().to(DEVICE)
60
+ return _mtcnn, _resnet
61
+
62
+
63
+ # ─── Public API ────────────────────────────────────────────────────────────
64
+
65
+ def decode_image(data_url: str) -> Image.Image:
66
+ """Convert a base64 data-URL to a PIL Image (RGB)."""
67
+ if "," in data_url:
68
+ data_url = data_url.split(",", 1)[1]
69
+ raw = base64.b64decode(data_url)
70
+ img = Image.open(io.BytesIO(raw)).convert("RGB")
71
+ return img
72
+
73
+
74
+ def get_face_embedding(pil_image: Image.Image):
75
+ """
76
+ Detect the largest face and return its 512-d embedding as a numpy array.
77
+ Returns (embedding: np.ndarray, face_crop: np.ndarray) or (None, None).
78
+ """
79
+ if not FACENET_OK:
80
+ return None, None
81
+
82
+ mtcnn, resnet = _get_models()
83
+
84
+ try:
85
+ # MTCNN returns aligned face tensor (or None)
86
+ face_tensor, prob = mtcnn(pil_image, return_prob=True)
87
+ except Exception as e:
88
+ logger.debug(f"MTCNN error: {e}")
89
+ return None, None
90
+
91
+ if face_tensor is None:
92
+ return None, None
93
+
94
+ # Get the face crop as numpy for anti-spoofing
95
+ boxes, _ = mtcnn.detect(pil_image)
96
+ face_crop = None
97
+ if boxes is not None and len(boxes) > 0:
98
+ b = boxes[0].astype(int)
99
+ arr = np.array(pil_image)
100
+ x1, y1, x2, y2 = max(0, b[0]), max(0, b[1]), b[2], b[3]
101
+ face_crop = arr[y1:y2, x1:x2]
102
+
103
+ import torch
104
+ with torch.no_grad():
105
+ embedding = resnet(face_tensor.unsqueeze(0).to(DEVICE))
106
+
107
+ return embedding.squeeze().cpu().numpy(), face_crop
108
+
109
+
110
+ def get_embeddings_from_frames(data_urls: list):
111
+ """
112
+ Process a list of base64 frame data-URLs.
113
+ Returns list of valid 512-d embeddings (may be empty).
114
+ """
115
+ embeddings = []
116
+ for url in data_urls:
117
+ try:
118
+ img = decode_image(url)
119
+ emb, _ = get_face_embedding(img)
120
+ if emb is not None:
121
+ embeddings.append(emb.tolist())
122
+ except Exception as e:
123
+ logger.debug(f"Frame processing error: {e}")
124
+ return embeddings
125
+
126
+
127
+ def get_embeddings_and_crops_from_frames(data_urls: list):
128
+ """
129
+ Process a list of base64 frame data-URLs.
130
+ Returns (embeddings: list of 512-d lists, face_crops: list of np.ndarray or None).
131
+ face_crops[i] is the face crop for frame i (None if no face in that frame).
132
+ """
133
+ embeddings = []
134
+ crops = []
135
+ for url in data_urls:
136
+ try:
137
+ img = decode_image(url)
138
+ emb, face_crop = get_face_embedding(img)
139
+ if emb is not None:
140
+ embeddings.append(emb.tolist())
141
+ crops.append(face_crop)
142
+ else:
143
+ crops.append(None)
144
+ except Exception as e:
145
+ logger.debug(f"Frame processing error: {e}")
146
+ crops.append(None)
147
+ return embeddings, crops
148
+
149
+
150
+ def get_face_crops_from_frames(data_urls: list):
151
+ """
152
+ Get face crops only from a list of base64 frame data-URLs (for liveness sequence).
153
+ Returns list of np.ndarray (face crops); frames with no face are omitted.
154
+ """
155
+ _, crops = get_embeddings_and_crops_from_frames(data_urls)
156
+ return [c for c in crops if c is not None and c.size > 0]
hf-space/requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ flask>=2.3.0
2
+ torch>=2.0.0
3
+ torchvision>=0.15.0
4
+ facenet-pytorch>=2.5.2
5
+ faiss-cpu>=1.7.4
6
+ numpy>=1.24.0
7
+ Pillow>=10.0.0
8
+ opencv-python-headless>=4.8.0
9
+ scikit-image>=0.21.0
10
+ mediapipe>=0.10.0
hf-space/static/css/style.css ADDED
@@ -0,0 +1,776 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ One Step Greener — Design System
3
+ Minimal, green-accent UI (Uniqlo-inspired). Waste management / sustainability.
4
+ ═══════════════════════════════════════════════════════════════════════════ */
5
+
6
+ /* ── Tokens ──────────────────────────────────────────────────────────────── */
7
+ :root {
8
+ --color-bg: #f8f9f7;
9
+ --color-surface: #ffffff;
10
+ --color-surface-2: #f0f2ef;
11
+ --color-border: #e8ebe6;
12
+ --color-primary: #2d6a4f;
13
+ --color-primary-light: #40916c;
14
+ --color-primary-dim: rgba(45, 106, 79, 0.12);
15
+ --color-danger: #c1121f;
16
+ --color-warn: #b08968;
17
+ --color-text: #1b1b1b;
18
+ --color-text-muted: #5c5c5c;
19
+ --color-text-subtle: #8d8d8d;
20
+
21
+ --radius-sm: 6px;
22
+ --radius-md: 10px;
23
+ --radius-lg: 14px;
24
+ --radius-full: 9999px;
25
+
26
+ --shadow-subtle: 0 1px 3px rgba(0, 0, 0, 0.06);
27
+ --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.06);
28
+
29
+ --transition: 0.2s ease;
30
+
31
+ --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
32
+ --max-w: 480px;
33
+ }
34
+
35
+ /* ── Reset ───────────────────────────────────────────────────────────────── */
36
+ *,
37
+ *::before,
38
+ *::after {
39
+ box-sizing: border-box;
40
+ margin: 0;
41
+ padding: 0;
42
+ }
43
+
44
+ html {
45
+ height: 100%;
46
+ -webkit-text-size-adjust: 100%;
47
+ }
48
+
49
+ body {
50
+ min-height: 100%;
51
+ background: var(--color-bg);
52
+ color: var(--color-text);
53
+ font-family: var(--font);
54
+ font-size: 16px;
55
+ line-height: 1.5;
56
+ -webkit-font-smoothing: antialiased;
57
+ }
58
+
59
+ /* ── Shell ───────────────────────────────────────────────────────────────── */
60
+ .app-shell {
61
+ min-height: 100dvh;
62
+ max-width: var(--max-w);
63
+ margin: 0 auto;
64
+ padding: 24px 20px 48px;
65
+ display: flex;
66
+ flex-direction: column;
67
+ }
68
+
69
+ /* ── Top bar ─────────────────────────────────────────────────────────────── */
70
+ .top-bar {
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: space-between;
74
+ padding: 0 0 16px;
75
+ font-size: 12px;
76
+ letter-spacing: 0.06em;
77
+ color: var(--color-text-muted);
78
+ border-bottom: 1px solid var(--color-border);
79
+ margin-bottom: 40px;
80
+ }
81
+
82
+ .top-bar a {
83
+ line-height: 0;
84
+ transition: opacity var(--transition);
85
+ }
86
+
87
+ .top-bar a:hover {
88
+ opacity: 0.7;
89
+ }
90
+
91
+ .top-bar-logo-wrap {
92
+ display: flex;
93
+ align-items: center;
94
+ text-decoration: none;
95
+ background: transparent;
96
+ }
97
+
98
+ .top-bar-logo {
99
+ height: 32px;
100
+ width: auto;
101
+ max-width: 160px;
102
+ object-fit: contain;
103
+ display: block;
104
+ background: transparent;
105
+ }
106
+
107
+ .top-bar-logo-sm {
108
+ height: 26px;
109
+ max-width: 120px;
110
+ background: transparent;
111
+ }
112
+
113
+ .clock {
114
+ font-variant-numeric: tabular-nums;
115
+ font-size: 13px;
116
+ font-weight: 500;
117
+ color: var(--color-primary);
118
+ letter-spacing: 0.05em;
119
+ }
120
+
121
+ /* ── Typography ──────────────────────────────────────────────────────────── */
122
+ h1 {
123
+ font-size: clamp(26px, 6vw, 36px);
124
+ font-weight: 600;
125
+ line-height: 1.2;
126
+ letter-spacing: -0.02em;
127
+ color: var(--color-text);
128
+ }
129
+
130
+ h2 {
131
+ font-size: 16px;
132
+ font-weight: 600;
133
+ letter-spacing: 0.02em;
134
+ color: var(--color-text);
135
+ }
136
+
137
+ h3 {
138
+ font-size: 12px;
139
+ font-weight: 600;
140
+ letter-spacing: 0.08em;
141
+ text-transform: uppercase;
142
+ margin-top: 8px;
143
+ color: var(--color-text);
144
+ }
145
+
146
+ p {
147
+ color: var(--color-text-muted);
148
+ font-size: 14px;
149
+ }
150
+
151
+ /* ── Grid menu (dashboard) ───────────────────────────────────────────────── */
152
+ .grid-menu {
153
+ display: grid;
154
+ grid-template-columns: 1fr 1fr;
155
+ gap: 14px;
156
+ margin-top: 24px;
157
+ }
158
+
159
+ .card {
160
+ display: flex;
161
+ flex-direction: column;
162
+ align-items: flex-start;
163
+ gap: 6px;
164
+ padding: 24px 18px;
165
+ background: var(--color-surface);
166
+ border: 1px solid var(--color-border);
167
+ border-radius: var(--radius-lg);
168
+ text-decoration: none;
169
+ color: var(--color-text);
170
+ cursor: pointer;
171
+ transition: border-color var(--transition), box-shadow var(--transition);
172
+ user-select: none;
173
+ box-shadow: var(--shadow-subtle);
174
+ }
175
+
176
+ .card svg {
177
+ color: var(--color-primary);
178
+ }
179
+
180
+ .card span {
181
+ font-size: 12px;
182
+ color: var(--color-text-muted);
183
+ }
184
+
185
+ .card:hover,
186
+ .card:focus-visible {
187
+ border-color: var(--color-primary);
188
+ box-shadow: var(--shadow-card);
189
+ outline: none;
190
+ }
191
+
192
+ .card:active {
193
+ opacity: 0.98;
194
+ }
195
+
196
+ /* ── PIN gate (Register) ──────────────────────────────────────────────────── */
197
+ .pin-gate {
198
+ margin-top: 24px;
199
+ max-width: 280px;
200
+ margin-left: auto;
201
+ margin-right: auto;
202
+ }
203
+
204
+ .pin-prompt {
205
+ font-size: 14px;
206
+ color: var(--color-text-muted);
207
+ margin-bottom: 16px;
208
+ text-align: center;
209
+ }
210
+
211
+ .pin-input {
212
+ display: block;
213
+ width: 100%;
214
+ padding: 14px 18px;
215
+ margin-bottom: 12px;
216
+ font-size: 18px;
217
+ letter-spacing: 0.2em;
218
+ text-align: center;
219
+ }
220
+
221
+ .pin-submit {
222
+ margin-top: 8px;
223
+ }
224
+
225
+ .pin-error {
226
+ font-size: 13px;
227
+ color: var(--color-danger);
228
+ margin-top: 12px;
229
+ text-align: center;
230
+ }
231
+
232
+ /* ── Manage / Delete users ────────────────────────────────────────────────── */
233
+ .manage-section {
234
+ margin-top: 8px;
235
+ }
236
+
237
+ .manage-intro {
238
+ font-size: 13px;
239
+ color: var(--color-text-muted);
240
+ margin-bottom: 20px;
241
+ }
242
+
243
+ .manage-list {
244
+ min-height: 40px;
245
+ }
246
+
247
+ .manage-loading {
248
+ font-size: 13px;
249
+ color: var(--color-text-subtle);
250
+ }
251
+
252
+ .manage-ul {
253
+ list-style: none;
254
+ margin: 0;
255
+ padding: 0;
256
+ }
257
+
258
+ .manage-item {
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: space-between;
262
+ gap: 12px;
263
+ padding: 12px 16px;
264
+ background: var(--color-surface);
265
+ border: 1px solid var(--color-border);
266
+ border-radius: var(--radius-md);
267
+ margin-bottom: 10px;
268
+ }
269
+
270
+ .manage-item-name {
271
+ font-size: 14px;
272
+ color: var(--color-text);
273
+ }
274
+
275
+ .manage-item-id {
276
+ font-size: 12px;
277
+ color: var(--color-text-muted);
278
+ margin-left: 4px;
279
+ }
280
+
281
+ .manage-delete-btn {
282
+ flex-shrink: 0;
283
+ padding: 8px 14px;
284
+ font-size: 12px;
285
+ font-weight: 600;
286
+ letter-spacing: 0.04em;
287
+ color: var(--color-danger);
288
+ background: transparent;
289
+ border: 1px solid var(--color-danger);
290
+ border-radius: var(--radius-sm);
291
+ cursor: pointer;
292
+ transition: background 0.2s, color 0.2s;
293
+ }
294
+
295
+ .manage-delete-btn:hover {
296
+ background: rgba(193, 18, 31, 0.1);
297
+ }
298
+
299
+ .manage-empty {
300
+ font-size: 13px;
301
+ color: var(--color-text-muted);
302
+ margin: 0;
303
+ }
304
+
305
+ /* ── Buttons ─────────────────────────────────────────────────────────────── */
306
+ .btn-primary,
307
+ .btn-outline {
308
+ display: block;
309
+ width: 100%;
310
+ padding: 14px 20px;
311
+ border-radius: var(--radius-full);
312
+ font-family: var(--font);
313
+ font-size: 13px;
314
+ font-weight: 600;
315
+ letter-spacing: 0.06em;
316
+ text-transform: uppercase;
317
+ cursor: pointer;
318
+ transition: background var(--transition), border-color var(--transition),
319
+ opacity var(--transition);
320
+ border: none;
321
+ }
322
+
323
+ .btn-primary {
324
+ background: var(--color-primary);
325
+ color: #fff;
326
+ }
327
+
328
+ .btn-primary:hover:not(:disabled) {
329
+ background: var(--color-primary-light);
330
+ }
331
+
332
+ .btn-primary:disabled {
333
+ opacity: 0.4;
334
+ cursor: not-allowed;
335
+ }
336
+
337
+ .btn-outline {
338
+ background: transparent;
339
+ border: 1px solid var(--color-border);
340
+ color: var(--color-text);
341
+ }
342
+
343
+ .btn-outline:hover {
344
+ border-color: var(--color-text-subtle);
345
+ background: var(--color-surface-2);
346
+ }
347
+
348
+ /* ── Form inputs ─────────────────────────────────────────────────────────── */
349
+ input[type="text"] {
350
+ display: block;
351
+ width: 100%;
352
+ padding: 12px 16px;
353
+ background: var(--color-surface);
354
+ border: 1px solid var(--color-border);
355
+ border-radius: var(--radius-md);
356
+ color: var(--color-text);
357
+ font-family: var(--font);
358
+ font-size: 15px;
359
+ margin-bottom: 10px;
360
+ transition: border-color var(--transition);
361
+ outline: none;
362
+ -webkit-appearance: none;
363
+ }
364
+
365
+ input[type="text"]::placeholder {
366
+ color: var(--color-text-subtle);
367
+ }
368
+
369
+ input[type="text"]:focus {
370
+ border-color: var(--color-primary);
371
+ }
372
+
373
+ /* ── Camera: attendance page (circle view) ───────────────────────────────── */
374
+ .camera-container {
375
+ position: relative;
376
+ width: 100%;
377
+ aspect-ratio: 1;
378
+ max-width: 320px;
379
+ margin: 28px auto 0;
380
+ border-radius: 50%;
381
+ overflow: hidden;
382
+ background: var(--color-surface-2);
383
+ border: 2px solid var(--color-border);
384
+ }
385
+
386
+ .circle-feed {
387
+ width: 100%;
388
+ height: 100%;
389
+ object-fit: cover;
390
+ border-radius: 50%;
391
+ transform: scaleX(-1);
392
+ }
393
+
394
+ .overlay-svg {
395
+ position: absolute;
396
+ inset: 0;
397
+ width: 100%;
398
+ height: 100%;
399
+ pointer-events: none;
400
+ }
401
+
402
+ .overlay-svg path,
403
+ .overlay-svg circle {
404
+ stroke: var(--color-primary) !important;
405
+ }
406
+
407
+ .spin-ring {
408
+ transform-origin: 150px 150px;
409
+ animation: spin 8s linear infinite;
410
+ }
411
+
412
+ @keyframes spin {
413
+ to { transform: rotate(360deg); }
414
+ }
415
+
416
+ /* ── Camera: register page (rect view) ──────────────────────────────────── */
417
+ .video-rect-container {
418
+ position: relative;
419
+ width: 100%;
420
+ aspect-ratio: 4/3;
421
+ border-radius: var(--radius-md);
422
+ overflow: hidden;
423
+ background: var(--color-surface-2);
424
+ border: 1px solid var(--color-border);
425
+ margin-bottom: 12px;
426
+ }
427
+
428
+ .video-rect-container video {
429
+ width: 100%;
430
+ height: 100%;
431
+ object-fit: cover;
432
+ transform: scaleX(-1);
433
+ }
434
+
435
+ .rect-overlay {
436
+ position: absolute;
437
+ inset: 12%;
438
+ border: 2px dashed var(--color-primary);
439
+ border-radius: var(--radius-sm);
440
+ pointer-events: none;
441
+ opacity: 0.5;
442
+ }
443
+
444
+ .progress-ring {
445
+ position: absolute;
446
+ bottom: 12px;
447
+ right: 12px;
448
+ width: 40px;
449
+ height: 40px;
450
+ transition: stroke-dashoffset 0.4s ease;
451
+ }
452
+
453
+ .progress-ring circle[stroke="#00C853"] {
454
+ stroke: var(--color-primary);
455
+ }
456
+
457
+ /* ── Status indicator ────────────────────────────────────────────────────── */
458
+ .status-indicator {
459
+ width: 8px;
460
+ height: 8px;
461
+ border-radius: 50%;
462
+ background: var(--color-text-subtle);
463
+ margin: 18px auto 6px;
464
+ transition: background var(--transition);
465
+ }
466
+
467
+ .status-indicator.active {
468
+ background: var(--color-primary);
469
+ animation: pulse 1.5s ease-in-out infinite;
470
+ }
471
+
472
+ @keyframes pulse {
473
+ 0%, 100% { opacity: 1; }
474
+ 50% { opacity: 0.6; }
475
+ }
476
+
477
+ .status-text {
478
+ text-align: center;
479
+ font-size: 13px;
480
+ font-weight: 500;
481
+ letter-spacing: 0.02em;
482
+ color: var(--color-text-muted);
483
+ min-height: 20px;
484
+ transition: color var(--transition);
485
+ }
486
+
487
+ /* ── Success modal ───────────────────────────────────────────────────────── */
488
+ .modal-overlay {
489
+ position: fixed;
490
+ inset: 0;
491
+ background: rgba(0, 0, 0, 0.4);
492
+ display: flex;
493
+ align-items: center;
494
+ justify-content: center;
495
+ padding: 24px;
496
+ opacity: 0;
497
+ pointer-events: none;
498
+ transition: opacity 0.25s ease;
499
+ z-index: 100;
500
+ backdrop-filter: blur(4px);
501
+ }
502
+
503
+ .modal-overlay.show {
504
+ opacity: 1;
505
+ pointer-events: auto;
506
+ }
507
+
508
+ .modal-card {
509
+ background: var(--color-surface);
510
+ border: 1px solid var(--color-border);
511
+ border-radius: var(--radius-lg);
512
+ padding: 36px 28px;
513
+ width: 100%;
514
+ max-width: 320px;
515
+ text-align: center;
516
+ box-shadow: var(--shadow-card);
517
+ transform: translateY(12px);
518
+ transition: transform 0.25s ease;
519
+ }
520
+
521
+ .modal-overlay.show .modal-card {
522
+ transform: translateY(0);
523
+ }
524
+
525
+ .checkmark-animated {
526
+ width: 64px;
527
+ height: 64px;
528
+ border-radius: 50%;
529
+ background: var(--color-primary-dim);
530
+ border: 2px solid var(--color-primary);
531
+ display: flex;
532
+ align-items: center;
533
+ justify-content: center;
534
+ font-size: 28px;
535
+ color: var(--color-primary);
536
+ margin: 0 auto 16px;
537
+ animation: pop 0.4s ease forwards;
538
+ }
539
+
540
+ @keyframes pop {
541
+ from { transform: scale(0.9); opacity: 0; }
542
+ to { transform: scale(1); opacity: 1; }
543
+ }
544
+
545
+ .modal-card h2 {
546
+ font-size: 18px;
547
+ margin-bottom: 6px;
548
+ }
549
+
550
+ .employee-name {
551
+ font-size: 17px;
552
+ font-weight: 600;
553
+ color: var(--color-text);
554
+ margin: 4px 0;
555
+ }
556
+
557
+ .timestamp {
558
+ font-size: 13px;
559
+ color: var(--color-text-muted);
560
+ margin-bottom: 20px;
561
+ }
562
+
563
+ /* ── Register success state ──────────────────────────────────────────────── */
564
+ .success-state {
565
+ text-align: center;
566
+ padding: 32px 0;
567
+ animation: fadeIn 0.35s ease;
568
+ }
569
+
570
+ @keyframes fadeIn {
571
+ from { opacity: 0; transform: translateY(12px); }
572
+ to { opacity: 1; transform: translateY(0); }
573
+ }
574
+
575
+ .checkmark-large {
576
+ width: 72px;
577
+ height: 72px;
578
+ border-radius: 50%;
579
+ background: var(--color-primary-dim);
580
+ border: 2px solid var(--color-primary);
581
+ display: flex;
582
+ align-items: center;
583
+ justify-content: center;
584
+ font-size: 36px;
585
+ color: var(--color-primary);
586
+ margin: 0 auto 20px;
587
+ }
588
+
589
+ .success-state h2 {
590
+ margin-bottom: 12px;
591
+ }
592
+
593
+ .chip {
594
+ display: inline-block;
595
+ padding: 6px 14px;
596
+ background: var(--color-surface-2);
597
+ border: 1px solid var(--color-border);
598
+ border-radius: var(--radius-full);
599
+ font-size: 13px;
600
+ font-weight: 500;
601
+ color: var(--color-text-muted);
602
+ margin-bottom: 24px;
603
+ }
604
+
605
+ /* ── Register form layout ────────────────────────────────────────────────── */
606
+ #registerForm {
607
+ display: flex;
608
+ flex-direction: column;
609
+ gap: 0;
610
+ }
611
+
612
+ /* ── Utility ─────────────────────────────────────────────────────────────── */
613
+ a {
614
+ color: var(--color-primary);
615
+ }
616
+
617
+ a:hover {
618
+ text-decoration: underline;
619
+ }
620
+
621
+ /* ── Scrollbar ───────────────────────────────────────────────────────────── */
622
+ ::-webkit-scrollbar {
623
+ width: 6px;
624
+ }
625
+
626
+ ::-webkit-scrollbar-track {
627
+ background: var(--color-surface-2);
628
+ }
629
+
630
+ ::-webkit-scrollbar-thumb {
631
+ background: var(--color-border);
632
+ border-radius: 3px;
633
+ }
634
+
635
+ /* ── Responsive ──────────────────────────────────────────────────────────── */
636
+ @media (min-width: 640px) {
637
+ .app-shell {
638
+ padding-top: 32px;
639
+ }
640
+ .camera-container {
641
+ max-width: 360px;
642
+ }
643
+ }
644
+
645
+ /* ── Toast (spoof / warning) ────────────────────────────────────────────── */
646
+ .toast {
647
+ position: fixed;
648
+ left: 50%;
649
+ transform: translateX(-50%) translateY(-120%);
650
+ top: 20px;
651
+ z-index: 200;
652
+ max-width: calc(var(--max-w) - 40px);
653
+ width: 100%;
654
+ display: flex;
655
+ align-items: flex-start;
656
+ gap: 12px;
657
+ padding: 14px 18px;
658
+ border-radius: var(--radius-md);
659
+ box-shadow: var(--shadow-card);
660
+ opacity: 0;
661
+ pointer-events: none;
662
+ transition: transform 0.3s ease, opacity 0.25s ease;
663
+ }
664
+
665
+ .toast.show {
666
+ transform: translateX(-50%) translateY(0);
667
+ opacity: 1;
668
+ pointer-events: auto;
669
+ }
670
+
671
+ .toast-icon {
672
+ flex-shrink: 0;
673
+ font-size: 18px;
674
+ line-height: 1.2;
675
+ }
676
+
677
+ .toast-body {
678
+ flex: 1;
679
+ min-width: 0;
680
+ }
681
+
682
+ .toast-title {
683
+ display: block;
684
+ font-size: 13px;
685
+ font-weight: 600;
686
+ margin-bottom: 2px;
687
+ }
688
+
689
+ .toast-message {
690
+ font-size: 13px;
691
+ line-height: 1.35;
692
+ margin: 0;
693
+ opacity: 0.9;
694
+ }
695
+
696
+ .toast-danger {
697
+ background: #fff5f5;
698
+ border: 1px solid rgba(193, 18, 31, 0.3);
699
+ color: #5c1010;
700
+ }
701
+
702
+ .toast-danger .toast-title {
703
+ color: var(--color-danger);
704
+ }
705
+
706
+ .toast-danger .toast-icon {
707
+ color: var(--color-danger);
708
+ }
709
+
710
+ /* ── Global snackbar (popup / snack bar) ──────────────────────────────────── */
711
+ .snackbar {
712
+ position: fixed;
713
+ left: 50%;
714
+ bottom: 24px;
715
+ transform: translateX(-50%) translateY(100px);
716
+ z-index: 300;
717
+ max-width: calc(var(--max-w) - 32px);
718
+ width: 100%;
719
+ display: flex;
720
+ align-items: center;
721
+ gap: 12px;
722
+ padding: 14px 20px;
723
+ border-radius: var(--radius-md);
724
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
725
+ opacity: 0;
726
+ pointer-events: none;
727
+ transition: transform 0.3s ease, opacity 0.25s ease;
728
+ }
729
+
730
+ .snackbar.show {
731
+ transform: translateX(-50%) translateY(0);
732
+ opacity: 1;
733
+ pointer-events: auto;
734
+ }
735
+
736
+ .snackbar-icon {
737
+ flex-shrink: 0;
738
+ font-size: 18px;
739
+ font-weight: 700;
740
+ line-height: 1;
741
+ }
742
+
743
+ .snackbar-message {
744
+ font-size: 14px;
745
+ line-height: 1.35;
746
+ }
747
+
748
+ .snackbar-success {
749
+ background: var(--color-surface);
750
+ border: 1px solid var(--color-primary);
751
+ color: var(--color-text);
752
+ }
753
+
754
+ .snackbar-success .snackbar-icon {
755
+ color: var(--color-primary);
756
+ }
757
+
758
+ .snackbar-error {
759
+ background: #fff5f5;
760
+ border: 1px solid rgba(193, 18, 31, 0.4);
761
+ color: #5c1010;
762
+ }
763
+
764
+ .snackbar-error .snackbar-icon {
765
+ color: var(--color-danger);
766
+ }
767
+
768
+ .snackbar-info {
769
+ background: var(--color-surface);
770
+ border: 1px solid var(--color-border);
771
+ color: var(--color-text);
772
+ }
773
+
774
+ .snackbar-info .snackbar-icon {
775
+ color: var(--color-primary);
776
+ }
hf-space/static/images/logo.png ADDED

Git LFS Details

  • SHA256: 23e59fea6fba4bda1e7f68d33dcca3b39ac849216dc2a76f672c51546629227e
  • Pointer size: 131 Bytes
  • Size of remote file: 364 kB
hf-space/static/js/attendance.js ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const video = document.getElementById('videoFeed');
2
+ const statusDot = document.getElementById('statusDot');
3
+ const statusText = document.getElementById('statusText');
4
+ const modal = document.getElementById('successModal');
5
+ const modalName = document.getElementById('modalName');
6
+ const modalTime = document.getElementById('modalTime');
7
+ const cameraContainer = document.getElementById('cameraContainer');
8
+
9
+ // Spoof toast
10
+ const spoofToast = document.getElementById('spoofToast');
11
+ const spoofToastMessage = document.getElementById('spoofToastMessage');
12
+
13
+ // Frame buffer for sequence liveness (motion + blink)
14
+ const FRAME_BUFFER_SIZE = 5;
15
+ let frameBuffer = [];
16
+
17
+ let isScanning = true;
18
+ let stream = null;
19
+ let toastDismissTimer = null;
20
+ let lastFaceAlertAt = 0;
21
+ const FACE_ALERT_COOLDOWN_MS = 4000;
22
+
23
+ // ─── Camera ─────────────────────────────────────────────────────────────────
24
+
25
+ async function startCamera() {
26
+ try {
27
+ stream = await navigator.mediaDevices.getUserMedia({
28
+ video: { facingMode: 'user', width: 640, height: 480 }
29
+ });
30
+ video.srcObject = stream;
31
+ startCaptureLoop();
32
+ } catch (err) {
33
+ console.error("Camera error:", err);
34
+ statusText.textContent = "Camera access denied or unavailable";
35
+ statusText.style.color = "red";
36
+ }
37
+ }
38
+
39
+ function stopCamera() {
40
+ if (stream) {
41
+ stream.getTracks().forEach(track => track.stop());
42
+ }
43
+ }
44
+
45
+ function captureFrame() {
46
+ const canvas = document.createElement('canvas');
47
+ canvas.width = video.videoWidth;
48
+ canvas.height = video.videoHeight;
49
+ const ctx = canvas.getContext('2d');
50
+ ctx.drawImage(video, 0, 0);
51
+ return canvas.toDataURL('image/jpeg', 0.8);
52
+ }
53
+
54
+ // ─── Capture loop ───────────────────────────────────────────────────────────
55
+
56
+ async function startCaptureLoop() {
57
+ while (isScanning) {
58
+ if (video.readyState === video.HAVE_ENOUGH_DATA) {
59
+ const frame = captureFrame();
60
+ frameBuffer.push(frame);
61
+ if (frameBuffer.length > FRAME_BUFFER_SIZE) frameBuffer.shift();
62
+
63
+ const payload = frameBuffer.length >= 2
64
+ ? { frames: frameBuffer.slice() }
65
+ : { frame: frame };
66
+
67
+ try {
68
+ const response = await fetch('/api/recognize', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify(payload)
72
+ });
73
+
74
+ const result = await response.json();
75
+ handleResult(result);
76
+ } catch (e) {
77
+ console.log("Network error", e);
78
+ }
79
+ }
80
+
81
+ await new Promise(r => setTimeout(r, 800));
82
+ }
83
+ }
84
+
85
+ // ─── Result handler ─────────────────────────────────────────────────────────
86
+
87
+ function handleResult(result) {
88
+ if (result.status === 'success') {
89
+ showSuccess(result);
90
+ if (typeof showSnackbar === 'function') {
91
+ showSnackbar('Checked in at ' + (result.timestamp || ''), 'success');
92
+ }
93
+ } else if (result.status === 'already_marked') {
94
+ statusText.textContent = `Already marked: ${result.name}`;
95
+ statusText.style.color = "#FFD700";
96
+ if (typeof showSnackbar === 'function') showSnackbar('Already marked today: ' + result.name, 'info');
97
+ } else if (result.status === 'spoof') {
98
+ showSpoofToast(result);
99
+ } else if (result.status === 'unknown') {
100
+ statusText.textContent = "Face not recognized";
101
+ statusText.style.color = "#A5A5A5";
102
+ if (typeof showSnackbar === 'function' && Date.now() - lastFaceAlertAt > FACE_ALERT_COOLDOWN_MS) {
103
+ lastFaceAlertAt = Date.now();
104
+ showSnackbar('Face not recognized — ensure your face is clearly visible.', 'info');
105
+ }
106
+ } else if (result.status === 'no_face') {
107
+ statusText.textContent = "Position your face in the frame";
108
+ statusText.style.color = "#A5A5A5";
109
+ if (typeof showSnackbar === 'function' && Date.now() - lastFaceAlertAt > FACE_ALERT_COOLDOWN_MS) {
110
+ lastFaceAlertAt = Date.now();
111
+ showSnackbar('Adjust position — keep your face clearly visible in the frame.', 'info');
112
+ }
113
+ }
114
+ }
115
+
116
+ // ─── Spoof toast (banner, auto-dismiss) ─────────────────────────────────────
117
+
118
+ function showSpoofToast(data) {
119
+ const msg = data.reason || data.message || "Use a live face, not a photo or screen.";
120
+ spoofToastMessage.textContent = msg;
121
+ spoofToast.classList.add('show');
122
+ statusText.textContent = "⚠ Spoofing detected";
123
+ statusText.style.color = "#FF3B30";
124
+
125
+ if (toastDismissTimer) clearTimeout(toastDismissTimer);
126
+ toastDismissTimer = setTimeout(() => {
127
+ spoofToast.classList.remove('show');
128
+ statusText.textContent = "Position your face in the frame";
129
+ statusText.style.color = "#A5A5A5";
130
+ toastDismissTimer = null;
131
+ }, 4500);
132
+ }
133
+
134
+ // ─── Success modal ──────────────────────────────────────────────────────────
135
+
136
+ function showSuccess(data) {
137
+ isScanning = false;
138
+ statusDot.classList.add('active');
139
+
140
+ modalName.textContent = data.name;
141
+ modalTime.textContent = data.timestamp;
142
+
143
+ modal.classList.add('show');
144
+
145
+ // Auto dismiss after 4s
146
+ setTimeout(dismissModal, 4000);
147
+ }
148
+
149
+ function dismissModal() {
150
+ modal.classList.remove('show');
151
+ statusDot.classList.remove('active');
152
+ statusText.textContent = "Position your face in the frame";
153
+ statusText.style.color = "#A5A5A5";
154
+ isScanning = true;
155
+ startCaptureLoop();
156
+ }
157
+
158
+ // ─── Init ───────────────────────────────────────────────────────────────────
159
+ startCamera();
160
+ window.addEventListener('beforeunload', stopCamera);
hf-space/static/js/camera.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const video = document.getElementById('videoFeed');
2
+
3
+ async function startCamera() {
4
+ try {
5
+ const stream = await navigator.mediaDevices.getUserMedia({
6
+ video: { facingMode: 'user', width: 640, height: 480 }
7
+ });
8
+ video.srcObject = stream;
9
+ } catch (err) {
10
+ console.error("Camera error:", err);
11
+ }
12
+ }
hf-space/static/js/register.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const video = document.getElementById('videoFeed');
2
+ const empIdInput = document.getElementById('empId');
3
+ const empNameInput = document.getElementById('empName');
4
+ const btnRegister = document.getElementById('btnRegister');
5
+ const statusText = document.getElementById('statusText');
6
+ const progressCircle = document.getElementById('progressCircle');
7
+ const spoofToast = document.getElementById('spoofToast');
8
+ const spoofToastMessage = document.getElementById('spoofToastMessage');
9
+
10
+ let capturedFrames = [];
11
+ let isCapturing = false;
12
+ const REQUIRED_FRAMES = 5;
13
+ let toastDismissTimer = null;
14
+
15
+ // Start Camera
16
+ navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: 640, height: 480 } })
17
+ .then(stream => { video.srcObject = stream; })
18
+ .catch(err => console.error(err));
19
+
20
+ // Monitor inputs to enable capture
21
+ [empIdInput, empNameInput].forEach(input => {
22
+ input.addEventListener('input', checkInputs);
23
+ });
24
+
25
+ function checkInputs() {
26
+ const valid = empIdInput.value.trim().length > 0 && empNameInput.value.trim().length > 0;
27
+ if (valid && !isCapturing && capturedFrames.length === 0) {
28
+ btnRegister.disabled = false;
29
+ btnRegister.textContent = "START CAPTURE";
30
+ btnRegister.onclick = startCaptureProcess;
31
+ } else if (capturedFrames.length === REQUIRED_FRAMES) {
32
+ btnRegister.disabled = false;
33
+ btnRegister.textContent = "Register";
34
+ btnRegister.onclick = submitRegistration;
35
+ } else {
36
+ btnRegister.disabled = true;
37
+ }
38
+ }
39
+
40
+ function startCaptureProcess() {
41
+ isCapturing = true;
42
+ btnRegister.disabled = true;
43
+ capturedFrames = [];
44
+ statusText.textContent = "Keep face steady...";
45
+
46
+ let count = 0;
47
+ const interval = setInterval(() => {
48
+ if (count >= REQUIRED_FRAMES) {
49
+ clearInterval(interval);
50
+ finishCapture();
51
+ return;
52
+ }
53
+
54
+ const canvas = document.createElement('canvas');
55
+ canvas.width = video.videoWidth;
56
+ canvas.height = video.videoHeight;
57
+ canvas.getContext('2d').drawImage(video, 0, 0);
58
+ capturedFrames.push(canvas.toDataURL('image/jpeg', 0.8));
59
+
60
+ count++;
61
+ updateProgress(count / REQUIRED_FRAMES);
62
+ statusText.textContent = `Scanning... ${Math.round((count/REQUIRED_FRAMES)*100)}%`;
63
+
64
+ }, 600);
65
+ }
66
+
67
+ function updateProgress(percent) {
68
+ const offset = 113 - (113 * percent);
69
+ progressCircle.style.strokeDashoffset = offset;
70
+ }
71
+
72
+ function finishCapture() {
73
+ isCapturing = false;
74
+ statusText.textContent = "Face captured ✓";
75
+ statusText.style.color = "var(--color-primary)";
76
+ checkInputs(); // Re-enable button for submit
77
+ }
78
+
79
+ async function submitRegistration() {
80
+ btnRegister.disabled = true;
81
+ btnRegister.textContent = "REGISTERING...";
82
+
83
+ const payload = {
84
+ employee_id: empIdInput.value.trim(),
85
+ name: empNameInput.value.trim(),
86
+ frames: capturedFrames
87
+ };
88
+
89
+ try {
90
+ const res = await fetch('/api/register', {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify(payload)
94
+ });
95
+
96
+ const data = await res.json();
97
+
98
+ if (data.status === 'registered') {
99
+ document.getElementById('registerForm').style.display = 'none';
100
+ document.getElementById('successState').style.display = 'block';
101
+ document.getElementById('successChip').textContent = `${payload.name} — ${payload.employee_id}`;
102
+ } else if (data.status === 'spoof') {
103
+ showSpoofToast(data);
104
+ btnRegister.disabled = false;
105
+ btnRegister.textContent = "Register";
106
+ } else {
107
+ // Duplicate user or other error → snackbar
108
+ var msg = data.message || "Unknown error";
109
+ if (msg.indexOf("already registered") !== -1 || msg.indexOf("already registered to") !== -1) {
110
+ if (typeof showSnackbar === 'function') showSnackbar("Duplicate registration — this face is already registered.", 'error');
111
+ } else if (msg.indexOf("No face detected") !== -1 || msg.indexOf("no face") !== -1) {
112
+ if (typeof showSnackbar === 'function') showSnackbar("Clear photo — ensure your face is visible and well lit.", 'info');
113
+ } else {
114
+ if (typeof showSnackbar === 'function') showSnackbar(msg, 'error');
115
+ }
116
+ btnRegister.disabled = false;
117
+ btnRegister.textContent = "Register";
118
+ }
119
+ } catch (e) {
120
+ if (typeof showSnackbar === 'function') showSnackbar("Network error. Please try again.", 'error');
121
+ btnRegister.disabled = false;
122
+ btnRegister.textContent = "Register";
123
+ }
124
+ }
125
+
126
+ function showSpoofToast(data) {
127
+ const msg = data.reason || data.message || "Use a live face, not a photo or screen.";
128
+ spoofToastMessage.textContent = msg;
129
+ spoofToast.classList.add('show');
130
+ if (toastDismissTimer) clearTimeout(toastDismissTimer);
131
+ toastDismissTimer = setTimeout(() => {
132
+ spoofToast.classList.remove('show');
133
+ toastDismissTimer = null;
134
+ }, 4500);
135
+ }
hf-space/templates/attendance.html ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="top-bar">
5
+ <a href="/dashboard" style="color: var(--color-text); text-decoration: none;">
6
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7
+ <path d="M19 12H5M12 19l-7-7 7-7" />
8
+ </svg>
9
+ </a>
10
+ <h2>Check in</h2>
11
+ <a href="/dashboard" class="top-bar-logo-wrap">
12
+ <img src="{{ url_for('static', filename='images/logo.png') }}" alt="One Step Greener" class="top-bar-logo top-bar-logo-sm" />
13
+ </a>
14
+ </div>
15
+
16
+ <div class="camera-container" id="cameraContainer">
17
+ <video id="videoFeed" class="circle-feed" autoplay playsinline muted></video>
18
+
19
+ <svg class="overlay-svg" viewBox="0 0 300 300">
20
+ <!-- Corner brackets -->
21
+ <path d="M60 40 L40 40 L40 60" stroke="#00C853" stroke-width="4" fill="none" />
22
+ <path d="M240 40 L260 40 L260 60" stroke="#00C853" stroke-width="4" fill="none" />
23
+ <path d="M60 260 L40 260 L40 240" stroke="#00C853" stroke-width="4" fill="none" />
24
+ <path d="M240 260 L260 260 L260 240" stroke="#00C853" stroke-width="4" fill="none" />
25
+
26
+ <!-- Rotating ring -->
27
+ <circle cx="150" cy="150" r="140" stroke="#00C853" stroke-width="2" stroke-dasharray="20 20" fill="none"
28
+ class="spin-ring" opacity="0.5" />
29
+ </svg>
30
+ </div>
31
+
32
+ <div class="status-indicator" id="statusDot"></div>
33
+ <p class="status-text" id="statusText">Position your face in the frame</p>
34
+
35
+ <!-- ════ Spoof toast (banner, auto-dismiss) ═══════════════════════════════ -->
36
+ <div id="spoofToast" class="toast toast-danger" role="alert" aria-live="polite">
37
+ <span class="toast-icon">⚠</span>
38
+ <div class="toast-body">
39
+ <strong class="toast-title">Spoofing detected</strong>
40
+ <p class="toast-message" id="spoofToastMessage">Use a live face, not a photo or screen.</p>
41
+ </div>
42
+ </div>
43
+
44
+ <!-- ════ Success Modal ═══════════════════════════════════════════════════ -->
45
+ <div id="successModal" class="modal-overlay">
46
+ <div class="modal-card">
47
+ <div class="checkmark-animated">✓</div>
48
+ <h2>Checked in</h2>
49
+ <p class="employee-name" id="modalName">John Doe</p>
50
+ <p class="timestamp" id="modalTime">09:42 AM</p>
51
+ <button class="btn-primary" onclick="dismissModal()">DONE</button>
52
+ </div>
53
+ </div>
54
+
55
+ {% endblock %}
56
+
57
+ {% block scripts %}
58
+ <script src="{{ url_for('static', filename='js/attendance.js') }}"></script>
59
+ {% endblock %}
hf-space/templates/base.html ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>One Step Greener – Attendance</title>
7
+ <meta name="description" content="One Step Greener – Face recognition attendance for waste management">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
12
+ </head>
13
+ <body>
14
+ <div class="app-shell">
15
+ {% block content %}{% endblock %}
16
+ </div>
17
+
18
+ <!-- Global snackbar (popup / snack bar) for alerts -->
19
+ <div id="appSnackbar" class="snackbar" role="alert" aria-live="polite">
20
+ <span class="snackbar-icon"></span>
21
+ <span class="snackbar-message"></span>
22
+ </div>
23
+
24
+ <script>
25
+ window.showSnackbar = function(message, type) {
26
+ type = type || 'info';
27
+ var el = document.getElementById('appSnackbar');
28
+ if (!el) return;
29
+ el.className = 'snackbar snackbar-' + type + ' show';
30
+ var icon = el.querySelector('.snackbar-icon');
31
+ var msg = el.querySelector('.snackbar-message');
32
+ icon.textContent = type === 'success' ? '✓' : (type === 'error' ? '!' : 'ℹ');
33
+ if (msg) msg.textContent = message;
34
+ clearTimeout(window._snackbarTimer);
35
+ window._snackbarTimer = setTimeout(function() { el.classList.remove('show'); }, 4500);
36
+ };
37
+ </script>
38
+ {% block scripts %}{% endblock %}
39
+ </body>
40
+ </html>
hf-space/templates/dashboard.html ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="top-bar">
5
+ <a href="/dashboard" class="top-bar-logo-wrap">
6
+ <img src="{{ url_for('static', filename='images/logo.png') }}" alt="One Step Greener" class="top-bar-logo" />
7
+ </a>
8
+ <div class="clock" id="clock">00:00:00</div>
9
+ </div>
10
+
11
+ <h1>Check in</h1>
12
+ <p style="margin-bottom: 24px;">Waste management attendance — choose an action below.</p>
13
+
14
+ <div class="grid-menu">
15
+ <a href="/attendance" class="card">
16
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
17
+ <path d="M3 7V5a2 2 0 0 1 2-2h2"></path>
18
+ <path d="M17 3h2a2 2 0 0 1 2 2v2"></path>
19
+ <path d="M21 17v2a2 2 0 0 1-2 2h-2"></path>
20
+ <path d="M7 21H5a2 2 0 0 1-2-2v-2"></path>
21
+ <circle cx="12" cy="12" r="5"></circle>
22
+ <line x1="12" y1="7" x2="12" y2="7.01"></line>
23
+ <line x1="12" y1="17" x2="12" y2="17.01"></line>
24
+ <line x1="17" y1="12" x2="17.01" y2="12"></line>
25
+ <line x1="7" y1="12" x2="7.01" y2="12"></line>
26
+ </svg>
27
+ <h3>Attendance</h3>
28
+ <span>Scan face to check in</span>
29
+ </a>
30
+
31
+ <a href="/register" class="card">
32
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
33
+ <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
34
+ <circle cx="8.5" cy="7" r="4"></circle>
35
+ <line x1="20" y1="8" x2="20" y2="14"></line>
36
+ <line x1="23" y1="11" x2="17" y2="11"></line>
37
+ </svg>
38
+ <h3>Register</h3>
39
+ <span>Enroll new team member</span>
40
+ </a>
41
+
42
+ <a href="/manage" class="card">
43
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
44
+ <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
45
+ <circle cx="9" cy="7" r="4"></circle>
46
+ <path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
47
+ <path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
48
+ <line x1="18" y1="9" x2="23" y2="9"></line>
49
+ <line x1="20.5" y1="6.5" x2="20.5" y2="11.5"></line>
50
+ </svg>
51
+ <h3>Delete users</h3>
52
+ <span>Remove registered users</span>
53
+ </a>
54
+ </div>
55
+
56
+ <script>
57
+ setInterval(function() {
58
+ document.getElementById('clock').textContent = new Date().toLocaleTimeString();
59
+ }, 1000);
60
+ </script>
61
+ {% endblock %}
hf-space/templates/manage.html ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="top-bar">
5
+ <a href="/dashboard" style="color: var(--color-text); text-decoration: none;">
6
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7
+ <path d="M19 12H5M12 19l-7-7 7-7"/>
8
+ </svg>
9
+ </a>
10
+ <h2>Delete users</h2>
11
+ <a href="/dashboard" class="top-bar-logo-wrap">
12
+ <img src="{{ url_for('static', filename='images/logo.png') }}" alt="One Step Greener" class="top-bar-logo top-bar-logo-sm" />
13
+ </a>
14
+ </div>
15
+
16
+ <section class="manage-section">
17
+ <p class="manage-intro">Remove registered users. Deleted users can no longer check in until registered again.</p>
18
+ <div id="manageList" class="manage-list">
19
+ <span class="manage-loading" id="manageLoading">Loading…</span>
20
+ <ul id="manageUl" class="manage-ul" style="display: none;"></ul>
21
+ <p id="manageEmpty" class="manage-empty" style="display: none;">No registered users.</p>
22
+ </div>
23
+ </section>
24
+
25
+ {% endblock %}
26
+
27
+ {% block scripts %}
28
+ <script>
29
+ (function() {
30
+ var loading = document.getElementById('manageLoading');
31
+ var ul = document.getElementById('manageUl');
32
+ var empty = document.getElementById('manageEmpty');
33
+
34
+ function loadList() {
35
+ loading.style.display = 'block';
36
+ ul.style.display = 'none';
37
+ empty.style.display = 'none';
38
+ fetch('/api/employees')
39
+ .then(function(r) { return r.json(); })
40
+ .then(function(data) {
41
+ loading.style.display = 'none';
42
+ if (data.employees && data.employees.length > 0) {
43
+ ul.style.display = 'block';
44
+ ul.innerHTML = '';
45
+ data.employees.forEach(function(emp) {
46
+ var li = document.createElement('li');
47
+ li.className = 'manage-item';
48
+ li.innerHTML = '<span class="manage-item-name">' + (emp.name || emp.id) + ' <span class="manage-item-id">' + (emp.id || '') + '</span></span><button type="button" class="manage-delete-btn" data-id="' + (emp.id || '') + '">Delete</button>';
49
+ ul.appendChild(li);
50
+ });
51
+ ul.querySelectorAll('.manage-delete-btn').forEach(function(btn) {
52
+ btn.addEventListener('click', function() {
53
+ var id = this.getAttribute('data-id');
54
+ if (!id) return;
55
+ if (!confirm('Delete this user? They will need to register again to check in.')) return;
56
+ var row = this.closest('li');
57
+ fetch('/api/employees/' + encodeURIComponent(id), { method: 'DELETE' })
58
+ .then(function(r) { return r.json(); })
59
+ .then(function(data) {
60
+ if (data.status === 'ok') {
61
+ if (typeof showSnackbar === 'function') showSnackbar('User deleted.', 'success');
62
+ row.remove();
63
+ if (ul.children.length === 0) {
64
+ ul.style.display = 'none';
65
+ empty.style.display = 'block';
66
+ }
67
+ } else {
68
+ if (typeof showSnackbar === 'function') showSnackbar(data.message || 'Delete failed', 'error');
69
+ }
70
+ })
71
+ .catch(function() {
72
+ if (typeof showSnackbar === 'function') showSnackbar('Request failed', 'error');
73
+ });
74
+ });
75
+ });
76
+ } else {
77
+ empty.style.display = 'block';
78
+ }
79
+ })
80
+ .catch(function() {
81
+ loading.textContent = 'Could not load list.';
82
+ loading.style.display = 'block';
83
+ });
84
+ }
85
+
86
+ loadList();
87
+ })();
88
+ </script>
89
+ {% endblock %}
hf-space/templates/register.html ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="top-bar">
5
+ <a href="/dashboard" style="color: var(--color-text); text-decoration: none;">
6
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7
+ <path d="M19 12H5M12 19l-7-7 7-7"/>
8
+ </svg>
9
+ </a>
10
+ <h2>Register</h2>
11
+ <a href="/dashboard" class="top-bar-logo-wrap">
12
+ <img src="{{ url_for('static', filename='images/logo.png') }}" alt="One Step Greener" class="top-bar-logo top-bar-logo-sm" />
13
+ </a>
14
+ </div>
15
+
16
+ <!-- PIN gate: always shown first; hidden after correct PIN -->
17
+ <div id="pinGate" class="pin-gate">
18
+ <p class="pin-prompt">Enter PIN to access Register.</p>
19
+ <input type="password" id="pinInput" class="pin-input" placeholder="PIN" maxlength="8" inputmode="numeric" autocomplete="off" />
20
+ <button type="button" id="pinSubmit" class="btn-primary pin-submit">Continue</button>
21
+ <p id="pinError" class="pin-error" style="display: none;"></p>
22
+ </div>
23
+
24
+ <!-- Register form: hidden until PIN verified -->
25
+ <div id="registerForm" class="register-form-locked" style="display: none;">
26
+ <input type="text" id="empId" placeholder="Employee ID (e.g. EMP-001)">
27
+ <input type="text" id="empName" placeholder="Full name">
28
+
29
+ <div class="video-rect-container">
30
+ <video id="videoFeed" autoplay playsinline muted></video>
31
+ <div class="rect-overlay"></div>
32
+
33
+ <!-- Progress Ring -->
34
+ <svg class="progress-ring" viewBox="0 0 40 40">
35
+ <circle cx="20" cy="20" r="18" stroke="#333" stroke-width="4" fill="none"/>
36
+ <circle id="progressCircle" cx="20" cy="20" r="18" stroke="var(--color-primary)" stroke-width="4" fill="none" stroke-dasharray="113" stroke-dashoffset="113" transform="rotate(-90 20 20)"/>
37
+ </svg>
38
+ </div>
39
+
40
+ <p class="status-text" id="statusText">Align face within frame</p>
41
+
42
+ <button id="btnRegister" class="btn-primary" disabled onclick="submitRegistration()">Register</button>
43
+ </div>
44
+
45
+ <!-- Spoof toast (same as attendance) -->
46
+ <div id="spoofToast" class="toast toast-danger" role="alert" aria-live="polite">
47
+ <span class="toast-icon">⚠</span>
48
+ <div class="toast-body">
49
+ <strong class="toast-title">Spoofing detected</strong>
50
+ <p class="toast-message" id="spoofToastMessage">Use a live face, not a photo or screen.</p>
51
+ </div>
52
+ </div>
53
+
54
+ <div id="successState" class="success-state" style="display:none;">
55
+ <div class="checkmark-large">✓</div>
56
+ <h2>Registered</h2>
57
+ <div class="chip" id="successChip"></div>
58
+ <button class="btn-outline" onclick="location.reload()">Register Another</button>
59
+ <br>
60
+ <a href="/dashboard" style="color: var(--color-text-muted); font-size: 13px; margin-top: 20px; display:inline-block;">Back to check in</a>
61
+ </div>
62
+
63
+ {% endblock %}
64
+
65
+ {% block scripts %}
66
+ <script>
67
+ (function() {
68
+ var pinGate = document.getElementById('pinGate');
69
+ var registerForm = document.getElementById('registerForm');
70
+ var pinInput = document.getElementById('pinInput');
71
+ var pinSubmit = document.getElementById('pinSubmit');
72
+ var pinError = document.getElementById('pinError');
73
+
74
+ if (pinGate && registerForm && pinInput && pinSubmit) {
75
+ function doVerify() {
76
+ var pin = (pinInput.value || '').trim();
77
+ pinError.style.display = 'none';
78
+ if (!pin) {
79
+ pinError.textContent = 'Enter PIN';
80
+ pinError.style.display = 'block';
81
+ return;
82
+ }
83
+ pinSubmit.disabled = true;
84
+ fetch('/api/verify-pin', {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({ pin: pin })
88
+ })
89
+ .then(function(r) { return r.json(); })
90
+ .then(function(data) {
91
+ pinSubmit.disabled = false;
92
+ if (data.status === 'ok') {
93
+ pinGate.style.display = 'none';
94
+ registerForm.style.display = 'block';
95
+ if (window.loadRegisterScript) window.loadRegisterScript();
96
+ } else {
97
+ pinError.textContent = data.message || 'Incorrect PIN';
98
+ pinError.style.display = 'block';
99
+ }
100
+ })
101
+ .catch(function() {
102
+ pinSubmit.disabled = false;
103
+ pinError.textContent = 'Request failed';
104
+ pinError.style.display = 'block';
105
+ });
106
+ }
107
+ pinSubmit.addEventListener('click', doVerify);
108
+ pinInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') doVerify(); });
109
+
110
+ window.loadRegisterScript = function() {
111
+ if (window.registerScriptLoaded) return;
112
+ window.registerScriptLoaded = true;
113
+ var s = document.createElement('script');
114
+ s.src = "{{ url_for('static', filename='js/register.js') }}";
115
+ document.body.appendChild(s);
116
+ };
117
+ }
118
+ })();
119
+ </script>
120
+ {% endblock %}