Initial commit

This commit is contained in:
Joseph 2025-04-25 10:23:54 +07:00
commit 6f5efceb8f
14 changed files with 599 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
path
__pycache__
image

0
README.md Normal file
View File

45
camera.py Normal file
View File

@ -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()

8
database.py Normal file
View File

@ -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()

BIN
frame.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

121
main.py Normal file
View File

@ -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

28
models.py Normal file
View File

@ -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")

17
package-lock.json generated Normal file
View File

@ -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=="
}
}
}

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"database": "^0.0.2"
}
}

372
static/index.html Normal file
View File

@ -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

BIN
uploads/checkin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
uploads/frame.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB