update(ttw): update auto sync user #148
|
|
@ -6,46 +6,6 @@ from fastapi import UploadFile
|
||||||
|
|
||||||
URL_API = "https://ms.prology.net/api/v1"
|
URL_API = "https://ms.prology.net/api/v1"
|
||||||
|
|
||||||
# def send_image(id, file: UploadFile, student_name: str, status: str):
|
|
||||||
# id = str(id)
|
|
||||||
|
|
||||||
# # Tạo folder theo ngày
|
|
||||||
# today = datetime.datetime.now().strftime("%Y_%m_%d")
|
|
||||||
# folder_path = f"./images/{today}"
|
|
||||||
|
|
||||||
# if not os.path.exists(folder_path):
|
|
||||||
# os.makedirs(folder_path)
|
|
||||||
|
|
||||||
# # Tạo file name chuẩn
|
|
||||||
# file_name = (
|
|
||||||
# f"{student_name}_"
|
|
||||||
# f"{status}_at_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}.png"
|
|
||||||
# )
|
|
||||||
|
|
||||||
# file_path = os.path.join(folder_path, file_name)
|
|
||||||
|
|
||||||
# # Lưu file UploadFile xuống
|
|
||||||
# with open(file_path, "wb") as f:
|
|
||||||
# f.write(file.file.read())
|
|
||||||
|
|
||||||
# # Mở lại file để gửi API
|
|
||||||
# with open(file_path, "rb") as image_file:
|
|
||||||
# files = {"image": image_file}
|
|
||||||
# data = {"id": id, "file_name": file_name}
|
|
||||||
|
|
||||||
# try:
|
|
||||||
# response = requests.post(
|
|
||||||
# URL_API + "/admin/tracking/send-image",
|
|
||||||
# data=data,
|
|
||||||
# files=files
|
|
||||||
# )
|
|
||||||
# response.raise_for_status()
|
|
||||||
# res = response.json()
|
|
||||||
# except Exception as e:
|
|
||||||
# return {"status": False, "message": str(e)}
|
|
||||||
|
|
||||||
# return res
|
|
||||||
|
|
||||||
|
|
||||||
def send_image(id, image_bytes, student_name: str, status: str):
|
def send_image(id, image_bytes, student_name: str, status: str):
|
||||||
id = str(id)
|
id = str(id)
|
||||||
|
|
@ -76,9 +36,7 @@ def send_image(id, image_bytes, student_name: str, status: str):
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Send image failed:", e)
|
print("Send image failed:", e)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_history(data):
|
def create_history(data):
|
||||||
# Gửi yêu cầu POST với dữ liệu đã chỉ định
|
# Gửi yêu cầu POST với dữ liệu đã chỉ định
|
||||||
|
|
@ -87,4 +45,12 @@ def create_history(data):
|
||||||
|
|
||||||
print(res)
|
print(res)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def users(params):
|
||||||
|
# Gửi yêu cầu POST với dữ liệu đã chỉ định
|
||||||
|
response = requests.get(URL_API+"/admin/timekeeping", params=params, headers={"authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL21zLnByb2xvZ3kubmV0L2FwaS92MS9hZG1pbi9sb2dpbiIsImlhdCI6MTc1Njg2MDQ1OSwiZXhwIjoxNzg4Mzk2NDU5LCJuYmYiOjE3NTY4NjA0NTksImp0aSI6IkRrb0NLbHBKV1pkNnZCN0QiLCJzdWIiOiIxNSIsInBydiI6ImQyZmYyOTMzOWE4YTNlODJjMzU4MmE1YThlNzM5ZGYxNzg5YmIxMmYifQ.DoHqHeAGGxpvzlNQ9dAZjZf2Yl573XCgNBT8ZiSx5N4"})
|
||||||
|
res = response.json()
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
@ -84,32 +84,20 @@ export default function TabFeatures() {
|
||||||
|
|
||||||
const { data } = await checkingApi.checkin({ file });
|
const { data } = await checkingApi.checkin({ file });
|
||||||
|
|
||||||
if (!data || !data?.data) {
|
if (!data || !data?.status) {
|
||||||
toast.error(
|
toast.error(
|
||||||
(data as any)?.message || "Error In Checking: " + JSON.stringify(data)
|
(data as any)?.message || "Error In Checking: " + JSON.stringify(data)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.checking) {
|
|
||||||
setCurrentUser(data?.data || null);
|
|
||||||
|
|
||||||
// Set timeout mới
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
setCurrentUser(null);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
(data as any)?.message ||
|
(data as any)?.message ||
|
||||||
`Checking thành công lúc: ${formatTime(new Date().toLocaleString())}`;
|
`Checking thành công lúc: ${formatTime(new Date().toLocaleString())}`;
|
||||||
|
|
||||||
toast.success(message);
|
toast.success(message);
|
||||||
|
|
||||||
if (!data?.status) return;
|
speak({ type: data?.status_type });
|
||||||
|
|
||||||
speak({ type: data?.status });
|
|
||||||
setRefreshLog(true);
|
setRefreshLog(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const data = error as AxiosError;
|
const data = error as AxiosError;
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,12 @@
|
||||||
/* eslint-disable no-constant-binary-expression */
|
/* eslint-disable no-constant-binary-expression */
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
|
||||||
|
|
||||||
import { checkingApi } from "@/api/checking-api";
|
import { checkingApi } from "@/api/checking-api";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import useUserStore from "@/stores/use-user-store";
|
import useUserStore from "@/stores/use-user-store";
|
||||||
import { Edit, MoreVertical, Trash2, UserCheck, Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function TabUsers({ value }: { value: string }) {
|
export default function TabUsers({ value }: { value: string }) {
|
||||||
|
|
@ -47,24 +35,6 @@ export default function TabUsers({ value }: { value: string }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (user: IUser, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
console.log("Edit user:", user);
|
|
||||||
// TODO: Implement edit functionality
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (user: IUser, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
console.log("Delete user:", user);
|
|
||||||
// TODO: Implement delete functionality
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewDetails = (user: IUser, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
console.log("View details:", user);
|
|
||||||
// TODO: Implement view details functionality
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
|
@ -102,7 +72,7 @@ export default function TabUsers({ value }: { value: string }) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DropdownMenu>
|
{/* <DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
asChild
|
asChild
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|
@ -130,7 +100,7 @@ export default function TabUsers({ value }: { value: string }) {
|
||||||
<span>Xóa</span>
|
<span>Xóa</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu> */}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
|
@ -38,6 +38,7 @@ def get_db():
|
||||||
db.close()
|
db.close()
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
return FileResponse("static/index.html")
|
return FileResponse("static/index.html")
|
||||||
|
|
@ -190,112 +191,272 @@ 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)):
|
||||||
|
# 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)
|
||||||
|
# unknown_encodings = face_recognition.face_encodings(unknown_img)
|
||||||
|
|
||||||
|
# if not unknown_encodings:
|
||||||
|
# return {"message": "No face detected."}
|
||||||
|
|
||||||
|
# unknown_encoding = unknown_encodings[0]
|
||||||
|
|
||||||
|
# # Get all encodings with student info
|
||||||
|
# encodings = db.execute(
|
||||||
|
# text("""
|
||||||
|
# SELECT s.id, s.name, s.email, s.avatar, se.encoding
|
||||||
|
# FROM student_encodings se
|
||||||
|
# JOIN students s ON s.id = se.student_id
|
||||||
|
# """)
|
||||||
|
# ).fetchall()
|
||||||
|
|
||||||
|
# for encoding in encodings:
|
||||||
|
# known_encoding = np.frombuffer(encoding.encoding)
|
||||||
|
# result = face_recognition.compare_faces([known_encoding], unknown_encoding, tolerance=0.5)
|
||||||
|
# if result[0]:
|
||||||
|
# now = datetime.datetime.now()
|
||||||
|
|
||||||
|
# # Check recent checkin
|
||||||
|
# recent_check = db.execute(
|
||||||
|
# text("""
|
||||||
|
# SELECT id FROM checkin_logs
|
||||||
|
# WHERE student_id = :student_id
|
||||||
|
# AND time > :time_threshold
|
||||||
|
# """),
|
||||||
|
# {
|
||||||
|
# "student_id": encoding.id,
|
||||||
|
# "time_threshold": now - datetime.timedelta(minutes=0.5)
|
||||||
|
# }
|
||||||
|
# ).fetchone()
|
||||||
|
|
||||||
|
# if recent_check:
|
||||||
|
# return {
|
||||||
|
# "message": f"{encoding.name} already checked in recently.",
|
||||||
|
# "checking": False,
|
||||||
|
# "data": {
|
||||||
|
# "id": encoding.id,
|
||||||
|
# "name": encoding.name,
|
||||||
|
# "email": encoding.email,
|
||||||
|
# "avatar": encoding.avatar,
|
||||||
|
# "camera_id": camera_id,
|
||||||
|
# "time": now.isoformat()
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# # thêm dô đây
|
||||||
|
# # id_log = 0
|
||||||
|
# # ms_response = create_history({"name": encoding.name.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')
|
||||||
|
# status = "check in"
|
||||||
|
|
||||||
|
# # # reset pointer
|
||||||
|
# # file.file.seek(0)
|
||||||
|
|
||||||
|
# # background_tasks.add_task(
|
||||||
|
# # send_image,
|
||||||
|
# # id_log,
|
||||||
|
# # image_data, # truyền bytes, không phải UploadFile
|
||||||
|
# # encoding.name,
|
||||||
|
# # status
|
||||||
|
# # )
|
||||||
|
|
||||||
|
# # Insert new checkin
|
||||||
|
# db.execute(
|
||||||
|
# text("""
|
||||||
|
# INSERT INTO checkin_logs (student_id, time, camera_id, status)
|
||||||
|
# VALUES (:student_id, :time, :camera_id, :status)
|
||||||
|
# """),
|
||||||
|
# {
|
||||||
|
# "student_id": encoding.id,
|
||||||
|
# "time": now,
|
||||||
|
# "camera_id": camera_id,
|
||||||
|
# "status": status
|
||||||
|
# }
|
||||||
|
# )
|
||||||
|
# db.commit()
|
||||||
|
|
||||||
|
# return {
|
||||||
|
# "message": f"Check-in successful for {encoding.name}",
|
||||||
|
# "checking": True,
|
||||||
|
# "status": status,
|
||||||
|
# "data": {
|
||||||
|
# "id": encoding.id,
|
||||||
|
# "name": encoding.name,
|
||||||
|
# "email": encoding.email,
|
||||||
|
# "avatar": encoding.avatar,
|
||||||
|
# "camera_id": camera_id,
|
||||||
|
# "time": now.isoformat()
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# return {"message": "No match found."}
|
||||||
|
|
||||||
@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")
|
path = os.path.join(UPLOAD_DIR, "checkin.jpg")
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
f.write(image_data)
|
f.write(image_data)
|
||||||
|
|
||||||
unknown_img = face_recognition.load_image_file(path)
|
unknown_img = face_recognition.load_image_file(path)
|
||||||
unknown_encodings = face_recognition.face_encodings(unknown_img)
|
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
unknown_encodings = face_recognition.face_encodings(unknown_img)
|
||||||
if not unknown_encodings:
|
if not unknown_encodings:
|
||||||
return {"message": "No face detected."}
|
return {"message": "No face detected.", "status": False}
|
||||||
|
|
||||||
unknown_encoding = unknown_encodings[0]
|
unknown_encoding = unknown_encodings[0]
|
||||||
|
|
||||||
# Get all encodings with student info
|
# TÙY CHỈNH: threshold nhỏ hơn → ít nhầm lẫn, nhưng dễ false negative.
|
||||||
encodings = db.execute(
|
# 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("""
|
text("""
|
||||||
SELECT s.id, s.name, s.email, s.avatar, se.encoding
|
SELECT s.id AS student_id, s.name AS student_name, se.encoding AS encoding_blob
|
||||||
FROM student_encodings se
|
FROM student_encodings se
|
||||||
JOIN students s ON s.id = se.student_id
|
JOIN students s ON s.id = se.student_id
|
||||||
""")
|
""")
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
for encoding in encodings:
|
# Gom các encoding theo student_id
|
||||||
known_encoding = np.frombuffer(encoding.encoding)
|
from collections import defaultdict
|
||||||
result = face_recognition.compare_faces([known_encoding], unknown_encoding, tolerance=0.5)
|
student_encodings = defaultdict(list)
|
||||||
if result[0]:
|
student_names = {}
|
||||||
now = datetime.datetime.now()
|
|
||||||
|
|
||||||
# Check recent checkin
|
for r in rows:
|
||||||
recent_check = db.execute(
|
sid = r.student_id
|
||||||
text("""
|
student_names[sid] = r.student_name
|
||||||
SELECT id FROM checkin_logs
|
# chuyển BLOB -> numpy array đúng dtype & shape
|
||||||
WHERE student_id = :student_id
|
try:
|
||||||
AND time > :time_threshold
|
enc = np.frombuffer(r.encoding_blob, dtype=np.float64)
|
||||||
"""),
|
# Một bản encoding phải dài 128
|
||||||
{
|
if enc.size == 128:
|
||||||
"student_id": encoding.id,
|
student_encodings[sid].append(enc)
|
||||||
"time_threshold": now - datetime.timedelta(minutes=0.5)
|
else:
|
||||||
}
|
logging.warning(f"encoding size invalid for student {sid}: {enc.size}")
|
||||||
).fetchone()
|
except Exception as e:
|
||||||
|
logging.exception(f"Error decoding encoding for student {sid}: {e}")
|
||||||
|
|
||||||
if recent_check:
|
# Nếu không có encoding nào trong DB
|
||||||
return {
|
if not student_encodings:
|
||||||
"message": f"{encoding.name} already checked in recently.",
|
return {"message": "No known encodings in DB.", "status": False}
|
||||||
"checking": False,
|
|
||||||
"data": {
|
|
||||||
"id": encoding.id,
|
|
||||||
"name": encoding.name,
|
|
||||||
"email": encoding.email,
|
|
||||||
"avatar": encoding.avatar,
|
|
||||||
"camera_id": camera_id,
|
|
||||||
"time": now.isoformat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# Tìm khoảng cách nhỏ nhất cho 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)
|
||||||
|
|
||||||
# thêm dô đây
|
min_dist = float(np.min(dists))
|
||||||
id_log = 0
|
logging.info(f"Student {sid} ({student_names.get(sid)}) min_dist = {min_dist:.4f}")
|
||||||
ms_response = create_history({"name": encoding.name.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
|
|
||||||
encoding.name,
|
|
||||||
status
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert new checkin
|
# update best / second best global
|
||||||
db.execute(
|
if min_dist < best_distance:
|
||||||
text("""
|
second_best_distance = best_distance
|
||||||
INSERT INTO checkin_logs (student_id, time, camera_id, status)
|
best_distance = min_dist
|
||||||
VALUES (:student_id, :time, :camera_id, :status)
|
best_student = sid
|
||||||
"""),
|
elif min_dist < second_best_distance:
|
||||||
{
|
second_best_distance = min_dist
|
||||||
"student_id": encoding.id,
|
|
||||||
"time": now,
|
# Debug log best/second distances
|
||||||
"camera_id": camera_id,
|
logging.info(f"Best student {best_student} dist={best_distance:.4f}, second_best={second_best_distance:.4f}")
|
||||||
"status": status
|
|
||||||
}
|
# Ratio check: nếu best much better than second best => more confident
|
||||||
)
|
ratio_ok = True
|
||||||
db.commit()
|
if second_best_distance < float("inf"):
|
||||||
|
ratio = best_distance / (second_best_distance + 1e-8)
|
||||||
return {
|
logging.info(f"Distance ratio (best/second) = {ratio:.4f}")
|
||||||
"message": f"Check-in successful for {encoding.name}",
|
# Nếu ratio quá gần 1 (ví dụ > 0.85) => không đủ phân biệt
|
||||||
"checking": True,
|
if ratio > 0.85:
|
||||||
"status": status,
|
ratio_ok = False
|
||||||
"data": {
|
|
||||||
"id": encoding.id,
|
# Quyết định match nếu best_distance nhỏ hơn threshold và ratio ok
|
||||||
"name": encoding.name,
|
if best_distance <= DIST_THRESHOLD and ratio_ok and best_student is not None:
|
||||||
"email": encoding.email,
|
# kiểm tra recent check (nửa phút trước)
|
||||||
"avatar": encoding.avatar,
|
now = datetime.datetime.now()
|
||||||
"camera_id": camera_id,
|
recent_check = db.execute(
|
||||||
"time": now.isoformat()
|
text("""
|
||||||
}
|
SELECT id FROM checkin_logs
|
||||||
|
WHERE student_id = :student_id
|
||||||
|
AND time > :time_threshold
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"student_id": best_student,
|
||||||
|
"time_threshold": now - datetime.timedelta(minutes=0.5)
|
||||||
}
|
}
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
return {"message": "No match found."}
|
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')
|
||||||
|
status = "check in"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO checkin_logs (student_id, time, camera_id, status)
|
||||||
|
VALUES (:student_id, :time, :camera_id, :status)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"student_id": best_student,
|
||||||
|
"time": now,
|
||||||
|
"camera_id": camera_id,
|
||||||
|
"status": status
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"message": f"Check-in successful for {student_names.get(best_student)} (dist={best_distance:.4f})", "status": True, "status_type":status}
|
||||||
|
|
||||||
|
# Nếu không thỏa threshold/rule thì trả no match (và log lý do)
|
||||||
|
reasons = []
|
||||||
|
if best_distance > DIST_THRESHOLD:
|
||||||
|
reasons.append(f"best_distance({best_distance:.4f}) > threshold({DIST_THRESHOLD})")
|
||||||
|
if not ratio_ok:
|
||||||
|
reasons.append(f"ratio not confident ({best_distance:.4f}/{second_best_distance:.4f})")
|
||||||
|
|
||||||
|
logging.info("No confident match: " + "; ".join(reasons))
|
||||||
|
return {"message": "No match found.", "reasons": reasons, "status": False}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logs")
|
@app.get("/logs")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class Student(Base):
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String(100), nullable=False)
|
name = Column(String(100), nullable=False)
|
||||||
email = Column(String(100), nullable=False, unique=True, index=True)
|
email = Column(String(100), nullable=False, unique=True, index=True)
|
||||||
avatar = Column(String(500), nullable=True, unique=True, index=True)
|
avatar = Column(String(500), nullable=True, index=True)
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint('email', name='uq_student_email'),)
|
__table_args__ = (UniqueConstraint('email', name='uq_student_email'),)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<script
|
<script
|
||||||
type="module"
|
type="module"
|
||||||
crossorigin
|
crossorigin
|
||||||
src="/camera/static/assets/index-Cs3L7CRl.js"
|
src="/camera/static/assets/index-BtpLNeIZ.js"
|
||||||
></script>
|
></script>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
from api import users
|
||||||
|
from database import SessionLocal
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import tempfile
|
||||||
|
import face_recognition
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy import text
|
||||||
|
from database import SessionLocal
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
URL_BASE_RESOURCE = "https://ms.prology.net/image/storage/"
|
||||||
|
|
||||||
|
def register_face_handler(name: str, email: str, avatar: str | None, image_url: str):
|
||||||
|
print(f"[DEBUG] Bắt đầu register_face_handler với email: {email}, image_url: {image_url}")
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Tải ảnh từ URL
|
||||||
|
try:
|
||||||
|
res = requests.get(image_url)
|
||||||
|
res.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Không tải được ảnh từ URL {image_url}: {e}")
|
||||||
|
return {"status": False, "message": "Không tải được ảnh từ URL."}
|
||||||
|
|
||||||
|
# 2. Lưu ảnh vào file tạm
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
|
||||||
|
tmp.write(res.content)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
print(f"[DEBUG] Ảnh đã lưu tạm ở: {tmp_path}")
|
||||||
|
|
||||||
|
# 3. Encode khuôn mặt
|
||||||
|
image = face_recognition.load_image_file(tmp_path)
|
||||||
|
encodings = face_recognition.face_encodings(image)
|
||||||
|
|
||||||
|
if not encodings:
|
||||||
|
print("[ERROR] Không phát hiện khuôn mặt trong ảnh.")
|
||||||
|
return {"status": False, "message": "Không phát hiện khuôn mặt."}
|
||||||
|
|
||||||
|
encoding_bytes = encodings[0].tobytes()
|
||||||
|
print("[DEBUG] Đã encode khuôn mặt.")
|
||||||
|
|
||||||
|
# 4. Check email tồn tại
|
||||||
|
print(f"[DEBUG] Kiểm tra email tồn tại: {email}")
|
||||||
|
existing = db.execute(
|
||||||
|
text("SELECT id FROM students WHERE email = :email"),
|
||||||
|
{"email": email}
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
student_id = existing[0]
|
||||||
|
print(f"[DEBUG] Email đã tồn tại, student_id = {student_id}. Thêm encoding mới…")
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO student_encodings (student_id, encoding)
|
||||||
|
VALUES (:student_id, :encoding)
|
||||||
|
"""),
|
||||||
|
{"student_id": student_id, "encoding": encoding_bytes}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": True, "message": "Đã thêm encoding mới."}
|
||||||
|
|
||||||
|
# 5. Email chưa tồn tại → tạo student mới
|
||||||
|
print(f"[DEBUG] Email chưa tồn tại, tạo student mới: {name}, {email}")
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO students (name, email, avatar)
|
||||||
|
VALUES (:name, :email, :avatar)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"avatar": avatar,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
student_id = db.execute(text("SELECT LAST_INSERT_ID()")).fetchone()[0]
|
||||||
|
print(f"[DEBUG] Student mới ID = {student_id}")
|
||||||
|
|
||||||
|
# 6. Lưu encoding
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO student_encodings (student_id, encoding)
|
||||||
|
VALUES (:student_id, :encoding)
|
||||||
|
"""),
|
||||||
|
{"student_id": student_id, "encoding": encoding_bytes}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": True, "message": "Đăng ký thành công."}
|
||||||
|
|
||||||
|
except IntegrityError as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"[ERROR] IntegrityError (email có thể đã tồn tại): {e}")
|
||||||
|
return {"status": False, "message": "Email đã tồn tại."}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Lỗi không xác định: {e}")
|
||||||
|
return {"status": False, "message": "Lỗi server."}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def extract_images(history_list):
|
||||||
|
images = []
|
||||||
|
|
||||||
|
for day_item in history_list:
|
||||||
|
values = day_item.get("values", [])
|
||||||
|
for v in values:
|
||||||
|
img = v.get("image")
|
||||||
|
if img:
|
||||||
|
images.append(img)
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
def sync_data_user():
|
||||||
|
response = users({"month": 11, "year": 2025})
|
||||||
|
|
||||||
|
if not response.get("status"):
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_data = response.get("data")
|
||||||
|
|
||||||
|
if isinstance(raw_data, str):
|
||||||
|
try:
|
||||||
|
data = json.loads(raw_data)
|
||||||
|
except:
|
||||||
|
print(raw_data)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
data = raw_data
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
histories = item.get("history", [])
|
||||||
|
user = item.get("user")
|
||||||
|
|
||||||
|
if len(histories) <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 👉 Lấy số lượng encoding hiện có trong DB
|
||||||
|
try:
|
||||||
|
count = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM student_encodings se
|
||||||
|
JOIN students s ON s.id = se.student_id
|
||||||
|
WHERE s.email = :email
|
||||||
|
"""),
|
||||||
|
{"email": user.get("email")}
|
||||||
|
).fetchone()[0]
|
||||||
|
except Exception as e:
|
||||||
|
print("[ERROR] Khi lấy count:", e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 👉 Nếu đủ 5 bản ghi → SKIP người này
|
||||||
|
limit = 10
|
||||||
|
if count >= limit:
|
||||||
|
print(f"==> Bỏ qua {user.get('email')} vì đã đủ {limit} encoding ({count}/{limit})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 👉 Nếu chưa đủ thì mới xử lý ảnh
|
||||||
|
histories_list = extract_images(histories)
|
||||||
|
|
||||||
|
for image in histories_list:
|
||||||
|
# Kiểm tra lại lần nữa trước khi thêm (tránh dư khi có nhiềsu ảnh)
|
||||||
|
if count >= limit:
|
||||||
|
print(f"==> Đã đạt {limit} encoding, dừng cho {user.get('email')}")
|
||||||
|
break
|
||||||
|
|
||||||
|
avatar = URL_BASE_RESOURCE + user.get("avatar", "")
|
||||||
|
image_url = URL_BASE_RESOURCE + image
|
||||||
|
|
||||||
|
print(user.get("name"), image_url)
|
||||||
|
|
||||||
|
result = register_face_handler(
|
||||||
|
name=user.get("name"),
|
||||||
|
email=user.get("email"),
|
||||||
|
avatar=avatar,
|
||||||
|
image_url=image_url
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Result:", result)
|
||||||
|
|
||||||
|
# Tăng biến đếm sau mỗi lần thêm
|
||||||
|
if result.get("status"):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def test_valid_data():
|
||||||
|
response = users({"month": 10, "year": 2025})
|
||||||
|
|
||||||
|
if not response.get("status"):
|
||||||
|
print("API trả status=False")
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_data = response.get("data")
|
||||||
|
|
||||||
|
if isinstance(raw_data, str):
|
||||||
|
try:
|
||||||
|
data = json.loads(raw_data)
|
||||||
|
except Exception as e:
|
||||||
|
print("[ERROR] Không parse được data:", e)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
data = raw_data
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
histories = item.get("history", [])
|
||||||
|
user = item.get("user")
|
||||||
|
|
||||||
|
if len(histories) <= 0 or not user:
|
||||||
|
continue
|
||||||
|
|
||||||
|
histories_list = extract_images(histories)
|
||||||
|
|
||||||
|
for image in histories_list:
|
||||||
|
# Tải ảnh từ server trước khi gửi
|
||||||
|
image_url = URL_BASE_RESOURCE + image
|
||||||
|
try:
|
||||||
|
res = requests.get(image_url)
|
||||||
|
res.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Không tải được ảnh {image_url}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Lưu tạm để upload
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
|
||||||
|
tmp.write(res.content)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
# Gửi lên API
|
||||||
|
with open(tmp_path, "rb") as f:
|
||||||
|
r = requests.post(
|
||||||
|
"http://localhost:8000/checkin",
|
||||||
|
files={"file": f},
|
||||||
|
data={"camera_id": "cam1"}
|
||||||
|
)
|
||||||
|
print(r.status_code, r.json(), user.get("name"))
|
||||||
|
|
||||||
|
# Xóa file tạm
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# sync_data_user()
|
||||||
|
|
||||||
|
# test_valid_data()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |