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 numpy as np
import os import os
import datetime import datetime
import threading
import logging
import cv2
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from database import SessionLocal, engine from database import SessionLocal, engine
@ -14,6 +17,71 @@ from sqlalchemy import text
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from api import create_history, send_image 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() app = FastAPI()
# --- CORS --- # --- CORS ---
@ -64,8 +132,8 @@ async def register_face(
with open(image_path, "wb") as f: with open(image_path, "wb") as f:
f.write(image_data) f.write(image_data)
# Encode face # Encode face — dùng _preprocess_image để tránh load lại từ disk
image = face_recognition.load_image_file(image_path) image = _preprocess_image(image_data)
encodings = face_recognition.face_encodings(image) encodings = face_recognition.face_encodings(image)
if not encodings: if not encodings:
@ -89,7 +157,7 @@ async def register_face(
{"student_id": student_id, "encoding": encoding_bytes} {"student_id": student_id, "encoding": encoding_bytes}
) )
db.commit() db.commit()
invalidate_encoding_cache()
return {"message": "Đã thêm encoding mới."} return {"message": "Đã thêm encoding mới."}
else: else:
@ -118,7 +186,7 @@ async def register_face(
{"student_id": student_id, "encoding": encoding_bytes} {"student_id": student_id, "encoding": encoding_bytes}
) )
db.commit() db.commit()
invalidate_encoding_cache()
return {"message": "Đăng ký thành công."} return {"message": "Đăng ký thành công."}
except IntegrityError: except IntegrityError:
@ -192,20 +260,10 @@ async def register_student(
@app.post("/checkin") @app.post("/checkin")
async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...), camera_id: str = Form("cam1"), db: Session = Depends(get_db)): 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() 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) unknown_encodings = face_recognition.face_encodings(unknown_img)
if not unknown_encodings: if not unknown_encodings:
return {"message": "No face detected.", "status": False} 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) # Thử: 0.4 (chặt), 0.45 (cân bằng), 0.55 (lỏng)
DIST_THRESHOLD = 0.42 DIST_THRESHOLD = 0.42
# Lấy tất cả encodings (mỗi row là một encoding blob) kèm student info # Phương án 1: dùng cache RAM thay vì query DB mỗi request
rows = db.execute( enc_matrix, enc_sids, enc_names = _load_encoding_cache(db)
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()
# Gom các encoding theo student_id if enc_matrix.shape[0] == 0:
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:
return {"message": "No known encodings in DB.", "status": False} 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_student = None
best_distance = float("inf") best_distance = float("inf")
second_best_distance = float("inf") second_best_distance = float("inf")
for sid, enc_list in student_encodings.items(): for sid in np.unique(enc_sids):
# calc distances between unknown and all encs of this student mask = enc_sids == sid
try: min_dist = float(np.min(all_dists[mask]))
dists = face_recognition.face_distance(enc_list, unknown_encoding) # returns array logging.info(f"Student {sid} ({enc_names.get(sid)}) min_dist = {min_dist:.4f}")
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)
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: if min_dist < best_distance:
second_best_distance = best_distance second_best_distance = best_distance
best_distance = min_dist best_distance = min_dist
best_student = sid best_student = int(sid)
elif min_dist < second_best_distance: elif min_dist < second_best_distance:
second_best_distance = min_dist second_best_distance = min_dist
@ -291,8 +318,8 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
now = datetime.datetime.now() now = datetime.datetime.now()
recent_check = db.execute( recent_check = db.execute(
text(""" text("""
SELECT id FROM checkin_logs SELECT id FROM checkin_logs
WHERE student_id = :student_id WHERE student_id = :student_id
AND time > :time_threshold AND time > :time_threshold
"""), """),
{ {
@ -302,30 +329,21 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
).fetchone() ).fetchone()
if recent_check: if recent_check:
return {"message": f"{student_names.get(best_student)} already checked in recently.", "status": True} return {"message": f"{enc_names.get(best_student)} already checked in recently.", "status": True}
last_log = db.execute(
text("""
# thêm dô đây SELECT status FROM checkin_logs
id_log = 0 WHERE student_id = :student_id
ms_response = create_history({"name": student_names.get(best_student).split('\n')[0], "time_string": f"{datetime.datetime.now()}", "status": "check in"}) ORDER BY time DESC LIMIT 1
id_log = ms_response.get('data').get('id') """),
status = ms_response.get('data').get('status') {"student_id": best_student}
).fetchone()
# reset pointer status = "check out" if last_log and last_log.status == "check in" else "check in"
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
)
db.execute( db.execute(
text(""" 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) VALUES (:student_id, :time, :camera_id, :status)
"""), """),
{ {
@ -336,8 +354,37 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
} }
) )
db.commit() 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( student = db.execute(
text(""" text("""
SELECT id, name, email SELECT id, name, email
@ -346,15 +393,14 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
"""), """),
{"id": best_student} {"id": best_student}
).fetchone() ).fetchone()
user_data = { user_data = {
"id": student.id, "id": student.id,
"name": student.name, "name": student.name,
"email": student.email, "email": student.email,
} if student else None } if student else None
return {"message": f"{status} successful for {enc_names.get(best_student)} (dist={best_distance:.4f})", "status": True, "status_type": status, "data": user_data}
return {"message": f"{status} successful for {student_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) # Nếu không thỏa threshold/rule thì trả no match (và log lý do)
reasons = [] reasons = []