Spaces:
Running
Running
| # 🤖 HuggingFace BFS Face Swap API (CPU Optimized) | |
| import io | |
| import os | |
| import logging | |
| import shutil | |
| from pathlib import Path | |
| import torch | |
| from PIL import Image | |
| import uvicorn | |
| from fastapi import FastAPI, File, UploadFile, Form, HTTPException | |
| from fastapi.responses import Response, JSONResponse | |
| from diffusers import QwenImageEditPlusPipeline | |
| from huggingface_hub import snapshot_download | |
| # Настройка логирования | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| app = FastAPI(title="BFS Face Swap API", version="3.0.0") | |
| TEMP_DIR = Path("/tmp/bfs") | |
| TEMP_DIR.mkdir(exist_ok=True) | |
| MODEL_CACHE = Path("/app/model_cache") | |
| MODEL_CACHE.mkdir(exist_ok=True) | |
| # Глобальные переменные | |
| pipe = None | |
| lora_loaded = False | |
| # Конфигурация модели | |
| BASE_MODEL = "Qwen/Qwen-Image-Edit-2511" # или 2509 | |
| LORA_REPO = "Alissonerdx/BFS-Best-Face-Swap" | |
| LORA_FILE = "bfs_head_v5_2511_merged_version_rank_16_fp16.safetensors" # Рекомендованная версия [citation:2][citation:4] | |
| def load_pipeline(): | |
| """Загрузка квантованной модели с CPU offload""" | |
| global pipe, lora_loaded | |
| try: | |
| logger.info("🔄 Loading 4-bit quantized model (first load takes 10-15 minutes)...") | |
| # Используем 4-bit версию для экономии RAM | |
| pipe = QwenImageEditPlusPipeline.from_pretrained( | |
| "toandev/Qwen-Image-Edit-2511-4bit", # 4-bit quantized version | |
| torch_dtype=torch.bfloat16, | |
| low_cpu_mem_usage=True, | |
| cache_dir=MODEL_CACHE | |
| ) | |
| # КРИТИЧЕСКИ ВАЖНО для CPU: включаем offload | |
| pipe.enable_model_cpu_offload() | |
| # Загружаем BFS LoRA веса | |
| logger.info("🔄 Loading BFS LoRA weights...") | |
| # Скачиваем LoRA если ещё нет | |
| lora_path = MODEL_CACHE / LORA_FILE | |
| if not lora_path.exists(): | |
| snapshot_download( | |
| repo_id=LORA_REPO, | |
| allow_patterns=[LORA_FILE], | |
| local_dir=MODEL_CACHE | |
| ) | |
| pipe.load_lora_weights(str(lora_path)) | |
| lora_loaded = True | |
| logger.info("✅ Model and LoRA loaded successfully") | |
| return True | |
| except Exception as e: | |
| logger.error(f"❌ Failed to load model: {e}") | |
| return False | |
| def save_upload_file(upload_file: UploadFile) -> Path: | |
| """Сохраняет загруженный файл""" | |
| contents = upload_file.file.read() | |
| unique_name = f"{os.urandom(8).hex()}_{upload_file.filename}" | |
| file_path = TEMP_DIR / unique_name | |
| with open(file_path, "wb") as f: | |
| f.write(contents) | |
| return file_path | |
| def optimize_image(image: Image.Image, max_size=1024) -> Image.Image: | |
| """Оптимизация размера для ускорения""" | |
| w, h = image.size | |
| if max(w, h) > max_size: | |
| scale = max_size / max(w, h) | |
| new_w, new_h = int(w * scale), int(h * scale) | |
| return image.resize((new_w, new_h), Image.Resampling.LANCZOS) | |
| return image | |
| async def startup_event(): | |
| """Загрузка модели при старте""" | |
| logger.info("🚀 BFS Face Swap API starting...") | |
| success = load_pipeline() | |
| if not success: | |
| logger.warning("⚠️ Model failed to load, API will return fallback responses") | |
| async def swap_face( | |
| target: UploadFile = File(...), # BODY (тело) - ПЕРВОЕ! | |
| source: UploadFile = File(...), # FACE (лицо) - ВТОРОЕ! | |
| num_steps: int = Form(20), # Можно уменьшить до 10-15 для скорости | |
| guidance_scale: float = Form(1.0) | |
| ): | |
| """ | |
| Замена лица с BFS Head V5 | |
| ВАЖНО: порядок файлов - сначала тело (target), потом лицо (source) [citation:2][citation:4] | |
| """ | |
| target_path = source_path = None | |
| try: | |
| # Сохраняем файлы | |
| target_path = save_upload_file(target) | |
| source_path = save_upload_file(source) | |
| logger.info(f"🔄 Processing BFS V5 swap: {target.filename} (body) <- {source.filename} (face)") | |
| # Загружаем и оптимизируем изображения | |
| body_img = optimize_image(Image.open(target_path).convert("RGB")) | |
| face_img = optimize_image(Image.open(source_path).convert("RGB")) | |
| if pipe is not None and lora_loaded: | |
| # Промпт для Head V5 из официальной документации [citation:2][citation:4] | |
| prompt = """head_swap: start with Picture 1 as the base image, keeping its lighting, environment, and background. remove the head from Picture 1 completely and replace it with the head from Picture 2, strictly preserving the hair, eye color, and nose structure of Picture 2. copy the eye direction, head rotation, and micro-expressions from Picture 1. high quality, sharp details, 4k""" | |
| # Инвертированный порядок: [body, face] [citation:2] | |
| inputs = { | |
| "image": [body_img, face_img], | |
| "prompt": prompt, | |
| "generator": torch.manual_seed(42), | |
| "true_cfg_scale": 4.0, | |
| "negative_prompt": "blurry, low quality, distorted face, bad anatomy, unnatural lighting", | |
| "num_inference_steps": num_steps, | |
| "guidance_scale": guidance_scale, | |
| } | |
| logger.info("🔄 Running inference (this takes 1-3 minutes on CPU)...") | |
| with torch.inference_mode(): | |
| output = pipe(**inputs) | |
| result_img = output.images[0] | |
| else: | |
| # Fallback: если модель не загрузилась | |
| logger.warning("Using fallback mode - returning body image") | |
| result_img = body_img | |
| # Сохраняем результат с высоким качеством | |
| result_bytes = io.BytesIO() | |
| result_img.save(result_bytes, format="JPEG", quality=98, optimize=True) | |
| logger.info(f"✅ Swap completed: {len(result_bytes.getvalue())} bytes") | |
| return Response(content=result_bytes.getvalue(), media_type="image/jpeg") | |
| except Exception as e: | |
| logger.error(f"❌ Swap error: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| finally: | |
| # Очистка временных файлов | |
| for p in (target_path, source_path): | |
| if p and p.exists(): | |
| p.unlink() | |
| async def swap_face_with_prompt( | |
| target: UploadFile = File(...), # BODY | |
| source: UploadFile = File(...), # FACE | |
| custom_prompt: str = Form(...), # Кастомный промпт | |
| num_steps: int = Form(20) | |
| ): | |
| """Замена лица с кастомным промптом""" | |
| target_path = source_path = None | |
| try: | |
| target_path = save_upload_file(target) | |
| source_path = save_upload_file(source) | |
| body_img = optimize_image(Image.open(target_path).convert("RGB")) | |
| face_img = optimize_image(Image.open(source_path).convert("RGB")) | |
| if pipe is not None and lora_loaded: | |
| # Добавляем требования качества к кастомному промпту | |
| enhanced_prompt = f"{custom_prompt}. high quality, sharp details, 4k, photorealistic" | |
| inputs = { | |
| "image": [body_img, face_img], | |
| "prompt": enhanced_prompt, | |
| "generator": torch.manual_seed(42), | |
| "true_cfg_scale": 4.0, | |
| "negative_prompt": "blurry, low quality, distorted", | |
| "num_inference_steps": num_steps, | |
| "guidance_scale": 1.0, | |
| } | |
| with torch.inference_mode(): | |
| output = pipe(**inputs) | |
| result_img = output.images[0] | |
| else: | |
| result_img = body_img | |
| result_bytes = io.BytesIO() | |
| result_img.save(result_bytes, format="JPEG", quality=98) | |
| return Response(content=result_bytes.getvalue(), media_type="image/jpeg") | |
| except Exception as e: | |
| logger.error(f"❌ Swap error: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| finally: | |
| for p in (target_path, source_path): | |
| if p and p.exists(): | |
| p.unlink() | |
| async def health(): | |
| """Проверка статуса""" | |
| return { | |
| "status": "ok", | |
| "model_loaded": pipe is not None, | |
| "lora_loaded": lora_loaded, | |
| "base_model": BASE_MODEL, | |
| "lora_file": LORA_FILE | |
| } | |
| async def get_models(): | |
| """Информация о доступных версиях BFS [citation:2]""" | |
| return JSONResponse(content={ | |
| "base_model": BASE_MODEL, | |
| "lora_repo": LORA_REPO, | |
| "current_lora": LORA_FILE, | |
| "available_versions": [ | |
| { | |
| "version": "Face V1", | |
| "file": "bfs_face_v1_qwen_image_edit_2509.safetensors", | |
| "order": "Face then Body", | |
| "description": "Swaps only face, preserves hair" | |
| }, | |
| { | |
| "version": "Head V1", | |
| "file": "bfs_head_v1_qwen_image_edit_2509.safetensors", | |
| "order": "Face then Body", | |
| "description": "Full head swap" | |
| }, | |
| { | |
| "version": "Head V3 (Recommended for 2509)", | |
| "file": "bfs_head_v3_qwen_image_edit_2509.safetensors", | |
| "order": "Body then Face", | |
| "description": "Most stable for 2509" | |
| }, | |
| { | |
| "version": "Head V5 (Recommended for 2511)", | |
| "file": "bfs_head_v5_2511_merged_version_rank_16_fp16.safetensors", | |
| "order": "Body then Face", | |
| "description": "Latest, best expression transfer" | |
| } | |
| ], | |
| "prompts": { | |
| "head_v5": "head_swap: start with Picture 1 as the base image, keeping its lighting, environment, and background. remove the head from Picture 1 completely and replace it with the head from Picture 2, strictly preserving the hair, eye color, and nose structure of Picture 2. copy the eye direction, head rotation, and micro-expressions from Picture 1. high quality, sharp details, 4k" | |
| } | |
| }) | |
| async def shutdown_event(): | |
| """Очистка при остановке""" | |
| logger.info("🛑 Shutting down...") | |
| shutil.rmtree(TEMP_DIR, ignore_errors=True) | |
| if __name__ == "__main__": | |
| uvicorn.run("app:app", host="0.0.0.0", port=7860) |