TIFNJK_E41222030/app/api.py

429 lines
13 KiB
Python

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