update(tracking-tool-web): refactor to speed checkin

This commit is contained in:
Admin 2026-05-12 08:43:12 +07:00
parent bd0d4fa13b
commit 2f1e26d3f6
2 changed files with 269 additions and 89 deletions

134
TrackingToolWeb/CLAUDE.md Normal file
View File

@ -0,0 +1,134 @@
# TrackingToolWeb — CLAUDE.md
## Tổng quan dự án
Hệ thống điểm danh khuôn mặt (Face Check-in) tích hợp với Management System tại `ms.prology.net`. Camera nhận diện khuôn mặt → FastAPI backend so khớp → ghi log → đồng bộ sang hệ thống quản lý.
---
## Kiến trúc
```
Frontend (React/Vite) → Backend (FastAPI/Python) → MySQL
External MS API (ms.prology.net)
```
**Backend**: `main.py` (FastAPI) + `api.py` (external calls) + `sync.py` (data sync)
**Frontend**: `client/src/` — React 19, TypeScript, TailwindCSS, Zustand
**Database**: MySQL — database `face_checkin`
**Deployment**: Backend phục vụ luôn frontend build (`static/`) qua route `/`
---
## Commands
### Backend
```bash
# Development
uvicorn main:app --reload
# Production
nohup uvicorn main:app --host 172.16.6.38 --port 8080 > log.log 2>&1 &
```
### Frontend
```bash
cd client
npm run dev # dev server (Vite HMR)
npm run build # build to client/dist/
npm run lint # ESLint
```
### Deploy frontend
Sau khi build, copy `client/dist/` vào `static/`. Đảm bảo asset paths trong `index.html` dùng prefix `/camera/static/assets/`.
---
## Cấu hình
### Backend (hardcoded — cần đưa vào .env)
| Biến | Giá trị hiện tại | File |
|------|-----------------|------|
| DB URL | `mysql+pymysql://root:123@localhost/face_checkin` | `database.py` |
| MS API base | `https://ms.prology.net/api/v1` | `api.py` |
| JWT token | hardcoded string | `api.py` |
| Face threshold | `0.42` | `main.py:217` |
| Ratio threshold | `0.85` | `main.py:286` |
| Recent check window | 0.5 phút | `main.py` |
### Frontend (.env trong `client/`)
```
VITE_API_BASE_URL=/camera # production (proxy qua nginx)
VITE_API_BASE_MS=https://ms.prology.net
```
---
## API Endpoints
| Method | Path | Mô tả |
|--------|------|-------|
| GET | `/` | Phục vụ `static/index.html` |
| POST | `/register` | Đăng ký khuôn mặt (name, email, file ảnh) |
| POST | `/register-simple` | Đăng ký/cập nhật user không cần ảnh |
| POST | `/checkin` | Nhận diện & điểm danh (file ảnh, camera_id) |
| GET | `/logs` | 20 log điểm danh gần nhất |
| GET | `/users` | Danh sách users + 5 checkpoint gần nhất |
---
## Database Schema
```sql
students (id, name, email UNIQUE, avatar)
student_encodings (id, student_id FK, encoding BLOB[1024 bytes = 128 float64], created_at)
checkin_logs (id, student_id FK, time, camera_id, status[check in/check out])
```
**Encoding format**: `np.float64` array 128 chiều → `.tobytes()` → BLOB 1024 bytes
**Giải mã**: `np.frombuffer(blob, dtype=np.float64)` — validate `enc.size == 128`
---
## Logic nhận diện khuôn mặt (`/checkin`)
1. Nhận ảnh JPEG → lưu tạm `uploads/checkin.jpg`
2. `face_recognition.face_encodings()` → encoding 128-dim
3. Load **tất cả** encodings từ DB → so khớp `face_recognition.face_distance()`
4. Chọn student có `min_dist` nhỏ nhất
5. Kiểm tra: `best_distance ≤ 0.42` **AND** `ratio = best/second_best ≤ 0.85`
6. Kiểm tra recent check (tránh điểm danh 2 lần trong 30 giây)
7. Ghi `checkin_logs``BackgroundTask`: gửi ảnh + tạo history trên MS API
**Bottleneck chính**: Bước 3 — load toàn bộ encodings, giải mã numpy, so khớp tuần tự trong request.
---
## External API (ms.prology.net)
- `POST /api/v1/admin/tracking/scan-create` — tạo history check-in
- `POST /api/v1/admin/tracking/send-image` — upload ảnh check-in
- `GET /api/v1/admin/timekeeping` — lấy dữ liệu chấm công (dùng trong `sync.py`)
Token JWT được hardcode trong `api.py` — cần chuyển sang env variable.
---
## Frontend State Management
**Zustand stores:**
- `use-app-store.ts``isAutoChecking`, `isCountDown`, `refreshLog`, video/canvas refs
- `use-user-store.ts``currentUser` (user được chọn cho checkpoint)
**Auto check-in**: interval 3000ms, gọi `/checkin` liên tục khi `isAutoChecking = true`
---
## Các lưu ý quan trọng
- `UPLOAD_DIR = ./uploads/` — lưu ảnh tạm check-in, bị ghi đè mỗi lần (`checkin.jpg`)
- `images/{YYYY_MM_DD}/` — lưu ảnh vĩnh viễn theo ngày (tạo trong `sync.py`)
- DB session trong `/checkin` dùng `Depends(get_db)`, các endpoint khác tạo `SessionLocal()` trực tiếp — cần thống nhất
- Tối đa 10 encodings/user (giới hạn trong `sync.py`)
- CORS `allow_origins=["*"]` — chấp nhận vì deploy nội bộ

