first commit

This commit is contained in:
AfrizalAlka 2026-07-22 17:05:34 +07:00
commit bd43607caf
14 changed files with 2355 additions and 0 deletions

46
.env.example Normal file
View File

@ -0,0 +1,46 @@
# Flask
FLASK_DEBUG=true
# Training
IMAGE_SIZE=224
BATCH_SIZE=32
EPOCHS=20
LEARNING_RATE=0.001
RANDOM_SEED=42
MIN_IMAGES_PER_CLASS=30
TRAIN_RATIO=0.70
VAL_RATIO=0.15
TEST_RATIO=0.15
# Model
MODEL_FILENAME=cnn_face_recognition.keras
CLASS_NAMES_FILENAME=class_names.json
RECOGNITION_THRESHOLD=0.70
# Camera
CAMERA_ID=1
CAMERA_CHECK_INTERVAL_SEC=1.0
# DB
DB_DRIVER=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=sas
DB_USER=root
DB_PASSWORD=
# Mapping tabel Laravel SAS
STUDENT_TABLE=students
STUDENT_ID_COLUMN=id
STUDENT_NAME_COLUMN=name
STUDENT_CLASS_COLUMN=id_class
ATTENDANCE_TABLE=attendance_history_dailys
# Laravel Integration
APP_URL=http://localhost:8000
LARAVEL_STORAGE_PATH=photo-webcam
LARAVEL_ATTENDANCE_PICTURES_PATH=daily_attendance_pictures
# Attendance Settings
ATTENDANCE_CUTOFF_HOUR=7
ATTENDANCE_CUTOFF_MINUTE=0

57
.gitignore vendored Normal file
View File

@ -0,0 +1,57 @@
# Environment
.env
.env.local
.env.*.local
# Python cache and tooling
__pycache__/
*.py[cod]
*.pyo
*.pyd
.python-version
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
env/
ENV/
# Local IDE files
.vscode/
.idea/
# OS files
Thumbs.db
Desktop.ini
.DS_Store
# Local database
app_presensiku.sqlite3
*.sqlite3
# Machine learning artifacts
models/
experiment_logs/
# Generated datasets
dataset/Dataset_Preprocessed/
dataset/Dataset_Raw/
# Test and coverage output
.coverage
coverage.xml
htmlcov/
dist/
build/
*.egg-info/
# Jupyter notebooks checkpoints
.ipynb_checkpoints/
*.md
sas.sql
SQL_INSERT_USERS_STUDENTS.sql
*.ps1
attendance_pictures/

428
app/api.py Normal file
View File

@ -0,0 +1,428 @@
"""Flask app that exposes health, training, and attendance endpoints."""
import cv2
import numpy as np
from flask import Flask, jsonify, request
from flask_cors import CORS
from pathlib import Path
from datetime import datetime
import requests
from io import BytesIO
from config import AppConfig
from database import Database, DatabaseError
from services.attendance import AttendanceService
from services.camera_absensi import CameraAttendanceRunner
from services.inference import FacePredictor
from services.preprocess import preprocess_dataset
from services.train_cnn import train_model
from services.fetch_laravel_dataset import LaravelDatasetFetcher
from services.attendance_utils import calculate_attendance_status, format_attendance_status
def _decode_uploaded_image() -> np.ndarray:
"""Read the uploaded file and convert it into an OpenCV image."""
if "image" not in request.files:
raise ValueError("Field file 'image' wajib diisi.")
file = request.files["image"]
image_bytes = file.read()
img_array = np.frombuffer(image_bytes, np.uint8)
frame = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
if frame is None:
raise ValueError("Gagal decode file gambar.")
return frame
def _save_attendance_picture(frame: np.ndarray, config: AppConfig) -> str:
"""Save attendance picture to Laravel storage and return filename.
Args:
- frame: OpenCV image (BGR)
- config: App configuration
Returns: Relative path for database storage
(e.g., 'daily_attendance_pictures/2026-02-05_14-30-45_123.jpg')
"""
try:
# Generate filename
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3]
filename = f"{timestamp}.jpg"
# Encode frame to JPG
success, jpg_buffer = cv2.imencode(".jpg", frame)
if not success:
raise ValueError("Gagal encode gambar ke JPG")
jpg_bytes = jpg_buffer.tobytes()
# Upload to Laravel via API
url = f"{config.laravel_url}/api/attendance/upload-picture"
files = {"image": (filename, BytesIO(jpg_bytes), "image/jpeg")}
data = {"storage_path": config.laravel_attendance_pictures_path}
print(f"[UPLOAD] Sending to: {url}")
print(f"[UPLOAD] Storage path: {config.laravel_attendance_pictures_path}")
print(f"[UPLOAD] Filename: {filename}")
response = requests.post(url, files=files, data=data, timeout=10)
print(f"[UPLOAD] Response status: {response.status_code}")
print(f"[UPLOAD] Response headers: {response.headers}")
print(f"[UPLOAD] Response body (first 500 chars): {response.text[:500]}")
if response.status_code == 200:
try:
result = response.json()
print(f"[UPLOAD] Response JSON: {result}")
uploaded_path = result.get("path", f"{config.laravel_attendance_pictures_path}/{filename}")
print(f"[UPLOAD] Success! Path: {uploaded_path}")
return uploaded_path
except Exception as json_error:
print(f"[UPLOAD] JSON parse error: {str(json_error)}")
print(f"[UPLOAD] Fallback to local storage")
return _save_attendance_picture_locally(frame, config, filename)
else:
# Log error response
print(f"[UPLOAD] Error response: {response.text}")
print(f"[UPLOAD] Fallback to local storage")
return _save_attendance_picture_locally(frame, config, filename)
except requests.exceptions.ConnectionError as e:
print(f"[UPLOAD] Connection error to Laravel: {str(e)}")
print(f"[UPLOAD] Fallback to local storage")
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3]
filename = f"{timestamp}.jpg"
return _save_attendance_picture_locally(frame, config, filename)
except Exception as e:
print(f"[UPLOAD] Unexpected error: {str(e)}")
print(f"[UPLOAD] Fallback to local storage")
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3]
filename = f"{timestamp}.jpg"
return _save_attendance_picture_locally(frame, config, filename)
def _save_attendance_picture_locally(
frame: np.ndarray, config: AppConfig, filename: str = None
) -> str:
"""Fallback: Save attendance picture to local disk.
Args:
- frame: OpenCV image (BGR)
- config: App configuration
- filename: Optional filename (default: timestamp)
Returns: Relative path (e.g., 'attendance_pictures/2026-02-05_14-30-45_123.jpg')
"""
if filename is None:
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3]
filename = f"{timestamp}.jpg"
picture_dir = config.project_root / "attendance_pictures"
picture_dir.mkdir(exist_ok=True)
filepath = picture_dir / filename
cv2.imwrite(str(filepath), frame)
# Return relative path for database storage
return f"attendance_pictures/{filename}"
def _read_pipeline_options(config: AppConfig) -> dict:
"""Collect pipeline options from the request body."""
payload = request.get_json(silent=True) or {}
return {
"fetch_from_laravel": bool(payload.get("fetch_from_laravel", False)),
"run_preprocess": bool(payload.get("preprocess", True)),
"run_train": bool(payload.get("train", True)),
"overwrite": bool(payload.get("overwrite", False)),
"epochs": int(payload.get("epochs", config.epochs)),
"min_images": int(payload.get("min_images_per_class", config.min_images_per_class)),
"train_ratio": float(payload.get("train_ratio", config.train_ratio)),
"val_ratio": float(payload.get("val_ratio", config.val_ratio)),
"test_ratio": float(payload.get("test_ratio", config.test_ratio)),
}
def create_app() -> Flask:
app = Flask(__name__)
CORS(app, origins=[
"https://presensiku.site",
"http://presensiku.site",
"http://127.0.0.1:8000",
"http://localhost:8000",
"http://127.0.0.1:3000",
]) # Izinkan origin yang terdaftar
config = AppConfig.from_env()
db = Database(config)
# Note: Database schema already exists in Laravel database
# No need to call db.init_schema_if_needed()
predictor = FacePredictor(
model_path=config.model_path,
class_names_path=config.class_names_path,
image_size=config.image_size,
)
attendance_service = AttendanceService(db)
camera_runner = CameraAttendanceRunner(config, predictor, attendance_service)
@app.get("/health")
def health():
status = {
"status": "ok",
"model_exists": config.model_path.exists(),
"dataset_root": str(config.dataset_root),
}
return jsonify(status)
@app.post("/pipeline/run")
def run_pipeline():
options = _read_pipeline_options(config)
results = {}
# Fetch dataset from Laravel if requested
if options["fetch_from_laravel"]:
try:
fetcher = LaravelDatasetFetcher(
db=db,
laravel_url=config.laravel_url,
storage_path=config.laravel_storage_path,
)
fetch_result = fetcher.fetch_and_organize(
output_dir=config.raw_dir,
overwrite=options["overwrite"],
)
results["fetch_laravel"] = fetch_result
if not fetch_result["success"]:
return jsonify({
"status": "error",
"message": "Gagal fetch dataset dari Laravel",
"details": fetch_result,
}), 400
# Cleanup and reorganize
cleanup_result = fetcher.cleanup_and_reorganize(
dataset_dir=config.raw_dir,
target_size=(config.image_size, config.image_size),
)
results["cleanup"] = cleanup_result
except Exception as e:
return jsonify({
"status": "error",
"message": f"Error fetch dari Laravel: {str(e)}",
}), 500
if options["run_preprocess"]:
results["preprocess"] = preprocess_dataset(
source_dir=config.raw_dir,
output_dir=config.preprocessed_dir,
target_size=config.image_size,
min_images_per_class=options["min_images"],
seed=config.random_seed,
overwrite=options["overwrite"],
)
if options["run_train"]:
results["train"] = train_model(
dataset_dir=config.preprocessed_dir,
model_path=config.model_path,
class_names_path=config.class_names_path,
logs_dir=config.logs_dir,
image_size=config.image_size,
batch_size=config.batch_size,
epochs=options["epochs"],
learning_rate=config.learning_rate,
seed=config.random_seed,
train_ratio=options["train_ratio"],
val_ratio=options["val_ratio"],
test_ratio=options["test_ratio"],
)
return jsonify({"message": "pipeline selesai", "results": results})
@app.post("/attendance/recognize")
def recognize_and_attend():
frame = _decode_uploaded_image()
prediction = predictor.predict(frame)
if prediction is None:
return jsonify({"status": "no_face", "message": "Wajah tidak terdeteksi."}), 200
if prediction.confidence < config.threshold:
unknown_response = {
"status": "unknown",
"name": prediction.recognized_name,
"confidence": prediction.confidence,
"message": "Prediksi di bawah threshold.",
}
return jsonify(unknown_response), 200
# Calculate attendance status (tepat_waktu or terlambat)
# Batas masuk berdasarkan config (default: jam 7:00)
attendance_status = calculate_attendance_status(
cutoff_hour=config.attendance_cutoff_hour,
cutoff_minute=config.attendance_cutoff_minute,
)
# Save the picture
picture_filename = _save_attendance_picture(frame, config)
attendance = attendance_service.mark_attendance(
recognized_name=prediction.recognized_name,
confidence=prediction.confidence,
source="upload",
picture_filename=picture_filename,
status=attendance_status,
)
return jsonify(
{
"status": attendance.status,
"message": attendance.message,
"name": prediction.recognized_name,
"confidence": prediction.confidence,
"student_name": attendance.student_name,
"attendance_id": attendance.attendance_id,
"student_id": attendance.student_id,
"class_id": attendance.class_id,
"attendance_status": attendance_status,
"attendance_status_display": format_attendance_status(attendance_status),
"picture": picture_filename,
}
)
@app.post("/attendance/camera/start")
def start_camera_attendance():
"""Start camera attendance (background mode)."""
camera_runner.start()
return jsonify({
"status": "started",
"mode": "background",
"message": "Camera attendance dimulai (background mode)"
})
@app.post("/attendance/camera/stop")
def stop_camera_attendance():
"""Stop camera attendance."""
camera_runner.stop()
return jsonify({"status": "stopped", "message": "Camera attendance dihentikan"})
@app.get("/attendance/camera/status")
def camera_status():
state = camera_runner.state()
return jsonify(
{
"running": state.running,
"last_identity": state.last_identity,
"last_confidence": state.last_confidence,
"last_message": state.last_message,
}
)
@app.route('/tes-konek', methods=['GET'])
def test_db_connection():
try:
# Mengetes koneksi menggunakan context manager with db.connection() bawaan kodemu
with db.connection() as conn:
# Kita buat cursor untuk ngetes query basic
if db.driver == "mysql":
cur = conn.cursor()
cur.execute("SELECT VERSION();")
version = cur.fetchone()
cur.close()
db_version = version[0]
else:
# Jika sewaktu-waktu beralih ke sqlite
row = conn.execute("SELECT sqlite_version();").fetchone()
db_version = row[0]
return jsonify({
"status": "success",
"message": f"Gokil! Flask faceapi berhasil konek ke database {db.driver} Shared Hosting!",
"database_version": db_version,
"target_table_student": config.student_table
}), 200
except Exception as e:
# Jika gagal handshake (IP diblokir, password keliru, dsb)
return jsonify({
"status": "error",
"message": "Aduh, gagal konek ke database shared hosting!",
"error_detail": str(e)
}), 500
@app.errorhandler(DatabaseError)
def handle_db_error(err):
return jsonify({"error": str(err)}), 500
@app.errorhandler(ValueError)
def handle_value_error(err):
return jsonify({"error": str(err)}), 400
@app.post("/dataset/fetch-from-laravel")
def fetch_dataset_from_laravel():
"""Fetch student photos from Laravel storage and organize into dataset."""
try:
payload = request.get_json(silent=True) or {}
overwrite = bool(payload.get("overwrite", False))
fetcher = LaravelDatasetFetcher(
db=db,
laravel_url=config.laravel_url,
storage_path=config.laravel_storage_path,
)
# Fetch and organize
fetch_result = fetcher.fetch_and_organize(
output_dir=config.raw_dir,
overwrite=overwrite,
)
if not fetch_result["success"]:
return jsonify({
"status": "error",
"message": "Gagal fetch dataset dari Laravel",
"details": fetch_result,
}), 400
# Cleanup and reorganize images
cleanup_result = fetcher.cleanup_and_reorganize(
dataset_dir=config.raw_dir,
target_size=(config.image_size, config.image_size),
)
return jsonify({
"status": "success",
"message": "Dataset berhasil di-fetch dari Laravel",
"fetch_result": fetch_result,
"cleanup_result": cleanup_result,
}), 200
except Exception as e:
return jsonify({
"status": "error",
"message": f"Error fetch dataset: {str(e)}",
}), 500
@app.errorhandler(FileNotFoundError)
def handle_not_found(err):
return jsonify({"error": str(err)}), 404
@app.errorhandler(Exception)
def handle_unexpected(err):
return jsonify({"error": f"Unexpected error: {err}"}), 500
return app
if __name__ == "__main__":
flask_app = create_app()
cfg = AppConfig.from_env()
flask_app.run(host="0.0.0.0", port=5000, debug=cfg.debug)

