facefusion-api / app.py
Dmitry1313's picture
Update app.py
99e2cf8 verified
# 🤖 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
@app.on_event("startup")
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")
@app.post("/swap")
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()
@app.post("/swap-with-prompt")
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()
@app.get("/health")
async def health():
"""Проверка статуса"""
return {
"status": "ok",
"model_loaded": pipe is not None,
"lora_loaded": lora_loaded,
"base_model": BASE_MODEL,
"lora_file": LORA_FILE
}
@app.get("/models")
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"
}
})
@app.on_event("shutdown")
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)