update(tracking-tool-web): refactor to speed checkin #159
|
|
@ -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ộ
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -302,26 +329,17 @@ 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}
|
||||
return {"message": f"{enc_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
|
||||
)
|
||||
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("""
|
||||
|
|
@ -336,7 +354,36 @@ 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("""
|
||||
|
|
@ -353,8 +400,7 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
|
|||
"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 = []
|
||||
|
|
|
|||
Loading…
Reference in New Issue