124
app/config.py Normal file
View File

@ -0,0 +1,124 @@
import os
from dataclasses import dataclass
from pathlib import Path
def _env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _env_float(name: str, default: float) -> float:
value = os.getenv(name)
if value is None:
return default
try:
return float(value)
except ValueError:
return default
def _env_int(name: str, default: int) -> int:
value = os.getenv(name)
if value is None:
return default
try:
return int(value)
except ValueError:
return default
@dataclass(frozen=True)
class AppConfig:
project_root: Path
dataset_root: Path
raw_dir: Path
preprocessed_dir: Path
model_path: Path
class_names_path: Path
logs_dir: Path
image_size: int
batch_size: int
epochs: int
learning_rate: float
min_images_per_class: int
train_ratio: float
val_ratio: float
test_ratio: float
random_seed: int
threshold: float
camera_id: int
camera_check_interval_sec: float
debug: bool
# DB config (MySQL Laravel)
db_driver: str
db_host: str
db_port: int
db_name: str
db_user: str
db_password: str
# Schema mapping (Laravel database)
student_table: str
student_id_column: str
student_name_column: str
student_class_column: str
attendance_table: str
# Attendance settings
attendance_cutoff_hour: int
attendance_cutoff_minute: int
# Laravel storage config
laravel_url: str
laravel_storage_path: str
laravel_attendance_pictures_path: str
@staticmethod
def from_env() -> "AppConfig":
project_root = Path(__file__).resolve().parents[1]
dataset_root = project_root / "dataset"
model_dir = project_root / "models"
logs_dir = project_root / "experiment_logs"
return AppConfig(
project_root=project_root,
dataset_root=dataset_root,
raw_dir=dataset_root / "Dataset_Raw",
preprocessed_dir=dataset_root / "Dataset_Preprocessed",
model_path=model_dir / os.getenv("MODEL_FILENAME", "cnn_face_recognition.keras"),
class_names_path=model_dir / os.getenv("CLASS_NAMES_FILENAME", "class_names.json"),
logs_dir=logs_dir,
image_size=_env_int("IMAGE_SIZE", 224),
batch_size=_env_int("BATCH_SIZE", 32),
epochs=_env_int("EPOCHS", 20),
learning_rate=_env_float("LEARNING_RATE", 1e-3),
min_images_per_class=_env_int("MIN_IMAGES_PER_CLASS", 30),
train_ratio=_env_float("TRAIN_RATIO", 0.70),
val_ratio=_env_float("VAL_RATIO", 0.15),
test_ratio=_env_float("TEST_RATIO", 0.15),
random_seed=_env_int("RANDOM_SEED", 42),
threshold=_env_float("RECOGNITION_THRESHOLD", 0.70),
camera_id=_env_int("CAMERA_ID", 0),
camera_check_interval_sec=_env_float("CAMERA_CHECK_INTERVAL_SEC", 1.0),
debug=_env_bool("FLASK_DEBUG", True),
db_driver=os.getenv("DB_DRIVER", "mysql").strip().lower(),
db_host=os.getenv("DB_HOST", "127.0.0.1"),
db_port=_env_int("DB_PORT", 3306),
db_name=os.getenv("DB_NAME", "sas"),
db_user=os.getenv("DB_USER", "root"),
db_password=os.getenv("DB_PASSWORD", ""),
student_table=os.getenv("STUDENT_TABLE", "students"),
student_id_column=os.getenv("STUDENT_ID_COLUMN", "id"),
student_name_column=os.getenv("STUDENT_NAME_COLUMN", "name"),
student_class_column=os.getenv("STUDENT_CLASS_COLUMN", "id_class"),
attendance_table=os.getenv("ATTENDANCE_TABLE", "attendance_history_dailys"),
laravel_url=os.getenv("APP_URL", "http://localhost:8000"),
laravel_storage_path=os.getenv("LARAVEL_STORAGE_PATH", "photo-webcam"),
laravel_attendance_pictures_path=os.getenv("LARAVEL_ATTENDANCE_PICTURES_PATH", "daily_attendance_pictures"),
attendance_cutoff_hour=_env_int("ATTENDANCE_CUTOFF_HOUR", 7),
attendance_cutoff_minute=_env_int("ATTENDANCE_CUTOFF_MINUTE", 0),
)

