| import os |
| import random |
| import tempfile |
| import base64 |
| import traceback |
| from io import BytesIO |
| from contextlib import asynccontextmanager |
| from fastapi import FastAPI, HTTPException |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel |
| from PIL import Image, UnidentifiedImageError |
| from gradio_client import Client, handle_file |
| from transformers import AutoImageProcessor, AutoModelForImageClassification |
| import torch |
| import logging |
| import time |
|
|
| logging.basicConfig(level=logging.INFO) |
| logger = logging.getLogger(__name__) |
|
|
| |
| HF_API_NAME = "Seniordev22/face-swap-app2k6" |
| TARGET_SIZE = (768, 768) |
| DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| MAX_RETRIES = 2 |
|
|
| |
| client = None |
| emotion_targets = {} |
| feature_extractor = None |
| emotion_model = None |
| EMOTION_LABELS = [] |
|
|
| |
| def load_emotion_model(): |
| global feature_extractor, emotion_model, EMOTION_LABELS |
| if emotion_model is not None: |
| return |
| logger.info(f"Loading emotion model on {DEVICE}...") |
| try: |
| model_name = "dima806/facial_emotions_image_detection" |
| feature_extractor = AutoImageProcessor.from_pretrained(model_name) |
| emotion_model = AutoModelForImageClassification.from_pretrained(model_name) |
| emotion_model.eval() |
| emotion_model.to(DEVICE) |
|
|
| |
| id2label = emotion_model.config.id2label |
| |
| EMOTION_LABELS = [id2label[i] for i in range(len(id2label))] |
| logger.info(f"✅ Emotion model loaded. Labels: {EMOTION_LABELS}") |
| except Exception as e: |
| logger.error(f"Failed to load emotion model: {e}") |
| raise |
|
|
| def load_all_targets(): |
| """Load target images organized as: target/{male,female}/{emotion}/image.jpg""" |
| global emotion_targets |
| emotion_targets.clear() |
| base_dir = "target" |
| if not os.path.exists(base_dir): |
| logger.warning("⚠️ 'target/' folder not found! Please create it with subfolders: male/female/emotion/images") |
| return |
|
|
| |
| valid_emotions = set(EMOTION_LABELS) |
| loaded_count = 0 |
|
|
| for gender in ["male", "female"]: |
| gender_path = os.path.join(base_dir, gender) |
| if not os.path.exists(gender_path): |
| logger.warning(f"⚠️ Missing folder: {gender_path}") |
| continue |
|
|
| for emotion in valid_emotions: |
| emotion_dir = os.path.join(gender_path, emotion) |
| if not os.path.exists(emotion_dir): |
| logger.debug(f"No folder for {gender}/{emotion} – will fallback to random of same gender") |
| continue |
|
|
| images = [] |
| for f in os.listdir(emotion_dir): |
| ext = os.path.splitext(f)[1].lower() |
| if ext in {'.jpg', '.jpeg', '.png', '.webp', '.avif', '.bmp'}: |
| full_path = os.path.join(emotion_dir, f) |
| prepped = preprocess_image(full_path) |
| if prepped: |
| images.append(prepped) |
| if images: |
| emotion_targets[(gender, emotion)] = images |
| loaded_count += len(images) |
| logger.info(f" Loaded {len(images)} images for {gender}/{emotion}") |
|
|
| logger.info(f"🎯 Total target images loaded: {loaded_count}") |
| if loaded_count == 0: |
| logger.error("No target images found! Please populate target/male/emotion/ and target/female/emotion/") |
|
|
| |
| def preprocess_image(image_path: str): |
| """Resize image if too large, return path (may be new temp path)""" |
| try: |
| img = Image.open(image_path).convert("RGB") |
| if max(img.size) > max(TARGET_SIZE): |
| img.thumbnail(TARGET_SIZE, Image.Resampling.LANCZOS) |
| |
| fd, temp_path = tempfile.mkstemp(suffix=".jpg") |
| os.close(fd) |
| img.save(temp_path, "JPEG", quality=88, optimize=True) |
| return temp_path |
| return image_path |
| except Exception as e: |
| logger.warning(f"Preprocess failed for {image_path}: {e}") |
| return None |
|
|
| def get_emotion(img_path: str) -> str: |
| """Detect emotion from image file (no caching because temp files are unique)""" |
| try: |
| image = Image.open(img_path).convert("RGB") |
| inputs = feature_extractor(images=image, return_tensors="pt") |
| inputs = {k: v.to(DEVICE) for k, v in inputs.items()} |
|
|
| with torch.no_grad(): |
| outputs = emotion_model(**inputs) |
| pred_idx = outputs.logits.argmax(-1).item() |
| emotion = EMOTION_LABELS[pred_idx] |
| logger.debug(f"Detected emotion: {emotion} (idx {pred_idx})") |
| return emotion |
| except Exception as e: |
| logger.error(f"Emotion detection failed: {e}") |
| return "neutral" |
|
|
| def safe_cleanup(path: str): |
| try: |
| if path and os.path.exists(path): |
| os.unlink(path) |
| except Exception: |
| pass |
|
|
| def swap_face(source_img: Image.Image, gender: str): |
| """Main face swap logic with emotion matching""" |
| temp_files = [] |
|
|
| try: |
| |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: |
| source_img = source_img.convert("RGB") |
| source_img.save(tmp.name, "JPEG", quality=92) |
| src_path = tmp.name |
| temp_files.append(src_path) |
|
|
| src_fixed = preprocess_image(src_path) |
| if not src_fixed: |
| return None, "Failed to preprocess source image" |
| if src_fixed != src_path: |
| temp_files.append(src_fixed) |
|
|
| |
| emotion = get_emotion(src_fixed) |
| gender_key = "male" if gender.lower() in ["boy", "male"] else "female" |
| logger.info(f"Source → Gender: {gender_key}, Emotion: {emotion}") |
|
|
| |
| target_paths = emotion_targets.get((gender_key, emotion)) |
| if not target_paths: |
| |
| all_for_gender = [] |
| for (g, _), paths in emotion_targets.items(): |
| if g == gender_key: |
| all_for_gender.extend(paths) |
| if not all_for_gender: |
| return None, f"No target images available for gender '{gender_key}'" |
| target_fixed = random.choice(all_for_gender) |
| logger.warning(f"No {emotion} target for {gender_key} → using random from same gender") |
| else: |
| target_fixed = random.choice(target_paths) |
| logger.info(f"Using target: {os.path.basename(target_fixed)} (emotion: {emotion})") |
|
|
| |
| result_img_path = None |
| for attempt in range(MAX_RETRIES + 1): |
| try: |
| result = client.predict( |
| handle_file(src_fixed), |
| handle_file(target_fixed), |
| 1, 1, |
| api_name="/lambda_2" |
| ) |
| |
| if isinstance(result, (list, tuple)): |
| img_path = result[0] |
| else: |
| img_path = result |
| if isinstance(img_path, dict): |
| img_path = img_path.get("path") or img_path.get("url") or str(img_path) |
| result_img_path = img_path |
| break |
| except Exception as e: |
| if attempt == MAX_RETRIES: |
| raise |
| logger.warning(f"Gradio attempt {attempt+1} failed: {e}. Retrying...") |
| time.sleep(1) |
|
|
| swapped_img = Image.open(result_img_path) |
| return swapped_img, f"Success (emotion: {emotion})" |
|
|
| except Exception as e: |
| logger.error(f"Swap failed: {str(e)}\n{traceback.format_exc()}") |
| return None, f"Face swap error: {str(e)}" |
| finally: |
| |
| for f in temp_files: |
| safe_cleanup(f) |
|
|
| |
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| global client |
| try: |
| load_emotion_model() |
| load_all_targets() |
| client = Client(HF_API_NAME, verbose=False) |
| logger.info("✅ Connected to Gradio Space") |
| logger.info("🚀 Server is fully ready!") |
| except Exception as e: |
| logger.error(f"Startup failed: {e}") |
| yield |
|
|
| |
| app = FastAPI(title="Gender Swap API", lifespan=lifespan) |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) |
|
|
| class SwapRequest(BaseModel): |
| image: str |
| gender: str |
|
|
| class SwapResponse(BaseModel): |
| image: str |
| emotion: str |
| message: str |
|
|
| @app.post("/swap/gender", response_model=SwapResponse) |
| async def swap_gender(request: SwapRequest): |
| start_time = time.time() |
|
|
| try: |
| |
| try: |
| image_data = base64.b64decode(request.image) |
| source_img = Image.open(BytesIO(image_data)).convert("RGB") |
| except Exception as e: |
| raise HTTPException(status_code=400, detail=f"Invalid base64 image: {str(e)}") |
|
|
| |
| result_img, message = swap_face(source_img, request.gender) |
| if result_img is None: |
| raise HTTPException(status_code=500, detail=message) |
|
|
| |
| buffered = BytesIO() |
| result_img.save(buffered, format="JPEG", quality=85, optimize=True) |
| result_b64 = base64.b64encode(buffered.getvalue()).decode() |
|
|
| |
| |
| |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: |
| source_img.save(tmp.name, "JPEG", quality=90) |
| emotion = get_emotion(tmp.name) |
| safe_cleanup(tmp.name) |
|
|
| logger.info(f"✅ Swap completed in {time.time()-start_time:.2f}s | Emotion: {emotion}") |
| return SwapResponse(image=result_b64, emotion=emotion, message=message) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"Unexpected error: {traceback.format_exc()}") |
| raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") |
|
|
| @app.get("/health") |
| def health(): |
| return { |
| "status": "ok", |
| "targets_loaded": sum(len(v) for v in emotion_targets.values()), |
| "emotion_labels": EMOTION_LABELS |
| } |
|
|
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run(app, host="0.0.0.0", port=7860, workers=1) |
|
|