Spaces:
Runtime error
Runtime error
Upload 31 files
Browse files- .gitattributes +3 -0
- Dockerfile +27 -0
- README.md +210 -6
- __pycache__/app.cpython-38.pyc +0 -0
- app.py +286 -0
- database/__init__.py +0 -0
- database/__pycache__/__init__.cpython-38.pyc +0 -0
- database/__pycache__/db.cpython-38.pyc +0 -0
- database/constable.db +0 -0
- database/db.py +191 -0
- database/face_index.faiss +3 -0
- database/face_meta.json +1 -0
- models/__init__.py +0 -0
- models/__pycache__/__init__.cpython-38.pyc +0 -0
- models/__pycache__/anti_spoof.cpython-38.pyc +0 -0
- models/__pycache__/embeddings_store.cpython-38.pyc +0 -0
- models/__pycache__/face_engine.cpython-38.pyc +0 -0
- models/anti_spoof.py +476 -0
- models/embeddings_store.py +138 -0
- models/face_engine.py +191 -0
- requirements.txt +10 -0
- static/css/style.css +842 -0
- static/images/face-id-success.png +3 -0
- static/images/logo.png +3 -0
- static/js/attendance.js +171 -0
- static/js/camera.js +12 -0
- static/js/register.js +360 -0
- templates/attendance.html +60 -0
- templates/base.html +40 -0
- templates/dashboard.html +48 -0
- templates/manage.html +64 -0
- templates/register.html +129 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AttendanceFaceRecognition
|
| 3 |
+
emoji: 🐠
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
short_description: Face recognition attendance for One Step Greener
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# 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
|
static/images/logo.png
ADDED
|
Git LFS Details
|
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 %}
|