Commit ·
13bd96e
1
Parent(s): bc0b671
vfdhbvjfn
Browse files- controllers/main.py +11 -1
- database/database.py +23 -0
- database/migrate_db_v3_employee_login.py +24 -0
- service/face_service.py +57 -12
controllers/main.py
CHANGED
|
@@ -4149,20 +4149,26 @@ async def register(
|
|
| 4149 |
raise Exception("Số CCCD này đã được đăng ký trong hệ thống!")
|
| 4150 |
|
| 4151 |
user_descriptor = None
|
|
|
|
|
|
|
| 4152 |
for i, img_file in enumerate(images):
|
| 4153 |
img_bytes = await img_file.read()
|
| 4154 |
detections = face_ai_service.extract_faces(img_bytes)
|
| 4155 |
|
| 4156 |
if len(detections) == 0:
|
|
|
|
|
|
|
| 4157 |
raise Exception(f"Không tìm thấy khuôn mặt trong ảnh mẫu thứ {i + 1}.")
|
| 4158 |
if len(detections) > 1:
|
|
|
|
|
|
|
| 4159 |
raise Exception(f"Ảnh mẫu thứ {i + 1} có nhiều hơn 1 khuôn mặt.")
|
| 4160 |
|
| 4161 |
descriptor = detections[0]["descriptor"]
|
| 4162 |
emb_id = str(uuid.uuid4())
|
| 4163 |
img_b64 = face_ai_service.bytes_to_base64(img_bytes)
|
| 4164 |
|
| 4165 |
-
if
|
| 4166 |
user_descriptor = descriptor
|
| 4167 |
avatar_b64 = img_b64
|
| 4168 |
cursor.execute(
|
|
@@ -4178,6 +4184,10 @@ async def register(
|
|
| 4178 |
)
|
| 4179 |
new_encodings.append((person_id, name, role, avatar_b64, expiry_val, descriptor))
|
| 4180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4181 |
front_b64 = back_b64 = ""
|
| 4182 |
|
| 4183 |
if cccd_front:
|
|
|
|
| 4149 |
raise Exception("Số CCCD này đã được đăng ký trong hệ thống!")
|
| 4150 |
|
| 4151 |
user_descriptor = None
|
| 4152 |
+
avatar_b64 = ""
|
| 4153 |
+
sample_errors = []
|
| 4154 |
for i, img_file in enumerate(images):
|
| 4155 |
img_bytes = await img_file.read()
|
| 4156 |
detections = face_ai_service.extract_faces(img_bytes)
|
| 4157 |
|
| 4158 |
if len(detections) == 0:
|
| 4159 |
+
sample_errors.append(f"Anh mau {i + 1}: khong tim thay khuon mat.")
|
| 4160 |
+
continue
|
| 4161 |
raise Exception(f"Không tìm thấy khuôn mặt trong ảnh mẫu thứ {i + 1}.")
|
| 4162 |
if len(detections) > 1:
|
| 4163 |
+
sample_errors.append(f"Anh mau {i + 1}: co nhieu hon 1 khuon mat.")
|
| 4164 |
+
continue
|
| 4165 |
raise Exception(f"Ảnh mẫu thứ {i + 1} có nhiều hơn 1 khuôn mặt.")
|
| 4166 |
|
| 4167 |
descriptor = detections[0]["descriptor"]
|
| 4168 |
emb_id = str(uuid.uuid4())
|
| 4169 |
img_b64 = face_ai_service.bytes_to_base64(img_bytes)
|
| 4170 |
|
| 4171 |
+
if user_descriptor is None:
|
| 4172 |
user_descriptor = descriptor
|
| 4173 |
avatar_b64 = img_b64
|
| 4174 |
cursor.execute(
|
|
|
|
| 4184 |
)
|
| 4185 |
new_encodings.append((person_id, name, role, avatar_b64, expiry_val, descriptor))
|
| 4186 |
|
| 4187 |
+
if user_descriptor is None:
|
| 4188 |
+
detail = " ".join(sample_errors[:3])
|
| 4189 |
+
raise Exception(f"Khong tim thay khuon mat hop le trong cac anh mau. {detail}".strip())
|
| 4190 |
+
|
| 4191 |
front_b64 = back_b64 = ""
|
| 4192 |
|
| 4193 |
if cccd_front:
|
database/database.py
CHANGED
|
@@ -117,6 +117,14 @@ def _ensure_recognition_log_columns(cursor):
|
|
| 117 |
if not _column_exists(cursor, "recognition_logs", column_name):
|
| 118 |
cursor.execute(statement)
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
def _ensure_attendance_notification_columns(cursor):
|
| 121 |
additions = [
|
| 122 |
("recognition_log_id", "ALTER TABLE attendance_notifications ADD COLUMN recognition_log_id VARCHAR(36) NULL AFTER person_id"),
|
|
@@ -144,11 +152,25 @@ def _ensure_attendance_notification_columns(cursor):
|
|
| 144 |
indexes = [
|
| 145 |
("idx_notification_person_status", "CREATE INDEX idx_notification_person_status ON attendance_notifications (person_id, status)"),
|
| 146 |
("idx_notification_attendance_time", "CREATE INDEX idx_notification_attendance_time ON attendance_notifications (attendance_time)"),
|
|
|
|
|
|
|
|
|
|
| 147 |
]
|
| 148 |
for index_name, statement in indexes:
|
| 149 |
if not _index_exists(cursor, "attendance_notifications", index_name):
|
| 150 |
cursor.execute(statement)
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
def init_database():
|
| 153 |
"""Tu dong tao Schema theo chuan MySQL ban cung cap"""
|
| 154 |
|
|
@@ -318,6 +340,7 @@ def init_database():
|
|
| 318 |
_ensure_employee_account_columns(cursor)
|
| 319 |
_ensure_recognition_log_columns(cursor)
|
| 320 |
_ensure_attendance_notification_columns(cursor)
|
|
|
|
| 321 |
_ensure_default_admin(cursor)
|
| 322 |
conn.commit()
|
| 323 |
print("[OK] Da kiem tra va khoi tao cau truc CSDL thanh cong tren Railway!")
|
|
|
|
| 117 |
if not _column_exists(cursor, "recognition_logs", column_name):
|
| 118 |
cursor.execute(statement)
|
| 119 |
|
| 120 |
+
indexes = [
|
| 121 |
+
("idx_recognition_person_status_created", "CREATE INDEX idx_recognition_person_status_created ON recognition_logs (person_id, status, created_at)"),
|
| 122 |
+
("idx_recognition_created", "CREATE INDEX idx_recognition_created ON recognition_logs (created_at)"),
|
| 123 |
+
]
|
| 124 |
+
for index_name, statement in indexes:
|
| 125 |
+
if not _index_exists(cursor, "recognition_logs", index_name):
|
| 126 |
+
cursor.execute(statement)
|
| 127 |
+
|
| 128 |
def _ensure_attendance_notification_columns(cursor):
|
| 129 |
additions = [
|
| 130 |
("recognition_log_id", "ALTER TABLE attendance_notifications ADD COLUMN recognition_log_id VARCHAR(36) NULL AFTER person_id"),
|
|
|
|
| 152 |
indexes = [
|
| 153 |
("idx_notification_person_status", "CREATE INDEX idx_notification_person_status ON attendance_notifications (person_id, status)"),
|
| 154 |
("idx_notification_attendance_time", "CREATE INDEX idx_notification_attendance_time ON attendance_notifications (attendance_time)"),
|
| 155 |
+
("idx_notification_person_time", "CREATE INDEX idx_notification_person_time ON attendance_notifications (person_id, attendance_time)"),
|
| 156 |
+
("idx_notification_log", "CREATE INDEX idx_notification_log ON attendance_notifications (recognition_log_id)"),
|
| 157 |
+
("idx_notification_person_status_time", "CREATE INDEX idx_notification_person_status_time ON attendance_notifications (person_id, status, attendance_time)"),
|
| 158 |
]
|
| 159 |
for index_name, statement in indexes:
|
| 160 |
if not _index_exists(cursor, "attendance_notifications", index_name):
|
| 161 |
cursor.execute(statement)
|
| 162 |
|
| 163 |
+
def _ensure_performance_indexes(cursor):
|
| 164 |
+
indexes = [
|
| 165 |
+
("persons", "idx_person_status_name", "CREATE INDEX idx_person_status_name ON persons (status, name)"),
|
| 166 |
+
("persons", "idx_person_status_registered", "CREATE INDEX idx_person_status_registered ON persons (status, registered_at)"),
|
| 167 |
+
("face_embeddings", "idx_face_person_created", "CREATE INDEX idx_face_person_created ON face_embeddings (person_id, created_at)"),
|
| 168 |
+
("citizen_ids", "idx_citizen_person_id_number", "CREATE INDEX idx_citizen_person_id_number ON citizen_ids (person_id, id_number)"),
|
| 169 |
+
]
|
| 170 |
+
for table_name, index_name, statement in indexes:
|
| 171 |
+
if not _index_exists(cursor, table_name, index_name):
|
| 172 |
+
cursor.execute(statement)
|
| 173 |
+
|
| 174 |
def init_database():
|
| 175 |
"""Tu dong tao Schema theo chuan MySQL ban cung cap"""
|
| 176 |
|
|
|
|
| 340 |
_ensure_employee_account_columns(cursor)
|
| 341 |
_ensure_recognition_log_columns(cursor)
|
| 342 |
_ensure_attendance_notification_columns(cursor)
|
| 343 |
+
_ensure_performance_indexes(cursor)
|
| 344 |
_ensure_default_admin(cursor)
|
| 345 |
conn.commit()
|
| 346 |
print("[OK] Da kiem tra va khoi tao cau truc CSDL thanh cong tren Railway!")
|
database/migrate_db_v3_employee_login.py
CHANGED
|
@@ -77,6 +77,14 @@ def ensure_recognition_log_columns(cursor) -> None:
|
|
| 77 |
if not column_exists(cursor, "recognition_logs", column_name):
|
| 78 |
cursor.execute(statement)
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
def ensure_attendance_notification_columns(cursor) -> None:
|
| 82 |
additions = [
|
|
@@ -105,12 +113,27 @@ def ensure_attendance_notification_columns(cursor) -> None:
|
|
| 105 |
indexes = [
|
| 106 |
("idx_notification_person_status", "CREATE INDEX idx_notification_person_status ON attendance_notifications (person_id, status)"),
|
| 107 |
("idx_notification_attendance_time", "CREATE INDEX idx_notification_attendance_time ON attendance_notifications (attendance_time)"),
|
|
|
|
|
|
|
|
|
|
| 108 |
]
|
| 109 |
for index_name, statement in indexes:
|
| 110 |
if not index_exists(cursor, "attendance_notifications", index_name):
|
| 111 |
cursor.execute(statement)
|
| 112 |
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
def migrate():
|
| 115 |
conn = get_db_connection()
|
| 116 |
cursor = conn.cursor(dictionary=True)
|
|
@@ -201,6 +224,7 @@ def migrate():
|
|
| 201 |
ensure_employee_columns(cursor)
|
| 202 |
ensure_recognition_log_columns(cursor)
|
| 203 |
ensure_attendance_notification_columns(cursor)
|
|
|
|
| 204 |
|
| 205 |
admin_username = DEFAULT_ADMIN_USERNAME
|
| 206 |
admin_password = DEFAULT_ADMIN_PASSWORD
|
|
|
|
| 77 |
if not column_exists(cursor, "recognition_logs", column_name):
|
| 78 |
cursor.execute(statement)
|
| 79 |
|
| 80 |
+
indexes = [
|
| 81 |
+
("idx_recognition_person_status_created", "CREATE INDEX idx_recognition_person_status_created ON recognition_logs (person_id, status, created_at)"),
|
| 82 |
+
("idx_recognition_created", "CREATE INDEX idx_recognition_created ON recognition_logs (created_at)"),
|
| 83 |
+
]
|
| 84 |
+
for index_name, statement in indexes:
|
| 85 |
+
if not index_exists(cursor, "recognition_logs", index_name):
|
| 86 |
+
cursor.execute(statement)
|
| 87 |
+
|
| 88 |
|
| 89 |
def ensure_attendance_notification_columns(cursor) -> None:
|
| 90 |
additions = [
|
|
|
|
| 113 |
indexes = [
|
| 114 |
("idx_notification_person_status", "CREATE INDEX idx_notification_person_status ON attendance_notifications (person_id, status)"),
|
| 115 |
("idx_notification_attendance_time", "CREATE INDEX idx_notification_attendance_time ON attendance_notifications (attendance_time)"),
|
| 116 |
+
("idx_notification_person_time", "CREATE INDEX idx_notification_person_time ON attendance_notifications (person_id, attendance_time)"),
|
| 117 |
+
("idx_notification_log", "CREATE INDEX idx_notification_log ON attendance_notifications (recognition_log_id)"),
|
| 118 |
+
("idx_notification_person_status_time", "CREATE INDEX idx_notification_person_status_time ON attendance_notifications (person_id, status, attendance_time)"),
|
| 119 |
]
|
| 120 |
for index_name, statement in indexes:
|
| 121 |
if not index_exists(cursor, "attendance_notifications", index_name):
|
| 122 |
cursor.execute(statement)
|
| 123 |
|
| 124 |
|
| 125 |
+
def ensure_performance_indexes(cursor) -> None:
|
| 126 |
+
indexes = [
|
| 127 |
+
("persons", "idx_person_status_name", "CREATE INDEX idx_person_status_name ON persons (status, name)"),
|
| 128 |
+
("persons", "idx_person_status_registered", "CREATE INDEX idx_person_status_registered ON persons (status, registered_at)"),
|
| 129 |
+
("face_embeddings", "idx_face_person_created", "CREATE INDEX idx_face_person_created ON face_embeddings (person_id, created_at)"),
|
| 130 |
+
("citizen_ids", "idx_citizen_person_id_number", "CREATE INDEX idx_citizen_person_id_number ON citizen_ids (person_id, id_number)"),
|
| 131 |
+
]
|
| 132 |
+
for table_name, index_name, statement in indexes:
|
| 133 |
+
if not index_exists(cursor, table_name, index_name):
|
| 134 |
+
cursor.execute(statement)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
def migrate():
|
| 138 |
conn = get_db_connection()
|
| 139 |
cursor = conn.cursor(dictionary=True)
|
|
|
|
| 224 |
ensure_employee_columns(cursor)
|
| 225 |
ensure_recognition_log_columns(cursor)
|
| 226 |
ensure_attendance_notification_columns(cursor)
|
| 227 |
+
ensure_performance_indexes(cursor)
|
| 228 |
|
| 229 |
admin_username = DEFAULT_ADMIN_USERNAME
|
| 230 |
admin_password = DEFAULT_ADMIN_PASSWORD
|
service/face_service.py
CHANGED
|
@@ -264,7 +264,7 @@
|
|
| 264 |
import cv2, numpy as np, io, os, threading, logging, urllib.request
|
| 265 |
from dataclasses import dataclass, field
|
| 266 |
from typing import Optional
|
| 267 |
-
from PIL import Image
|
| 268 |
|
| 269 |
logger = logging.getLogger(__name__)
|
| 270 |
|
|
@@ -379,35 +379,80 @@ class FaceAiService:
|
|
| 379 |
_download_model(YUNET_URL, YUNET_PATH, "YuNet")
|
| 380 |
_download_model(SFACE_URL, SFACE_PATH, "SFace")
|
| 381 |
logger.info("[AI] Khởi tạo YuNet + SFace...")
|
| 382 |
-
self._detector = cv2.FaceDetectorYN.create(YUNET_PATH, "", (320,240), score_threshold=0.
|
| 383 |
self._recognizer = cv2.FaceRecognizerSF.create(SFACE_PATH, "")
|
| 384 |
logger.info("[AI] Sẵn sàng")
|
| 385 |
|
| 386 |
@staticmethod
|
| 387 |
def _decode(file_bytes: bytes):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
try:
|
| 389 |
arr = np.frombuffer(file_bytes, np.uint8)
|
| 390 |
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
| 391 |
if img is not None: return img
|
| 392 |
-
except Exception: pass
|
| 393 |
-
try:
|
| 394 |
-
pil = Image.open(io.BytesIO(file_bytes)).convert("RGB")
|
| 395 |
-
return cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
|
| 396 |
except Exception as e:
|
| 397 |
logger.error(f"[AI] Không đọc ảnh: {e}"); return None
|
| 398 |
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
h, w = img.shape[:2]
|
| 403 |
self._detector.setInputSize((w, h))
|
| 404 |
_, faces_raw = self._detector.detect(img)
|
| 405 |
-
if faces_raw is None or len(faces_raw) == 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
results = []
|
| 407 |
for fd in faces_raw:
|
| 408 |
x,y,fw,fh = [int(v) for v in fd[:4]]
|
| 409 |
x=max(0,x); y=max(0,y); fw=min(fw,w-x); fh=min(fh,h-y)
|
| 410 |
-
aligned = self._recognizer.alignCrop(
|
| 411 |
feature = self._recognizer.feature(aligned)
|
| 412 |
results.append({
|
| 413 |
"box": {"x":x,"y":y,"width":fw,"height":fh},
|
|
@@ -438,4 +483,4 @@ class FaceAiService:
|
|
| 438 |
|
| 439 |
|
| 440 |
face_ai_service = FaceAiService()
|
| 441 |
-
face_memory_store = FaceMemoryStore()
|
|
|
|
| 264 |
import cv2, numpy as np, io, os, threading, logging, urllib.request
|
| 265 |
from dataclasses import dataclass, field
|
| 266 |
from typing import Optional
|
| 267 |
+
from PIL import Image, ImageOps
|
| 268 |
|
| 269 |
logger = logging.getLogger(__name__)
|
| 270 |
|
|
|
|
| 379 |
_download_model(YUNET_URL, YUNET_PATH, "YuNet")
|
| 380 |
_download_model(SFACE_URL, SFACE_PATH, "SFace")
|
| 381 |
logger.info("[AI] Khởi tạo YuNet + SFace...")
|
| 382 |
+
self._detector = cv2.FaceDetectorYN.create(YUNET_PATH, "", (320,240), score_threshold=0.45, nms_threshold=0.3, top_k=5)
|
| 383 |
self._recognizer = cv2.FaceRecognizerSF.create(SFACE_PATH, "")
|
| 384 |
logger.info("[AI] Sẵn sàng")
|
| 385 |
|
| 386 |
@staticmethod
|
| 387 |
def _decode(file_bytes: bytes):
|
| 388 |
+
try:
|
| 389 |
+
pil = Image.open(io.BytesIO(file_bytes))
|
| 390 |
+
pil = ImageOps.exif_transpose(pil).convert("RGB")
|
| 391 |
+
return cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
|
| 392 |
+
except Exception: pass
|
| 393 |
try:
|
| 394 |
arr = np.frombuffer(file_bytes, np.uint8)
|
| 395 |
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
| 396 |
if img is not None: return img
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
except Exception as e:
|
| 398 |
logger.error(f"[AI] Không đọc ảnh: {e}"); return None
|
| 399 |
|
| 400 |
+
@staticmethod
|
| 401 |
+
def _image_variants(img):
|
| 402 |
+
variants = [("original", img)]
|
| 403 |
+
h, w = img.shape[:2]
|
| 404 |
+
max_side = max(h, w)
|
| 405 |
+
min_side = min(h, w)
|
| 406 |
+
|
| 407 |
+
if max_side > 1600:
|
| 408 |
+
scale = 1600 / max_side
|
| 409 |
+
variants.append(("downscale", cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)))
|
| 410 |
+
if min_side < 480:
|
| 411 |
+
scale = 480 / min_side
|
| 412 |
+
variants.append(("upscale", cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_CUBIC)))
|
| 413 |
+
|
| 414 |
+
try:
|
| 415 |
+
lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
|
| 416 |
+
l, a, b = cv2.split(lab)
|
| 417 |
+
l = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(l)
|
| 418 |
+
variants.append(("contrast", cv2.cvtColor(cv2.merge((l, a, b)), cv2.COLOR_LAB2BGR)))
|
| 419 |
+
except Exception:
|
| 420 |
+
pass
|
| 421 |
+
|
| 422 |
+
variants.extend([
|
| 423 |
+
("rotate90", cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)),
|
| 424 |
+
("rotate270", cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)),
|
| 425 |
+
("rotate180", cv2.rotate(img, cv2.ROTATE_180)),
|
| 426 |
+
])
|
| 427 |
+
return variants
|
| 428 |
+
|
| 429 |
+
def _detect_raw(self, img):
|
| 430 |
h, w = img.shape[:2]
|
| 431 |
self._detector.setInputSize((w, h))
|
| 432 |
_, faces_raw = self._detector.detect(img)
|
| 433 |
+
if faces_raw is None or len(faces_raw) == 0:
|
| 434 |
+
return None
|
| 435 |
+
return sorted(faces_raw, key=lambda fd: float(fd[-1]), reverse=True)
|
| 436 |
+
|
| 437 |
+
def extract_faces(self, file_bytes: bytes) -> list[dict]:
|
| 438 |
+
img = self._decode(file_bytes)
|
| 439 |
+
if img is None: return []
|
| 440 |
+
selected_img = img
|
| 441 |
+
faces_raw = None
|
| 442 |
+
used_variant = "original"
|
| 443 |
+
for variant_name, candidate in self._image_variants(img):
|
| 444 |
+
faces_raw = self._detect_raw(candidate)
|
| 445 |
+
if faces_raw is not None:
|
| 446 |
+
selected_img = candidate
|
| 447 |
+
used_variant = variant_name
|
| 448 |
+
break
|
| 449 |
+
if faces_raw is None: return []
|
| 450 |
+
h, w = selected_img.shape[:2]
|
| 451 |
results = []
|
| 452 |
for fd in faces_raw:
|
| 453 |
x,y,fw,fh = [int(v) for v in fd[:4]]
|
| 454 |
x=max(0,x); y=max(0,y); fw=min(fw,w-x); fh=min(fh,h-y)
|
| 455 |
+
aligned = self._recognizer.alignCrop(selected_img, fd)
|
| 456 |
feature = self._recognizer.feature(aligned)
|
| 457 |
results.append({
|
| 458 |
"box": {"x":x,"y":y,"width":fw,"height":fh},
|
|
|
|
| 483 |
|
| 484 |
|
| 485 |
face_ai_service = FaceAiService()
|
| 486 |
+
face_memory_store = FaceMemoryStore()
|