devindia commited on
Commit
701628b
·
verified ·
1 Parent(s): f568ccb

Upload 31 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ 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
+ database/face_index.faiss filter=lfs diff=lfs merge=lfs -text
37
+ static/images/face-id-success.png filter=lfs diff=lfs merge=lfs -text
38
+ static/images/logo.png filter=lfs diff=lfs merge=lfs -text
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
README.md CHANGED
@@ -1,11 +1,215 @@
1
  ---
2
- title: Attendance Apps
3
- emoji: 🐨
4
- colorFrom: gray
5
- colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # One Step Greener Face Recognition Attendance
12
+
13
+ A web-based **face recognition attendance system** for waste management teams. Employees and field workers (manforce) check in and out using their face—no cards or PINs. The app includes **anti-spoofing** (liveness detection) to block photos, screens, and replay attacks.
14
+
15
+ ---
16
+
17
+ ## What it does
18
+
19
+ - **Register** users by capturing their face (with live guidance: position, size, centering). Supports **employees** (by employee ID) and **manforce** (by Aadhaar, name, mobile).
20
+ - **Attendance** punch in/out via webcam: first scan of the day = punch in, next = punch out. One-minute cooldown between punches.
21
+ - **Dashboard** shows today’s attendance (punch-in and punch-out times) and quick links to Attendance and Register.
22
+ - **Liveness checks** during registration and recognition to reject printed photos, phone screens, and video replays (texture, motion, blink, and other cues).
23
+
24
+ ---
25
+
26
+ ## Features
27
+
28
+ | Feature | Description |
29
+ |--------|-------------|
30
+ | **Face registration** | Multi-frame capture with real-time feedback (face detected, centered, big enough). Optional PIN to unlock the Register page. |
31
+ | **Face recognition** | Match live face to stored embeddings (FAISS + 512-d FaceNet). Returns name, punch type (in/out), timestamp. |
32
+ | **Anti-spoofing** | Multi-layer checks: LBP texture, Moiré/FFT, color, edges, specular, central-difference; plus motion and blink for sequences. |
33
+ | **User types** | **Employee**: ID + optional name. **Manforce**: Aadhaar, full name, mobile. |
34
+ | **Duplicate prevention** | Same face cannot be registered for two different people. |
35
+ | **Cooldown** | 1-minute cooldown per user between punches to avoid double taps. |
36
+ | **Today’s view** | Today’s attendance list with first punch-in and last punch-out per person. |
37
+
38
+ ---
39
+
40
+ ## Tech stack
41
+
42
+ - **Backend:** Flask (Python 3.10)
43
+ - **Face detection & embeddings:** MTCNN + InceptionResnetV1 (VGGFace2) via `facenet-pytorch`
44
+ - **Embedding search:** FAISS (L2 index, cosine similarity)
45
+ - **Anti-spoofing:** Custom pipeline (LBP, FFT/Moiré, color, edges, specular, CDCN-style; MediaPipe for blink)
46
+ - **Database:** SQLite (`employees`, `attendance` tables)
47
+ - **Frontend:** HTML/CSS/JS, camera capture via browser
48
+
49
+ ---
50
+
51
+ ## Project structure
52
+
53
+ ```
54
+ .
55
+ ├── app.py # Flask app, routes, API handlers
56
+ ├── requirements.txt # Python dependencies
57
+ ├── Dockerfile # Docker image for HF Spaces (port 7860)
58
+ ├── database/
59
+ │ ├── db.py # SQLite helpers (employees, attendance)
60
+ │ ├── constable.db # SQLite DB (created at runtime)
61
+ │ ├── face_index.faiss # FAISS index (created at runtime)
62
+ │ └── face_meta.json # FAISS ID → employee_id mapping
63
+ ├── models/
64
+ │ ├── face_engine.py # MTCNN + InceptionResnetV1, decode/crop/embed
65
+ │ ├── embeddings_store.py # FAISS wrapper, add/search
66
+ │ └── anti_spoof.py # Liveness (single frame + sequence)
67
+ ├── static/
68
+ │ ├── css/style.css
69
+ │ ├── js/
70
+ │ │ ├── camera.js # Shared camera logic
71
+ │ │ ├── register.js # Registration flow + face-check
72
+ │ │ └── attendance.js # Recognition + punch
73
+ │ └── images/
74
+ └── templates/
75
+ ├── base.html
76
+ ├── dashboard.html # Home: Attendance + Register links
77
+ ├── register.html # Enroll employee / manforce
78
+ └── attendance.html # Punch in/out by face
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Prerequisites
84
+
85
+ - **Python 3.10** (or 3.8+)
86
+ - **Camera** for registration and attendance (browser will request access)
87
+ - **Optional:** GPU for faster face models (CUDA); runs on CPU otherwise
88
+
89
+ ---
90
+
91
+ ## Installation
92
+
93
+ ### 1. Clone and enter the project
94
+
95
+ ```bash
96
+ git clone <your-repo-url>
97
+ cd hf-space
98
+ ```
99
+
100
+ ### 2. Create a virtual environment (recommended)
101
+
102
+ ```bash
103
+ python3 -m venv venv
104
+ source venv/bin/activate # Linux/macOS
105
+ # or: venv\Scripts\activate # Windows
106
+ ```
107
+
108
+ ### 3. Install dependencies
109
+
110
+ ```bash
111
+ pip install -r requirements.txt
112
+ ```
113
+
114
+ On Linux, OpenCV and other libs may need system packages:
115
+
116
+ ```bash
117
+ # Debian/Ubuntu
118
+ sudo apt-get update
119
+ sudo apt-get install -y libgl1-mesa-glx libglib2.0-0 libsm6 libxext6 libxrender-dev
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Configuration
125
+
126
+ | Variable | Description | Default |
127
+ |----------|-------------|--------|
128
+ | `PORT` | HTTP port | `5000` (local) / `7860` (Docker/HF Spaces) |
129
+ | `SECRET_KEY` | Flask secret key | `constable-secret-2025` |
130
+ | `REGISTER_PIN` | PIN to unlock Register page | `3620` |
131
+ | `FLASK_DEBUG` | Set to `1` for debug mode | `0` |
132
+
133
+ Example:
134
+
135
+ ```bash
136
+ export REGISTER_PIN=1234
137
+ export PORT=5000
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Running the app
143
+
144
+ ### Local (development)
145
+
146
+ ```bash
147
+ python app.py
148
+ ```
149
+
150
+ Then open **http://localhost:5000** (or the port you set). You should see the dashboard with **Attendance** and **Register**.
151
+
152
+ ### Docker (e.g. Hugging Face Spaces)
153
+
154
+ The Dockerfile is set up for **Hugging Face Spaces** (port **7860**):
155
+
156
+ ```bash
157
+ docker build -t attendance-face .
158
+ docker run -p 7860:7860 attendance-face
159
+ ```
160
+
161
+ Open **http://localhost:7860**.
162
+
163
+ ---
164
+
165
+ ## Usage instructions
166
+
167
+ ### Dashboard (`/` or `/dashboard`)
168
+
169
+ - **Attendance** – Open the attendance page to punch in/out with your face.
170
+ - **Register** – Open the registration page (optionally enter a PIN if set).
171
+
172
+ ### Register (`/register`)
173
+
174
+ 1. Optionally enter the **Register PIN** (default `3620`) to unlock the form.
175
+ 2. Choose **Employee** or **Manforce**:
176
+ - **Employee:** Enter Employee ID (and optional name). Submit with face capture.
177
+ - **Manforce:** Enter Aadhaar, full name, and mobile. Submit with face capture.
178
+ 3. Allow camera access. Position your face in the oval; wait until the indicator shows **Ready** (face detected, centered, big enough).
179
+ 4. Capture multiple frames when prompted. The app runs **liveness checks** (e.g. motion, blink); do not use a photo or screen.
180
+ 5. On success, the person is stored in the DB and their face embeddings are added to the FAISS index. You can then use **Attendance** to punch in/out.
181
+
182
+ ### Attendance (`/attendance`)
183
+
184
+ 1. Open the Attendance page and allow camera access.
185
+ 2. Look at the camera. The app will:
186
+ - Detect your face and run **liveness** (single frame or sequence).
187
+ - Match your face to the stored embeddings.
188
+ - If matched: **first punch of the day** = punch **in**, **next** = punch **out** (with a 1-minute cooldown between punches).
189
+ 3. You’ll see your name, punch type (in/out), and time. Today’s attendance is available from the dashboard.
190
+
191
+ ### API (for integration)
192
+
193
+ | Endpoint | Method | Purpose |
194
+ |----------|--------|--------|
195
+ | `/api/face-check` | POST | Check if a frame has a valid face (centered, big enough). Body: `{ "frame": "<base64DataUrl>" }`. |
196
+ | `/api/register` | POST | Register employee or manforce. Body: `user_type`, `frames`, and either `employee_id` or `aadhaar`+`name`+`mobile`. |
197
+ | `/api/recognize` | POST | Recognize face and punch in/out. Body: `{ "frame": "..." }` or `{ "frames": ["...", ...] }`. |
198
+ | `/api/verify-pin` | POST | Verify Register PIN. Body: `{ "pin": "3620" }`. |
199
+ | `/api/employees` | GET | List all employees. |
200
+ | `/api/attendance/today` | GET | Today’s attendance records. |
201
+ | `/api/health` | GET | Health check + total indexed faces. |
202
+
203
+ ---
204
+
205
+ ## Notes
206
+
207
+ - **First run:** The app creates `database/constable.db`, `face_index.faiss`, and `face_meta.json` on first use. No manual DB setup required.
208
+ - **Hugging Face Spaces:** Use the Dockerfile and set the Space to use **Docker** and port **7860**.
209
+ - **Security:** Set `SECRET_KEY` and `REGISTER_PIN` in production; avoid default PIN in production.
210
+
211
+ ---
212
+
213
+ ## License
214
+
215
+ See repository license (if any).
__pycache__/app.cpython-38.pyc ADDED
Binary file (7.5 kB). View file
 
app.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, redirect
9
+
10
+ from database.db import init_db, add_employee, get_employee, get_all_employees, mark_attendance, get_today_attendance
11
+ from models.embeddings_store import EmbeddingStore
12
+ from models.face_engine import (
13
+ decode_image,
14
+ get_face_embedding,
15
+ check_face_in_frame,
16
+ get_embeddings_from_frames,
17
+ get_embeddings_and_crops_from_frames,
18
+ get_face_crops_from_frames,
19
+ )
20
+ from models.anti_spoof import check_liveness, check_liveness_sequence
21
+
22
+ logging.basicConfig(level=logging.INFO)
23
+ logger = logging.getLogger(__name__)
24
+
25
+ app = Flask(__name__)
26
+ app.secret_key = os.environ.get("SECRET_KEY", "constable-secret-2025")
27
+
28
+ REGISTER_PIN = os.environ.get("REGISTER_PIN", "3620")
29
+
30
+ # ── Initialise database and embedding store ─────────────────────────────────
31
+ init_db()
32
+ store = EmbeddingStore()
33
+
34
+ # ══════════════════════════════════════════════════════════════════════════════
35
+ # Page routes
36
+ # ══════════════════════════════════════════════════════════════════════════════
37
+
38
+ @app.route("/")
39
+ @app.route("/dashboard")
40
+ def dashboard():
41
+ return render_template("dashboard.html")
42
+
43
+
44
+ @app.route("/register")
45
+ def register_page():
46
+ return render_template("register.html")
47
+
48
+
49
+ @app.route("/manage")
50
+ def manage_redirect():
51
+ return redirect("/dashboard", code=302)
52
+
53
+
54
+ @app.route("/attendance")
55
+ def attendance_page():
56
+ return render_template("attendance.html")
57
+
58
+
59
+ # ══════════════════════════════════════════════════════════════════════════════
60
+ # API routes
61
+ # ══════════════════════════════════════════════════════════════════════════════
62
+
63
+ @app.route("/api/face-check", methods=["POST"])
64
+ def api_face_check():
65
+ """
66
+ Lightweight face-in-frame check for registration flow.
67
+ Body: { frame: base64DataUrl }
68
+ Returns: { face_detected, centered, big_enough, ready } (ready = all true).
69
+ """
70
+ data = request.get_json(force=True)
71
+ frame = data.get("frame", "")
72
+ if not frame:
73
+ return jsonify({"face_detected": False, "centered": False, "big_enough": False, "ready": False})
74
+ try:
75
+ img = decode_image(frame)
76
+ except Exception:
77
+ return jsonify({"face_detected": False, "centered": False, "big_enough": False, "ready": False})
78
+ r = check_face_in_frame(img)
79
+ r["ready"] = r["face_detected"] and r["centered"] and r["big_enough"]
80
+ return jsonify(r)
81
+
82
+
83
+ @app.route("/api/register", methods=["POST"])
84
+ def api_register():
85
+ """
86
+ Body JSON:
87
+ Manforce: { user_type: 'manforce', aadhaar, name, mobile, frames }
88
+ Employee: { user_type: 'employee', employee_id, frames }
89
+ """
90
+ data = request.get_json(force=True)
91
+ user_type = (data.get("user_type") or "employee").strip().lower()
92
+ frames = data.get("frames", [])
93
+
94
+ if not frames:
95
+ return jsonify({"status": "error", "message": "No frames provided."}), 400
96
+
97
+ if user_type == "manforce":
98
+ aadhaar = data.get("aadhaar", "").strip()
99
+ name = data.get("name", "").strip()
100
+ mobile = data.get("mobile", "").strip()
101
+ if not aadhaar or not name or not mobile:
102
+ return jsonify({"status": "error", "message": "Aadhaar number, full name and mobile number are required for Manforce."}), 400
103
+ employee_id = aadhaar
104
+ else:
105
+ employee_id = data.get("employee_id", "").strip()
106
+ if not employee_id:
107
+ return jsonify({"status": "error", "message": "Employee code is required."}), 400
108
+ name = data.get("name", "").strip() or employee_id
109
+ aadhaar = ""
110
+ mobile = ""
111
+
112
+ logger.info(f"Registering {employee_id} ({name}, type={user_type}) with {len(frames)} frames …")
113
+ embeddings, face_crops = get_embeddings_and_crops_from_frames(frames)
114
+
115
+ if not embeddings:
116
+ return jsonify({
117
+ "status": "error",
118
+ "message": "No face detected in the provided frames. "
119
+ "Please ensure good lighting and that your face is clearly visible."
120
+ }), 400
121
+
122
+ # Anti-spoofing: reject photo/screen/video (motion + blink + texture)
123
+ if len(face_crops) >= 2:
124
+ liveness = check_liveness_sequence([c for c in face_crops if c is not None and c.size > 0])
125
+ else:
126
+ liveness = check_liveness(face_crops[0]) if face_crops and face_crops[0] is not None else {"is_live": False}
127
+ if not liveness.get("is_live", True):
128
+ logger.warning(f"Registration rejected (spoof): {liveness.get('reason', 'liveness failed')}")
129
+ return jsonify({
130
+ "status": "spoof",
131
+ "message": liveness.get("reason", "Liveness check failed. Use a live face, not a photo or screen."),
132
+ "reason": liveness.get("reason", "Liveness check failed"),
133
+ "composite": liveness.get("score", 0.0),
134
+ }), 400
135
+
136
+ # Check for duplicate face registration
137
+ for emb in embeddings:
138
+ match_id, score = store.search(emb)
139
+ if match_id:
140
+ match_emp = get_employee(match_id)
141
+ match_name = match_emp["name"] if match_emp else match_id
142
+ logger.warning(f"Registration rejected: face already registered to {match_name} ({match_id})")
143
+ return jsonify({
144
+ "status": "error",
145
+ "message": f"This face is already registered to {match_name} ({match_id})."
146
+ }), 400
147
+
148
+ # Persist employee in DB and embeddings in FAISS
149
+ add_employee(employee_id, name, user_type=user_type, aadhaar=aadhaar, mobile=mobile)
150
+ store.add(employee_id, embeddings)
151
+
152
+ logger.info(f"Registered {employee_id} with {len(embeddings)} embedding(s).")
153
+ return jsonify({
154
+ "status": "registered",
155
+ "employee_id": employee_id,
156
+ "name": name,
157
+ "user_type": user_type,
158
+ "embeddings_stored": len(embeddings),
159
+ })
160
+
161
+
162
+ @app.route("/api/recognize", methods=["POST"])
163
+ def api_recognize():
164
+ """
165
+ Body JSON:
166
+ { frame: base64DataUrl } or { frames: [base64DataUrl, ...] }
167
+ When frames is provided, uses sequence liveness (motion + blink).
168
+
169
+ Response JSON (one of):
170
+ { status: 'success', name, timestamp }
171
+ { status: 'already_marked', name }
172
+ { status: 'spoof', reason, composite }
173
+ { status: 'unknown' }
174
+ { status: 'no_face' }
175
+ """
176
+ data = request.get_json(force=True)
177
+ frame = data.get("frame", "")
178
+ frames = data.get("frames", [])
179
+
180
+ # Prefer frames for sequence liveness (motion + blink) when available
181
+ if frames and len(frames) >= 2:
182
+ try:
183
+ face_crops = get_face_crops_from_frames(frames)
184
+ except Exception:
185
+ face_crops = []
186
+ if not face_crops:
187
+ return jsonify({"status": "no_face"})
188
+ # Use latest frame for identity
189
+ try:
190
+ img = decode_image(frames[-1])
191
+ except Exception:
192
+ return jsonify({"status": "no_face"})
193
+ embedding, _ = get_face_embedding(img)
194
+ if embedding is None:
195
+ return jsonify({"status": "no_face"})
196
+ liveness = check_liveness_sequence(face_crops)
197
+ else:
198
+ if not frame:
199
+ return jsonify({"status": "no_face"})
200
+ try:
201
+ img = decode_image(frame)
202
+ except Exception:
203
+ return jsonify({"status": "no_face"})
204
+ embedding, face_crop = get_face_embedding(img)
205
+ if embedding is None:
206
+ return jsonify({"status": "no_face"})
207
+ if face_crop is not None:
208
+ liveness = check_liveness(face_crop)
209
+ else:
210
+ liveness = {"is_live": True}
211
+
212
+ if not liveness.get("is_live", True):
213
+ logger.info(f"Spoof detected (score={liveness.get('score', 0):.4f}, reason={liveness.get('reason', '')})")
214
+ return jsonify({
215
+ "status": "spoof",
216
+ "reason": liveness.get("reason", "Liveness check failed"),
217
+ "scores": liveness.get("scores", {}),
218
+ "composite": liveness.get("score", 0.0),
219
+ })
220
+
221
+ # Identity search
222
+ employee_id, score = store.search(embedding)
223
+ if employee_id is None:
224
+ return jsonify({"status": "unknown"})
225
+
226
+ employee = get_employee(employee_id)
227
+ name = employee["name"] if employee else employee_id
228
+
229
+ result = mark_attendance(employee_id)
230
+
231
+ if result["status"] == "cooldown":
232
+ return jsonify({
233
+ "status": "cooldown",
234
+ "name": name,
235
+ "message": "Please wait 1 minute before punching again.",
236
+ })
237
+
238
+ punch_type = result.get("punch_type", "in")
239
+ logger.info(f"Punch {punch_type}: {employee_id} ({name}) at {result['timestamp']}")
240
+ return jsonify({
241
+ "status": "success",
242
+ "name": name,
243
+ "employee_id": employee_id,
244
+ "timestamp": result["timestamp"],
245
+ "punch_type": punch_type,
246
+ "confidence": round(score, 4),
247
+ })
248
+
249
+
250
+ @app.route("/api/verify-pin", methods=["POST"])
251
+ def api_verify_pin():
252
+ """Verify PIN to unlock Register form for this page. PIN must match REGISTER_PIN (default 3620)."""
253
+ data = request.get_json(force=True)
254
+ pin = (data.get("pin") or "").strip()
255
+ if pin == REGISTER_PIN:
256
+ return jsonify({"status": "ok", "message": "Verified"})
257
+ return jsonify({"status": "error", "message": "Incorrect PIN"}), 403
258
+
259
+
260
+ @app.route("/api/employees", methods=["GET"])
261
+ def api_employees_list():
262
+ employees = get_all_employees()
263
+ return jsonify({"status": "ok", "employees": employees, "count": len(employees)})
264
+
265
+
266
+ @app.route("/api/attendance/today", methods=["GET"])
267
+ def api_today_attendance():
268
+ records = get_today_attendance()
269
+ return jsonify({"status": "ok", "records": records, "count": len(records)})
270
+
271
+
272
+ @app.route("/api/health", methods=["GET"])
273
+ def health():
274
+ return jsonify({
275
+ "status": "ok",
276
+ "total_employees_indexed": store.total_vectors,
277
+ })
278
+
279
+
280
+ # ══════════════════════════════════════════════════════════════════════════════
281
+
282
+ if __name__ == "__main__":
283
+ port = int(os.environ.get("PORT", 5000))
284
+ debug = os.environ.get("FLASK_DEBUG", "0") == "1"
285
+ logger.info(f"One Step Greener starting on http://localhost:{port}")
286
+ app.run(host="0.0.0.0", port=port, debug=debug, threaded=True)
database/__init__.py ADDED
File without changes
database/__pycache__/__init__.cpython-38.pyc ADDED
Binary file (138 Bytes). View file
 
database/__pycache__/db.cpython-38.pyc ADDED
Binary file (6.85 kB). View file
 
database/constable.db ADDED
Binary file (20.5 kB). View file
 
database/db.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CONSTABLE – SQLite database helpers.
3
+ Tables:
4
+ employees – id (TEXT PK), name (TEXT), user_type (TEXT), aadhaar (TEXT), mobile (TEXT), registered_at (TEXT)
5
+ attendance – id (INTEGER PK), employee_id (TEXT FK), timestamp (TEXT), date (TEXT), punch_type (TEXT 'in'|'out')
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; add new columns to existing tables."""
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
+ user_type TEXT DEFAULT 'employee',
33
+ aadhaar TEXT DEFAULT '',
34
+ mobile TEXT DEFAULT ''
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS attendance (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ employee_id TEXT NOT NULL,
40
+ timestamp TEXT NOT NULL,
41
+ date TEXT NOT NULL,
42
+ punch_type TEXT NOT NULL DEFAULT 'in',
43
+ at_iso TEXT,
44
+ FOREIGN KEY (employee_id) REFERENCES employees(id)
45
+ );
46
+ """)
47
+ # Migrate: add punch_type to attendance if missing
48
+ try:
49
+ cur.execute("SELECT punch_type FROM attendance LIMIT 1")
50
+ except sqlite3.OperationalError:
51
+ cur.execute("ALTER TABLE attendance ADD COLUMN punch_type TEXT DEFAULT 'in'")
52
+ cur.execute("UPDATE attendance SET punch_type = 'in' WHERE punch_type IS NULL OR punch_type = ''")
53
+ # Migrate: add at_iso for cooldown if missing
54
+ cur.execute("PRAGMA table_info(attendance)")
55
+ cols = [r[1] for r in cur.fetchall()]
56
+ if "at_iso" not in cols:
57
+ cur.execute("ALTER TABLE attendance ADD COLUMN at_iso TEXT")
58
+ # Migrate employees: add user_type, aadhaar, mobile if missing
59
+ for col, default in [("user_type", "employee"), ("aadhaar", ""), ("mobile", "")]:
60
+ try:
61
+ cur.execute("SELECT " + col + " FROM employees LIMIT 1")
62
+ except sqlite3.OperationalError:
63
+ cur.execute("ALTER TABLE employees ADD COLUMN " + col + " TEXT DEFAULT '" + default.replace("'", "''") + "'")
64
+ cur.execute("UPDATE employees SET " + col + " = ? WHERE " + col + " IS NULL", (default,))
65
+ conn.commit()
66
+ conn.close()
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Employee helpers
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def add_employee(employee_id: str, name: str, user_type: str = "employee", aadhaar: str = "", mobile: str = "") -> bool:
74
+ """Insert or replace an employee record. Returns True on success."""
75
+ conn = get_connection()
76
+ try:
77
+ conn.execute(
78
+ """INSERT OR REPLACE INTO employees (id, name, registered_at, user_type, aadhaar, mobile)
79
+ VALUES (?, ?, ?, ?, ?, ?)""",
80
+ (employee_id, name, datetime.now().isoformat(timespec="seconds"), user_type, aadhaar or "", mobile or ""),
81
+ )
82
+ conn.commit()
83
+ return True
84
+ except Exception as e:
85
+ print(f"[DB] add_employee error: {e}")
86
+ return False
87
+ finally:
88
+ conn.close()
89
+
90
+
91
+ def get_employee(employee_id: str):
92
+ """Return employee row or None."""
93
+ conn = get_connection()
94
+ try:
95
+ row = conn.execute(
96
+ "SELECT * FROM employees WHERE id = ?", (employee_id,)
97
+ ).fetchone()
98
+ return dict(row) if row else None
99
+ finally:
100
+ conn.close()
101
+
102
+
103
+ def get_all_employees():
104
+ conn = get_connection()
105
+ try:
106
+ rows = conn.execute("SELECT * FROM employees ORDER BY registered_at DESC").fetchall()
107
+ return [dict(r) for r in rows]
108
+ finally:
109
+ conn.close()
110
+
111
+
112
+ def delete_employee(employee_id: str) -> bool:
113
+ """Delete an employee and their attendance records. Returns True on success."""
114
+ conn = get_connection()
115
+ try:
116
+ conn.execute("DELETE FROM attendance WHERE employee_id = ?", (employee_id,))
117
+ conn.execute("DELETE FROM employees WHERE id = ?", (employee_id,))
118
+ conn.commit()
119
+ return True
120
+ except Exception as e:
121
+ print(f"[DB] delete_employee error: {e}")
122
+ return False
123
+ finally:
124
+ conn.close()
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Attendance: one punch in per day, then only punch out; 1 min cooldown; last punch out only
129
+ # ---------------------------------------------------------------------------
130
+
131
+ COOLDOWN_SECONDS = 60
132
+
133
+
134
+ def mark_attendance(employee_id: str) -> dict:
135
+ """
136
+ First time today = punch in only. Every other time = punch out only.
137
+ 1 min cooldown for same user. Only last punch out time is used for display.
138
+ Returns {'status': 'success'|'cooldown', 'punch_type': 'in'|'out', 'timestamp': ...}
139
+ """
140
+ today = date.today().isoformat()
141
+ now = datetime.now()
142
+ now_iso = now.isoformat()
143
+ ts = now.strftime("%I:%M %p")
144
+ conn = get_connection()
145
+ try:
146
+ last_row = conn.execute(
147
+ "SELECT at_iso FROM attendance WHERE employee_id = ? AND date = ? ORDER BY id DESC LIMIT 1",
148
+ (employee_id, today),
149
+ ).fetchone()
150
+ if last_row and last_row["at_iso"]:
151
+ try:
152
+ last_dt = datetime.fromisoformat(last_row["at_iso"])
153
+ if (now - last_dt).total_seconds() < COOLDOWN_SECONDS:
154
+ return {"status": "cooldown", "punch_type": None, "timestamp": None}
155
+ except (ValueError, TypeError):
156
+ pass
157
+
158
+ has_any_today = conn.execute(
159
+ "SELECT 1 FROM attendance WHERE employee_id = ? AND date = ? LIMIT 1",
160
+ (employee_id, today),
161
+ ).fetchone()
162
+ next_punch = "out" if has_any_today else "in"
163
+
164
+ conn.execute(
165
+ "INSERT INTO attendance (employee_id, timestamp, date, punch_type, at_iso) VALUES (?, ?, ?, ?, ?)",
166
+ (employee_id, ts, today, next_punch, now_iso),
167
+ )
168
+ conn.commit()
169
+ return {"status": "success", "punch_type": next_punch, "timestamp": ts}
170
+ finally:
171
+ conn.close()
172
+
173
+
174
+ def get_today_attendance():
175
+ """Return today's attendance: one row per employee with first punch_in and last punch_out."""
176
+ today = date.today().isoformat()
177
+ conn = get_connection()
178
+ try:
179
+ rows = conn.execute(
180
+ """SELECT e.id, e.name,
181
+ (SELECT MIN(a.timestamp) FROM attendance a WHERE a.employee_id = e.id AND a.date = ? AND a.punch_type = 'in') AS punch_in,
182
+ (SELECT MAX(a.timestamp) FROM attendance a WHERE a.employee_id = e.id AND a.date = ? AND a.punch_type = 'out') AS punch_out
183
+ FROM attendance a
184
+ JOIN employees e ON a.employee_id = e.id
185
+ WHERE a.date = ?
186
+ GROUP BY e.id, e.name""",
187
+ (today, today, today),
188
+ ).fetchall()
189
+ return [dict(r) for r in rows]
190
+ finally:
191
+ conn.close()
database/face_index.faiss ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:02cc24dd7047fd0c7d8673f3757598ddb98516aed8e86731522b58b56298c062
3
+ size 104493
database/face_meta.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"0": "sahil", "1": "sahil", "2": "sahil", "3": "sahil", "4": "sahil", "5": "tt", "6": "tt", "7": "tt", "8": "tt", "9": "tt", "10": "EMP-TEST", "11": "dasdasd", "12": "dasdasd", "13": "dasdasd", "14": "dasdasd", "15": "dasdasd", "16": "TANISHK", "17": "TANISHK", "18": "TANISHK", "19": "TANISHK", "20": "TANISHK", "21": "PRIYANSHU", "22": "PRIYANSHU", "23": "PRIYANSHU", "24": "PRIYANSHU", "25": "PRIYANSHU", "26": "Amber", "27": "Amber", "28": "Amber", "29": "Amber", "30": "Amber", "31": "Gunjan", "32": "Gunjan", "33": "Gunjan", "34": "Gunjan", "35": "Gunjan", "36": "Abhilash", "37": "Abhilash", "38": "Abhilash", "39": "Abhilash", "40": "Abhilash", "41": "48670hy", "42": "48670hy", "43": "48670hy", "44": "48670hy", "45": "48670hy", "46": "sadsa", "47": "sadsa", "48": "sadsa", "49": "sadsa", "50": "sadsa"}
models/__init__.py ADDED
File without changes
models/__pycache__/__init__.cpython-38.pyc ADDED
Binary file (136 Bytes). View file
 
models/__pycache__/anti_spoof.cpython-38.pyc ADDED
Binary file (13.5 kB). View file
 
models/__pycache__/embeddings_store.cpython-38.pyc ADDED
Binary file (4.77 kB). View file
 
models/__pycache__/face_engine.cpython-38.pyc ADDED
Binary file (5.26 kB). View file
 
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.60 # 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
+ }
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))
models/face_engine.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 check_face_in_frame(pil_image: Image.Image) -> dict:
75
+ """
76
+ Lightweight face check for live feedback (no embedding).
77
+ Returns dict: face_detected, centered, big_enough.
78
+ Face is centered if bbox center lies in middle 50% of image.
79
+ Big enough if face width >= 80px and area >= 3% of image.
80
+ """
81
+ out = {"face_detected": False, "centered": False, "big_enough": False}
82
+ if not FACENET_OK:
83
+ return out
84
+ mtcnn, _ = _get_models()
85
+ try:
86
+ boxes, _ = mtcnn.detect(pil_image)
87
+ except Exception as e:
88
+ logger.debug(f"Face check error: {e}")
89
+ return out
90
+ if boxes is None or len(boxes) == 0:
91
+ return out
92
+ w, h = pil_image.size
93
+ b = boxes[0]
94
+ x1, y1, x2, y2 = float(b[0]), float(b[1]), float(b[2]), float(b[3])
95
+ face_w = x2 - x1
96
+ face_h = y2 - y1
97
+ face_area = face_w * face_h
98
+ img_area = w * h
99
+ out["face_detected"] = True
100
+ # Centered: face center in middle 50% of frame
101
+ cx = (x1 + x2) / 2
102
+ cy = (y1 + y2) / 2
103
+ out["centered"] = (0.25 * w <= cx <= 0.75 * w) and (0.25 * h <= cy <= 0.75 * h)
104
+ # Big enough: width >= 80 and area >= 3% of image
105
+ out["big_enough"] = face_w >= 80 and (face_area / max(img_area, 1)) >= 0.03
106
+ return out
107
+
108
+
109
+ def get_face_embedding(pil_image: Image.Image):
110
+ """
111
+ Detect the largest face and return its 512-d embedding as a numpy array.
112
+ Returns (embedding: np.ndarray, face_crop: np.ndarray) or (None, None).
113
+ """
114
+ if not FACENET_OK:
115
+ return None, None
116
+
117
+ mtcnn, resnet = _get_models()
118
+
119
+ try:
120
+ # MTCNN returns aligned face tensor (or None)
121
+ face_tensor, prob = mtcnn(pil_image, return_prob=True)
122
+ except Exception as e:
123
+ logger.debug(f"MTCNN error: {e}")
124
+ return None, None
125
+
126
+ if face_tensor is None:
127
+ return None, None
128
+
129
+ # Get the face crop as numpy for anti-spoofing
130
+ boxes, _ = mtcnn.detect(pil_image)
131
+ face_crop = None
132
+ if boxes is not None and len(boxes) > 0:
133
+ b = boxes[0].astype(int)
134
+ arr = np.array(pil_image)
135
+ x1, y1, x2, y2 = max(0, b[0]), max(0, b[1]), b[2], b[3]
136
+ face_crop = arr[y1:y2, x1:x2]
137
+
138
+ import torch
139
+ with torch.no_grad():
140
+ embedding = resnet(face_tensor.unsqueeze(0).to(DEVICE))
141
+
142
+ return embedding.squeeze().cpu().numpy(), face_crop
143
+
144
+
145
+ def get_embeddings_from_frames(data_urls: list):
146
+ """
147
+ Process a list of base64 frame data-URLs.
148
+ Returns list of valid 512-d embeddings (may be empty).
149
+ """
150
+ embeddings = []
151
+ for url in data_urls:
152
+ try:
153
+ img = decode_image(url)
154
+ emb, _ = get_face_embedding(img)
155
+ if emb is not None:
156
+ embeddings.append(emb.tolist())
157
+ except Exception as e:
158
+ logger.debug(f"Frame processing error: {e}")
159
+ return embeddings
160
+
161
+
162
+ def get_embeddings_and_crops_from_frames(data_urls: list):
163
+ """
164
+ Process a list of base64 frame data-URLs.
165
+ Returns (embeddings: list of 512-d lists, face_crops: list of np.ndarray or None).
166
+ face_crops[i] is the face crop for frame i (None if no face in that frame).
167
+ """
168
+ embeddings = []
169
+ crops = []
170
+ for url in data_urls:
171
+ try:
172
+ img = decode_image(url)
173
+ emb, face_crop = get_face_embedding(img)
174
+ if emb is not None:
175
+ embeddings.append(emb.tolist())
176
+ crops.append(face_crop)
177
+ else:
178
+ crops.append(None)
179
+ except Exception as e:
180
+ logger.debug(f"Frame processing error: {e}")
181
+ crops.append(None)
182
+ return embeddings, crops
183
+
184
+
185
+ def get_face_crops_from_frames(data_urls: list):
186
+ """
187
+ Get face crops only from a list of base64 frame data-URLs (for liveness sequence).
188
+ Returns list of np.ndarray (face crops); frames with no face are omitted.
189
+ """
190
+ _, crops = get_embeddings_and_crops_from_frames(data_urls)
191
+ return [c for c in crops if c is not None and c.size > 0]
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
static/css/style.css ADDED
@@ -0,0 +1,842 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ .status-hint {
488
+ text-align: center;
489
+ font-size: 12px;
490
+ color: var(--color-text-subtle);
491
+ margin-top: 4px;
492
+ margin-bottom: 0;
493
+ }
494
+
495
+ /* ── Success modal ───────────────────────────────────────────────────────── */
496
+ .modal-overlay {
497
+ position: fixed;
498
+ inset: 0;
499
+ background: rgba(0, 0, 0, 0.4);
500
+ display: flex;
501
+ align-items: center;
502
+ justify-content: center;
503
+ padding: 24px;
504
+ opacity: 0;
505
+ pointer-events: none;
506
+ transition: opacity 0.25s ease;
507
+ z-index: 100;
508
+ backdrop-filter: blur(4px);
509
+ }
510
+
511
+ .modal-overlay.show {
512
+ opacity: 1;
513
+ pointer-events: auto;
514
+ }
515
+
516
+ .modal-card {
517
+ background: var(--color-surface);
518
+ border: 1px solid var(--color-border);
519
+ border-radius: var(--radius-lg);
520
+ padding: 36px 28px;
521
+ width: 100%;
522
+ max-width: 320px;
523
+ text-align: center;
524
+ box-shadow: var(--shadow-card);
525
+ transform: translateY(12px);
526
+ transition: transform 0.25s ease;
527
+ }
528
+
529
+ .modal-overlay.show .modal-card {
530
+ transform: translateY(0);
531
+ }
532
+
533
+ .checkmark-animated {
534
+ width: 64px;
535
+ height: 64px;
536
+ border-radius: 50%;
537
+ background: var(--color-primary-dim);
538
+ border: 2px solid var(--color-primary);
539
+ display: flex;
540
+ align-items: center;
541
+ justify-content: center;
542
+ font-size: 28px;
543
+ color: var(--color-primary);
544
+ margin: 0 auto 16px;
545
+ animation: pop 0.4s ease forwards;
546
+ }
547
+
548
+ @keyframes pop {
549
+ from { transform: scale(0.9); opacity: 0; }
550
+ to { transform: scale(1); opacity: 1; }
551
+ }
552
+
553
+ .modal-card h2 {
554
+ font-size: 18px;
555
+ margin-bottom: 6px;
556
+ }
557
+
558
+ .employee-name {
559
+ font-size: 17px;
560
+ font-weight: 600;
561
+ color: var(--color-text);
562
+ margin: 4px 0;
563
+ }
564
+
565
+ .timestamp {
566
+ font-size: 13px;
567
+ color: var(--color-text-muted);
568
+ margin-bottom: 20px;
569
+ }
570
+
571
+ /* ── Register success state ──────────────────────────────────────────────── */
572
+ .success-state {
573
+ text-align: center;
574
+ padding: 32px 0;
575
+ animation: fadeIn 0.35s ease;
576
+ }
577
+
578
+ @keyframes fadeIn {
579
+ from { opacity: 0; transform: translateY(12px); }
580
+ to { opacity: 1; transform: translateY(0); }
581
+ }
582
+
583
+ .checkmark-large,
584
+ .success-face-icon {
585
+ width: 80px;
586
+ height: 80px;
587
+ margin: 0 auto 20px;
588
+ display: block;
589
+ }
590
+
591
+ .success-face-icon {
592
+ object-fit: contain;
593
+ }
594
+
595
+ .checkmark-large {
596
+ border-radius: 50%;
597
+ background: var(--color-primary-dim);
598
+ border: 2px solid var(--color-primary);
599
+ display: flex;
600
+ align-items: center;
601
+ justify-content: center;
602
+ font-size: 36px;
603
+ color: var(--color-primary);
604
+ }
605
+
606
+ .success-state h2 {
607
+ margin-bottom: 12px;
608
+ }
609
+
610
+ .chip {
611
+ display: inline-block;
612
+ padding: 6px 14px;
613
+ background: var(--color-surface-2);
614
+ border: 1px solid var(--color-border);
615
+ border-radius: var(--radius-full);
616
+ font-size: 13px;
617
+ font-weight: 500;
618
+ color: var(--color-text-muted);
619
+ margin-bottom: 24px;
620
+ }
621
+
622
+ /* ── Register form: switchable tab Employee | Manforce ─────────────────────── */
623
+ #registerForm {
624
+ display: flex;
625
+ flex-direction: column;
626
+ gap: 0;
627
+ }
628
+
629
+ .register-tabs {
630
+ display: flex;
631
+ gap: 0;
632
+ margin-bottom: 16px;
633
+ border-bottom: 1px solid var(--color-border);
634
+ }
635
+
636
+ .register-tab {
637
+ flex: 1;
638
+ padding: 12px 16px;
639
+ font-size: 14px;
640
+ font-weight: 600;
641
+ letter-spacing: 0.02em;
642
+ color: var(--color-text-muted);
643
+ background: transparent;
644
+ border: none;
645
+ border-bottom: 3px solid transparent;
646
+ cursor: pointer;
647
+ transition: color var(--transition), border-color var(--transition);
648
+ }
649
+
650
+ .register-tab:hover {
651
+ color: var(--color-text);
652
+ }
653
+
654
+ .register-tab.active {
655
+ color: var(--color-primary);
656
+ border-bottom-color: var(--color-primary);
657
+ }
658
+
659
+ .register-panel {
660
+ display: none;
661
+ flex-direction: column;
662
+ gap: 10px;
663
+ margin-bottom: 16px;
664
+ }
665
+
666
+ .register-panel.active {
667
+ display: flex;
668
+ }
669
+
670
+ .register-panel input {
671
+ margin-bottom: 0;
672
+ }
673
+
674
+ .register-video-shared {
675
+ margin-top: 8px;
676
+ }
677
+
678
+ /* ── Utility ─────────────────────────────────────────────────────────────── */
679
+ a {
680
+ color: var(--color-primary);
681
+ }
682
+
683
+ a:hover {
684
+ text-decoration: underline;
685
+ }
686
+
687
+ /* ── Scrollbar ───────────────────────────────────────────────────────────── */
688
+ ::-webkit-scrollbar {
689
+ width: 6px;
690
+ }
691
+
692
+ ::-webkit-scrollbar-track {
693
+ background: var(--color-surface-2);
694
+ }
695
+
696
+ ::-webkit-scrollbar-thumb {
697
+ background: var(--color-border);
698
+ border-radius: 3px;
699
+ }
700
+
701
+ /* ── Responsive ──────────────────────────────────────────────────────────── */
702
+ @media (min-width: 640px) {
703
+ .app-shell {
704
+ padding-top: 32px;
705
+ }
706
+ .camera-container {
707
+ max-width: 360px;
708
+ }
709
+ }
710
+
711
+ /* ── Toast (spoof / warning) ────────────────────────────────────────────── */
712
+ .toast {
713
+ position: fixed;
714
+ left: 50%;
715
+ transform: translateX(-50%) translateY(-120%);
716
+ top: 20px;
717
+ z-index: 200;
718
+ max-width: calc(var(--max-w) - 40px);
719
+ width: 100%;
720
+ display: flex;
721
+ align-items: flex-start;
722
+ gap: 12px;
723
+ padding: 14px 18px;
724
+ border-radius: var(--radius-md);
725
+ box-shadow: var(--shadow-card);
726
+ opacity: 0;
727
+ pointer-events: none;
728
+ transition: transform 0.3s ease, opacity 0.25s ease;
729
+ }
730
+
731
+ .toast.show {
732
+ transform: translateX(-50%) translateY(0);
733
+ opacity: 1;
734
+ pointer-events: auto;
735
+ }
736
+
737
+ .toast-icon {
738
+ flex-shrink: 0;
739
+ font-size: 18px;
740
+ line-height: 1.2;
741
+ }
742
+
743
+ .toast-body {
744
+ flex: 1;
745
+ min-width: 0;
746
+ }
747
+
748
+ .toast-title {
749
+ display: block;
750
+ font-size: 13px;
751
+ font-weight: 600;
752
+ margin-bottom: 2px;
753
+ }
754
+
755
+ .toast-message {
756
+ font-size: 13px;
757
+ line-height: 1.35;
758
+ margin: 0;
759
+ opacity: 0.9;
760
+ }
761
+
762
+ .toast-danger {
763
+ background: #fff5f5;
764
+ border: 1px solid rgba(193, 18, 31, 0.3);
765
+ color: #5c1010;
766
+ }
767
+
768
+ .toast-danger .toast-title {
769
+ color: var(--color-danger);
770
+ }
771
+
772
+ .toast-danger .toast-icon {
773
+ color: var(--color-danger);
774
+ }
775
+
776
+ /* ── Global snackbar (popup / snack bar) ──────────────────────────────────── */
777
+ .snackbar {
778
+ position: fixed;
779
+ left: 50%;
780
+ bottom: 24px;
781
+ transform: translateX(-50%) translateY(100px);
782
+ z-index: 300;
783
+ max-width: calc(var(--max-w) - 32px);
784
+ width: 100%;
785
+ display: flex;
786
+ align-items: center;
787
+ gap: 12px;
788
+ padding: 14px 20px;
789
+ border-radius: var(--radius-md);
790
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
791
+ opacity: 0;
792
+ pointer-events: none;
793
+ transition: transform 0.3s ease, opacity 0.25s ease;
794
+ }
795
+
796
+ .snackbar.show {
797
+ transform: translateX(-50%) translateY(0);
798
+ opacity: 1;
799
+ pointer-events: auto;
800
+ }
801
+
802
+ .snackbar-icon {
803
+ flex-shrink: 0;
804
+ font-size: 18px;
805
+ font-weight: 700;
806
+ line-height: 1;
807
+ }
808
+
809
+ .snackbar-message {
810
+ font-size: 14px;
811
+ line-height: 1.35;
812
+ }
813
+
814
+ .snackbar-success {
815
+ background: var(--color-surface);
816
+ border: 1px solid var(--color-primary);
817
+ color: var(--color-text);
818
+ }
819
+
820
+ .snackbar-success .snackbar-icon {
821
+ color: var(--color-primary);
822
+ }
823
+
824
+ .snackbar-error {
825
+ background: #fff5f5;
826
+ border: 1px solid rgba(193, 18, 31, 0.4);
827
+ color: #5c1010;
828
+ }
829
+
830
+ .snackbar-error .snackbar-icon {
831
+ color: var(--color-danger);
832
+ }
833
+
834
+ .snackbar-info {
835
+ background: var(--color-surface);
836
+ border: 1px solid var(--color-border);
837
+ color: var(--color-text);
838
+ }
839
+
840
+ .snackbar-info .snackbar-icon {
841
+ color: var(--color-primary);
842
+ }
static/images/face-id-success.png ADDED

Git LFS Details

  • SHA256: e31dfcaae055e0dbb25a5f5da11396ca6b4179a8d2bf1e34b6d77fb2f802b28d
  • Pointer size: 131 Bytes
  • Size of remote file: 322 kB
static/images/logo.png ADDED

Git LFS Details

  • SHA256: 23e59fea6fba4bda1e7f68d33dcca3b39ac849216dc2a76f672c51546629227e
  • Pointer size: 131 Bytes
  • Size of remote file: 364 kB
static/js/attendance.js ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 modalTitle = document.getElementById('modalTitle');
6
+ const modalName = document.getElementById('modalName');
7
+ const modalTime = document.getElementById('modalTime');
8
+ const cameraContainer = document.getElementById('cameraContainer');
9
+ const statusHint = document.getElementById('statusHint');
10
+
11
+ // Spoof toast
12
+ const spoofToast = document.getElementById('spoofToast');
13
+ const spoofToastMessage = document.getElementById('spoofToastMessage');
14
+
15
+ // Frame buffer for sequence liveness (motion + blink); need enough frames to catch a blink
16
+ const FRAME_BUFFER_SIZE = 14;
17
+ const CAPTURE_INTERVAL_MS = 280;
18
+ let frameBuffer = [];
19
+
20
+ let isScanning = true;
21
+ let stream = null;
22
+ let toastDismissTimer = null;
23
+ let lastFaceAlertAt = 0;
24
+ const FACE_ALERT_COOLDOWN_MS = 4000;
25
+
26
+ // ─── Camera ─────────────────────────────────────────────────────────────────
27
+
28
+ async function startCamera() {
29
+ try {
30
+ stream = await navigator.mediaDevices.getUserMedia({
31
+ video: { facingMode: 'user', width: 640, height: 480 }
32
+ });
33
+ video.srcObject = stream;
34
+ startCaptureLoop();
35
+ } catch (err) {
36
+ console.error("Camera error:", err);
37
+ statusText.textContent = "Camera access denied or unavailable";
38
+ statusText.style.color = "red";
39
+ }
40
+ }
41
+
42
+ function stopCamera() {
43
+ if (stream) {
44
+ stream.getTracks().forEach(track => track.stop());
45
+ }
46
+ }
47
+
48
+ function captureFrame() {
49
+ const canvas = document.createElement('canvas');
50
+ canvas.width = video.videoWidth;
51
+ canvas.height = video.videoHeight;
52
+ const ctx = canvas.getContext('2d');
53
+ ctx.drawImage(video, 0, 0);
54
+ return canvas.toDataURL('image/jpeg', 0.8);
55
+ }
56
+
57
+ // ─── Capture loop ───────────────────────────────────────────────────────────
58
+
59
+ async function startCaptureLoop() {
60
+ while (isScanning) {
61
+ if (video.readyState === video.HAVE_ENOUGH_DATA) {
62
+ const frame = captureFrame();
63
+ frameBuffer.push(frame);
64
+ if (frameBuffer.length > FRAME_BUFFER_SIZE) frameBuffer.shift();
65
+
66
+ // Send sequence only when we have enough frames for blink detection (backend needs ~4+ frames)
67
+ const payload = frameBuffer.length >= 6
68
+ ? { frames: frameBuffer.slice() }
69
+ : { frame: frame };
70
+
71
+ try {
72
+ const response = await fetch('/api/recognize', {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify(payload)
76
+ });
77
+
78
+ const result = await response.json();
79
+ handleResult(result);
80
+ } catch (e) {
81
+ console.log("Network error", e);
82
+ }
83
+ }
84
+
85
+ await new Promise(r => setTimeout(r, CAPTURE_INTERVAL_MS));
86
+ }
87
+ }
88
+
89
+ // ─── Result handler ─────────────────────────────────────────────────────────
90
+
91
+ function handleResult(result) {
92
+ if (result.status === 'success') {
93
+ showSuccess(result);
94
+ var action = (result.punch_type === 'out') ? 'Punched out' : 'Punched in';
95
+ if (typeof showSnackbar === 'function') {
96
+ showSnackbar(action + ' at ' + (result.timestamp || ''), 'success');
97
+ }
98
+ } else if (result.status === 'cooldown') {
99
+ statusText.textContent = 'Please wait 1 min';
100
+ statusText.style.color = "#FFD700";
101
+ if (typeof showSnackbar === 'function') showSnackbar(result.message || 'Please wait 1 minute before punching again.', 'info');
102
+ } else if (result.status === 'spoof') {
103
+ showSpoofToast(result);
104
+ } else if (result.status === 'unknown') {
105
+ statusText.textContent = "Face not recognized";
106
+ statusText.style.color = "#A5A5A5";
107
+ if (typeof showSnackbar === 'function' && Date.now() - lastFaceAlertAt > FACE_ALERT_COOLDOWN_MS) {
108
+ lastFaceAlertAt = Date.now();
109
+ showSnackbar('Face not recognized — ensure your face is clearly visible.', 'info');
110
+ }
111
+ } else if (result.status === 'no_face') {
112
+ statusText.textContent = "Position your face in the frame";
113
+ statusText.style.color = "#A5A5A5";
114
+ if (typeof showSnackbar === 'function' && Date.now() - lastFaceAlertAt > FACE_ALERT_COOLDOWN_MS) {
115
+ lastFaceAlertAt = Date.now();
116
+ showSnackbar('Adjust position — keep your face clearly visible in the frame.', 'info');
117
+ }
118
+ } else if (result.status === 'spoof' && result.reason && result.reason.toLowerCase().includes('blink')) {
119
+ statusText.textContent = "Please blink to verify";
120
+ statusText.style.color = "#FFD700";
121
+ }
122
+ }
123
+
124
+ // ─── Spoof toast (banner, auto-dismiss) ─────────────────────────────────────
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
+ statusText.textContent = "⚠ Spoofing detected";
131
+ statusText.style.color = "#FF3B30";
132
+
133
+ if (toastDismissTimer) clearTimeout(toastDismissTimer);
134
+ toastDismissTimer = setTimeout(() => {
135
+ spoofToast.classList.remove('show');
136
+ statusText.textContent = "Position your face in the frame";
137
+ statusText.style.color = "#A5A5A5";
138
+ toastDismissTimer = null;
139
+ }, 4500);
140
+ }
141
+
142
+ // ─── Success modal ──────────────────────────────────────────────────────────
143
+
144
+ function showSuccess(data) {
145
+ isScanning = false;
146
+ statusDot.classList.add('active');
147
+ if (statusHint) statusHint.style.visibility = 'hidden';
148
+
149
+ if (modalTitle) modalTitle.textContent = (data.punch_type === 'out') ? 'Punched out' : 'Punched in';
150
+ if (modalName) modalName.textContent = data.name;
151
+ if (modalTime) modalTime.textContent = data.timestamp || '';
152
+
153
+ modal.classList.add('show');
154
+
155
+ // Auto dismiss after 4s
156
+ setTimeout(dismissModal, 4000);
157
+ }
158
+
159
+ function dismissModal() {
160
+ modal.classList.remove('show');
161
+ statusDot.classList.remove('active');
162
+ statusText.textContent = "Position your face in the frame";
163
+ statusText.style.color = "#A5A5A5";
164
+ if (statusHint) statusHint.style.visibility = "";
165
+ isScanning = true;
166
+ startCaptureLoop();
167
+ }
168
+
169
+ // ─── Init ───────────────────────────────────────────────────────────────────
170
+ startCamera();
171
+ window.addEventListener('beforeunload', stopCamera);
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
+ }
static/js/register.js ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const video = document.getElementById('videoFeed');
2
+ const progressCircle = document.getElementById('progressCircle');
3
+ const statusText = document.getElementById('statusText');
4
+
5
+ const tabEmployee = document.getElementById('tabEmployee');
6
+ const tabManforce = document.getElementById('tabManforce');
7
+ const panelEmployee = document.getElementById('panelEmployee');
8
+ const panelManforce = document.getElementById('panelManforce');
9
+
10
+ const manforceAadhaar = document.getElementById('manforceAadhaar');
11
+ const manforceName = document.getElementById('manforceName');
12
+ const manforceMobile = document.getElementById('manforceMobile');
13
+ const btnRegisterManforce = document.getElementById('btnRegisterManforce');
14
+ const statusTextManforce = document.getElementById('statusTextManforce');
15
+
16
+ const employeeCode = document.getElementById('employeeCode');
17
+ const btnRegisterEmployee = document.getElementById('btnRegisterEmployee');
18
+ const statusTextEmployee = document.getElementById('statusTextEmployee');
19
+
20
+ const spoofToast = document.getElementById('spoofToast');
21
+ const spoofToastMessage = document.getElementById('spoofToastMessage');
22
+
23
+ if (!video) console.warn('Register: video element missing');
24
+
25
+ let activeTab = 'employee';
26
+ let manforceFrames = [];
27
+ let employeeFrames = [];
28
+ let isCapturing = false;
29
+ let captureTarget = null;
30
+ const REQUIRED_FRAMES = 5;
31
+ const CAPTURE_INTERVAL_MS = 650; // 5 frames over ~3.2s so blink is likely
32
+ const FACE_CHECK_POLL_MS = 500;
33
+ const MAX_RETRIES_PER_SLOT = 2; // retry each slot up to 2 times if no face
34
+ let toastDismissTimer = null;
35
+ let faceCheckInterval = null;
36
+ let faceReady = false;
37
+
38
+ // Start camera
39
+ navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: 640, height: 480 } })
40
+ .then(function(stream) { if (video) video.srcObject = stream; })
41
+ .catch(function(err) { console.error(err); });
42
+
43
+ function switchTab(tab) {
44
+ activeTab = tab;
45
+ if (tab === 'employee') {
46
+ if (tabEmployee) { tabEmployee.classList.add('active'); tabEmployee.setAttribute('aria-selected', 'true'); }
47
+ if (tabManforce) { tabManforce.classList.remove('active'); tabManforce.setAttribute('aria-selected', 'false'); }
48
+ if (panelEmployee) { panelEmployee.classList.add('active'); panelEmployee.removeAttribute('hidden'); }
49
+ if (panelManforce) { panelManforce.classList.remove('active'); panelManforce.setAttribute('hidden', ''); }
50
+ } else {
51
+ if (tabManforce) { tabManforce.classList.add('active'); tabManforce.setAttribute('aria-selected', 'true'); }
52
+ if (tabEmployee) { tabEmployee.classList.remove('active'); tabEmployee.setAttribute('aria-selected', 'false'); }
53
+ if (panelManforce) { panelManforce.classList.add('active'); panelManforce.removeAttribute('hidden'); }
54
+ if (panelEmployee) { panelEmployee.classList.remove('active'); panelEmployee.setAttribute('hidden', ''); }
55
+ }
56
+ updateVisibleButton();
57
+ }
58
+
59
+ function getFramesFor(type) {
60
+ return type === 'manforce' ? manforceFrames : employeeFrames;
61
+ }
62
+
63
+ function setFramesFor(type, frames) {
64
+ if (type === 'manforce') manforceFrames = frames; else employeeFrames = frames;
65
+ }
66
+
67
+ function updateVisibleButton() {
68
+ if (activeTab === 'manforce') updateManforceButton(); else updateEmployeeButton();
69
+ }
70
+
71
+ function updateManforceButton() {
72
+ if (!manforceAadhaar || !manforceName || !manforceMobile || !btnRegisterManforce) return;
73
+ var valid = manforceAadhaar.value.trim().length > 0 && manforceName.value.trim().length > 0 && manforceMobile.value.trim().length > 0;
74
+ if (isCapturing && captureTarget === 'manforce') {
75
+ btnRegisterManforce.disabled = true;
76
+ btnRegisterManforce.textContent = 'CAPTURING...';
77
+ } else if (manforceFrames.length === REQUIRED_FRAMES) {
78
+ btnRegisterManforce.disabled = false;
79
+ btnRegisterManforce.textContent = 'Register';
80
+ btnRegisterManforce.onclick = function() { submitRegistration('manforce'); };
81
+ } else if (valid && faceReady) {
82
+ btnRegisterManforce.disabled = false;
83
+ btnRegisterManforce.textContent = 'START CAPTURE';
84
+ btnRegisterManforce.onclick = function() { startCaptureProcess('manforce'); };
85
+ } else if (valid) {
86
+ btnRegisterManforce.disabled = true;
87
+ btnRegisterManforce.textContent = 'START CAPTURE';
88
+ btnRegisterManforce.onclick = null;
89
+ } else {
90
+ btnRegisterManforce.disabled = true;
91
+ btnRegisterManforce.onclick = null;
92
+ }
93
+ }
94
+
95
+ function updateEmployeeButton() {
96
+ if (!employeeCode || !btnRegisterEmployee) return;
97
+ var valid = employeeCode.value.trim().length > 0;
98
+ if (isCapturing && captureTarget === 'employee') {
99
+ btnRegisterEmployee.disabled = true;
100
+ btnRegisterEmployee.textContent = 'CAPTURING...';
101
+ } else if (employeeFrames.length === REQUIRED_FRAMES) {
102
+ btnRegisterEmployee.disabled = false;
103
+ btnRegisterEmployee.textContent = 'Register';
104
+ btnRegisterEmployee.onclick = function() { submitRegistration('employee'); };
105
+ } else if (valid && faceReady) {
106
+ btnRegisterEmployee.disabled = false;
107
+ btnRegisterEmployee.textContent = 'START CAPTURE';
108
+ btnRegisterEmployee.onclick = function() { startCaptureProcess('employee'); };
109
+ } else if (valid) {
110
+ btnRegisterEmployee.disabled = true;
111
+ btnRegisterEmployee.textContent = 'START CAPTURE';
112
+ btnRegisterEmployee.onclick = null;
113
+ } else {
114
+ btnRegisterEmployee.disabled = true;
115
+ btnRegisterEmployee.onclick = null;
116
+ }
117
+ }
118
+
119
+ if (tabEmployee) tabEmployee.addEventListener('click', function() { switchTab('employee'); });
120
+ if (tabManforce) tabManforce.addEventListener('click', function() { switchTab('manforce'); });
121
+
122
+ // Swipe on tab bar to switch
123
+ var tabBar = document.querySelector('.register-tabs');
124
+ if (tabBar) {
125
+ var touchStartX = 0;
126
+ tabBar.addEventListener('touchstart', function(e) { touchStartX = e.touches[0].clientX; }, { passive: true });
127
+ tabBar.addEventListener('touchend', function(e) {
128
+ var dx = (e.changedTouches[0].clientX - touchStartX);
129
+ if (Math.abs(dx) > 50) {
130
+ if (dx > 0 && activeTab === 'manforce') switchTab('employee');
131
+ else if (dx < 0 && activeTab === 'employee') switchTab('manforce');
132
+ }
133
+ }, { passive: true });
134
+ }
135
+
136
+ [manforceAadhaar, manforceName, manforceMobile].forEach(function(input) {
137
+ if (input) input.addEventListener('input', function() { updateManforceButton(); });
138
+ });
139
+ if (employeeCode) employeeCode.addEventListener('input', function() { updateEmployeeButton(); });
140
+
141
+ function updateProgress(percent) {
142
+ if (progressCircle) {
143
+ progressCircle.style.strokeDashoffset = 113 - (113 * percent);
144
+ }
145
+ }
146
+
147
+ function captureFrameAsDataUrl() {
148
+ if (!video || video.readyState < 2) return null;
149
+ var canvas = document.createElement('canvas');
150
+ canvas.width = video.videoWidth;
151
+ canvas.height = video.videoHeight;
152
+ canvas.getContext('2d').drawImage(video, 0, 0);
153
+ return canvas.toDataURL('image/jpeg', 0.8);
154
+ }
155
+
156
+ function runFaceCheck() {
157
+ if (isCapturing || !video || video.readyState < 2) return;
158
+ var frame = captureFrameAsDataUrl();
159
+ if (!frame) return;
160
+ fetch('/api/face-check', {
161
+ method: 'POST',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({ frame: frame })
164
+ })
165
+ .then(function(r) { return r.json(); })
166
+ .then(function(data) {
167
+ var wasReady = faceReady;
168
+ faceReady = data.ready === true;
169
+ if (faceReady && !wasReady) updateVisibleButton();
170
+ if (!faceReady && wasReady) updateVisibleButton();
171
+ var statusEl = activeTab === 'manforce' ? statusTextManforce : statusTextEmployee;
172
+ var globalStatus = statusText;
173
+ if (getFramesFor(activeTab).length === REQUIRED_FRAMES) return;
174
+ if (data.ready) {
175
+ if (statusEl) statusEl.textContent = 'Face detected — click START CAPTURE';
176
+ if (globalStatus) globalStatus.textContent = 'Face detected — click START CAPTURE';
177
+ } else if (data.face_detected) {
178
+ if (statusEl) statusEl.textContent = 'Move closer and center your face';
179
+ if (globalStatus) globalStatus.textContent = 'Move closer and center your face';
180
+ } else {
181
+ if (statusEl) statusEl.textContent = 'Position your face in the frame';
182
+ if (globalStatus) globalStatus.textContent = 'Position your face in the frame';
183
+ }
184
+ })
185
+ .catch(function() {});
186
+ }
187
+
188
+ function startFaceCheckPolling() {
189
+ stopFaceCheckPolling();
190
+ faceCheckInterval = setInterval(runFaceCheck, FACE_CHECK_POLL_MS);
191
+ runFaceCheck();
192
+ }
193
+
194
+ function stopFaceCheckPolling() {
195
+ if (faceCheckInterval) {
196
+ clearInterval(faceCheckInterval);
197
+ faceCheckInterval = null;
198
+ }
199
+ }
200
+
201
+ function startCaptureProcess(target) {
202
+ if (isCapturing) return;
203
+ captureTarget = target;
204
+ isCapturing = true;
205
+ stopFaceCheckPolling();
206
+ setFramesFor(target, []);
207
+ updateVisibleButton();
208
+ var statusEl = target === 'manforce' ? statusTextManforce : statusTextEmployee;
209
+ if (statusEl) statusEl.textContent = 'Look at the camera and blink naturally...';
210
+ if (statusText) statusText.textContent = 'Look at the camera and blink naturally during capture.';
211
+
212
+ var count = 0;
213
+ var slotRetries = 0;
214
+ function trySlot() {
215
+ if (count >= REQUIRED_FRAMES) {
216
+ finishCapture(target);
217
+ return;
218
+ }
219
+ var frame = captureFrameAsDataUrl();
220
+ if (!frame) {
221
+ setTimeout(trySlot, CAPTURE_INTERVAL_MS);
222
+ return;
223
+ }
224
+ fetch('/api/face-check', {
225
+ method: 'POST',
226
+ headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({ frame: frame })
228
+ })
229
+ .then(function(r) { return r.json(); })
230
+ .then(function(data) {
231
+ if (data.face_detected) {
232
+ var frames = getFramesFor(target);
233
+ frames.push(frame);
234
+ setFramesFor(target, frames);
235
+ count++;
236
+ slotRetries = 0;
237
+ updateProgress(count / REQUIRED_FRAMES);
238
+ var pct = Math.round((count / REQUIRED_FRAMES) * 100);
239
+ if (statusEl) statusEl.textContent = 'Scanning... ' + pct + '%';
240
+ if (statusText) statusText.textContent = 'Scanning... ' + pct + '%';
241
+ setTimeout(trySlot, CAPTURE_INTERVAL_MS);
242
+ } else if (slotRetries < MAX_RETRIES_PER_SLOT) {
243
+ slotRetries++;
244
+ if (statusEl) statusEl.textContent = 'Face not in frame — hold still...';
245
+ if (statusText) statusText.textContent = 'Face not in frame — hold still...';
246
+ setTimeout(trySlot, CAPTURE_INTERVAL_MS);
247
+ } else {
248
+ var frames = getFramesFor(target);
249
+ frames.push(frame);
250
+ setFramesFor(target, frames);
251
+ count++;
252
+ slotRetries = 0;
253
+ updateProgress(count / REQUIRED_FRAMES);
254
+ var pct = Math.round((count / REQUIRED_FRAMES) * 100);
255
+ if (statusEl) statusEl.textContent = 'Scanning... ' + pct + '%';
256
+ if (statusText) statusText.textContent = 'Scanning... ' + pct + '%';
257
+ setTimeout(trySlot, CAPTURE_INTERVAL_MS);
258
+ }
259
+ })
260
+ .catch(function() {
261
+ setTimeout(trySlot, CAPTURE_INTERVAL_MS);
262
+ });
263
+ }
264
+ setTimeout(trySlot, CAPTURE_INTERVAL_MS);
265
+ }
266
+
267
+ function finishCapture(target) {
268
+ isCapturing = false;
269
+ captureTarget = null;
270
+ startFaceCheckPolling();
271
+ var statusEl = target === 'manforce' ? statusTextManforce : statusTextEmployee;
272
+ if (statusEl) {
273
+ statusEl.textContent = 'Face captured ✓';
274
+ statusEl.style.color = 'var(--color-primary)';
275
+ }
276
+ if (statusText) {
277
+ statusText.textContent = 'Face captured ✓';
278
+ statusText.style.color = 'var(--color-primary)';
279
+ }
280
+ updateVisibleButton();
281
+ }
282
+
283
+ async function submitRegistration(userType) {
284
+ var isManforce = userType === 'manforce';
285
+ var btn = isManforce ? btnRegisterManforce : btnRegisterEmployee;
286
+ var frames = getFramesFor(userType);
287
+ if (!btn || !frames || frames.length === 0) return;
288
+
289
+ btn.disabled = true;
290
+ btn.textContent = 'REGISTERING...';
291
+
292
+ var payload;
293
+ if (isManforce) {
294
+ if (!manforceAadhaar || !manforceName || !manforceMobile) return;
295
+ payload = {
296
+ user_type: 'manforce',
297
+ aadhaar: manforceAadhaar.value.trim(),
298
+ name: manforceName.value.trim(),
299
+ mobile: manforceMobile.value.trim(),
300
+ frames: frames
301
+ };
302
+ } else {
303
+ if (!employeeCode) return;
304
+ payload = {
305
+ user_type: 'employee',
306
+ employee_id: employeeCode.value.trim(),
307
+ frames: frames
308
+ };
309
+ }
310
+
311
+ try {
312
+ var res = await fetch('/api/register', {
313
+ method: 'POST',
314
+ headers: { 'Content-Type': 'application/json' },
315
+ body: JSON.stringify(payload)
316
+ });
317
+ var data = await res.json();
318
+
319
+ if (data.status === 'registered') {
320
+ document.getElementById('registerForm').style.display = 'none';
321
+ document.getElementById('successState').style.display = 'block';
322
+ var label = isManforce ? payload.name + ' — ' + payload.aadhaar : payload.employee_id;
323
+ document.getElementById('successChip').textContent = label;
324
+ } else if (data.status === 'spoof') {
325
+ showSpoofToast(data);
326
+ btn.disabled = false;
327
+ btn.textContent = 'Register';
328
+ } else {
329
+ var msg = data.message || 'Unknown error';
330
+ if (msg.indexOf('already registered') !== -1 || msg.indexOf('already registered to') !== -1) {
331
+ if (typeof showSnackbar === 'function') showSnackbar('Duplicate registration — this face is already registered.', 'error');
332
+ } else if (msg.indexOf('No face detected') !== -1 || msg.indexOf('no face') !== -1) {
333
+ if (typeof showSnackbar === 'function') showSnackbar('Clear photo — ensure your face is visible and well lit.', 'info');
334
+ } else {
335
+ if (typeof showSnackbar === 'function') showSnackbar(msg, 'error');
336
+ }
337
+ btn.disabled = false;
338
+ btn.textContent = 'Register';
339
+ }
340
+ } catch (e) {
341
+ if (typeof showSnackbar === 'function') showSnackbar('Network error. Please try again.', 'error');
342
+ btn.disabled = false;
343
+ btn.textContent = 'Register';
344
+ }
345
+ }
346
+
347
+ function showSpoofToast(data) {
348
+ var msg = data.reason || data.message || 'Use a live face, not a photo or screen.';
349
+ if (spoofToastMessage) spoofToastMessage.textContent = msg;
350
+ if (spoofToast) spoofToast.classList.add('show');
351
+ if (toastDismissTimer) clearTimeout(toastDismissTimer);
352
+ toastDismissTimer = setTimeout(function() {
353
+ if (spoofToast) spoofToast.classList.remove('show');
354
+ toastDismissTimer = null;
355
+ }, 4500);
356
+ }
357
+
358
+ // Init: ensure Employee tab/panel active and start live face feedback
359
+ switchTab('employee');
360
+ startFaceCheckPolling();
templates/attendance.html ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ <p class="status-hint" id="statusHint">Look at the camera and blink naturally to verify you're live</p>
35
+
36
+ <!-- ════ Spoof toast (banner, auto-dismiss) ═══════════════════════════════ -->
37
+ <div id="spoofToast" class="toast toast-danger" role="alert" aria-live="polite">
38
+ <span class="toast-icon">⚠</span>
39
+ <div class="toast-body">
40
+ <strong class="toast-title">Spoofing detected</strong>
41
+ <p class="toast-message" id="spoofToastMessage">Use a live face, not a photo or screen.</p>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- ════ Success Modal ═══════════════════════════════════════════════════ -->
46
+ <div id="successModal" class="modal-overlay">
47
+ <div class="modal-card">
48
+ <div class="checkmark-animated">✓</div>
49
+ <h2 id="modalTitle">Checked in</h2>
50
+ <p class="employee-name" id="modalName">John Doe</p>
51
+ <p class="timestamp" id="modalTime">09:42 AM</p>
52
+ <button class="btn-primary" onclick="dismissModal()">DONE</button>
53
+ </div>
54
+ </div>
55
+
56
+ {% endblock %}
57
+
58
+ {% block scripts %}
59
+ <script src="{{ url_for('static', filename='js/attendance.js') }}"></script>
60
+ {% endblock %}
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>
templates/dashboard.html ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ </div>
42
+
43
+ <script>
44
+ setInterval(function() {
45
+ document.getElementById('clock').textContent = new Date().toLocaleTimeString();
46
+ }, 1000);
47
+ </script>
48
+ {% endblock %}
templates/manage.html ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">Registered users. To remove a user, delete them from the database.</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>';
49
+ ul.appendChild(li);
50
+ });
51
+ } else {
52
+ empty.style.display = 'block';
53
+ }
54
+ })
55
+ .catch(function() {
56
+ loading.textContent = 'Could not load list.';
57
+ loading.style.display = 'block';
58
+ });
59
+ }
60
+
61
+ loadList();
62
+ })();
63
+ </script>
64
+ {% endblock %}
templates/register.html ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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; switchable tab Employee | Manforce -->
25
+ <div id="registerForm" class="register-form-locked" style="display: none;">
26
+ <div class="register-tabs" role="tablist">
27
+ <button type="button" id="tabEmployee" class="register-tab active" role="tab" aria-selected="true" data-tab="employee">Employee</button>
28
+ <button type="button" id="tabManforce" class="register-tab" role="tab" aria-selected="false" data-tab="manforce">Manforce</button>
29
+ </div>
30
+ <div id="panelEmployee" class="register-panel active" role="tabpanel">
31
+ <input type="text" id="employeeCode" placeholder="Employee code">
32
+ <p class="status-text" id="statusTextEmployee">Align face within frame</p>
33
+ <button id="btnRegisterEmployee" class="btn-primary" disabled>Register</button>
34
+ </div>
35
+ <div id="panelManforce" class="register-panel" role="tabpanel" hidden>
36
+ <input type="text" id="manforceAadhaar" placeholder="Aadhaar number">
37
+ <input type="text" id="manforceName" placeholder="Full name">
38
+ <input type="text" id="manforceMobile" placeholder="Mobile number">
39
+ <p class="status-text" id="statusTextManforce">Align face within frame</p>
40
+ <button id="btnRegisterManforce" class="btn-primary" disabled>Register</button>
41
+ </div>
42
+ <div class="video-rect-container register-video-shared">
43
+ <video id="videoFeed" autoplay playsinline muted></video>
44
+ <div class="rect-overlay"></div>
45
+ <svg class="progress-ring" viewBox="0 0 40 40">
46
+ <circle cx="20" cy="20" r="18" stroke="#333" stroke-width="4" fill="none"/>
47
+ <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)"/>
48
+ </svg>
49
+ </div>
50
+ <p class="status-text" id="statusText">Align face within frame</p>
51
+ <p class="status-hint register-capture-hint">Look at the camera and blink naturally during capture.</p>
52
+ </div>
53
+
54
+ <!-- Spoof toast (same as attendance) -->
55
+ <div id="spoofToast" class="toast toast-danger" role="alert" aria-live="polite">
56
+ <span class="toast-icon">⚠</span>
57
+ <div class="toast-body">
58
+ <strong class="toast-title">Spoofing detected</strong>
59
+ <p class="toast-message" id="spoofToastMessage">Use a live face, not a photo or screen.</p>
60
+ </div>
61
+ </div>
62
+
63
+ <div id="successState" class="success-state" style="display:none;">
64
+ <img src="{{ url_for('static', filename='images/face-id-success.png') }}" alt="Face registered" class="success-face-icon" />
65
+ <h2>Registered</h2>
66
+ <div class="chip" id="successChip"></div>
67
+ <button class="btn-outline" onclick="location.reload()">Register Another</button>
68
+ <br>
69
+ <a href="/dashboard" style="color: var(--color-text-muted); font-size: 13px; margin-top: 20px; display:inline-block;">Back to check in</a>
70
+ </div>
71
+
72
+ {% endblock %}
73
+
74
+ {% block scripts %}
75
+ <script>
76
+ (function() {
77
+ var pinGate = document.getElementById('pinGate');
78
+ var registerForm = document.getElementById('registerForm');
79
+ var pinInput = document.getElementById('pinInput');
80
+ var pinSubmit = document.getElementById('pinSubmit');
81
+ var pinError = document.getElementById('pinError');
82
+
83
+ if (pinGate && registerForm && pinInput && pinSubmit) {
84
+ function doVerify() {
85
+ var pin = (pinInput.value || '').trim();
86
+ pinError.style.display = 'none';
87
+ if (!pin) {
88
+ pinError.textContent = 'Enter PIN';
89
+ pinError.style.display = 'block';
90
+ return;
91
+ }
92
+ pinSubmit.disabled = true;
93
+ fetch('/api/verify-pin', {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify({ pin: pin })
97
+ })
98
+ .then(function(r) { return r.json(); })
99
+ .then(function(data) {
100
+ pinSubmit.disabled = false;
101
+ if (data.status === 'ok') {
102
+ pinGate.style.display = 'none';
103
+ registerForm.style.display = 'block';
104
+ if (window.loadRegisterScript) window.loadRegisterScript();
105
+ } else {
106
+ pinError.textContent = data.message || 'Incorrect PIN';
107
+ pinError.style.display = 'block';
108
+ }
109
+ })
110
+ .catch(function() {
111
+ pinSubmit.disabled = false;
112
+ pinError.textContent = 'Request failed';
113
+ pinError.style.display = 'block';
114
+ });
115
+ }
116
+ pinSubmit.addEventListener('click', doVerify);
117
+ pinInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') doVerify(); });
118
+
119
+ window.loadRegisterScript = function() {
120
+ if (window.registerScriptLoaded) return;
121
+ window.registerScriptLoaded = true;
122
+ var s = document.createElement('script');
123
+ s.src = "{{ url_for('static', filename='js/register.js') }}";
124
+ document.body.appendChild(s);
125
+ };
126
+ }
127
+ })();
128
+ </script>
129
+ {% endblock %}