first commit
This commit is contained in:
commit
bd43607caf
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -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)
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Package marker for services modules.
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 = [cls0…cls3, '...', cls-4…cls-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,
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue