diff --git a/TrackingToolWeb/CLAUDE.md b/TrackingToolWeb/CLAUDE.md new file mode 100644 index 0000000..4ae4195 --- /dev/null +++ b/TrackingToolWeb/CLAUDE.md @@ -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ộ diff --git a/TrackingToolWeb/main.py b/TrackingToolWeb/main.py index 398d3f6..9c47621 100644 --- a/TrackingToolWeb/main.py +++ b/TrackingToolWeb/main.py @@ -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 = []