Initial commit
This commit is contained in:
commit
6f5efceb8f
|
|
@ -0,0 +1,3 @@
|
|||
path
|
||||
__pycache__
|
||||
image
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import cv2
|
||||
import requests
|
||||
# source path/to/venv/bin/activate
|
||||
API_URL = "http://localhost:8000/checkin" # Đổi lại nếu backend chạy ở địa chỉ khác
|
||||
CAMERA_ID = "cam_pc_01"
|
||||
|
||||
def capture_and_checkin():
|
||||
cap = cv2.VideoCapture(0) # Dùng camera mặc định (webcam)
|
||||
|
||||
if not cap.isOpened():
|
||||
print("Không mở được camera.")
|
||||
return
|
||||
|
||||
print("Đang mở camera. Nhấn phím 'c' để check-in, 'q' để thoát.")
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
print("Không đọc được frame.")
|
||||
break
|
||||
|
||||
cv2.imshow("Camera", frame)
|
||||
|
||||
key = cv2.waitKey(1)
|
||||
if key == ord("q"):
|
||||
break
|
||||
elif key == ord("c"):
|
||||
# Ghi tạm ảnh ra file
|
||||
filename = "frame.jpg"
|
||||
cv2.imwrite(filename, frame)
|
||||
|
||||
# Gửi ảnh lên server
|
||||
with open(filename, "rb") as f:
|
||||
response = requests.post(
|
||||
API_URL,
|
||||
files={"file": ("frame.jpg", f, "image/jpeg")},
|
||||
data={"camera_id": CAMERA_ID}
|
||||
)
|
||||
|
||||
print("📡 Server:", response.json())
|
||||
|
||||
cap.release()
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
if __name__ == "__main__":
|
||||
capture_and_checkin()
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
DATABASE_URL = "mysql+pymysql://root:@localhost/face_checkin?charset=utf8mb4"
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False)
|
||||
Base = declarative_base()
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
import face_recognition
|
||||
import numpy as np
|
||||
import os
|
||||
import datetime
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from database import SessionLocal, engine
|
||||
from models import Base, Student, CheckInLog
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy import text
|
||||
|
||||
app = FastAPI()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
UPLOAD_DIR = "./uploads"
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
@app.post("/register")
|
||||
async def register_face(name: str = Form(...),email: str = Form(...),file: UploadFile = File(...)):
|
||||
db = SessionLocal()
|
||||
|
||||
# Check if email already exists
|
||||
existing = db.execute(
|
||||
text("SELECT id FROM students WHERE email = :email"),
|
||||
{"email": email}
|
||||
).fetchone()
|
||||
if existing:
|
||||
db.close()
|
||||
raise HTTPException(status_code=400, detail="Email đã được đăng ký.")
|
||||
|
||||
# Save image
|
||||
image_data = await file.read()
|
||||
image_path = f"./uploads/{file.filename}"
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
# Encode face
|
||||
image = face_recognition.load_image_file(image_path)
|
||||
encodings = face_recognition.face_encodings(image)
|
||||
|
||||
if not encodings:
|
||||
db.close()
|
||||
return JSONResponse(content={"message": "Không phát hiện khuôn mặt."}, status_code=400)
|
||||
|
||||
encoding_bytes = encodings[0].tobytes()
|
||||
|
||||
try:
|
||||
db.execute(
|
||||
text("INSERT INTO students (name, email, encoding) VALUES (:name, :email, :encoding)"),
|
||||
{"name": name, "email": email, "encoding": encoding_bytes}
|
||||
)
|
||||
db.commit()
|
||||
return {"message": "Đăng ký thành công."}
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail="Email đã tồn tại.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.post("/checkin")
|
||||
async def checkin(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]
|
||||
|
||||
students = db.query(Student).all()
|
||||
for student in students:
|
||||
known_encoding = np.frombuffer(student.encoding)
|
||||
result = face_recognition.compare_faces([known_encoding], unknown_encoding, tolerance=0.5)
|
||||
if result[0]:
|
||||
now = datetime.datetime.now()
|
||||
recent_check = db.query(CheckInLog).filter(
|
||||
CheckInLog.student_id == student.id,
|
||||
CheckInLog.time > now - datetime.timedelta(minutes=5)
|
||||
).first()
|
||||
|
||||
if recent_check:
|
||||
return {"message": f"{student.name} already checked in recently."}
|
||||
|
||||
log = CheckInLog(student_id=student.id, time=now, camera_id=camera_id)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
return {"message": f"Check-in successful for {student.name}"}
|
||||
|
||||
return {"message": "No match found."}
|
||||
|
||||
@app.get("/logs")
|
||||
def get_logs(db: Session = Depends(get_db)):
|
||||
logs = db.query(CheckInLog).all()
|
||||
result = []
|
||||
for log in logs:
|
||||
result.append({
|
||||
"name": log.student.name,
|
||||
"time": log.time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"camera_id": log.camera_id
|
||||
})
|
||||
return result
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, LargeBinary, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
import datetime
|
||||
|
||||
class Student(Base):
|
||||
__tablename__ = "students"
|
||||
|
||||
__tablename__ = "students"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
email = Column(String(100), nullable=False, unique=True, index=True)
|
||||
encoding = Column(LargeBinary, nullable=False)
|
||||
|
||||
__table_args__ = (UniqueConstraint('email', name='uq_student_email'),)
|
||||
|
||||
checkins = relationship("CheckInLog", back_populates="student")
|
||||
|
||||
class CheckInLog(Base):
|
||||
__tablename__ = "checkin_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
student_id = Column(Integer, ForeignKey("students.id"))
|
||||
time = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
camera_id = Column(String(100))
|
||||
|
||||
student = relationship("Student", back_populates="checkins")
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "school-checkin",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"database": "^0.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/database": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/database/-/database-0.0.2.tgz",
|
||||
"integrity": "sha512-lrqvU32firrb2TBjn4Iv9V1Mh90rqUtyyLAvdSqdGP2w7IrNy+oI5IqGNowHq8o+/WVnQtKNfNIpLYd6uWZLHw=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"database": "^0.0.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Face Check-In / Register</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="email"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus, input[type="email"]:focus {
|
||||
border-color: #3498db;
|
||||
outline: none;
|
||||
box-shadow: 0 0 5px rgba(52, 152, 219, 0.5);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
margin: 10px 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
#register {
|
||||
background-color: #2ecc71;
|
||||
}
|
||||
|
||||
#register:hover {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background-color: #9b59b6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background-color: #8e44ad;
|
||||
}
|
||||
|
||||
.empty-logs {
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
padding: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.newest-log {
|
||||
background-color: #e8f8f5;
|
||||
font-weight: bold;
|
||||
animation: highlight 2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
0% { background-color: #d4efdf; }
|
||||
50% { background-color: #a9dfbf; }
|
||||
100% { background-color: #e8f8f5; }
|
||||
}
|
||||
|
||||
.shortcut-hint {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.custom-alert {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background-color: #f87171; /* red-400 */
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
font-family: sans-serif;
|
||||
font-size: 16px;
|
||||
z-index: 9999;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div class="left-panel">
|
||||
<div class="container">
|
||||
<h2>📸 Face Camera</h2>
|
||||
<video id="video" autoplay></video>
|
||||
<br>
|
||||
|
||||
<div class="form-group">
|
||||
<input type="text" id="name" placeholder="Tên học sinh (khi đăng ký)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="email" id="email" placeholder="Email học sinh (bắt buộc)">
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="register">📥 Đăng ký khuôn mặt</button>
|
||||
<button id="checkin">✅ Điểm danh</button>
|
||||
</div>
|
||||
<div class="shortcut-hint">
|
||||
Nhấn phím Space để điểm danh nhanh
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-panel">
|
||||
<div class="logs-container">
|
||||
<h2>📋 Lịch sử điểm danh</h2>
|
||||
<button id="refresh-logs" class="refresh-btn">🔄 Làm mới dữ liệu</button>
|
||||
<div id="logs-table-container">
|
||||
<table id="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tên học sinh</th>
|
||||
<th>Thời gian</th>
|
||||
<th>Camera ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logs-body">
|
||||
<!-- Dữ liệu logs sẽ được thêm vào đây -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas id="canvas" width="400" height="300" style="display:none;"></canvas>
|
||||
|
||||
<script>
|
||||
const video = document.getElementById('video');
|
||||
const canvas = document.getElementById('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
// Mở camera
|
||||
navigator.mediaDevices.getUserMedia({ video: true })
|
||||
.then((stream) => {
|
||||
video.srcObject = stream;
|
||||
})
|
||||
.catch((err) => {
|
||||
showAlert("Không mở được camera: " + err);
|
||||
});
|
||||
|
||||
function showAlert(text) {
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = 'custom-alert';
|
||||
alertDiv.innerText = text;
|
||||
document.body.appendChild(alertDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
alertDiv.style.opacity = '0';
|
||||
alertDiv.style.transition = 'opacity 0.5s ease-out';
|
||||
setTimeout(() => {
|
||||
alertDiv.remove();
|
||||
}, 500);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Hàm chụp ảnh từ video và gửi đến API
|
||||
function sendImage(url, extraData = {}) {
|
||||
context.drawImage(video, 0, 0, 400, 300);
|
||||
canvas.toBlob((blob) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", blob, "frame.jpg");
|
||||
|
||||
for (const [key, value] of Object.entries(extraData)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
body: formData
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
showAlert(data.message || JSON.stringify(data));
|
||||
if (url === "/checkin" && data.message && data.message.includes("successful")) {
|
||||
loadLogs(); // Tải lại logs sau khi check-in thành công
|
||||
}
|
||||
})
|
||||
.catch(err => showAlert("Lỗi gửi ảnh: " + err));
|
||||
}, "image/jpeg");
|
||||
}
|
||||
|
||||
// Sự kiện: Đăng ký
|
||||
document.getElementById('register').addEventListener('click', () => {
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
if (!name || !email) return showAlert("Vui lòng nhập cả tên và email.");
|
||||
sendImage("/register", { name, email });
|
||||
});
|
||||
|
||||
// Sự kiện: Check-in
|
||||
document.getElementById('checkin').addEventListener('click', () => {
|
||||
sendImage("/checkin", { camera_id: "webcam" });
|
||||
});
|
||||
|
||||
// Sự kiện: Nhấn phím Space để điểm danh
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.code === 'Space' || event.keyCode === 32) {
|
||||
event.preventDefault(); // Ngăn chặn hành vi mặc định của phím Space
|
||||
sendImage("/checkin", { camera_id: "webcam" });
|
||||
}
|
||||
});
|
||||
|
||||
// Hàm tải logs từ server
|
||||
function loadLogs() {
|
||||
fetch("/logs")
|
||||
.then(res => res.json())
|
||||
.then(logs => {
|
||||
const logsBody = document.getElementById('logs-body');
|
||||
logsBody.innerHTML = '';
|
||||
|
||||
if (logs.length === 0) {
|
||||
logsBody.innerHTML = '<tr><td colspan="3" class="empty-logs">Chưa có dữ liệu điểm danh</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sắp xếp logs từ mới đến cũ
|
||||
logs.sort((a, b) => new Date(b.time) - new Date(a.time));
|
||||
|
||||
logs.forEach((log, index) => {
|
||||
// Highlight log mới nhất
|
||||
const isNewest = index === 0;
|
||||
const row = document.createElement('tr');
|
||||
if (isNewest) {
|
||||
row.classList.add('newest-log');
|
||||
}
|
||||
row.innerHTML = `
|
||||
<td>${log.name}</td>
|
||||
<td>${log.time}</td>
|
||||
<td>${log.camera_id}</td>
|
||||
`;
|
||||
logsBody.appendChild(row);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Lỗi tải logs:", err);
|
||||
document.getElementById('logs-body').innerHTML =
|
||||
'<tr><td colspan="3" class="empty-logs">Lỗi khi tải dữ liệu</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
// Sự kiện: Làm mới logs
|
||||
document.getElementById('refresh-logs').addEventListener('click', loadLogs);
|
||||
|
||||
// Tải logs khi trang được tải
|
||||
document.addEventListener('DOMContentLoaded', loadLogs);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 499 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Loading…
Reference in New Issue