commit bd43607caf9660791b07ce86017d27ff8434f50c Author: AfrizalAlka Date: Wed Jul 22 17:05:34 2026 +0700 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5ef968b --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd576db --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..62f6300 --- /dev/null +++ b/app/api.py @@ -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) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..cfaac10 --- /dev/null +++ b/app/config.py @@ -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), + ) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..bdb07cc --- /dev/null +++ b/app/database.py @@ -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 + diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..19d1d4d --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Package marker for services modules. diff --git a/app/services/attendance.py b/app/services/attendance.py new file mode 100644 index 0000000..0b725ce --- /dev/null +++ b/app/services/attendance.py @@ -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, + ) + diff --git a/app/services/attendance_utils.py b/app/services/attendance_utils.py new file mode 100644 index 0000000..084d7b0 --- /dev/null +++ b/app/services/attendance_utils.py @@ -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) diff --git a/app/services/camera_absensi.py b/app/services/camera_absensi.py new file mode 100644 index 0000000..4106d60 --- /dev/null +++ b/app/services/camera_absensi.py @@ -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() + diff --git a/app/services/fetch_laravel_dataset.py b/app/services/fetch_laravel_dataset.py new file mode 100644 index 0000000..6603a3c --- /dev/null +++ b/app/services/fetch_laravel_dataset.py @@ -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 diff --git a/app/services/inference.py b/app/services/inference.py new file mode 100644 index 0000000..c89431e --- /dev/null +++ b/app/services/inference.py @@ -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, + ) diff --git a/app/services/preprocess.py b/app/services/preprocess.py new file mode 100644 index 0000000..9cb1ac5 --- /dev/null +++ b/app/services/preprocess.py @@ -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() diff --git a/app/services/train_cnn.py b/app/services/train_cnn.py new file mode 100644 index 0000000..0f9037a --- /dev/null +++ b/app/services/train_cnn.py @@ -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, + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..42430fe --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file