195
app/database.py Normal file
View File

@ -0,0 +1,195 @@
import sqlite3
from contextlib import contextmanager
from datetime import datetime
from typing import Any, Dict, Generator, Optional
from config import AppConfig
class DatabaseError(RuntimeError):
pass
class Database:
def __init__(self, config: AppConfig) -> None:
self.config = config
self.driver = config.db_driver
if self.driver not in {"mysql", "sqlite"}:
raise DatabaseError("DB_DRIVER harus 'mysql' atau 'sqlite'.")
self._mysql_connector = None
if self.driver == "mysql":
try:
import mysql.connector # type: ignore
self._mysql_connector = mysql.connector
except ImportError as exc:
raise DatabaseError(
"mysql-connector-python belum terpasang. Install dependency terlebih dulu."
) from exc
@contextmanager
def connection(self) -> Generator[Any, None, None]:
if self.driver == "sqlite":
db_path = str(self.config.project_root / "app_presensiku.sqlite3")
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
finally:
conn.close()
return
conn = self._mysql_connector.connect(
host=self.config.db_host,
port=self.config.db_port,
database=self.config.db_name,
user=self.config.db_user,
password=self.config.db_password,
)
try:
yield conn
conn.commit()
finally:
conn.close()
def fetch_all(self, query: str, params: tuple = ()) -> list:
"""Execute a query and fetch all results.
Args:
query: SQL query string
params: Query parameters (for parameterized queries)
Returns: List of tuples (for sqlite) or list of dicts (for mysql)
"""
if self.driver == "sqlite":
with self.connection() as conn:
rows = conn.execute(query, params).fetchall()
return rows or []
with self.connection() as conn:
cur = conn.cursor(dictionary=True)
cur.execute(query, params)
rows = cur.fetchall()
cur.close()
return rows or []
def find_student_by_name(self, recognized_name: str) -> Optional[Dict[str, Any]]:
"""Find student by name from Laravel students table.
Returns: {"id": int, "name": str, "id_class": int} or None
"""
student_table = self.config.student_table
id_col = self.config.student_id_column
name_col = self.config.student_name_column
class_col = self.config.student_class_column
if self.driver == "sqlite":
with self.connection() as conn:
row = conn.execute(
f"SELECT {id_col}, {name_col}, {class_col} FROM {student_table} WHERE lower({name_col}) = lower(?) LIMIT 1",
(recognized_name,),
).fetchone()
if row is None:
return None
return {
"id": row[id_col],
"name": row[name_col],
"id_class": row[class_col]
}
with self.connection() as conn:
cur = conn.cursor(dictionary=True)
cur.execute(
f"SELECT {id_col} AS id, {name_col} AS name, {class_col} AS id_class FROM {student_table} "
f"WHERE LOWER({name_col}) = LOWER(%s) LIMIT 1",
(recognized_name,),
)
row = cur.fetchone()
cur.close()
return row
def has_attendance_today(self, student_id: int, id_class: int) -> bool:
"""Check if student already has attendance recorded today for this class."""
attendance_table = self.config.attendance_table
now = datetime.now()
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
if self.driver == "sqlite":
with self.connection() as conn:
row = conn.execute(
f"SELECT id FROM {attendance_table} WHERE id_student = ? "
f"AND id_class = ? AND created_at >= ? LIMIT 1",
(student_id, id_class, day_start.isoformat()),
).fetchone()
return row is not None
with self.connection() as conn:
cur = conn.cursor()
cur.execute(
f"SELECT id FROM {attendance_table} WHERE id_student = %s "
f"AND id_class = %s AND DATE(created_at) = DATE(%s) LIMIT 1",
(student_id, id_class, now),
)
row = cur.fetchone()
cur.close()
return row is not None
def insert_attendance(
self,
id_student: int,
id_class: int,
picture_filename: str,
status: str = "tepat waktu",
) -> int:
"""Insert attendance record into attendance_history_dailys table.
Args:
- id_student: Student ID from students table
- id_class: Class ID from clases table
- picture_filename: Name/path of the picture file
- status: 'tepat waktu' or 'terlambat'
Returns: Attendance ID
"""
attendance_table = self.config.attendance_table
now = datetime.now()
if self.driver == "sqlite":
with self.connection() as conn:
cur = conn.execute(
f"INSERT INTO {attendance_table} "
"(id_student, id_class, picture, status, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
id_student,
id_class,
picture_filename,
status,
now.isoformat(),
now.isoformat(),
),
)
return int(cur.lastrowid)
with self.connection() as conn:
cur = conn.cursor()
cur.execute(
f"INSERT INTO {attendance_table} "
"(id_student, id_class, picture, status, created_at, updated_at) "
"VALUES (%s, %s, %s, %s, %s, %s)",
(
id_student,
id_class,
picture_filename,
status,
now,
now,
),
)
attendance_id = int(cur.lastrowid)
cur.close()
return attendance_id

1
app/services/__init__.py Normal file
View File

@ -0,0 +1 @@
# Package marker for services modules.

101
app/services/attendance.py Normal file
View File

@ -0,0 +1,101 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from database import Database
@dataclass
class AttendanceResult:
status: str
message: str
attendance_id: Optional[int]
student_id: Optional[int]
student_name: Optional[str]
class_id: Optional[int]
class AttendanceService:
def __init__(self, db: Database) -> None:
self.db = db
def can_mark_attendance(self, recognized_name: str) -> tuple[bool, str, Optional[int], Optional[str]]:
"""Check if a student can mark attendance today (not already attended).
Returns: (can_attend, message, student_id, student_name)
"""
student = self.db.find_student_by_name(recognized_name)
if not student:
return False, f"Siswa '{recognized_name}' tidak ditemukan di database.", None, None
student_id = int(student["id"])
id_class = int(student["id_class"])
student_name = student["name"]
if self.db.has_attendance_today(student_id=student_id, id_class=id_class):
return False, "Absensi hari ini sudah tercatat untuk kelas ini.", student_id, student_name
return True, "Siswa bisa absensi.", student_id, student_name
def mark_attendance(
self,
recognized_name: str,
confidence: float,
source: str,
picture_filename: str,
status: str = "tepat waktu",
) -> AttendanceResult:
"""Mark attendance from recognized face.
Args:
- recognized_name: Student name recognized by model (must match student name in DB)
- confidence: Model confidence score
- source: Source of detection ('camera' or 'upload')
- picture_filename: Filename/path of the picture being processed
- status: 'tepat waktu' (on-time) or 'terlambat' (late)
Returns: AttendanceResult with details of the attendance record
"""
student = self.db.find_student_by_name(recognized_name)
if not student:
return AttendanceResult(
status="not_found",
message=f"Siswa '{recognized_name}' tidak ditemukan di database.",
attendance_id=None,
student_id=None,
student_name=None,
class_id=None,
)
student_id = int(student["id"])
id_class = int(student["id_class"])
student_name = student["name"]
if self.db.has_attendance_today(student_id=student_id, id_class=id_class):
return AttendanceResult(
status="duplicate",
message="Absensi hari ini sudah tercatat untuk kelas ini.",
attendance_id=None,
student_id=student_id,
student_name=student_name,
class_id=id_class,
)
attendance_id = self.db.insert_attendance(
id_student=student_id,
id_class=id_class,
picture_filename=picture_filename,
status=status,
)
return AttendanceResult(
status="marked",
message=f"Absensi {student_name} berhasil dicatat ({status}).",
attendance_id=attendance_id,
student_id=student_id,
student_name=student_name,
class_id=id_class,
)

View File

@ -0,0 +1,53 @@
"""Helper utilities for attendance system."""
from datetime import datetime, time
from typing import Literal
def calculate_attendance_status(
attendance_time: datetime = None,
cutoff_hour: int = 7,
cutoff_minute: int = 0,
) -> Literal["tepat_waktu", "terlambat"]:
"""Calculate attendance status based on cutoff time.
Args:
attendance_time: Time of attendance (default: now)
cutoff_hour: Cutoff hour (default: 7)
cutoff_minute: Cutoff minute (default: 0)
Returns:
'tepat_waktu' if before/at cutoff, 'terlambat' if after cutoff
Example:
>>> status = calculate_attendance_status() # Uses current time, cutoff 07:00
>>> status = calculate_attendance_status(cutoff_hour=8, cutoff_minute=30) # Cutoff 08:30
"""
if attendance_time is None:
attendance_time = datetime.now()
cutoff_time = time(cutoff_hour, cutoff_minute, 0)
current_time = attendance_time.time()
# Jika waktu <= cutoff_time → tepat_waktu
if current_time <= cutoff_time:
return "tepat_waktu"
else:
return "terlambat"
def format_attendance_status(status: str) -> str:
"""Format status for display.
Args:
status: 'tepat_waktu' or 'terlambat'
Returns:
Human-readable status
"""
status_map = {
"tepat_waktu": "Tepat Waktu ✓",
"terlambat": "Terlambat ⚠",
"izin": "Izin",
}
return status_map.get(status, status)

View File

@ -0,0 +1,130 @@
import threading
import time
from dataclasses import dataclass
from typing import Optional
from datetime import datetime
from pathlib import Path
import cv2
from config import AppConfig
from services.attendance import AttendanceService
from services.inference import FacePredictor
@dataclass
class CameraState:
running: bool
last_identity: Optional[str]
last_confidence: float
last_message: str
class CameraAttendanceRunner:
def __init__(
self,
config: AppConfig,
predictor: FacePredictor,
attendance_service: AttendanceService,
) -> None:
self.config = config
self.predictor = predictor
self.attendance_service = attendance_service
self._running = False
self._thread: Optional[threading.Thread] = None
self._last_identity: Optional[str] = None
self._last_confidence: float = 0.0
self._last_message: str = "idle"
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
if self._thread is not None:
self._thread.join(timeout=2.0)
def state(self) -> CameraState:
return CameraState(
running=self._running,
last_identity=self._last_identity,
last_confidence=self._last_confidence,
last_message=self._last_message,
)
def _save_camera_frame(self, frame: cv2.Mat) -> str:
"""Save camera frame to disk and return relative path.
Returns: Relative path like 'attendance_pictures/camera_2026-02-05_14-30-45_123.jpg'
"""
picture_dir = self.config.project_root / "attendance_pictures"
picture_dir.mkdir(exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3]
filename = f"camera_{timestamp}.jpg"
filepath = picture_dir / filename
cv2.imwrite(str(filepath), frame)
return f"attendance_pictures/{filename}"
def _run_loop(self) -> None:
cap = cv2.VideoCapture(self.config.camera_id)
if not cap.isOpened():
self._last_message = "camera open failed"
self._running = False
return
last_action_ts = 0.0
try:
while self._running:
ok, frame = cap.read()
if not ok:
self._last_message = "frame read failed"
time.sleep(0.2)
continue
prediction = self.predictor.predict(frame)
if prediction is None:
self._last_message = "no face detected"
continue
self._last_identity = prediction.recognized_name
self._last_confidence = prediction.confidence
if prediction.confidence < self.config.threshold:
self._last_message = "low confidence"
continue
# Check if student can mark attendance (not already attended today)
can_attend, check_message, student_id, student_name = self.attendance_service.can_mark_attendance(
recognized_name=prediction.recognized_name
)
if not can_attend:
self._last_message = check_message
continue
now_ts = time.time()
if now_ts - last_action_ts < self.config.camera_check_interval_sec:
continue
# Save the frame only if eligibility check passed
picture_filename = self._save_camera_frame(frame)
result = self.attendance_service.mark_attendance(
recognized_name=prediction.recognized_name,
confidence=prediction.confidence,
source="camera",
picture_filename=picture_filename,
)
self._last_message = result.message
last_action_ts = now_ts
finally:
cap.release()

View File

