LogicGoInfotechSpaces commited on
Commit
72fce06
·
verified ·
1 Parent(s): 4461b96

Create media_clicks_app.py

Browse files
Files changed (1) hide show
  1. media_clicks_app.py +1300 -0
media_clicks_app.py ADDED
@@ -0,0 +1,1300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #####################FASTAPI___________________##############
2
+ import os
3
+ os.environ["OMP_NUM_THREADS"] = "1"
4
+ import shutil
5
+ import uuid
6
+ import cv2
7
+ import numpy as np
8
+ import threading
9
+ import asyncio
10
+ import subprocess
11
+ import logging
12
+ import tempfile
13
+ import sys
14
+ import time
15
+ from datetime import datetime,timedelta
16
+ import tempfile
17
+ import insightface
18
+ from insightface.app import FaceAnalysis
19
+ from huggingface_hub import hf_hub_download
20
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Response, Depends, Security, Form
21
+ from fastapi.responses import RedirectResponse
22
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
23
+ from motor.motor_asyncio import AsyncIOMotorClient
24
+ from bson import ObjectId
25
+ from bson.errors import InvalidId
26
+ import httpx
27
+ import uvicorn
28
+ from PIL import Image
29
+ import io
30
+ import requests
31
+ # DigitalOcean Spaces
32
+ import boto3
33
+ from botocore.client import Config
34
+ from typing import Optional
35
+
36
+ # --------------------- Logging ---------------------
37
+ logging.basicConfig(level=logging.INFO)
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # --------------------- Secrets & Paths ---------------------
41
+ REPO_ID = "HariLogicgo/face_swap_models"
42
+ MODELS_DIR = "./models"
43
+ os.makedirs(MODELS_DIR, exist_ok=True)
44
+
45
+ HF_TOKEN = os.getenv("HF_TOKEN")
46
+ API_SECRET_TOKEN = os.getenv("API_SECRET_TOKEN")
47
+
48
+ DO_SPACES_REGION = os.getenv("DO_SPACES_REGION", "blr1")
49
+ DO_SPACES_ENDPOINT = f"https://{DO_SPACES_REGION}.digitaloceanspaces.com"
50
+ DO_SPACES_KEY = os.getenv("DO_SPACES_KEY")
51
+ DO_SPACES_SECRET = os.getenv("DO_SPACES_SECRET")
52
+ DO_SPACES_BUCKET = os.getenv("DO_SPACES_BUCKET")
53
+
54
+ # NEW admin DB (with error handling for missing env vars)
55
+ ADMIN_MONGO_URL = os.getenv("ADMIN_MONGO_URL")
56
+ admin_client = None
57
+ admin_db = None
58
+ subcategories_col = None
59
+ if ADMIN_MONGO_URL:
60
+ try:
61
+ admin_client = AsyncIOMotorClient(ADMIN_MONGO_URL)
62
+ admin_db = admin_client.adminPanel
63
+ subcategories_col = admin_db.subcategories
64
+ except Exception as e:
65
+ logger.warning(f"MongoDB admin connection failed (optional): {e}")
66
+
67
+ # Collage Maker DB (optional)
68
+ COLLAGE_MAKER_DB_URL = os.getenv("COLLAGE_MAKER_DB_URL")
69
+ collage_maker_client = None
70
+ collage_maker_db = None
71
+ collage_subcategories_col = None
72
+ if COLLAGE_MAKER_DB_URL:
73
+ try:
74
+ collage_maker_client = AsyncIOMotorClient(COLLAGE_MAKER_DB_URL)
75
+ collage_maker_db = collage_maker_client.adminPanel
76
+ collage_subcategories_col = collage_maker_db.subcategories
77
+ except Exception as e:
78
+ logger.warning(f"MongoDB collage-maker connection failed (optional): {e}")
79
+
80
+ # AI Enhancer DB (optional)
81
+
82
+ AI_ENHANCER_DB_URL = os.getenv("AI_ENHANCER_DB_URL")
83
+ ai_enhancer_client = None
84
+ ai_enhancer_db = None
85
+ ai_enhancer_subcategories_col = None
86
+
87
+ if AI_ENHANCER_DB_URL:
88
+ try:
89
+ ai_enhancer_client = AsyncIOMotorClient(AI_ENHANCER_DB_URL)
90
+ ai_enhancer_db = ai_enhancer_client.test # 🔴 test database
91
+ ai_enhancer_subcategories_col = ai_enhancer_db.subcategories
92
+ except Exception as e:
93
+ logger.warning(f"MongoDB ai-enhancer connection failed (optional): {e}")
94
+
95
+
96
+ # OLD logs DB
97
+ # MONGODB_URL = os.getenv("MONGODB_URL")
98
+ # client = None
99
+ # database = None
100
+ FACESWAP_LOGS_MONGO_URL = os.getenv("MONGODB_URL")
101
+
102
+ logs_client = None
103
+ logs_collection = None
104
+ # --------------------- Download Models ---------------------
105
+ def download_models():
106
+ try:
107
+ logger.info("Downloading models...")
108
+ inswapper_path = hf_hub_download(
109
+ repo_id=REPO_ID,
110
+ filename="models/inswapper_128.onnx",
111
+ repo_type="model",
112
+ local_dir=MODELS_DIR,
113
+ token=HF_TOKEN
114
+ )
115
+
116
+ buffalo_files = ["1k3d68.onnx", "2d106det.onnx", "genderage.onnx", "det_10g.onnx", "w600k_r50.onnx"]
117
+ for f in buffalo_files:
118
+ hf_hub_download(
119
+ repo_id=REPO_ID,
120
+ filename=f"models/buffalo_l/" + f,
121
+ repo_type="model",
122
+ local_dir=MODELS_DIR,
123
+ token=HF_TOKEN
124
+ )
125
+
126
+ logger.info("Models downloaded successfully.")
127
+ return inswapper_path
128
+ except Exception as e:
129
+ logger.error(f"Model download failed: {e}")
130
+ raise
131
+
132
+ try:
133
+ inswapper_path = download_models()
134
+
135
+ # --------------------- Face Analysis + Swapper ---------------------
136
+ providers = ['CUDAExecutionProvider', 'CPUExecutionProvider']
137
+ face_analysis_app = FaceAnalysis(name="buffalo_l", root=MODELS_DIR, providers=providers)
138
+ face_analysis_app.prepare(ctx_id=0, det_size=(640, 640))
139
+ swapper = insightface.model_zoo.get_model(inswapper_path, providers=providers)
140
+ logger.info("Face analysis models loaded successfully")
141
+ except Exception as e:
142
+ logger.error(f"Failed to initialize face analysis models: {e}")
143
+ # Set defaults to prevent crash
144
+ inswapper_path = None
145
+ face_analysis_app = None
146
+ swapper = None
147
+
148
+ # --------------------- CodeFormer ---------------------
149
+ CODEFORMER_PATH = "CodeFormer/inference_codeformer.py"
150
+
151
+ def ensure_codeformer():
152
+ """
153
+ Ensure CodeFormer's local basicsr + facelib are importable and
154
+ pretrained weights are downloaded. No setup.py needed — we use
155
+ sys.path / PYTHONPATH instead.
156
+ """
157
+ try:
158
+ if not os.path.exists("CodeFormer"):
159
+ logger.info("CodeFormer not found, cloning repository...")
160
+ subprocess.run("git clone https://github.com/sczhou/CodeFormer.git", shell=True, check=True)
161
+ subprocess.run("pip install -r CodeFormer/requirements.txt", shell=True, check=False)
162
+
163
+ # Add CodeFormer root to sys.path so `import basicsr` and
164
+ # `import facelib` resolve to the local (compatible) versions
165
+ # instead of the broken PyPI basicsr==1.4.2.
166
+ codeformer_root = os.path.join(os.getcwd(), "CodeFormer")
167
+ if codeformer_root not in sys.path:
168
+ sys.path.insert(0, codeformer_root)
169
+ logger.info(f"Added {codeformer_root} to sys.path for local basicsr/facelib")
170
+
171
+ # NOTE: We do NOT need the PyPI 'realesrgan' package.
172
+ # Both in-process and subprocess paths use CodeFormer's local
173
+ # basicsr.utils.realesrgan_utils.RealESRGANer instead.
174
+ # Installing PyPI realesrgan at runtime would re-install the
175
+ # broken basicsr==1.4.2 and break everything.
176
+
177
+ # Download pretrained weights if not already present
178
+ if os.path.exists("CodeFormer"):
179
+ try:
180
+ subprocess.run("python CodeFormer/scripts/download_pretrained_models.py facelib", shell=True, check=False, timeout=300)
181
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
182
+ logger.warning("Failed to download facelib models (optional)")
183
+ try:
184
+ subprocess.run("python CodeFormer/scripts/download_pretrained_models.py CodeFormer", shell=True, check=False, timeout=300)
185
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
186
+ logger.warning("Failed to download CodeFormer models (optional)")
187
+ except Exception as e:
188
+ logger.error(f"CodeFormer setup failed: {e}")
189
+ logger.warning("Continuing without CodeFormer features...")
190
+
191
+ ensure_codeformer()
192
+
193
+ # --------------------- In-Process CodeFormer (No Subprocess!) ---------------------
194
+ # Load CodeFormer models ONCE at startup instead of spawning a new Python process per request.
195
+ # This eliminates 15-40s of model loading overhead per request.
196
+
197
+ codeformer_net = None
198
+ codeformer_upsampler = None
199
+ codeformer_face_helper = None
200
+ codeformer_device = None
201
+
202
+ def init_codeformer_in_process():
203
+ """Load CodeFormer models once into memory for fast per-request inference."""
204
+ global codeformer_net, codeformer_upsampler, codeformer_face_helper, codeformer_device
205
+ try:
206
+ import torch
207
+ from torchvision.transforms.functional import normalize as torch_normalize
208
+
209
+ # Add CodeFormer to Python path
210
+ codeformer_root = os.path.join(os.getcwd(), "CodeFormer")
211
+ if codeformer_root not in sys.path:
212
+ sys.path.insert(0, codeformer_root)
213
+
214
+ from basicsr.utils.registry import ARCH_REGISTRY
215
+ from basicsr.utils.download_util import load_file_from_url
216
+ from facelib.utils.face_restoration_helper import FaceRestoreHelper
217
+
218
+ codeformer_device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
219
+ logger.info(f"Initializing CodeFormer on device: {codeformer_device}")
220
+
221
+ # 1) Load CodeFormer network
222
+ net = ARCH_REGISTRY.get('CodeFormer')(
223
+ dim_embd=512, codebook_size=1024, n_head=8, n_layers=9,
224
+ connect_list=['32', '64', '128', '256']
225
+ ).to(codeformer_device)
226
+
227
+ ckpt_path = load_file_from_url(
228
+ url='https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth',
229
+ model_dir='weights/CodeFormer', progress=True, file_name=None
230
+ )
231
+ checkpoint = torch.load(ckpt_path, map_location=codeformer_device)['params_ema']
232
+ net.load_state_dict(checkpoint)
233
+ net.eval()
234
+ codeformer_net = net
235
+
236
+ # 2) RealESRGAN upsampler — SKIPPED for face swap
237
+ # Background/face upsampling is the #1 bottleneck (~20s per image).
238
+ # For face swap we only need CodeFormer face restoration, not super-resolution.
239
+ # The upsampler is kept as None; we no longer download the 64MB model at startup.
240
+ codeformer_upsampler = None
241
+
242
+ # 3) Create FaceRestoreHelper (reused per request)
243
+ # NOTE: local CodeFormer uses "upscale_factor" (not "upscale")
244
+ # upscale_factor=1 → keep original resolution (no 2x upscale needed for face swap)
245
+ codeformer_face_helper = FaceRestoreHelper(
246
+ upscale_factor=1,
247
+ face_size=512,
248
+ crop_ratio=(1, 1),
249
+ det_model='retinaface_resnet50',
250
+ save_ext='png',
251
+ use_parse=True,
252
+ device=codeformer_device
253
+ )
254
+
255
+ logger.info("✅ CodeFormer models loaded in-process successfully!")
256
+ return True
257
+ except Exception as e:
258
+ logger.error(f"Failed to load CodeFormer in-process: {e}")
259
+ logger.warning("CodeFormer enhancement will be unavailable.")
260
+ return False
261
+
262
+ # Try to load CodeFormer models in-process
263
+ _codeformer_available = init_codeformer_in_process()
264
+ # --------------------- FastAPI ---------------------
265
+ fastapi_app = FastAPI()
266
+
267
+ @fastapi_app.on_event("startup")
268
+ async def startup_db():
269
+ global logs_client, logs_collection
270
+
271
+ if FACESWAP_LOGS_MONGO_URL:
272
+ try:
273
+ logger.info("Initializing NEW logs DB → logs/faceswap-new")
274
+
275
+ logs_client = AsyncIOMotorClient(FACESWAP_LOGS_MONGO_URL)
276
+
277
+ # Force database and collection
278
+ logs_db = logs_client["logs"]
279
+ logs_collection = logs_db["faceswap-new"]
280
+
281
+ logger.info("MongoDB logs collection ready")
282
+ except Exception as e:
283
+ logger.warning(f"Logs MongoDB connection failed: {e}")
284
+ logs_client = None
285
+ logs_collection = None
286
+ else:
287
+ logger.warning("FACESWAP_LOGS_MONGO_URL not set")
288
+ @fastapi_app.on_event("shutdown")
289
+ async def shutdown_db():
290
+ # global client, admin_client, collage_maker_client
291
+ global logs_client, admin_client, collage_maker_client
292
+ # if client is not None:
293
+ # client.close()
294
+ if logs_client is not None:
295
+ logs_client.close()
296
+ logger.info("MongoDB connection closed")
297
+ if admin_client is not None:
298
+ admin_client.close()
299
+ logger.info("Admin MongoDB connection closed")
300
+ if collage_maker_client is not None:
301
+ collage_maker_client.close()
302
+ logger.info("Collage Maker MongoDB connection closed")
303
+
304
+ # --------------------- Auth ---------------------
305
+ security = HTTPBearer()
306
+
307
+ def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)):
308
+ if credentials.credentials != API_SECRET_TOKEN:
309
+ raise HTTPException(status_code=401, detail="Invalid or missing token")
310
+ return credentials.credentials
311
+
312
+ # --------------------- DB Selector ---------------------
313
+ def get_app_db_collections(appname: Optional[str] = None):
314
+ """
315
+ Returns subcategories_collection based on appname.
316
+ """
317
+
318
+ if appname:
319
+ app = appname.strip().lower()
320
+
321
+ if app == "collage-maker":
322
+ if collage_subcategories_col is not None:
323
+ return collage_subcategories_col
324
+ logger.warning("Collage-maker DB not configured, falling back to admin")
325
+
326
+ elif app == "ai-enhancer":
327
+ if ai_enhancer_subcategories_col is not None:
328
+ return ai_enhancer_subcategories_col
329
+ logger.warning("AI-Enhancer DB not configured, falling back to admin")
330
+
331
+ # default fallback
332
+ return subcategories_col
333
+
334
+
335
+
336
+ # --------------------- Logging API Hits ---------------------
337
+ async def log_faceswap_hit(token: str, status: str = "success"):
338
+ global database
339
+ if database is None:
340
+ return
341
+ await database.api_logs.insert_one({
342
+ "token": token,
343
+ "endpoint": "/faceswap",
344
+ "status": status,
345
+ "timestamp": datetime.utcnow()
346
+ })
347
+
348
+ # --------------------- Face Swap Pipeline ---------------------
349
+ swap_lock = threading.Lock()
350
+
351
+ def enhance_image_with_codeformer(rgb_img, temp_dir=None, w=0.7):
352
+ """
353
+ Enhance face image using CodeFormer.
354
+ Uses in-process models (fast) if available, falls back to subprocess (slow).
355
+ """
356
+ global codeformer_net, codeformer_upsampler, codeformer_face_helper, codeformer_device
357
+
358
+ t0 = time.time()
359
+
360
+ # ── FAST PATH: In-process CodeFormer (no subprocess!) ──
361
+ if codeformer_net is not None and codeformer_face_helper is not None:
362
+ import torch
363
+ from torchvision.transforms.functional import normalize as torch_normalize
364
+ from basicsr.utils import img2tensor, tensor2img
365
+ from facelib.utils.misc import is_gray
366
+
367
+ bgr_img = cv2.cvtColor(rgb_img, cv2.COLOR_RGB2BGR)
368
+
369
+ # Reset face helper state
370
+ codeformer_face_helper.clean_all()
371
+ codeformer_face_helper.read_image(bgr_img)
372
+
373
+ num_faces = codeformer_face_helper.get_face_landmarks_5(
374
+ only_center_face=False, resize=640, eye_dist_threshold=5
375
+ )
376
+ logger.info(f"[CodeFormer] Detected {num_faces} faces in {time.time()-t0:.2f}s")
377
+
378
+ codeformer_face_helper.align_warp_face()
379
+
380
+ # Enhance each cropped face with CodeFormer neural net
381
+ t_faces = time.time()
382
+ for idx, cropped_face in enumerate(codeformer_face_helper.cropped_faces):
383
+ cropped_face_t = img2tensor(cropped_face / 255., bgr2rgb=True, float32=True)
384
+ torch_normalize(cropped_face_t, (0.5, 0.5, 0.5), (0.5, 0.5, 0.5), inplace=True)
385
+ cropped_face_t = cropped_face_t.unsqueeze(0).to(codeformer_device)
386
+
387
+ try:
388
+ with torch.no_grad():
389
+ output = codeformer_net(cropped_face_t, w=w, adain=True)[0]
390
+ restored_face = tensor2img(output, rgb2bgr=True, min_max=(-1, 1))
391
+ del output
392
+ torch.cuda.empty_cache()
393
+ except Exception as e:
394
+ logger.warning(f"[CodeFormer] Face {idx} inference failed: {e}")
395
+ restored_face = tensor2img(cropped_face_t, rgb2bgr=True, min_max=(-1, 1))
396
+
397
+ restored_face = restored_face.astype('uint8')
398
+ codeformer_face_helper.add_restored_face(restored_face, cropped_face)
399
+ logger.info(f"[CodeFormer] Face restoration ({num_faces} faces): {time.time()-t_faces:.2f}s")
400
+
401
+ # Paste restored faces back onto original image
402
+ # NOTE: We skip RealESRGAN background/face upsampling — it's the #1 bottleneck
403
+ # (~20s) and unnecessary for face swap. We only need CodeFormer face restoration.
404
+ t_paste = time.time()
405
+ codeformer_face_helper.get_inverse_affine(None)
406
+ restored_img = codeformer_face_helper.paste_faces_to_input_image(
407
+ upsample_img=None, draw_box=False
408
+ )
409
+ logger.info(f"[CodeFormer] Paste back: {time.time()-t_paste:.2f}s")
410
+
411
+ logger.info(f"[CodeFormer] In-process enhancement done in {time.time()-t0:.2f}s")
412
+ return cv2.cvtColor(restored_img, cv2.COLOR_BGR2RGB)
413
+
414
+ # ── SLOW FALLBACK: Subprocess CodeFormer (with timeout!) ──
415
+ logger.warning("[CodeFormer] In-process models unavailable, falling back to subprocess")
416
+ if temp_dir is None:
417
+ temp_dir = os.path.join(tempfile.gettempdir(), f"enhance_{uuid.uuid4().hex[:8]}")
418
+ os.makedirs(temp_dir, exist_ok=True)
419
+
420
+ input_path = os.path.join(temp_dir, "input.jpg")
421
+ cv2.imwrite(input_path, cv2.cvtColor(rgb_img, cv2.COLOR_RGB2BGR))
422
+
423
+ python_cmd = sys.executable if sys.executable else "python3"
424
+ cmd = (
425
+ f"{python_cmd} {CODEFORMER_PATH} "
426
+ f"-w {w} "
427
+ f"--input_path {input_path} "
428
+ f"--output_path {temp_dir} "
429
+ f"--bg_upsampler None "
430
+ f"--upscale 1"
431
+ )
432
+
433
+ result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120)
434
+ if result.returncode != 0:
435
+ raise RuntimeError(result.stderr)
436
+
437
+ final_dir = os.path.join(temp_dir, "final_results")
438
+ files = [f for f in os.listdir(final_dir) if f.endswith(".png")]
439
+ if not files:
440
+ raise RuntimeError("No enhanced output")
441
+
442
+ final_path = os.path.join(final_dir, files[0])
443
+ enhanced = cv2.imread(final_path)
444
+ logger.info(f"[CodeFormer] Subprocess enhancement done in {time.time()-t0:.2f}s")
445
+ return cv2.cvtColor(enhanced, cv2.COLOR_BGR2RGB)
446
+
447
+ def multi_face_swap(src_img, tgt_img):
448
+ pipeline_start = time.time()
449
+ src_bgr = cv2.cvtColor(src_img, cv2.COLOR_RGB2BGR)
450
+ tgt_bgr = cv2.cvtColor(tgt_img, cv2.COLOR_RGB2BGR)
451
+
452
+ t0 = time.time()
453
+ src_faces = face_analysis_app.get(src_bgr)
454
+ tgt_faces = face_analysis_app.get(tgt_bgr)
455
+ logger.info(f"[Pipeline] Multi-face detection: {time.time()-t0:.2f}s")
456
+
457
+ if not src_faces or not tgt_faces:
458
+ raise ValueError("No faces detected")
459
+
460
+ def face_sort_key(face):
461
+ x1, y1, x2, y2 = face.bbox
462
+ area = (x2 - x1) * (y2 - y1)
463
+ cx = (x1 + x2) / 2
464
+ return (-area, cx)
465
+
466
+ src_male = sorted([f for f in src_faces if f.gender == 1], key=face_sort_key)
467
+ src_female = sorted([f for f in src_faces if f.gender == 0], key=face_sort_key)
468
+ tgt_male = sorted([f for f in tgt_faces if f.gender == 1], key=face_sort_key)
469
+ tgt_female = sorted([f for f in tgt_faces if f.gender == 0], key=face_sort_key)
470
+
471
+ pairs = []
472
+ for s, t in zip(src_male, tgt_male):
473
+ pairs.append((s, t))
474
+ for s, t in zip(src_female, tgt_female):
475
+ pairs.append((s, t))
476
+
477
+ if not pairs:
478
+ src_faces = sorted(src_faces, key=face_sort_key)
479
+ tgt_faces = sorted(tgt_faces, key=face_sort_key)
480
+ pairs = list(zip(src_faces, tgt_faces))
481
+
482
+ t0 = time.time()
483
+ result_img = tgt_bgr.copy()
484
+ for src_face, _ in pairs:
485
+ if face_analysis_app is None:
486
+ raise ValueError("Face analysis models not initialized.")
487
+ current_faces = sorted(face_analysis_app.get(result_img), key=face_sort_key)
488
+ candidates = [f for f in current_faces if f.gender == src_face.gender] or current_faces
489
+ target_face = candidates[0]
490
+
491
+ if swapper is None:
492
+ raise ValueError("Face swap models not initialized.")
493
+ result_img = swapper.get(result_img, target_face, src_face, paste_back=True)
494
+ logger.info(f"[Pipeline] Multi-face swap ({len(pairs)} pairs): {time.time()-t0:.2f}s")
495
+
496
+ logger.info(f"[Pipeline] TOTAL multi_face_swap: {time.time()-pipeline_start:.2f}s")
497
+ return cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB)
498
+
499
+
500
+
501
+ def face_swap_and_enhance(src_img, tgt_img, temp_dir=None):
502
+ try:
503
+ with swap_lock:
504
+ pipeline_start = time.time()
505
+
506
+ if temp_dir is None:
507
+ temp_dir = os.path.join(tempfile.gettempdir(), f"faceswap_work_{uuid.uuid4().hex[:8]}")
508
+ if os.path.exists(temp_dir):
509
+ shutil.rmtree(temp_dir)
510
+ os.makedirs(temp_dir, exist_ok=True)
511
+
512
+ if face_analysis_app is None:
513
+ return None, None, "❌ Face analysis models not initialized."
514
+ if swapper is None:
515
+ return None, None, "❌ Face swap models not initialized."
516
+
517
+ src_bgr = cv2.cvtColor(src_img, cv2.COLOR_RGB2BGR)
518
+ tgt_bgr = cv2.cvtColor(tgt_img, cv2.COLOR_RGB2BGR)
519
+
520
+ t0 = time.time()
521
+ src_faces = face_analysis_app.get(src_bgr)
522
+ tgt_faces = face_analysis_app.get(tgt_bgr)
523
+ logger.info(f"[Pipeline] Face detection: {time.time()-t0:.2f}s")
524
+
525
+ if not src_faces or not tgt_faces:
526
+ return None, None, "❌ Face not detected in one of the images"
527
+
528
+ t0 = time.time()
529
+ swapped_bgr = swapper.get(tgt_bgr, tgt_faces[0], src_faces[0])
530
+ logger.info(f"[Pipeline] Face swap: {time.time()-t0:.2f}s")
531
+
532
+ if swapped_bgr is None:
533
+ return None, None, "❌ Face swap failed"
534
+
535
+ # Use in-process CodeFormer enhancement (fast path)
536
+ t0 = time.time()
537
+ swapped_rgb = cv2.cvtColor(swapped_bgr, cv2.COLOR_BGR2RGB)
538
+ try:
539
+ enhanced_rgb = enhance_image_with_codeformer(swapped_rgb)
540
+ enhanced_bgr = cv2.cvtColor(enhanced_rgb, cv2.COLOR_RGB2BGR)
541
+ except Exception as e:
542
+ logger.error(f"[Pipeline] CodeFormer failed, using raw swap: {e}")
543
+ enhanced_bgr = swapped_bgr
544
+ logger.info(f"[Pipeline] Enhancement: {time.time()-t0:.2f}s")
545
+
546
+ final_path = os.path.join(temp_dir, f"result_{uuid.uuid4().hex[:8]}.png")
547
+ cv2.imwrite(final_path, enhanced_bgr)
548
+
549
+ final_img = cv2.cvtColor(enhanced_bgr, cv2.COLOR_BGR2RGB)
550
+
551
+ logger.info(f"[Pipeline] TOTAL face_swap_and_enhance: {time.time()-pipeline_start:.2f}s")
552
+ return final_img, final_path, ""
553
+
554
+ except Exception as e:
555
+ return None, None, f"❌ Error: {str(e)}"
556
+
557
+ def compress_image(
558
+ image_bytes: bytes,
559
+ max_size=(1280, 1280), # max width/height
560
+ quality=75 # JPEG quality (60–80 is ideal)
561
+ ) -> bytes:
562
+ """
563
+ Compress image by resizing and lowering quality.
564
+ Returns compressed image bytes.
565
+ """
566
+ img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
567
+
568
+ # Resize while maintaining aspect ratio
569
+ img.thumbnail(max_size, Image.LANCZOS)
570
+
571
+ output = io.BytesIO()
572
+ img.save(
573
+ output,
574
+ format="JPEG",
575
+ quality=quality,
576
+ optimize=True,
577
+ progressive=True
578
+ )
579
+
580
+ return output.getvalue()
581
+
582
+ # --------------------- DigitalOcean Spaces Helper ---------------------
583
+ def get_spaces_client():
584
+ session = boto3.session.Session()
585
+ client = session.client(
586
+ 's3',
587
+ region_name=DO_SPACES_REGION,
588
+ endpoint_url=DO_SPACES_ENDPOINT,
589
+ aws_access_key_id=DO_SPACES_KEY,
590
+ aws_secret_access_key=DO_SPACES_SECRET,
591
+ config=Config(signature_version='s3v4')
592
+ )
593
+ return client
594
+
595
+ def upload_to_spaces(file_bytes, key, content_type="image/png"):
596
+ client = get_spaces_client()
597
+ client.put_object(Bucket=DO_SPACES_BUCKET, Key=key, Body=file_bytes, ContentType=content_type, ACL='public-read')
598
+ return f"{DO_SPACES_ENDPOINT}/{DO_SPACES_BUCKET}/{key}"
599
+
600
+ def download_from_spaces(key):
601
+ client = get_spaces_client()
602
+ obj = client.get_object(Bucket=DO_SPACES_BUCKET, Key=key)
603
+ return obj['Body'].read()
604
+
605
+ def mandatory_enhancement(rgb_img):
606
+ """
607
+ Always runs CodeFormer on the final image.
608
+ Fail-safe: returns original if enhancement fails.
609
+ """
610
+ try:
611
+ return enhance_image_with_codeformer(rgb_img)
612
+ except Exception as e:
613
+ logger.error(f"CodeFormer failed, returning original: {e}")
614
+ return rgb_img
615
+
616
+ # --------------------- API Endpoints ---------------------
617
+ @fastapi_app.get("/")
618
+ async def root():
619
+ """Root endpoint"""
620
+ return {
621
+ "success": True,
622
+ "message": "FaceSwap API",
623
+ "data": {
624
+ "version": "1.0.0",
625
+ "Product Name":"Beauty Camera - GlowCam AI Studio",
626
+ "Released By" : "LogicGo Infotech"
627
+ }
628
+ }
629
+ @fastapi_app.get("/health")
630
+ async def health():
631
+ return {"status": "healthy"}
632
+
633
+ @fastapi_app.get("/test-admin-db")
634
+ async def test_admin_db():
635
+ try:
636
+ doc = await admin_db.list_collection_names()
637
+ return {"ok": True, "collections": doc}
638
+ except Exception as e:
639
+ return {"ok": False, "error": str(e), "url": ADMIN_MONGO_URL}
640
+
641
+ @fastapi_app.post("/face-swap", dependencies=[Depends(verify_token)])
642
+ async def face_swap_api(
643
+ source: UploadFile = File(...),
644
+ image2: Optional[UploadFile] = File(None),
645
+ target_category_id: str = Form(None),
646
+ new_category_id: str = Form(None),
647
+ user_id: Optional[str] = Form(None),
648
+ appname: Optional[str] = Form(None),
649
+ credentials: HTTPAuthorizationCredentials = Security(security)
650
+ ):
651
+ start_time = datetime.utcnow()
652
+
653
+ try:
654
+ # ------------------------------------------------------------------
655
+ # VALIDATION
656
+ # ------------------------------------------------------------------
657
+ # --------------------------------------------------------------
658
+ # BACKWARD COMPATIBILITY FOR OLD ANDROID VERSIONS
659
+ # --------------------------------------------------------------
660
+ if target_category_id == "":
661
+ target_category_id = None
662
+
663
+ if new_category_id == "":
664
+ new_category_id = None
665
+
666
+ if user_id == "":
667
+ user_id = None
668
+
669
+ subcategories_collection = get_app_db_collections(appname)
670
+
671
+
672
+ logger.info(f"[FaceSwap] Incoming request → target_category_id={target_category_id}, new_category_id={new_category_id}, user_id={user_id}")
673
+
674
+ if target_category_id and new_category_id:
675
+ raise HTTPException(400, "Provide only one of new_category_id or target_category_id.")
676
+
677
+ if not target_category_id and not new_category_id:
678
+ raise HTTPException(400, "Either new_category_id or target_category_id is required.")
679
+
680
+ # ------------------------------------------------------------------
681
+ # READ SOURCE IMAGE
682
+ # ------------------------------------------------------------------
683
+ src_bytes = await source.read()
684
+ src_key = f"faceswap/source/{uuid.uuid4().hex}_{source.filename}"
685
+ upload_to_spaces(src_bytes, src_key, content_type=source.content_type)
686
+
687
+ # ------------------------------------------------------------------
688
+ # CASE 1 : new_category_id → MongoDB lookup
689
+ # ------------------------------------------------------------------
690
+ if new_category_id:
691
+
692
+ # doc = await subcategories_col.find_one({
693
+ # "asset_images._id": ObjectId(new_category_id)
694
+ # })
695
+ doc = await subcategories_collection.find_one({
696
+ "asset_images._id": ObjectId(new_category_id)
697
+ })
698
+
699
+
700
+ if not doc:
701
+ raise HTTPException(404, "Asset image not found in database")
702
+
703
+ # extract correct asset
704
+ asset = next(
705
+ (img for img in doc["asset_images"] if str(img["_id"]) == new_category_id),
706
+ None
707
+ )
708
+
709
+ if not asset:
710
+ raise HTTPException(404, "Asset image URL not found")
711
+
712
+ # correct URL
713
+ target_url = asset["url"]
714
+
715
+ # correct categoryId (ObjectId)
716
+ #category_oid = doc["categoryId"] # <-- DO NOT CONVERT TO STRING
717
+ subcategory_oid = doc["_id"]
718
+
719
+ # # ------------------------------------------------------------------
720
+ # # CASE 2 : target_category_id → DigitalOcean path (unchanged logic)
721
+ # # ------------------------------------------------------------------
722
+ if target_category_id:
723
+ client = get_spaces_client()
724
+ base_prefix = "faceswap/target/"
725
+ resp = client.list_objects_v2(
726
+ Bucket=DO_SPACES_BUCKET, Prefix=base_prefix, Delimiter="/"
727
+ )
728
+
729
+ # Extract categories from the CommonPrefixes
730
+ categories = [p["Prefix"].split("/")[2] for p in resp.get("CommonPrefixes", [])]
731
+
732
+ target_url = None
733
+
734
+ # --- FIX STARTS HERE ---
735
+ for category in categories:
736
+ original_prefix = f"faceswap/target/{category}/original/"
737
+ thumb_prefix = f"faceswap/target/{category}/thumb/" # Keep for file list check (optional but safe)
738
+
739
+ # List objects in original/
740
+ original_objects = client.list_objects_v2(
741
+ Bucket=DO_SPACES_BUCKET, Prefix=original_prefix
742
+ ).get("Contents", [])
743
+
744
+ # List objects in thumb/ (optional: for the old code's extra check)
745
+ thumb_objects = client.list_objects_v2(
746
+ Bucket=DO_SPACES_BUCKET, Prefix=thumb_prefix
747
+ ).get("Contents", [])
748
+
749
+ # Extract only the filenames and filter for .png
750
+ original_filenames = sorted([
751
+ obj["Key"].split("/")[-1] for obj in original_objects
752
+ if obj["Key"].split("/")[-1].endswith(".png")
753
+ ])
754
+ thumb_filenames = [
755
+ obj["Key"].split("/")[-1] for obj in thumb_objects
756
+ ]
757
+
758
+ # Replicate the old indexing logic based on sorted filenames
759
+ for idx, filename in enumerate(original_filenames, start=1):
760
+ cid = f"{category.lower()}image_{idx}"
761
+
762
+ # Optional: Replicate the thumb file check for 100% parity
763
+ # if filename in thumb_filenames and cid == target_category_id:
764
+ # Simpler check just on the ID, assuming thumb files are present
765
+ if cid == target_category_id:
766
+ # Construct the final target URL using the full prefix and the filename
767
+ target_url = f"{DO_SPACES_ENDPOINT}/{DO_SPACES_BUCKET}/{original_prefix}{filename}"
768
+ break
769
+
770
+ if target_url:
771
+ break
772
+ # --- FIX ENDS HERE ---
773
+
774
+ if not target_url:
775
+ raise HTTPException(404, "Target categoryId not found")
776
+ # # ------------------------------------------------------------------
777
+ # # DOWNLOAD TARGET IMAGE
778
+ # # ------------------------------------------------------------------
779
+ async with httpx.AsyncClient(timeout=30.0) as client:
780
+ response = await client.get(target_url)
781
+ response.raise_for_status()
782
+ tgt_bytes = response.content
783
+
784
+ src_bgr = cv2.imdecode(np.frombuffer(src_bytes, np.uint8), cv2.IMREAD_COLOR)
785
+ tgt_bgr = cv2.imdecode(np.frombuffer(tgt_bytes, np.uint8), cv2.IMREAD_COLOR)
786
+
787
+ if src_bgr is None or tgt_bgr is None:
788
+ raise HTTPException(400, "Invalid image data")
789
+
790
+ src_rgb = cv2.cvtColor(src_bgr, cv2.COLOR_BGR2RGB)
791
+ tgt_rgb = cv2.cvtColor(tgt_bgr, cv2.COLOR_BGR2RGB)
792
+
793
+ # ------------------------------------------------------------------
794
+ # READ OPTIONAL IMAGE2
795
+ # ------------------------------------------------------------------
796
+ img2_rgb = None
797
+ if image2:
798
+ img2_bytes = await image2.read()
799
+ img2_bgr = cv2.imdecode(np.frombuffer(img2_bytes, np.uint8), cv2.IMREAD_COLOR)
800
+ if img2_bgr is not None:
801
+ img2_rgb = cv2.cvtColor(img2_bgr, cv2.COLOR_BGR2RGB)
802
+
803
+ # ------------------------------------------------------------------
804
+ # FACE SWAP EXECUTION (run in thread to not block event loop)
805
+ # ------------------------------------------------------------------
806
+ if img2_rgb is not None:
807
+ def _couple_swap():
808
+ pipeline_start = time.time()
809
+ src_images = [src_rgb, img2_rgb]
810
+
811
+ all_src_faces = []
812
+ t0 = time.time()
813
+ for img in src_images:
814
+ faces = face_analysis_app.get(cv2.cvtColor(img, cv2.COLOR_RGB2BGR))
815
+ all_src_faces.extend(faces)
816
+
817
+ tgt_faces = face_analysis_app.get(cv2.cvtColor(tgt_rgb, cv2.COLOR_RGB2BGR))
818
+ logger.info(f"[Pipeline] Couple face detection: {time.time()-t0:.2f}s")
819
+
820
+ if not all_src_faces:
821
+ raise ValueError("No faces detected in source images")
822
+ if not tgt_faces:
823
+ raise ValueError("No faces detected in target image")
824
+
825
+ def face_sort_key(face):
826
+ x1, y1, x2, y2 = face.bbox
827
+ area = (x2 - x1) * (y2 - y1)
828
+ cx = (x1 + x2) / 2
829
+ return (-area, cx)
830
+
831
+ src_male = sorted([f for f in all_src_faces if f.gender == 1], key=face_sort_key)
832
+ src_female = sorted([f for f in all_src_faces if f.gender == 0], key=face_sort_key)
833
+ tgt_male = sorted([f for f in tgt_faces if f.gender == 1], key=face_sort_key)
834
+ tgt_female = sorted([f for f in tgt_faces if f.gender == 0], key=face_sort_key)
835
+
836
+ pairs = []
837
+ for s, t in zip(src_male, tgt_male):
838
+ pairs.append((s, t))
839
+ for s, t in zip(src_female, tgt_female):
840
+ pairs.append((s, t))
841
+
842
+ if not pairs:
843
+ src_all = sorted(all_src_faces, key=face_sort_key)
844
+ tgt_all = sorted(tgt_faces, key=face_sort_key)
845
+ pairs = list(zip(src_all, tgt_all))
846
+
847
+ t0 = time.time()
848
+ with swap_lock:
849
+ result_img = cv2.cvtColor(tgt_rgb, cv2.COLOR_RGB2BGR)
850
+ for src_face, _ in pairs:
851
+ current_faces = sorted(face_analysis_app.get(result_img), key=face_sort_key)
852
+ candidates = [f for f in current_faces if f.gender == src_face.gender] or current_faces
853
+ target_face = candidates[0]
854
+ result_img = swapper.get(result_img, target_face, src_face, paste_back=True)
855
+ logger.info(f"[Pipeline] Couple face swap: {time.time()-t0:.2f}s")
856
+
857
+ result_rgb_out = cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB)
858
+
859
+ t0 = time.time()
860
+ enhanced_rgb = mandatory_enhancement(result_rgb_out)
861
+ logger.info(f"[Pipeline] Couple enhancement: {time.time()-t0:.2f}s")
862
+
863
+ enhanced_bgr = cv2.cvtColor(enhanced_rgb, cv2.COLOR_RGB2BGR)
864
+
865
+ temp_dir = tempfile.mkdtemp(prefix="faceswap_")
866
+ final_path = os.path.join(temp_dir, "result.png")
867
+ cv2.imwrite(final_path, enhanced_bgr)
868
+
869
+ with open(final_path, "rb") as f:
870
+ result_bytes = f.read()
871
+
872
+ logger.info(f"[Pipeline] TOTAL couple swap: {time.time()-pipeline_start:.2f}s")
873
+ return result_bytes
874
+
875
+ try:
876
+ result_bytes = await asyncio.to_thread(_couple_swap)
877
+ except ValueError as ve:
878
+ raise HTTPException(400, str(ve))
879
+
880
+ else:
881
+ # ----- SINGLE SOURCE SWAP (run in thread) -----
882
+ def _single_swap():
883
+ return face_swap_and_enhance(src_rgb, tgt_rgb)
884
+
885
+ final_img, final_path, err = await asyncio.to_thread(_single_swap)
886
+
887
+ if err:
888
+ raise HTTPException(500, err)
889
+
890
+ with open(final_path, "rb") as f:
891
+ result_bytes = f.read()
892
+
893
+ result_key = f"faceswap/result/{uuid.uuid4().hex}_enhanced.png"
894
+ result_url = upload_to_spaces(result_bytes, result_key)
895
+ # -------------------------------------------------
896
+ # COMPRESS IMAGE (2–3 MB target)
897
+ # -------------------------------------------------
898
+ compressed_bytes = compress_image(
899
+ image_bytes=result_bytes,
900
+ max_size=(1280, 1280),
901
+ quality=72
902
+ )
903
+
904
+ compressed_key = f"faceswap/result/{uuid.uuid4().hex}_enhanced_compressed.jpg"
905
+ compressed_url = upload_to_spaces(
906
+ compressed_bytes,
907
+ compressed_key,
908
+ content_type="image/jpeg"
909
+ )
910
+ end_time = datetime.utcnow()
911
+ response_time_ms = (end_time - start_time).total_seconds() * 1000
912
+
913
+ # if database is not None:
914
+ # log_entry = {
915
+ # "endpoint": "/face-swap",
916
+ # "status": "success",
917
+ # "response_time_ms": response_time_ms,
918
+ # "timestamp": end_time
919
+ # }
920
+ # if appname:
921
+ # log_entry["appname"] = appname
922
+ # await database.api_logs.insert_one(log_entry)
923
+ if logs_collection is not None:
924
+ log_entry = {
925
+ "endpoint": "/face-swap",
926
+ "status": "success",
927
+ "response_time_ms": float(response_time_ms),
928
+ "timestamp": end_time,
929
+ "appname": appname if appname else None,
930
+ "error": None
931
+ }
932
+
933
+ await logs_collection.insert_one(log_entry)
934
+
935
+
936
+ return {
937
+ "result_key": result_key,
938
+ "result_url": result_url,
939
+ "Compressed_Image_URL": compressed_url
940
+ }
941
+
942
+ except Exception as e:
943
+ end_time = datetime.utcnow()
944
+ response_time_ms = (end_time - start_time).total_seconds() * 1000
945
+
946
+ # if database is not None:
947
+ # log_entry = {
948
+ # "endpoint": "/face-swap",
949
+ # "status": "fail",
950
+ # "response_time_ms": response_time_ms,
951
+ # "timestamp": end_time,
952
+ # "error": str(e)
953
+ # }
954
+ # if appname:
955
+ # log_entry["appname"] = appname
956
+ # await database.api_logs.insert_one(log_entry)
957
+ if logs_collection is not None:
958
+ log_entry = {
959
+ "endpoint": "/face-swap",
960
+ "status": "fail",
961
+ "response_time_ms": float(response_time_ms),
962
+ "timestamp": end_time,
963
+ "appname": appname if appname else None,
964
+ "error": str(e)
965
+ }
966
+
967
+ await logs_collection.insert_one(log_entry)
968
+
969
+ raise HTTPException(500, f"Face swap failed: {str(e)}")
970
+
971
+ @fastapi_app.get("/preview/{result_key:path}")
972
+ async def preview_result(result_key: str):
973
+ try:
974
+ img_bytes = download_from_spaces(result_key)
975
+ except Exception:
976
+ raise HTTPException(status_code=404, detail="Result not found")
977
+ return Response(
978
+ content=img_bytes,
979
+ media_type="image/png",
980
+ headers={"Content-Disposition": "inline; filename=result.png"}
981
+ )
982
+
983
+ @fastapi_app.post("/multi-face-swap", dependencies=[Depends(verify_token)])
984
+ async def multi_face_swap_api(
985
+ source_image: UploadFile = File(...),
986
+ target_image: UploadFile = File(...)
987
+ ):
988
+ start_time = datetime.utcnow()
989
+
990
+ try:
991
+ # -----------------------------
992
+ # Read images
993
+ # -----------------------------
994
+ src_bytes = await source_image.read()
995
+ tgt_bytes = await target_image.read()
996
+
997
+ src_bgr = cv2.imdecode(np.frombuffer(src_bytes, np.uint8), cv2.IMREAD_COLOR)
998
+ tgt_bgr = cv2.imdecode(np.frombuffer(tgt_bytes, np.uint8), cv2.IMREAD_COLOR)
999
+
1000
+ if src_bgr is None or tgt_bgr is None:
1001
+ raise HTTPException(400, "Invalid image data")
1002
+
1003
+ src_rgb = cv2.cvtColor(src_bgr, cv2.COLOR_BGR2RGB)
1004
+ tgt_rgb = cv2.cvtColor(tgt_bgr, cv2.COLOR_BGR2RGB)
1005
+
1006
+ # -----------------------------
1007
+ # Multi-face swap (run in thread to not block event loop)
1008
+ # -----------------------------
1009
+ def _multi_swap_and_enhance():
1010
+ swapped_rgb = multi_face_swap(src_rgb, tgt_rgb)
1011
+ return mandatory_enhancement(swapped_rgb)
1012
+
1013
+ final_rgb = await asyncio.to_thread(_multi_swap_and_enhance)
1014
+
1015
+ final_bgr = cv2.cvtColor(final_rgb, cv2.COLOR_RGB2BGR)
1016
+
1017
+ # -----------------------------
1018
+ # Save temp result
1019
+ # -----------------------------
1020
+ temp_dir = tempfile.mkdtemp(prefix="multi_faceswap_")
1021
+ result_path = os.path.join(temp_dir, "result.png")
1022
+ cv2.imwrite(result_path, final_bgr)
1023
+
1024
+ with open(result_path, "rb") as f:
1025
+ result_bytes = f.read()
1026
+
1027
+ # -----------------------------
1028
+ # Upload
1029
+ # -----------------------------
1030
+ result_key = f"faceswap/multi/{uuid.uuid4().hex}.png"
1031
+ result_url = upload_to_spaces(
1032
+ result_bytes,
1033
+ result_key,
1034
+ content_type="image/png"
1035
+ )
1036
+
1037
+ return {
1038
+ "result_key": result_key,
1039
+ "result_url": result_url
1040
+ }
1041
+
1042
+ except Exception as e:
1043
+ raise HTTPException(status_code=500, detail=str(e))
1044
+
1045
+
1046
+ @fastapi_app.post("/face-swap-couple", dependencies=[Depends(verify_token)])
1047
+ async def face_swap_couple_api(
1048
+ image1: UploadFile = File(...),
1049
+ image2: Optional[UploadFile] = File(None),
1050
+ target_category_id: str = Form(None),
1051
+ new_category_id: str = Form(None),
1052
+ user_id: Optional[str] = Form(None),
1053
+ appname: Optional[str] = Form(None),
1054
+ credentials: HTTPAuthorizationCredentials = Security(security)
1055
+ ):
1056
+ """
1057
+ Production-ready face swap endpoint supporting:
1058
+ - Multiple source images (image1 + optional image2)
1059
+ - Gender-based pairing
1060
+ - Merged faces from multiple sources
1061
+ - Mandatory CodeFormer enhancement
1062
+ """
1063
+ start_time = datetime.utcnow()
1064
+
1065
+ try:
1066
+ # -----------------------------
1067
+ # Validate input
1068
+ # -----------------------------
1069
+ if target_category_id == "":
1070
+ target_category_id = None
1071
+ if new_category_id == "":
1072
+ new_category_id = None
1073
+ if user_id == "":
1074
+ user_id = None
1075
+
1076
+ subcategories_collection = get_app_db_collections(appname)
1077
+ logger.info(f"[FaceSwapCouple] appname={appname}, subcategories_collection={subcategories_collection.full_name if subcategories_collection is not None else 'None'}")
1078
+
1079
+ if target_category_id and new_category_id:
1080
+ raise HTTPException(400, "Provide only one of new_category_id or target_category_id.")
1081
+ if not target_category_id and not new_category_id:
1082
+ raise HTTPException(400, "Either new_category_id or target_category_id is required.")
1083
+
1084
+ logger.info(f"[FaceSwap] Incoming request → target_category_id={target_category_id}, new_category_id={new_category_id}, user_id={user_id}")
1085
+
1086
+ # -----------------------------
1087
+ # Read source images
1088
+ # -----------------------------
1089
+ src_images = []
1090
+ img1_bytes = await image1.read()
1091
+ src1 = cv2.imdecode(np.frombuffer(img1_bytes, np.uint8), cv2.IMREAD_COLOR)
1092
+ if src1 is None:
1093
+ raise HTTPException(400, "Invalid image1 data")
1094
+ src_images.append(cv2.cvtColor(src1, cv2.COLOR_BGR2RGB))
1095
+
1096
+ if image2:
1097
+ img2_bytes = await image2.read()
1098
+ src2 = cv2.imdecode(np.frombuffer(img2_bytes, np.uint8), cv2.IMREAD_COLOR)
1099
+ if src2 is not None:
1100
+ src_images.append(cv2.cvtColor(src2, cv2.COLOR_BGR2RGB))
1101
+
1102
+ # -----------------------------
1103
+ # Resolve target image
1104
+ # -----------------------------
1105
+ target_url = None
1106
+ if new_category_id:
1107
+ # doc = await subcategories_col.find_one({
1108
+ # "asset_images._id": ObjectId(new_category_id)
1109
+ # })
1110
+ doc = await subcategories_collection.find_one({
1111
+ "asset_images._id": ObjectId(new_category_id)
1112
+ })
1113
+
1114
+ if not doc:
1115
+ raise HTTPException(404, "Asset image not found in database")
1116
+
1117
+ asset = next(
1118
+ (img for img in doc["asset_images"] if str(img["_id"]) == new_category_id),
1119
+ None
1120
+ )
1121
+
1122
+ if not asset:
1123
+ raise HTTPException(404, "Asset image URL not found")
1124
+
1125
+ target_url = asset["url"]
1126
+ subcategory_oid = doc["_id"]
1127
+
1128
+ if target_category_id:
1129
+ client = get_spaces_client()
1130
+ base_prefix = "faceswap/target/"
1131
+ resp = client.list_objects_v2(
1132
+ Bucket=DO_SPACES_BUCKET, Prefix=base_prefix, Delimiter="/"
1133
+ )
1134
+
1135
+ categories = [p["Prefix"].split("/")[2] for p in resp.get("CommonPrefixes", [])]
1136
+
1137
+ for category in categories:
1138
+ original_prefix = f"faceswap/target/{category}/original/"
1139
+ thumb_prefix = f"faceswap/target/{category}/thumb/"
1140
+
1141
+ original_objects = client.list_objects_v2(
1142
+ Bucket=DO_SPACES_BUCKET, Prefix=original_prefix
1143
+ ).get("Contents", [])
1144
+
1145
+ thumb_objects = client.list_objects_v2(
1146
+ Bucket=DO_SPACES_BUCKET, Prefix=thumb_prefix
1147
+ ).get("Contents", [])
1148
+
1149
+ original_filenames = sorted([
1150
+ obj["Key"].split("/")[-1] for obj in original_objects
1151
+ if obj["Key"].split("/")[-1].endswith(".png")
1152
+ ])
1153
+
1154
+ for idx, filename in enumerate(original_filenames, start=1):
1155
+ cid = f"{category.lower()}image_{idx}"
1156
+ if cid == target_category_id:
1157
+ target_url = f"{DO_SPACES_ENDPOINT}/{DO_SPACES_BUCKET}/{original_prefix}{filename}"
1158
+ break
1159
+
1160
+ if target_url:
1161
+ break
1162
+
1163
+ if not target_url:
1164
+ raise HTTPException(404, "Target categoryId not found")
1165
+
1166
+ async with httpx.AsyncClient(timeout=30.0) as client:
1167
+ response = await client.get(target_url)
1168
+ response.raise_for_status()
1169
+ tgt_bytes = response.content
1170
+
1171
+ tgt_bgr = cv2.imdecode(np.frombuffer(tgt_bytes, np.uint8), cv2.IMREAD_COLOR)
1172
+ if tgt_bgr is None:
1173
+ raise HTTPException(400, "Invalid target image data")
1174
+
1175
+ # -----------------------------
1176
+ # Couple face swap + enhance (run in thread)
1177
+ # -----------------------------
1178
+ def _couple_face_swap_and_enhance():
1179
+ pipeline_start = time.time()
1180
+
1181
+ all_src_faces = []
1182
+ t0 = time.time()
1183
+ for img in src_images:
1184
+ faces = face_analysis_app.get(cv2.cvtColor(img, cv2.COLOR_RGB2BGR))
1185
+ all_src_faces.extend(faces)
1186
+
1187
+ tgt_faces = face_analysis_app.get(tgt_bgr)
1188
+ logger.info(f"[Pipeline] Couple-ep face detection: {time.time()-t0:.2f}s")
1189
+
1190
+ if not all_src_faces:
1191
+ raise ValueError("No faces detected in source images")
1192
+ if not tgt_faces:
1193
+ raise ValueError("No faces detected in target image")
1194
+
1195
+ def face_sort_key(face):
1196
+ x1, y1, x2, y2 = face.bbox
1197
+ area = (x2 - x1) * (y2 - y1)
1198
+ cx = (x1 + x2) / 2
1199
+ return (-area, cx)
1200
+
1201
+ src_male = sorted([f for f in all_src_faces if f.gender == 1], key=face_sort_key)
1202
+ src_female = sorted([f for f in all_src_faces if f.gender == 0], key=face_sort_key)
1203
+ tgt_male = sorted([f for f in tgt_faces if f.gender == 1], key=face_sort_key)
1204
+ tgt_female = sorted([f for f in tgt_faces if f.gender == 0], key=face_sort_key)
1205
+
1206
+ pairs = []
1207
+ for s, t in zip(src_male, tgt_male):
1208
+ pairs.append((s, t))
1209
+ for s, t in zip(src_female, tgt_female):
1210
+ pairs.append((s, t))
1211
+
1212
+ if not pairs:
1213
+ src_all = sorted(all_src_faces, key=face_sort_key)
1214
+ tgt_all = sorted(tgt_faces, key=face_sort_key)
1215
+ pairs = list(zip(src_all, tgt_all))
1216
+
1217
+ t0 = time.time()
1218
+ with swap_lock:
1219
+ result_img = tgt_bgr.copy()
1220
+ for src_face, _ in pairs:
1221
+ current_faces = sorted(face_analysis_app.get(result_img), key=face_sort_key)
1222
+ candidates = [f for f in current_faces if f.gender == src_face.gender] or current_faces
1223
+ target_face = candidates[0]
1224
+ result_img = swapper.get(result_img, target_face, src_face, paste_back=True)
1225
+ logger.info(f"[Pipeline] Couple-ep face swap: {time.time()-t0:.2f}s")
1226
+
1227
+ result_rgb = cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB)
1228
+
1229
+ t0 = time.time()
1230
+ enhanced_rgb = mandatory_enhancement(result_rgb)
1231
+ logger.info(f"[Pipeline] Couple-ep enhancement: {time.time()-t0:.2f}s")
1232
+
1233
+ enhanced_bgr = cv2.cvtColor(enhanced_rgb, cv2.COLOR_RGB2BGR)
1234
+
1235
+ temp_dir = tempfile.mkdtemp(prefix="faceswap_")
1236
+ final_path = os.path.join(temp_dir, "result.png")
1237
+ cv2.imwrite(final_path, enhanced_bgr)
1238
+
1239
+ with open(final_path, "rb") as f:
1240
+ result_bytes = f.read()
1241
+
1242
+ logger.info(f"[Pipeline] TOTAL couple-ep swap: {time.time()-pipeline_start:.2f}s")
1243
+ return result_bytes
1244
+
1245
+ try:
1246
+ result_bytes = await asyncio.to_thread(_couple_face_swap_and_enhance)
1247
+ except ValueError as ve:
1248
+ raise HTTPException(400, str(ve))
1249
+
1250
+ result_key = f"faceswap/result/{uuid.uuid4().hex}_enhanced.png"
1251
+ result_url = upload_to_spaces(result_bytes, result_key)
1252
+
1253
+ compressed_bytes = compress_image(result_bytes, max_size=(1280, 1280), quality=72)
1254
+ compressed_key = f"faceswap/result/{uuid.uuid4().hex}_enhanced_compressed.jpg"
1255
+ compressed_url = upload_to_spaces(compressed_bytes, compressed_key, content_type="image/jpeg")
1256
+
1257
+ # -----------------------------
1258
+ # Log API usage
1259
+ # -----------------------------
1260
+ end_time = datetime.utcnow()
1261
+ response_time_ms = (end_time - start_time).total_seconds() * 1000
1262
+
1263
+ if logs_collection is not None:
1264
+ log_entry = {
1265
+ "endpoint": "/face-swap-couple",
1266
+ "status": "success",
1267
+ "response_time_ms": float(response_time_ms),
1268
+ "timestamp": end_time,
1269
+ "appname": appname if appname else None,
1270
+ "error": None
1271
+ }
1272
+
1273
+ await logs_collection.insert_one(log_entry)
1274
+
1275
+ return {
1276
+ "result_key": result_key,
1277
+ "result_url": result_url,
1278
+ "compressed_url": compressed_url
1279
+ }
1280
+
1281
+ except Exception as e:
1282
+ end_time = datetime.utcnow()
1283
+ response_time_ms = (end_time - start_time).total_seconds() * 1000
1284
+
1285
+ if logs_collection is not None:
1286
+ log_entry = {
1287
+ "endpoint": "/face-swap-couple",
1288
+ "status": "fail",
1289
+ "response_time_ms": float(response_time_ms),
1290
+ "timestamp": end_time,
1291
+ "appname": appname if appname else None,
1292
+ "error": str(e)
1293
+ }
1294
+
1295
+ await logs_collection.insert_one(log_entry)
1296
+ raise HTTPException(500, f"Face swap failed: {str(e)}")
1297
+
1298
+
1299
+ if __name__ == "__main__":
1300
+ uvicorn.run(fastapi_app, host="0.0.0.0", port=7860)