View File

@ -5,6 +5,9 @@ import face_recognition
import numpy as np
import os
import datetime
import threading
import logging
import cv2
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from database import SessionLocal, engine
@ -14,6 +17,71 @@ from sqlalchemy import text
from fastapi.middleware.cors import CORSMiddleware
from api import create_history, send_image
logging.basicConfig(level=logging.INFO)
# --- Encoding cache (Phương án 1: RAM cache) ---
_enc_matrix: np.ndarray | None = None # shape (N, 128)
_enc_student_ids: np.ndarray | None = None # shape (N,) int64
_enc_student_names: dict = {}
_cache_lock = threading.Lock()
_cache_dirty = True
def invalidate_encoding_cache():
global _cache_dirty
_cache_dirty = True
def _load_encoding_cache(db):
global _enc_matrix, _enc_student_ids, _enc_student_names, _cache_dirty
with _cache_lock:
if not _cache_dirty and _enc_matrix is not None:
return _enc_matrix, _enc_student_ids, _enc_student_names
rows = db.execute(
text("""
SELECT s.id AS student_id, s.name AS student_name, se.encoding AS encoding_blob
FROM student_encodings se
JOIN students s ON s.id = se.student_id
""")
).fetchall()
encodings, student_ids, names = [], [], {}
for r in rows:
try:
enc = np.frombuffer(r.encoding_blob, dtype=np.float64)
if enc.size == 128:
encodings.append(enc)
student_ids.append(r.student_id)
names[r.student_id] = r.student_name
else:
logging.warning(f"encoding size invalid for student {r.student_id}: {enc.size}")
except Exception as e:
logging.exception(f"Error decoding encoding for student {r.student_id}: {e}")
if encodings:
_enc_matrix = np.vstack(encodings)
_enc_student_ids = np.array(student_ids, dtype=np.int64)
else:
_enc_matrix = np.empty((0, 128), dtype=np.float64)
_enc_student_ids = np.array([], dtype=np.int64)
_enc_student_names = names
_cache_dirty = False
logging.info(f"Encoding cache loaded: {_enc_matrix.shape[0]} encodings, {len(names)} students")
return _enc_matrix, _enc_student_ids, _enc_student_names
# --- Image preprocessing (Phương án 3: resize trước khi detect) ---
def _preprocess_image(image_data: bytes, max_width: int = 640) -> np.ndarray:
nparr = np.frombuffer(image_data, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
h, w = img.shape[:2]
if w > max_width:
scale = max_width / w
img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
app = FastAPI()
# --- CORS ---
@ -64,8 +132,8 @@ async def register_face(
with open(image_path, "wb") as f:
f.write(image_data)
# Encode face
image = face_recognition.load_image_file(image_path)
# Encode face — dùng _preprocess_image để tránh load lại từ disk
image = _preprocess_image(image_data)
encodings = face_recognition.face_encodings(image)
if not encodings:
@ -89,7 +157,7 @@ async def register_face(
{"student_id": student_id, "encoding": encoding_bytes}
)
db.commit()
invalidate_encoding_cache()
return {"message": "Đã thêm encoding mới."}
else:
@ -118,7 +186,7 @@ async def register_face(
{"student_id": student_id, "encoding": encoding_bytes}
)
db.commit()
invalidate_encoding_cache()
return {"message": "Đăng ký thành công."}
except IntegrityError:
@ -192,20 +260,10 @@ async def register_student(
@app.post("/checkin")
async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...), camera_id: str = Form("cam1"), db: Session = Depends(get_db)):
import logging
logging.basicConfig(level=logging.INFO)
image_data = await file.read()
path = os.path.join(UPLOAD_DIR, "checkin.jpg")
with open(path, "wb") as f:
f.write(image_data)
unknown_img = face_recognition.load_image_file(path)
# Option: dùng CNN detector (chậm nhưng chính xác hơn) nếu bạn đã cài dlib với CUDA / muốn chính xác tối đa:
# unknown_locations = face_recognition.face_locations(unknown_img, model="cnn")
# unknown_encodings = face_recognition.face_encodings(unknown_img, unknown_locations)
# Phương án 3: resize ảnh trước khi detect — bỏ disk I/O
unknown_img = _preprocess_image(image_data)
unknown_encodings = face_recognition.face_encodings(unknown_img)
if not unknown_encodings:
return {"message": "No face detected.", "status": False}
@ -216,60 +274,29 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
# Thử: 0.4 (chặt), 0.45 (cân bằng), 0.55 (lỏng)
DIST_THRESHOLD = 0.42
# Lấy tất cả encodings (mỗi row là một encoding blob) kèm student info
rows = db.execute(
text("""
SELECT s.id AS student_id, s.name AS student_name, se.encoding AS encoding_blob
FROM student_encodings se
JOIN students s ON s.id = se.student_id
""")
).fetchall()
# Phương án 1: dùng cache RAM thay vì query DB mỗi request
enc_matrix, enc_sids, enc_names = _load_encoding_cache(db)
# Gom các encoding theo student_id
from collections import defaultdict
student_encodings = defaultdict(list)
student_names = {}
for r in rows:
sid = r.student_id
student_names[sid] = r.student_name
# chuyển BLOB -> numpy array đúng dtype & shape
try:
enc = np.frombuffer(r.encoding_blob, dtype=np.float64)
# Một bản encoding phải dài 128
if enc.size == 128:
student_encodings[sid].append(enc)
else:
logging.warning(f"encoding size invalid for student {sid}: {enc.size}")
except Exception as e:
logging.exception(f"Error decoding encoding for student {sid}: {e}")
# Nếu không có encoding nào trong DB
if not student_encodings:
if enc_matrix.shape[0] == 0:
return {"message": "No known encodings in DB.", "status": False}
# Tìm khoảng cách nhỏ nhất cho từng student
# Phương án 2: vectorized — tính tất cả distances 1 lần qua BLAS
all_dists = face_recognition.face_distance(enc_matrix, unknown_encoding)
# Tìm min distance theo từng student
best_student = None
best_distance = float("inf")
second_best_distance = float("inf")
for sid, enc_list in student_encodings.items():
# calc distances between unknown and all encs of this student
try:
dists = face_recognition.face_distance(enc_list, unknown_encoding) # returns array
except Exception:
# fallback if enc_list is list of 1D arrays -> convert to 2D array
arr = np.vstack(enc_list)
dists = face_recognition.face_distance(arr, unknown_encoding)
for sid in np.unique(enc_sids):
mask = enc_sids == sid
min_dist = float(np.min(all_dists[mask]))
logging.info(f"Student {sid} ({enc_names.get(sid)}) min_dist = {min_dist:.4f}")
min_dist = float(np.min(dists))
logging.info(f"Student {sid} ({student_names.get(sid)}) min_dist = {min_dist:.4f}")
# update best / second best global
if min_dist < best_distance:
second_best_distance = best_distance
best_distance = min_dist
best_student = sid
best_student = int(sid)
elif min_dist < second_best_distance:
second_best_distance = min_dist
@ -291,8 +318,8 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
now = datetime.datetime.now()
recent_check = db.execute(
text("""
SELECT id FROM checkin_logs
WHERE student_id = :student_id
SELECT id FROM checkin_logs
WHERE student_id = :student_id
AND time > :time_threshold
"""),
{
@ -302,30 +329,21 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
).fetchone()
if recent_check:
return {"message": f"{student_names.get(best_student)} already checked in recently.", "status": True}
# thêm dô đây
id_log = 0
ms_response = create_history({"name": student_names.get(best_student).split('\n')[0], "time_string": f"{datetime.datetime.now()}", "status": "check in"})
id_log = ms_response.get('data').get('id')
status = ms_response.get('data').get('status')
# reset pointer
file.file.seek(0)
background_tasks.add_task(
send_image,
id_log,
image_data, # truyền bytes, không phải UploadFile
student_names.get(best_student),
status
)
return {"message": f"{enc_names.get(best_student)} already checked in recently.", "status": True}
last_log = db.execute(
text("""
SELECT status FROM checkin_logs
WHERE student_id = :student_id
ORDER BY time DESC LIMIT 1
"""),
{"student_id": best_student}
).fetchone()
status = "check out" if last_log and last_log.status == "check in" else "check in"
db.execute(
text("""
INSERT INTO checkin_logs (student_id, time, camera_id, status)
INSERT INTO checkin_logs (student_id, time, camera_id, status)
VALUES (:student_id, :time, :camera_id, :status)
"""),
{
@ -336,8 +354,37 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
}
)
db.commit()
log_id = db.execute(text("SELECT LAST_INSERT_ID()")).scalar()
def _sync_to_ms(name: str, time_string: str, img_data: bytes, local_status: str, checkin_log_id: int):
try:
ms_response = create_history({"name": name.split('\n')[0], "time_string": time_string, "status": local_status})
id_log = ms_response.get('data', {}).get('id', 0)
ms_status = ms_response.get('data', {}).get('status', local_status)
if ms_status != local_status:
fix_db = SessionLocal()
try:
fix_db.execute(
text("UPDATE checkin_logs SET status = :status WHERE id = :id"),
{"status": ms_status, "id": checkin_log_id}
)
fix_db.commit()
logging.info(f"Corrected log #{checkin_log_id} status: {local_status}{ms_status}")
finally:
fix_db.close()
send_image(id_log, img_data, name, ms_status)
except Exception as e:
logging.error(f"MS sync error: {e}")
background_tasks.add_task(
_sync_to_ms,
enc_names.get(best_student),
f"{datetime.datetime.now()}",
image_data,
status,
log_id,
)
student = db.execute(
text("""
SELECT id, name, email
@ -346,15 +393,14 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
"""),
{"id": best_student}
).fetchone()
user_data = {
"id": student.id,
"name": student.name,
"email": student.email,
} if student else None
return {"message": f"{status} successful for {student_names.get(best_student)} (dist={best_distance:.4f})", "status": True, "status_type":status, "data": user_data}
return {"message": f"{status} successful for {enc_names.get(best_student)} (dist={best_distance:.4f})", "status": True, "status_type": status, "data": user_data}
# Nếu không thỏa threshold/rule thì trả no match (và log lý do)
reasons = []