@ -0,0 +1,204 @@
"""Service to fetch student photos from Laravel storage and organize into dataset folders."""
import os
import shutil
from pathlib import Path
from typing import Optional
import requests
import cv2
import numpy as np
from database import Database
class LaravelDatasetFetcher:
"""Fetch student photos from Laravel storage and organize into training dataset."""
def __init__(self, db: Database, laravel_url: str, storage_path: str):
"""Initialize fetcher.
Args:
db: Database connection
laravel_url: Base URL of Laravel app (e.g., 'https://example.com')
storage_path: Storage path (e.g., 'photo-webcam')
"""
self.db = db
self.laravel_url = laravel_url.rstrip("/")
self.storage_path = storage_path.strip("/")
def fetch_and_organize(
self, output_dir: Path, overwrite: bool = False
) -> dict:
"""Fetch photos from Laravel and organize into dataset structure.
Args:
output_dir: Output directory for organized dataset
overwrite: Whether to overwrite existing files
Returns:
{
'success': True/False,
'students_processed': int,
'photos_downloaded': int,
'photos_failed': int,
'errors': [error messages]
}
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
results = {
"success": True,
"students_processed": 0,
"photos_downloaded": 0,
"photos_failed": 0,
"errors": [],
}
try:
# Query students with pictures
students = self._get_students_with_pictures()
results["students_processed"] = len(students)
for student_id, student_name, pictures_str in students:
if not pictures_str:
continue
# Create folder for student
student_folder = output_dir / student_name
if student_folder.exists() and not overwrite:
results["errors"].append(
f"Folder '{student_name}' sudah ada, skipped"
)
continue
student_folder.mkdir(parents=True, exist_ok=True)
# Parse picture filenames (comma-separated)
picture_filenames = [
f.strip() for f in pictures_str.split(",") if f.strip()
]
for filename in picture_filenames:
try:
self._download_and_save_photo(
filename, student_folder, student_name
)
results["photos_downloaded"] += 1
except Exception as e:
results["photos_failed"] += 1
results["errors"].append(
f"Student: {student_name}, File: {filename} - {str(e)}"
)
except Exception as e:
results["success"] = False
results["errors"].append(f"Fetch failed: {str(e)}")
return results
def _get_students_with_pictures(self) -> list:
"""Get students that have pictures from database.
Returns:
List of tuples: [(id, name, pictures_str), ...]
"""
query = """
SELECT id, name, pictures
FROM students
WHERE pictures IS NOT NULL AND pictures != ''
ORDER BY name
"""
rows = self.db.fetch_all(query)
# Convert rows to list of tuples for consistent handling of sqlite and mysql results
result = []
for row in rows:
if isinstance(row, dict):
# MySQL result (dictionary)
result.append((row['id'], row['name'], row['pictures']))
else:
# SQLite result (tuple/Row object)
result.append((row[0], row[1], row[2]))
return result
def _download_and_save_photo(
self, filename: str, student_folder: Path, student_name: str
) -> None:
"""Download photo from Laravel storage and save locally.
Args:
filename: Filename from database (e.g., 'webcam_1234567_1_abc123.png')
student_folder: Folder to save the photo
student_name: Student name for logging
"""
# Build URL to photo
url = (
f"{self.laravel_url}/storage/{self.storage_path}/{filename}"
)
# Download with timeout
response = requests.get(url, timeout=10)
response.raise_for_status()
# Save locally
output_file = student_folder / filename
with open(output_file, "wb") as f:
f.write(response.content)
# Verify it's a valid image
img = cv2.imread(str(output_file))
if img is None:
output_file.unlink() # Delete invalid file
raise ValueError(f"Downloaded file is not a valid image: {filename}")
def cleanup_and_reorganize(
self, dataset_dir: Path, target_size: tuple = (224, 224)
) -> dict:
"""Cleanup corrupted images and resize to target size.
Args:
dataset_dir: Dataset directory with student folders
target_size: Target image size (width, height)
Returns:
{'cleaned': int, 'resized': int, 'removed': int, 'errors': []}
"""
dataset_dir = Path(dataset_dir)
results = {"cleaned": 0, "resized": 0, "removed": 0, "errors": []}
try:
for student_folder in dataset_dir.iterdir():
if not student_folder.is_dir():
continue
for img_file in student_folder.glob("*"):
try:
# Try to read image
img = cv2.imread(str(img_file))
if img is None:
img_file.unlink()
results["removed"] += 1
continue
# Resize to target size
img_resized = cv2.resize(img, target_size)
cv2.imwrite(str(img_file), img_resized)
results["resized"] += 1
results["cleaned"] += 1
except Exception as e:
results["errors"].append(
f"{img_file.name}: {str(e)}"
)
try:
img_file.unlink()
results["removed"] += 1
except:
pass
except Exception as e:
results["errors"].append(f"Cleanup failed: {str(e)}")
return results

78
app/services/inference.py Normal file
View File

@ -0,0 +1,78 @@
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import cv2
import numpy as np
import tensorflow as tf
@dataclass
class PredictionResult:
recognized_name: str
confidence: float
face_box: tuple[int, int, int, int]
class FacePredictor:
def __init__(self, model_path: Path, class_names_path: Path, image_size: int) -> None:
self.model_path = model_path
self.class_names_path = class_names_path
self.image_size = image_size
self.model: Optional[tf.keras.Model] = None
self.class_names: list[str] = []
self.face_cascade = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)
if self.face_cascade.empty():
raise RuntimeError("Gagal memuat Haar Cascade.")
def load(self) -> None:
if self.model is None:
if not self.model_path.exists():
raise FileNotFoundError(f"Model belum ditemukan: {self.model_path}")
if not self.class_names_path.exists():
raise FileNotFoundError(f"Class names belum ditemukan: {self.class_names_path}")
self.model = tf.keras.models.load_model(self.model_path)
with open(self.class_names_path, "r", encoding="utf-8") as f:
self.class_names = json.load(f)
def detect_largest_face(self, frame_bgr: np.ndarray) -> Optional[tuple[int, int, int, int]]:
gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
faces = self.face_cascade.detectMultiScale(
gray, scaleFactor=1.1, minNeighbors=5, minSize=(40, 40)
)
if len(faces) == 0:
return None
x, y, w, h = max(faces, key=lambda f: f[2] * f[3])
return int(x), int(y), int(w), int(h)
def predict(self, frame_bgr: np.ndarray) -> Optional[PredictionResult]:
self.load()
face_box = self.detect_largest_face(frame_bgr)
if face_box is None:
return None
x, y, w, h = face_box
crop = frame_bgr[y : y + h, x : x + w]
# Pipeline must match the training preprocessing (no background removal).
# Resize → convert to RGB → scale to [-1, 1] (MobileNetV2 requirement).
resized = cv2.resize(crop, (self.image_size, self.image_size), interpolation=cv2.INTER_AREA)
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
x_input = np.expand_dims(rgb.astype(np.float32), axis=0)
# MobileNetV2 preprocessing: scales pixel values from [0, 255] to [-1, 1]
x_input = tf.keras.applications.mobilenet_v2.preprocess_input(x_input)
probs = self.model.predict(x_input, verbose=0)[0]
pred_idx = int(np.argmax(probs))
confidence = float(probs[pred_idx])
return PredictionResult(
recognized_name=self.class_names[pred_idx],
confidence=confidence,
face_box=face_box,
)

223
app/services/preprocess.py Normal file
View File

@ -0,0 +1,223 @@
"""Preprocess face images by detecting, cropping, resizing, and augmenting them."""
import argparse
import random
import shutil
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import cv2
import numpy as np
from rembg import remove as rembg_remove
VALID_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
def _collect_class_dirs(root_dir: Path) -> List[Path]:
"""Return the class folders inside the source dataset."""
return sorted([p for p in root_dir.iterdir() if p.is_dir()])
def _collect_image_files(root_dir: Path) -> List[Path]:
"""Return all supported image files below one class folder."""
return sorted(p for p in root_dir.rglob("*") if p.is_file() and p.suffix.lower() in VALID_EXTENSIONS)
def load_face_cascade() -> cv2.CascadeClassifier:
"""Load the built-in Haar cascade for face detection."""
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
face_cascade = cv2.CascadeClassifier(cascade_path)
if face_cascade.empty():
raise RuntimeError("Gagal memuat Haar Cascade untuk deteksi wajah.")
return face_cascade
def detect_largest_face(image_bgr: np.ndarray, face_cascade: cv2.CascadeClassifier) -> Optional[Tuple[int, int, int, int]]:
"""Find the biggest face box in one image."""
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(40, 40))
if len(faces) == 0:
return None
x, y, w, h = max(faces, key=lambda face: face[2] * face[3])
return int(x), int(y), int(w), int(h)
def remove_background(image_bgr: np.ndarray, bg_color: Tuple[int, int, int] = (255, 255, 255)) -> np.ndarray:
"""Remove the background from a BGR image using rembg and composite onto a solid colour.
Args:
image_bgr: Input image in BGR format (as returned by cv2.imread).
bg_color: Background fill colour in BGR order. Defaults to white.
Returns:
BGR image with background replaced by *bg_color*.
"""
# rembg expects PNG bytes; encode the BGR frame to PNG in-memory
success, encoded = cv2.imencode(".png", image_bgr)
if not success:
return image_bgr # fall back to original if encoding fails
png_bytes = encoded.tobytes()
result_bytes = rembg_remove(png_bytes) # returns RGBA PNG bytes
# Decode the RGBA result
result_array = np.frombuffer(result_bytes, dtype=np.uint8)
rgba = cv2.imdecode(result_array, cv2.IMREAD_UNCHANGED)
if rgba is None or rgba.shape[2] != 4:
return image_bgr # fall back if decoding fails
# cv2.imdecode returns BGR data even for RGBA PNGs (channels: B, G, R, A)
# So we treat the first 3 channels as BGR directly — no further conversion needed.
alpha = rgba[:, :, 3:4].astype(np.float32) / 255.0
bgr = rgba[:, :, :3].astype(np.float32)
background = np.full_like(bgr, fill_value=bg_color, dtype=np.float32) # already BGR
composited = (bgr * alpha + background * (1.0 - alpha)).astype(np.uint8)
return composited
def crop_and_resize(image_bgr: np.ndarray, face_box: Tuple[int, int, int, int], target_size: int) -> np.ndarray:
"""Crop the face area and resize it to the model input size."""
x, y, w, h = face_box
face_crop = image_bgr[y : y + h, x : x + w]
return cv2.resize(face_crop, (target_size, target_size), interpolation=cv2.INTER_AREA)
def random_augment(color_img: np.ndarray, rng: random.Random) -> np.ndarray:
"""Create a slightly changed copy of one face image."""
image = color_img.copy()
height, width = image.shape[:2]
rotation = rng.uniform(-18, 18)
scale = rng.uniform(0.95, 1.05)
rotation_matrix = cv2.getRotationMatrix2D((width // 2, height // 2), rotation, scale)
image = cv2.warpAffine(
image,
rotation_matrix,
(width, height),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REFLECT_101,
)
shift_x = rng.randint(-10, 10)
shift_y = rng.randint(-10, 10)
translation_matrix = np.float32([[1, 0, shift_x], [0, 1, shift_y]])
image = cv2.warpAffine(
image,
translation_matrix,
(width, height),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REFLECT_101,
)
if rng.random() < 0.5:
image = cv2.flip(image, 1)
brightness = rng.uniform(0.85, 1.20)
contrast = rng.uniform(-20, 20)
image = cv2.convertScaleAbs(image, alpha=brightness, beta=contrast)
if rng.random() < 0.3:
kernel_size = rng.choice([3, 5])
image = cv2.GaussianBlur(image, (kernel_size, kernel_size), 0)
return image
def preprocess_dataset(
source_dir: Path,
output_dir: Path,
target_size: int,
min_images_per_class: int,
seed: int,
overwrite: bool = False,
) -> Dict[str, int]:
"""Process every class folder and save cleaned plus augmented images."""
if not source_dir.is_dir():
raise FileNotFoundError(f"Folder sumber tidak ditemukan: {source_dir}")
class_dirs = _collect_class_dirs(source_dir)
if not class_dirs:
raise RuntimeError("Dataset raw tidak memiliki subfolder kelas.")
# Always remove old preprocessed dataset to ensure clean data
# This prevents mixing old and new preprocessed images
if output_dir.exists():
shutil.rmtree(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
face_cascade = load_face_cascade()
rng = random.Random(seed)
stats = {"class_count": len(class_dirs), "processed": 0, "skipped": 0, "generated": 0, "total_output": 0}
for class_dir in class_dirs:
image_files = _collect_image_files(class_dir)
if not image_files:
continue
class_output_dir = output_dir / class_dir.name
class_output_dir.mkdir(parents=True, exist_ok=True)
clean_images: List[np.ndarray] = []
for index, src_path in enumerate(image_files, start=1):
image = cv2.imread(str(src_path), cv2.IMREAD_COLOR)
if image is None:
stats["skipped"] += 1
continue
# Step 1 remove background
image = remove_background(image)
# Step 2 detect the largest face on the clean image
face_box = detect_largest_face(image, face_cascade)
if face_box is None:
stats["skipped"] += 1
continue
# Step 3 crop and resize
resized = crop_and_resize(image, face_box, target_size)
output_path = class_output_dir / f"orig_{index:04d}.jpg"
if cv2.imwrite(str(output_path), resized):
stats["processed"] += 1
clean_images.append(resized)
else:
stats["skipped"] += 1
if not clean_images:
continue
needed_images = max(0, min_images_per_class - len(clean_images))
for index in range(needed_images):
base_image = clean_images[index % len(clean_images)]
augmented_image = random_augment(base_image, rng)
augmented_path = class_output_dir / f"aug_{index + 1:04d}.jpg"
if cv2.imwrite(str(augmented_path), augmented_image):
stats["generated"] += 1
stats["total_output"] = stats["processed"] + stats["generated"]
return stats
def main() -> None:
parser = argparse.ArgumentParser(description="Preprocess dataset: detect, crop, resize, augment.")
parser.add_argument("--source", type=str, default="dataset/Dataset_Raw")
parser.add_argument("--output", type=str, default="dataset/Dataset_Preprocessed")
parser.add_argument("--size", type=int, default=224)
parser.add_argument("--min_images", type=int, default=30)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--overwrite", action="store_true")
args = parser.parse_args()
stats = preprocess_dataset(
source_dir=Path(args.source),
output_dir=Path(args.output),
target_size=args.size,
min_images_per_class=args.min_images,
seed=args.seed,
overwrite=args.overwrite,
)
print(stats)
if __name__ == "__main__":
main()

686
app/services/train_cnn.py Normal file
View File

@ -0,0 +1,686 @@
"""Train a MobileNetV2-based face recognition model with K-Fold Cross Validation."""
import json
import random
import shutil
from pathlib import Path
from typing import Dict, List, Tuple
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
import tensorflow as tf
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.model_selection import KFold
# Use non-interactive backend so plots save correctly in a server environment
matplotlib.use("Agg")
VALID_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
IMAGE_SIZE = 224 # MobileNetV2 native input size
# ---------------------------------------------------------------------------
# Dataset helpers
# ---------------------------------------------------------------------------
def _collect_all_samples(
dataset_dir: Path,
seed: int,
) -> Tuple[np.ndarray, np.ndarray, List[str]]:
"""Return arrays of all image paths and integer labels, plus class names.
Files are shuffled with the given seed so cross-validation folds are
reproducible but not biased by directory listing order.
"""
if not dataset_dir.is_dir():
raise FileNotFoundError(f"Folder dataset tidak ditemukan: {dataset_dir}")
class_dirs = sorted(p for p in dataset_dir.iterdir() if p.is_dir())
if not class_dirs:
raise RuntimeError("Folder dataset tidak memiliki subfolder kelas.")
class_names: List[str] = [d.name for d in class_dirs]
class_to_index = {name: i for i, name in enumerate(class_names)}
all_paths: List[str] = []
all_labels: List[int] = []
for class_dir in class_dirs:
files = sorted(
p for p in class_dir.rglob("*")
if p.is_file() and p.suffix.lower() in VALID_EXTENSIONS
)
if len(files) < 2:
raise ValueError(
f"Kelas '{class_dir.name}' hanya punya {len(files)} gambar. Minimal 2 diperlukan."
)
idx = class_to_index[class_dir.name]
all_paths.extend(str(p) for p in files)
all_labels.extend([idx] * len(files))
# Shuffle together
rng = random.Random(seed)
combined = list(zip(all_paths, all_labels))
rng.shuffle(combined)
paths_arr, labels_arr = zip(*combined)
return np.array(paths_arr), np.array(labels_arr, dtype=np.int32), class_names
def _build_dataset(
image_paths: np.ndarray,
labels: np.ndarray,
batch_size: int,
shuffle: bool,
seed: int,
) -> tf.data.Dataset:
"""Build a tf.data.Dataset from paths and labels, resizing to IMAGE_SIZE."""
dataset = tf.data.Dataset.from_tensor_slices(
(image_paths.tolist(), labels.tolist())
)
if shuffle:
dataset = dataset.shuffle(
buffer_size=len(image_paths), seed=seed, reshuffle_each_iteration=True
)
def load_image(path: tf.Tensor, label: tf.Tensor):
image_bytes = tf.io.read_file(path)
image = tf.io.decode_image(image_bytes, channels=3, expand_animations=False)
image.set_shape([None, None, 3])
image = tf.image.resize(image, [IMAGE_SIZE, IMAGE_SIZE])
# MobileNetV2 preprocessing: scale to [-1, 1]
image = tf.keras.applications.mobilenet_v2.preprocess_input(image)
return image, label
return dataset.map(load_image, num_parallel_calls=tf.data.AUTOTUNE).batch(batch_size).prefetch(tf.data.AUTOTUNE)
# ---------------------------------------------------------------------------
# Model builder
# ---------------------------------------------------------------------------
def build_mobilenetv2(num_classes: int, learning_rate: float) -> tf.keras.Model:
"""Build a MobileNetV2 transfer-learning model for face classification.
Architecture:
MobileNetV2 (ImageNet weights, frozen) GlobalAveragePooling2D
Dense(256, relu) Dropout(0.4) Dense(num_classes, softmax)
"""
base_model = tf.keras.applications.MobileNetV2(
input_shape=(IMAGE_SIZE, IMAGE_SIZE, 3),
include_top=False,
weights="imagenet",
)
# Freeze the base; fine-tuning will be enabled in phase 2
base_model.trainable = False
inputs = tf.keras.Input(shape=(IMAGE_SIZE, IMAGE_SIZE, 3), name="input_layer")
x = base_model(inputs, training=False)
x = tf.keras.layers.GlobalAveragePooling2D(name="gap")(x)
x = tf.keras.layers.Dense(256, activation="relu", name="fc1")(x)
x = tf.keras.layers.Dropout(0.4, name="dropout")(x)
outputs = tf.keras.layers.Dense(num_classes, activation="softmax", name="predictions")(x)
model = tf.keras.Model(inputs, outputs, name="mobilenetv2_face_recognition")
model.compile(
optimizer=tf.keras.optimizers.Adam(learning_rate=learning_rate),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
return model
# ---------------------------------------------------------------------------
# Evaluation / plotting helpers
# ---------------------------------------------------------------------------
def _get_predictions(
model: tf.keras.Model, dataset: tf.data.Dataset
) -> Tuple[np.ndarray, np.ndarray]:
"""Collect all predicted and true labels from a dataset."""
y_pred_list, y_true_list = [], []
for images, labels in dataset:
preds = model.predict(images, verbose=0)
y_pred_list.extend(np.argmax(preds, axis=1))
y_true_list.extend(labels.numpy())
return np.array(y_pred_list), np.array(y_true_list)
def _save_classification_report(
y_true: np.ndarray,
y_pred: np.ndarray,
class_names: List[str],
logs_dir: Path,
filename: str = "classification_report.txt",
) -> None:
"""Write a classification report to a text file in logs_dir.
``labels`` is passed explicitly so sklearn never complains when a class
has no samples in the current test split.
"""
all_labels = list(range(len(class_names)))
report = classification_report(
y_true, y_pred,
labels=all_labels,
target_names=class_names,
digits=4,
zero_division=0,
)
path = logs_dir / filename
with open(path, "w", encoding="utf-8") as f:
f.write("Classification Report\n")
f.write("=" * 80 + "\n\n")
f.write(report)
print(f" ✓ Classification report → {path}")
print(report)
def _save_confusion_matrix(
y_true: np.ndarray,
y_pred: np.ndarray,
class_names: List[str],
logs_dir: Path,
filename: str = "confusion_matrix.png",
) -> None:
"""Save a raw-count confusion matrix heatmap to logs_dir.
Figure size scales tightly with the number of classes.
``labels`` is passed explicitly so the matrix always has shape (n, n) even
when some classes are absent from the current test split.
"""
all_labels = list(range(len(class_names)))
cm = confusion_matrix(y_true, y_pred, labels=all_labels)
n = len(class_names)
# Tight cell size so there's no dead space around each number
cell_size = 0.35
fig_w = max(12, n * cell_size + 3) # extra space for y-labels + colorbar
fig_h = max(10, n * cell_size + 2) # extra space for x-labels + title
# Annotation font: shrinks as class count grows, never below 4pt
annot_font = max(4, int(9 - n * 0.05))
# Tick label font: also shrinks but slightly more generous
tick_font = max(4, int(8 - n * 0.04))
fig, ax = plt.subplots(figsize=(fig_w, fig_h))
sns.heatmap(
cm,
annot=True,
fmt="d",
cmap="Blues",
xticklabels=class_names,
yticklabels=class_names,
ax=ax,
annot_kws={"size": annot_font, "weight": "bold"},
linewidths=0.15,
linecolor="white",
cbar_kws={"label": "Count", "shrink": 0.6},
)
ax.set_title("Confusion Matrix (Count)", fontsize=13, fontweight="bold", pad=12)
ax.set_xlabel("Predicted Label", fontsize=10, labelpad=8)
ax.set_ylabel("True Label", fontsize=10, labelpad=8)
ax.tick_params(axis="x", rotation=90, labelsize=tick_font)
ax.tick_params(axis="y", rotation=0, labelsize=tick_font)
plt.tight_layout()
path = logs_dir / filename
plt.savefig(str(path), dpi=200, bbox_inches="tight")
plt.close()
print(f" ✓ Confusion matrix → {path}")
def _save_confusion_matrix_subset(
y_true: np.ndarray,
y_pred: np.ndarray,
class_names: List[str],
logs_dir: Path,
n_edge: int = 4,
filename: str = "confusion_matrix_subset.png",
) -> None:
"""Save a compact confusion matrix showing first/last n_edge classes only.
Middle classes are replaced by a single '...' row and column so the
plot stays readable without showing all N×N cells.
Layout (n_edge=4): rows/cols = [cls0cls3, '...', cls-4cls-1]
"""
all_labels = list(range(len(class_names)))
cm_full = confusion_matrix(y_true, y_pred, labels=all_labels)
n = len(class_names)
# Only makes sense when there are more classes than 2*n_edge
if n <= n_edge * 2:
# Just save the full matrix under the subset filename too
_save_confusion_matrix(y_true, y_pred, class_names, logs_dir, filename)
return
# ── Build subset indices and labels ──────────────────────────────────
head_idx = list(range(n_edge))
tail_idx = list(range(n - n_edge, n))
sel_idx = head_idx + tail_idx # actual row/col indices in cm_full
head_names = [class_names[i] for i in head_idx]
tail_names = [class_names[i] for i in tail_idx]
sub_names = head_names + ["..."] + tail_names # length = 2*n_edge + 1
# ── Build subset cm (float so we can insert NaN for '...' row/col) ───
sel = np.ix_(sel_idx, sel_idx)
cm_corners = cm_full[sel].astype(float) # shape (2*n_edge, 2*n_edge)
size = 2 * n_edge + 1 # include '...' row and column
cm_sub = np.full((size, size), np.nan)
# Top-left block (head × head)
cm_sub[:n_edge, :n_edge] = cm_corners[:n_edge, :n_edge]
# Top-right block (head × tail)
cm_sub[:n_edge, n_edge+1:] = cm_corners[:n_edge, n_edge:]
# Bottom-left block (tail × head)
cm_sub[n_edge+1:, :n_edge] = cm_corners[n_edge:, :n_edge]
# Bottom-right block (tail × tail)
cm_sub[n_edge+1:, n_edge+1:] = cm_corners[n_edge:, n_edge:]
# '...' row and column stay NaN (rendered as blank)
# ── Custom annotation array ───────────────────────────────────────────
annot_arr = np.empty((size, size), dtype=object)
for r in range(size):
for c in range(size):
if r == n_edge or c == n_edge:
annot_arr[r, c] = "·" if r == n_edge and c == n_edge else ""
else:
annot_arr[r, c] = str(int(cm_sub[r, c]))
# Mark the separator row/col with a visible centre dot
annot_arr[n_edge, n_edge] = "···"
# ── Plot ──────────────────────────────────────────────────────────────
cell_px = 1.1
fig_size = size * cell_px + 4
fig, ax = plt.subplots(figsize=(fig_size, fig_size - 1))
# Use a masked array so NaN cells render as light grey
cm_masked = np.ma.array(cm_sub, mask=np.isnan(cm_sub))
cmap = plt.cm.Blues.copy()
cmap.set_bad(color="#f0f0f0") # grey for '...' cells
sns.heatmap(
cm_masked,
annot=annot_arr,
fmt="",
cmap=cmap,
xticklabels=sub_names,
yticklabels=sub_names,
ax=ax,
annot_kws={"size": 11, "weight": "bold"},
linewidths=0.5,
linecolor="white",
cbar_kws={"label": "Count", "shrink": 0.7},
vmin=0,
)
ax.set_title(
f"Confusion Matrix — {n_edge} first & last classes (of {n} total)",
fontsize=13, fontweight="bold", pad=12,
)
ax.set_xlabel("Predicted Label", fontsize=10, labelpad=8)
ax.set_ylabel("True Label", fontsize=10, labelpad=8)
ax.tick_params(axis="x", rotation=45, labelsize=9)
ax.tick_params(axis="y", rotation=0, labelsize=9)
# Style the '...' row and column separators
sep = n_edge + 0.5
ax.axhline(n_edge, color="grey", lw=1.5, ls="--")
ax.axhline(n_edge + 1, color="grey", lw=1.5, ls="--")
ax.axvline(n_edge, color="grey", lw=1.5, ls="--")
ax.axvline(n_edge + 1, color="grey", lw=1.5, ls="--")
plt.tight_layout()
path = logs_dir / filename
plt.savefig(str(path), dpi=180, bbox_inches="tight")
plt.close()
print(f" ✓ Confusion matrix subset → {path}")
def _save_training_history(
history: tf.keras.callbacks.History,
logs_dir: Path,
filename: str = "training_history.png",
) -> None:
"""Save loss and accuracy curves for one training run."""
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].plot(history.history["loss"], label="Train Loss", linewidth=2, marker="o")
axes[0].plot(history.history["val_loss"], label="Val Loss", linewidth=2, marker="s")
axes[0].set_title("Loss per Epoch", fontsize=13, fontweight="bold")
axes[0].set_xlabel("Epoch")
axes[0].set_ylabel("Loss")
axes[0].legend()
axes[0].grid(alpha=0.3)
axes[1].plot(history.history["accuracy"], label="Train Accuracy", linewidth=2, marker="o")
axes[1].plot(history.history["val_accuracy"], label="Val Accuracy", linewidth=2, marker="s")
axes[1].set_title("Accuracy per Epoch", fontsize=13, fontweight="bold")
axes[1].set_xlabel("Epoch")
axes[1].set_ylabel("Accuracy")
axes[1].legend()
axes[1].grid(alpha=0.3)
plt.tight_layout()
path = logs_dir / filename
plt.savefig(str(path), dpi=150, bbox_inches="tight")
plt.close()
print(f" ✓ Training history plot → {path}")
def _save_cv_summary(
fold_results: List[Dict],
logs_dir: Path,
) -> None:
"""Save cross-validation summary (table + bar chart) to logs_dir."""
n_folds = len(fold_results)
val_accs = [r["val_accuracy"] for r in fold_results]
test_accs = [r["test_accuracy"] for r in fold_results]
val_losses = [r["val_loss"] for r in fold_results]
test_losses = [r["test_loss"] for r in fold_results]
# ── Text summary ──────────────────────────────────────────────────────
summary_path = logs_dir / "cv_summary.txt"
with open(summary_path, "w", encoding="utf-8") as f:
f.write("K-Fold Cross Validation Summary\n")
f.write("=" * 70 + "\n\n")
f.write(f"{'Fold':>6} {'Val Acc':>10} {'Test Acc':>10} {'Val Loss':>10} {'Test Loss':>10}\n")
f.write("-" * 50 + "\n")
for i, r in enumerate(fold_results, 1):
f.write(
f"{i:>6} {r['val_accuracy']:>10.4f} {r['test_accuracy']:>10.4f}"
f" {r['val_loss']:>10.4f} {r['test_loss']:>10.4f}\n"
)
f.write("-" * 50 + "\n")
f.write(
f"{'Mean':>6} {np.mean(val_accs):>10.4f} {np.mean(test_accs):>10.4f}"
f" {np.mean(val_losses):>10.4f} {np.mean(test_losses):>10.4f}\n"
)
f.write(
f"{'Std':>6} {np.std(val_accs):>10.4f} {np.std(test_accs):>10.4f}"
f" {np.std(val_losses):>10.4f} {np.std(test_losses):>10.4f}\n"
)
print(f" ✓ CV summary (text) → {summary_path}")
# ── Bar chart ─────────────────────────────────────────────────────────
x = np.arange(n_folds)
width = 0.35
fig, ax = plt.subplots(figsize=(max(8, n_folds * 1.5), 5))
bars1 = ax.bar(x - width / 2, val_accs, width, label="Val Accuracy", color="steelblue")
bars2 = ax.bar(x + width / 2, test_accs, width, label="Test Accuracy", color="coral")
ax.axhline(np.mean(val_accs), color="steelblue", linestyle="--", linewidth=1.2, alpha=0.7, label=f"Mean Val ({np.mean(val_accs):.3f})")
ax.axhline(np.mean(test_accs), color="coral", linestyle="--", linewidth=1.2, alpha=0.7, label=f"Mean Test ({np.mean(test_accs):.3f})")
for bar in bars1:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.005,
f"{bar.get_height():.3f}", ha="center", va="bottom", fontsize=8)
for bar in bars2:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.005,
f"{bar.get_height():.3f}", ha="center", va="bottom", fontsize=8)
ax.set_xlabel("Fold")
ax.set_ylabel("Accuracy")
ax.set_title("K-Fold Cross Validation — Accuracy per Fold", fontsize=13, fontweight="bold")
ax.set_xticks(x)
ax.set_xticklabels([f"Fold {i+1}" for i in range(n_folds)])
ax.set_ylim(0, 1.05)
ax.legend()
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
chart_path = logs_dir / "cv_accuracy_chart.png"
plt.savefig(str(chart_path), dpi=150, bbox_inches="tight")
plt.close()
print(f" ✓ CV accuracy chart → {chart_path}")
# ---------------------------------------------------------------------------
# Main training entry point
# ---------------------------------------------------------------------------
def train_model(
dataset_dir: Path,
model_path: Path,
class_names_path: Path,
logs_dir: Path,
image_size: int,
batch_size: int,
epochs: int,
learning_rate: float,
seed: int,
train_ratio: float,
val_ratio: float,
test_ratio: float,
n_folds: int = 5,
) -> Dict:
"""Train MobileNetV2 with K-Fold Cross Validation.
Pipeline per fold:
1. Split indices train / val / test (80 % of fold train+val, 20 % test)
2. Build datasets (with MobileNetV2 preprocessing)
3. Train with EarlyStopping + ModelCheckpoint
4. Save per-fold: training_history plot, fold JSON history
After all folds:
5. Retrain final model on ALL data and evaluate on hold-out test set
6. Save: confusion_matrix, classification_report, cv_summary
Args:
n_folds: Number of cross-validation folds (default 5).
Returns:
Dictionary with mean/std of val_accuracy, test_accuracy across folds,
plus final model's test_accuracy.
"""
tf.random.set_seed(seed)
# ── Prepare directories ───────────────────────────────────────────────
logs_dir.mkdir(parents=True, exist_ok=True)
model_path.parent.mkdir(parents=True, exist_ok=True)
class_names_path.parent.mkdir(parents=True, exist_ok=True)
# Clean old model files
for f in model_path.parent.glob("*"):
if f.is_file():
f.unlink()
# ── Load all samples ──────────────────────────────────────────────────
print("\n" + "=" * 70)
print("Loading dataset …")
all_paths, all_labels, class_names = _collect_all_samples(dataset_dir, seed)
num_classes = len(class_names)
print(f" Classes : {num_classes}")
print(f" Total imgs: {len(all_paths)}")
# Save class names now (used even if training fails partway)
with open(class_names_path, "w", encoding="utf-8") as f:
json.dump(class_names, f, ensure_ascii=False, indent=2)
# ── K-Fold Cross Validation ───────────────────────────────────────────
kf = KFold(n_splits=n_folds, shuffle=True, random_state=seed)
fold_results: List[Dict] = []
all_history: Dict[str, List] = {} # accumulated history across folds
print(f"\n{'='*70}")
print(f"K-Fold Cross Validation (k={n_folds}, epochs={epochs})")
print("=" * 70)
for fold_idx, (trainval_idx, test_idx) in enumerate(kf.split(all_paths), start=1):
print(f"\n── Fold {fold_idx}/{n_folds} {''*50}")
# Split trainval → train / val (val_ratio out of the trainval portion)
trainval_paths = all_paths[trainval_idx]
trainval_labels = all_labels[trainval_idx]
test_paths_fold = all_paths[test_idx]
test_labels_fold = all_labels[test_idx]
# Use val_ratio relative to trainval size
val_size = max(1, int(len(trainval_idx) * val_ratio))
val_paths_fold = trainval_paths[:val_size]
val_labels_fold = trainval_labels[:val_size]
train_paths_fold = trainval_paths[val_size:]
train_labels_fold = trainval_labels[val_size:]
print(f" train={len(train_paths_fold)} val={len(val_paths_fold)} test={len(test_paths_fold)}")
train_ds = _build_dataset(train_paths_fold, train_labels_fold, batch_size, shuffle=True, seed=seed + fold_idx)
val_ds = _build_dataset(val_paths_fold, val_labels_fold, batch_size, shuffle=False, seed=seed)
test_ds = _build_dataset(test_paths_fold, test_labels_fold, batch_size, shuffle=False, seed=seed)
# Build fresh model for each fold
model = build_mobilenetv2(num_classes=num_classes, learning_rate=learning_rate)
fold_ckpt = str(model_path.parent / f"fold_{fold_idx}_best.keras")
callbacks = [
tf.keras.callbacks.EarlyStopping(
monitor="val_accuracy", mode="max", patience=5, restore_best_weights=True
),
tf.keras.callbacks.ModelCheckpoint(
filepath=fold_ckpt, monitor="val_accuracy", mode="max", save_best_only=True
),
]
history = model.fit(
train_ds, validation_data=val_ds, epochs=epochs, callbacks=callbacks, verbose=1
)
val_loss, val_acc = model.evaluate(val_ds, verbose=0)
test_loss, test_acc = model.evaluate(test_ds, verbose=0)
epoch_count = len(history.history.get("loss", []))
print(f" val_acc={val_acc:.4f} test_acc={test_acc:.4f} epochs_run={epoch_count}")
fold_result = {
"fold": fold_idx,
"val_accuracy": float(val_acc),
"val_loss": float(val_loss),
"test_accuracy": float(test_acc),
"test_loss": float(test_loss),
"epochs_run": epoch_count,
}
fold_results.append(fold_result)
# Save per-fold training history plot
_save_training_history(
history, logs_dir, filename=f"training_history_fold{fold_idx}.png"
)
# Accumulate history for a combined history JSON
for key, values in history.history.items():
all_history.setdefault(key, []).extend(values)
# Save per-fold history JSON
fold_history_path = logs_dir / f"history_fold{fold_idx}.json"
with open(fold_history_path, "w", encoding="utf-8") as f:
json.dump({"fold": fold_idx, "history": history.history}, f, indent=2)
# Clean per-fold checkpoint (keep disk clean; final model saved separately)
ckpt_path = Path(fold_ckpt)
if ckpt_path.exists():
ckpt_path.unlink()
# ── Cross-validation summary ──────────────────────────────────────────
print(f"\n{'='*70}")
print("Cross Validation Summary")
print("=" * 70)
mean_val = float(np.mean([r["val_accuracy"] for r in fold_results]))
std_val = float(np.std ([r["val_accuracy"] for r in fold_results]))
mean_test = float(np.mean([r["test_accuracy"] for r in fold_results]))
std_test = float(np.std ([r["test_accuracy"] for r in fold_results]))
print(f" Val Accuracy : {mean_val:.4f} ± {std_val:.4f}")
print(f" Test Accuracy : {mean_test:.4f} ± {std_test:.4f}")
_save_cv_summary(fold_results, logs_dir)
# Save fold results JSON
cv_json_path = logs_dir / "cv_results.json"
with open(cv_json_path, "w", encoding="utf-8") as f:
json.dump({"folds": fold_results, "mean_val_accuracy": mean_val,
"std_val_accuracy": std_val, "mean_test_accuracy": mean_test,
"std_test_accuracy": std_test}, f, indent=2)
print(f" ✓ CV results JSON → {cv_json_path}")
# ── Final model: retrain on ALL data ──────────────────────────────────
print(f"\n{'='*70}")
print("Training final model on full dataset …")
print("=" * 70)
# Hold out a test set from the full data for final evaluation
n_total = len(all_paths)
test_size = max(1, int(n_total * test_ratio))
val_size_full = max(1, int(n_total * val_ratio))
final_test_paths = all_paths[:test_size]
final_test_labels = all_labels[:test_size]
final_val_paths = all_paths[test_size:test_size + val_size_full]
final_val_labels = all_labels[test_size:test_size + val_size_full]
final_train_paths = all_paths[test_size + val_size_full:]
final_train_labels = all_labels[test_size + val_size_full:]
print(f" train={len(final_train_paths)} val={len(final_val_paths)} test={len(final_test_paths)}")
final_train_ds = _build_dataset(final_train_paths, final_train_labels, batch_size, shuffle=True, seed=seed)
final_val_ds = _build_dataset(final_val_paths, final_val_labels, batch_size, shuffle=False, seed=seed)
final_test_ds = _build_dataset(final_test_paths, final_test_labels, batch_size, shuffle=False, seed=seed)
final_model = build_mobilenetv2(num_classes=num_classes, learning_rate=learning_rate)
final_callbacks = [
tf.keras.callbacks.EarlyStopping(
monitor="val_accuracy", mode="max", patience=5, restore_best_weights=True
),
tf.keras.callbacks.ModelCheckpoint(
filepath=str(model_path), monitor="val_accuracy", mode="max", save_best_only=True
),
]
final_history = final_model.fit(
final_train_ds, validation_data=final_val_ds,
epochs=epochs, callbacks=final_callbacks, verbose=1
)
# Save final training history (plot + JSON)
_save_training_history(final_history, logs_dir, filename="training_history_final.png")
final_history_path = logs_dir / "training_history_final.json"
with open(final_history_path, "w", encoding="utf-8") as f:
json.dump(final_history.history, f, indent=2)
# ── Evaluate final model ──────────────────────────────────────────────
final_val_loss, final_val_acc = final_model.evaluate(final_val_ds, verbose=0)
final_test_loss, final_test_acc = final_model.evaluate(final_test_ds, verbose=0)
print(f"\n Final model val_acc={final_val_acc:.4f} test_acc={final_test_acc:.4f}")
# Generate classification report and confusion matrix on final test set
print(f"\n{'='*70}")
print("Generating evaluation artifacts …")
print("=" * 70)
y_pred, y_true = _get_predictions(final_model, final_test_ds)
_save_classification_report(y_true, y_pred, class_names, logs_dir)
_save_confusion_matrix(y_true, y_pred, class_names, logs_dir)
_save_confusion_matrix_subset(y_true, y_pred, class_names, logs_dir)
print(f"\n{'='*70}")
print(f"All experiment artifacts saved to: {logs_dir}")
print("=" * 70 + "\n")
return {
# Cross-validation metrics
"cv_mean_val_accuracy": mean_val,
"cv_std_val_accuracy": std_val,
"cv_mean_test_accuracy": mean_test,
"cv_std_test_accuracy": std_test,
# Final model metrics
"val_loss": float(final_val_loss),
"val_accuracy": float(final_val_acc),
"test_loss": float(final_test_loss),
"test_accuracy": float(final_test_acc),
"epoch_trained": float(len(final_history.history.get("loss", []))),
"num_classes": num_classes,
}

29
requirements.txt Normal file
View File

@ -0,0 +1,29 @@
# Core web framework
Flask==3.0.3
flask-cors==4.0.1
# Computer Vision
opencv-python==4.9.0.80
numpy==1.26.4
# Deep Learning
tensorflow==2.15.0
# Machine Learning utilities
scikit-learn==1.5.1
# Data visualization (used in train_cnn.py)
matplotlib==3.9.2
seaborn==0.13.2
# HTTP requests (used in fetch_laravel_dataset.py & api.py)
requests==2.32.3
# Database connector
mysql-connector-python==9.1.0
# Environment variables
python-dotenv==1.0.1
# Background removal
rembg==2.0.69