import os import shutil import tempfile import uuid import zipfile from typing import Dict, Any, List import torch from fastapi import ( FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks, ) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from huggingface_hub import hf_hub_download from PIL import Image from pydantic import BaseModel from models import UNet from test_functions import process_image # ========================================================= # SETTINGS # ========================================================= class AppSettings(BaseModel): model_repo: str = "Robys01/face-aging" model_filename: str = "best_unet_model.pth" max_upload_size_mb: int = 20 allowed_extensions: list[str] = ["jpg", "jpeg", "png", "webp"] settings = AppSettings() # ========================================================= # APP # ========================================================= app = FastAPI( title="Face Aging API", version="2.0.0", description="API-only FastAPI server for face aging image inference." ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["Content-Disposition"] ) # ========================================================= # MODEL LOAD # ========================================================= MODEL_DIR = "/tmp/model" os.makedirs(MODEL_DIR, exist_ok=True) MODEL_PATH = os.path.join(MODEL_DIR, settings.model_filename) def download_model() -> None: print("Downloading face aging model...") hf_hub_download( repo_id=settings.model_repo, filename=settings.model_filename, local_dir=MODEL_DIR, cache_dir=os.environ.get("HUGGINGFACE_HUB_CACHE"), ) if not os.path.exists(MODEL_PATH): download_model() model = UNet() model.load_state_dict( torch.load( MODEL_PATH, map_location=torch.device("cpu"), weights_only=False, ) ) model.eval() print("Face aging model loaded successfully") # ========================================================= # UTILITIES # ========================================================= def validate_image(upload_file: UploadFile) -> None: filename = upload_file.filename or "" if "." not in filename: raise HTTPException(status_code=400, detail="Invalid filename") ext = filename.rsplit(".", 1)[-1].lower() if ext not in settings.allowed_extensions: raise HTTPException(status_code=400, detail="Unsupported image format") def validate_age(age: int, field_name: str) -> None: if age < 0 or age > 100: raise HTTPException( status_code=400, detail=f"{field_name} must be between 0 and 100" ) def save_upload_temp(upload_file: UploadFile) -> str: suffix = "." + (upload_file.filename or "image.jpg").rsplit(".", 1)[-1] temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) with temp_file as buffer: shutil.copyfileobj(upload_file.file, buffer) return temp_file.name def save_jpeg_output(image: Image.Image, quality: int = 88) -> str: output_filename = f"{uuid.uuid4().hex}.jpg" output_path = os.path.join(tempfile.gettempdir(), output_filename) if image.mode != "RGB": image = image.convert("RGB") image.save( output_path, format="JPEG", quality=quality, optimize=True ) return output_path def cleanup_file(path: str) -> None: try: if path and os.path.exists(path): os.remove(path) except Exception: pass def parse_target_ages(target_ages: str) -> List[int]: """ Input ví dụ: "40,60" Output: [40, 60] """ if not target_ages or not target_ages.strip(): raise HTTPException(status_code=400, detail="target_ages is required") parsed = [] seen = set() for item in target_ages.split(","): item = item.strip() if not item: continue try: age = int(item) except ValueError: raise HTTPException( status_code=400, detail=f"Invalid target age: {item}" ) validate_age(age, "target_age") if age not in seen: seen.add(age) parsed.append(age) if not parsed: raise HTTPException(status_code=400, detail="No valid target ages provided") return parsed # ========================================================= # ROOT / HEALTH # ========================================================= @app.get("/") def root() -> Dict[str, Any]: return { "status": "ok", "message": "Face Aging API is running", "endpoints": { "health": "GET /health", "age_face": "POST /age-face (multipart/form-data: image, source_age, target_age)", "age_face_batch": "POST /age-face-batch (multipart/form-data: image, source_age, target_ages='40,60')" }, } @app.get("/health") def health() -> Dict[str, Any]: return { "status": "healthy", "model_loaded": True, "model_repo": settings.model_repo, } # ========================================================= # SINGLE IMAGE API # ========================================================= @app.post("/age-face") async def age_face( background_tasks: BackgroundTasks, image: UploadFile = File(...), source_age: int = Form(...), target_age: int = Form(...), ): input_path = None output_path = None try: validate_image(image) validate_age(source_age, "source_age") validate_age(target_age, "target_age") input_path = save_upload_temp(image) pil_image = Image.open(input_path) if pil_image.mode != "RGB": pil_image = pil_image.convert("RGB") processed_image = process_image( model, pil_image, source_age, target_age, ) output_path = save_jpeg_output(processed_image, quality=88) background_tasks.add_task(cleanup_file, input_path) background_tasks.add_task(cleanup_file, output_path) return FileResponse( path=output_path, media_type="image/jpeg", filename="aged_face.jpg", headers={ "Content-Disposition": "inline; filename=aged_face.jpg" } ) except HTTPException: if input_path: cleanup_file(input_path) if output_path: cleanup_file(output_path) raise except Exception as e: if input_path: cleanup_file(input_path) if output_path: cleanup_file(output_path) return JSONResponse( status_code=500, content={"success": False, "error": str(e)}, ) # ========================================================= # BATCH API # ========================================================= @app.post("/age-face-batch") async def age_face_batch( background_tasks: BackgroundTasks, image: UploadFile = File(...), source_age: int = Form(...), target_ages: str = Form(...), # ví dụ: "40,60" ): input_path = None zip_path = None try: validate_image(image) validate_age(source_age, "source_age") parsed_target_ages = parse_target_ages(target_ages) input_path = save_upload_temp(image) pil_image = Image.open(input_path) if pil_image.mode != "RGB": pil_image = pil_image.convert("RGB") zip_filename = f"{uuid.uuid4().hex}.zip" zip_path = os.path.join(tempfile.gettempdir(), zip_filename) with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf: for target_age in parsed_target_ages: processed_image = process_image( model, pil_image, source_age, target_age, ) temp_output_path = save_jpeg_output(processed_image, quality=88) zipf.write( temp_output_path, arcname=f"aged_face_{target_age}.jpg" ) cleanup_file(temp_output_path) background_tasks.add_task(cleanup_file, input_path) background_tasks.add_task(cleanup_file, zip_path) return FileResponse( path=zip_path, media_type="application/zip", filename="aged_faces.zip", headers={ "Content-Disposition": "attachment; filename=aged_faces.zip" } ) except HTTPException: if input_path: cleanup_file(input_path) if zip_path: cleanup_file(zip_path) raise except Exception as e: if input_path: cleanup_file(input_path) if zip_path: cleanup_file(zip_path) return JSONResponse( status_code=500, content={"success": False, "error": str(e)}, ) # ========================================================= # MAIN # ========================================================= if __name__ == "__main__": import uvicorn uvicorn.run( "app:app", host="0.0.0.0", port=int(os.environ.get("PORT", "7860")), reload=False, )