diff --git "a/api_routes.py" "b/api_routes.py" new file mode 100644--- /dev/null +++ "b/api_routes.py" @@ -0,0 +1,4675 @@ +import asyncio +import base64 +import functools +import glob +import hashlib +import inspect +import io +import json +import os +import shutil +import time +import uuid +import subprocess +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +import cv2 +import numpy as np +from fastapi import APIRouter, File, UploadFile, HTTPException, Query, Request, \ + Form + +try: + from tensorflow.keras import backend as keras_backend +except ImportError: + try: + from tf_keras import backend as keras_backend # type: ignore + except ImportError: + keras_backend = None + +try: + from starlette.datastructures import \ + UploadFile as StarletteUploadFile # 更精确的类型匹配 +except Exception: + StarletteUploadFile = None +from fastapi.responses import JSONResponse, FileResponse, HTMLResponse + +import wx_access_token +from config import logger, OUTPUT_DIR, IMAGES_DIR, DEEPFACE_AVAILABLE, \ + DLIB_AVAILABLE, GFPGAN_AVAILABLE, DDCOLOR_AVAILABLE, REALESRGAN_AVAILABLE, \ + UPSCALE_SIZE, CLIP_AVAILABLE, REALESRGAN_MODEL, REMBG_AVAILABLE, \ + ANIME_STYLE_AVAILABLE, SAVE_QUALITY, \ + AUTO_INIT_ANALYZER, AUTO_INIT_GFPGAN, AUTO_INIT_DDCOLOR, \ + AUTO_INIT_REALESRGAN, MODELS_PATH, \ + AUTO_INIT_REMBG, AUTO_INIT_ANIME_STYLE, RVM_AVAILABLE, AUTO_INIT_RVM, \ + FACE_SCORE_MAX_IMAGES, FEMALE_AGE_ADJUSTMENT, \ + FEMALE_AGE_ADJUSTMENT_THRESHOLD, CELEBRITY_SOURCE_DIR, \ + CELEBRITY_FIND_THRESHOLD +from database import ( + record_image_creation, + fetch_paged_image_records, + count_image_records, + fetch_records_by_paths, + infer_category_from_filename, + fetch_today_category_counts, +) + +SERVER_HOSTNAME = os.environ.get("HOSTNAME", "") + +# 尝试导入DeepFace +deepface_module = None +if DEEPFACE_AVAILABLE: + t_start = time.perf_counter() + + t_start = time.perf_counter() + + try: + from deepface import DeepFace + deepface_module = DeepFace + + # 为 DeepFace.verify 方法添加兼容性包装 + _original_verify = getattr(DeepFace, 'verify', None) + + if _original_verify: + def _wrapped_verify(*args, **kwargs): + """ + 包装 DeepFace.verify 方法以处理 SymbolicTensor 错误 + """ + try: + return _original_verify(*args, **kwargs) + except AttributeError as attr_err: + if "numpy" not in str(attr_err): + raise + logger.warning("DeepFace verify 触发 numpy AttributeError,尝试清理模型后重试") + _recover_deepface_model() + return _original_verify(*args, **kwargs) + except Exception as generic_exc: + if "SymbolicTensor" not in str(generic_exc) and "numpy" not in str(generic_exc): + raise + logger.warning( + f"DeepFace verify 触发 SymbolicTensor 异常({generic_exc}), 尝试清理模型后重试" + ) + _recover_deepface_model() + return _original_verify(*args, **kwargs) + + DeepFace.verify = _wrapped_verify + logger.info("Patched DeepFace.verify for SymbolicTensor compatibility") + + try: + from deepface.models import FacialRecognition as df_facial_recognition + + _original_forward = df_facial_recognition.FacialRecognition.forward + + def _safe_tensor_to_numpy(output_obj): + """尝试把tensorflow张量、安全列表转换为numpy数组。""" + if output_obj is None: + return None + if hasattr(output_obj, "numpy"): + try: + return output_obj.numpy() + except Exception: + return None + if isinstance(output_obj, np.ndarray): + return output_obj + if isinstance(output_obj, (list, tuple)): + # DeepFace只关心第一个输出 + for item in output_obj: + result = _safe_tensor_to_numpy(item) + if result is not None: + return result + return None + + def _patched_forward(self, img): + """ + 兼容Keras 3 / tf_keras 返回SymbolicTensor的情况,必要时退回predict。 + """ + try: + return _original_forward(self, img) + except AttributeError as attr_err: + if "numpy" not in str(attr_err): + raise + logger.warning("DeepFace 原始 forward 触发 numpy AttributeError,启用兼容路径") + except Exception as generic_exc: + if "SymbolicTensor" not in str(generic_exc) and "numpy" not in str(generic_exc): + raise + logger.warning( + f"DeepFace 原始 forward 触发 SymbolicTensor 异常({generic_exc}), 启用兼容路径" + ) + + if img.ndim == 3: + img = np.expand_dims(img, axis=0) + + if img.ndim != 4: + raise ValueError( + f"Input image must be (N, X, X, 3) shaped but it is {img.shape}" + ) + + embeddings = None + try: + outputs = self.model(img, training=False) + embeddings = _safe_tensor_to_numpy(outputs) + except Exception as call_exc: + logger.info(f"DeepFace forward fallback self.model 调用失败,改用 predict: {call_exc}") + + if embeddings is None: + # Keras 3 调用 self.model(...) 可能返回SymbolicTensor,退回 predict + predict_fn = getattr(self.model, "predict", None) + if predict_fn is None: + raise RuntimeError("DeepFace model 没有 predict 方法,无法转换 SymbolicTensor") + embeddings = predict_fn(img, verbose=0) + + embeddings = np.asarray(embeddings) + if embeddings.ndim == 0: + raise ValueError("Embeddings output is empty.") + + if embeddings.shape[0] == 1: + return embeddings[0].tolist() + return embeddings.tolist() + + df_facial_recognition.FacialRecognition.forward = _patched_forward + logger.info("Patched DeepFace FacialRecognition.forward for SymbolicTensor compatibility") + except Exception as patch_exc: + logger.warning(f"Failed to patch DeepFace forward method: {patch_exc}") + logger.info("DeepFace module imported successfully") + except ImportError as e: + logger.error(f"Failed to import DeepFace: {e}") + DEEPFACE_AVAILABLE = False + +# 添加模块初始化日志 +logger.info("Starting initialization of api_routes module...") +logger.info(f"Configuration status - GFPGAN: {GFPGAN_AVAILABLE}, DDCOLOR: {DDCOLOR_AVAILABLE}, REALESRGAN: {REALESRGAN_AVAILABLE}, REMBG: {REMBG_AVAILABLE}, CLIP: {CLIP_AVAILABLE}, ANIME_STYLE: {ANIME_STYLE_AVAILABLE}") + +# 初始化CLIP相关功能 +clip_encode_image = None +clip_encode_text = None +add_image_vector = None +search_text_vector = None +check_image_exists = None + +if CLIP_AVAILABLE: + try: + from clip_utils import encode_image, encode_text + from vector_store import add_image_vector, search_text_vector, check_image_exists + clip_encode_image = encode_image + clip_encode_text = encode_text + logger.info("CLIP text-image retrieval function initialized successfully") + except Exception as e: + logger.error(f"CLIP function import failed: {e}") + CLIP_AVAILABLE = False + +# 创建线程池执行器用于异步处理CPU密集型任务 +executor = ThreadPoolExecutor(max_workers=4) + + +def _log_stage_duration(stage: str, start_time: float, extra: str | None = None) -> float: + """ + 统一的耗时日志输出,便于快速定位慢点。 + """ + elapsed = time.perf_counter() - start_time + if extra: + logger.info("耗时统计 | %s: %.3fs (%s)", stage, elapsed, extra) + else: + logger.info("耗时统计 | %s: %.3fs", stage, elapsed) + return elapsed + + +async def process_cpu_intensive_task(func, *args, **kwargs): + """ + 异步执行CPU密集型任务 + :param func: 要执行的函数 + :param args: 函数参数 + :param kwargs: 函数关键字参数 + :return: 函数执行结果 + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor(executor, lambda: func(*args, **kwargs)) + + +def _keep_cpu_busy(duration: float, inner_loops: int = 5000) -> Dict[str, Any]: + """ + 在给定时间内执行纯CPU计算,用于防止服务器进入空闲态。 + """ + if duration <= 0: + return {"iterations": 0, "checksum": 0, "elapsed": 0.0} + + end_time = time.perf_counter() + duration + iterations = 0 + checksum = 0 + mask = (1 << 64) - 1 + start = time.perf_counter() + + while time.perf_counter() < end_time: + iterations += 1 + payload = f"{iterations}-{checksum}".encode("utf-8") + digest = hashlib.sha256(payload).digest() + checksum ^= int.from_bytes(digest[:8], "big") + checksum &= mask + + for _ in range(inner_loops): + checksum = ((checksum << 7) | (checksum >> 57)) & mask + checksum ^= 0xA5A5A5A5A5A5A5A5 + + return { + "iterations": iterations, + "checksum": checksum, + "elapsed": time.perf_counter() - start, + } + +deepface_call_lock: Optional[asyncio.Lock] = None + + +def _ensure_deepface_lock() -> asyncio.Lock: + """延迟初始化DeepFace调用锁,避免多线程混用同一模型导致状态损坏。""" + global deepface_call_lock + if deepface_call_lock is None: + deepface_call_lock = asyncio.Lock() + return deepface_call_lock + + +def _clear_keras_session() -> bool: + """清理Keras会话,防止模型状态异常持续存在。""" + if keras_backend is None: + return False + try: + keras_backend.clear_session() + return True + except Exception as exc: + logger.warning(f"清理Keras会话失败: {exc}") + return False + + +def _reset_deepface_model_cache(model_name: str = "ArcFace") -> None: + """移除DeepFace内部缓存的模型,确保下次调用重新加载。""" + if deepface_module is None: + return + try: + from deepface.commons import functions + except Exception as exc: + logger.warning( + f"无法导入deepface.commons.functions,跳过模型缓存重置: {exc}") + return + + removed = False + for attr_name in ("models", "model_cache", "built_models"): + cache = getattr(functions, attr_name, None) + if isinstance(cache, dict) and model_name in cache: + cache.pop(model_name, None) + removed = True + if removed: + logger.info(f"已清除DeepFace缓存模型: {model_name}") + + +def _recover_deepface_model(model_name: str = "ArcFace") -> None: + """组合清理动作,尽量恢复DeepFace模型可用状态。""" + cleared = _clear_keras_session() + _reset_deepface_model_cache(model_name) + if cleared: + logger.info(f"Keras会话已清理,将在下次调用时重新加载模型: {model_name}") + + +from models import ( + ModelType, + ImageFileList, + PagedImageFileList, + SearchRequest, + CelebrityMatchResponse, + CategoryStatsResponse, + CategoryStatItem, +) + +from face_analyzer import EnhancedFaceAnalyzer +from utils import ( + save_image_high_quality, + save_image_with_transparency, + human_readable_size, + convert_numpy_types, + compress_image_by_quality, + compress_image_by_dimensions, + compress_image_by_file_size, + convert_image_format, + upload_file_to_bos, + ensure_bos_resources, + download_bos_directory, +) +from cleanup_scheduler import get_cleanup_status, manual_cleanup + +# 初始化照片修复器(优先GFPGAN,备选简单修复器) +photo_restorer = None +restorer_type = "none" + +# 优先尝试GFPGAN(可配置是否启动时自动初始化) +if GFPGAN_AVAILABLE and AUTO_INIT_GFPGAN: + try: + from gfpgan_restorer import GFPGANRestorer + t_start = time.perf_counter() + photo_restorer = GFPGANRestorer() + init_time = time.perf_counter() - t_start + if photo_restorer.is_available(): + restorer_type = "gfpgan" + logger.info(f"GFPGAN restorer initialized successfully, time: {init_time:.3f}s") + else: + photo_restorer = None + logger.info(f"GFPGAN restorer initialization completed but not available, time: {init_time:.3f}s") + except Exception as e: + init_time = time.perf_counter() - t_start + logger.error(f"Failed to initialize GFPGAN restorer, time: {init_time:.3f}s, error: {e}") + photo_restorer = None +else: + logger.info("GFPGAN restorer is set to lazy initialization or unavailable") + +# 初始化DDColor上色器 +ddcolor_colorizer = None +if DDCOLOR_AVAILABLE and AUTO_INIT_DDCOLOR: + try: + from ddcolor_colorizer import DDColorColorizer + t_start = time.perf_counter() + ddcolor_colorizer = DDColorColorizer() + init_time = time.perf_counter() - t_start + if ddcolor_colorizer.is_available(): + logger.info(f"DDColor colorizer initialized successfully, time: {init_time:.3f}s") + else: + ddcolor_colorizer = None + logger.info(f"DDColor colorizer initialization completed but not available, time: {init_time:.3f}s") + except Exception as e: + init_time = time.perf_counter() - t_start + logger.error(f"Failed to initialize DDColor colorizer, time: {init_time:.3f}s, error: {e}") + ddcolor_colorizer = None +else: + logger.info("DDColor colorizer is set to lazy initialization or unavailable") + +# 如果GFPGAN不可用,服务将无法提供照片修复功能 +if photo_restorer is None: + logger.warning("Photo restoration feature unavailable: GFPGAN initialization failed") + +if ddcolor_colorizer is None: + if DDCOLOR_AVAILABLE: + logger.warning("Photo colorization feature unavailable: DDColor initialization failed") + else: + logger.info("Photo colorization feature not enabled or unavailable") + +# 初始化Real-ESRGAN超清处理器 +realesrgan_upscaler = None +if REALESRGAN_AVAILABLE and AUTO_INIT_REALESRGAN: + try: + from realesrgan_upscaler import get_upscaler + t_start = time.perf_counter() + realesrgan_upscaler = get_upscaler() + init_time = time.perf_counter() - t_start + if realesrgan_upscaler.is_available(): + logger.info(f"Real-ESRGAN super resolution processor initialized successfully, time: {init_time:.3f}s") + else: + realesrgan_upscaler = None + logger.info(f"Real-ESRGAN super resolution processor initialization completed but not available, time: {init_time:.3f}s") + except Exception as e: + init_time = time.perf_counter() - t_start + logger.error(f"Failed to initialize Real-ESRGAN super resolution processor, time: {init_time:.3f}s, error: {e}") + realesrgan_upscaler = None +else: + logger.info("Real-ESRGAN super resolution processor is set to lazy initialization or unavailable") + +if realesrgan_upscaler is None: + if REALESRGAN_AVAILABLE: + logger.warning("Photo super resolution feature unavailable: Real-ESRGAN initialization failed") + else: + logger.info("Photo super resolution feature not enabled or unavailable") + +# 初始化rembg抠图处理器 +rembg_processor = None +if REMBG_AVAILABLE and AUTO_INIT_REMBG: + try: + from rembg_processor import RembgProcessor + t_start = time.perf_counter() + rembg_processor = RembgProcessor() + init_time = time.perf_counter() - t_start + if rembg_processor.is_available(): + logger.info(f"rembg background removal processor initialized successfully, time: {init_time:.3f}s") + else: + rembg_processor = None + logger.info(f"rembg background removal processor initialization completed but not available, time: {init_time:.3f}s") + except Exception as e: + init_time = time.perf_counter() - t_start + logger.error(f"Failed to initialize rembg background removal processor, time: {init_time:.3f}s, error: {e}") + rembg_processor = None +else: + logger.info("rembg background removal processor is set to lazy initialization or unavailable") + +if rembg_processor is None: + if REMBG_AVAILABLE: + logger.warning("ID photo background removal feature unavailable: rembg initialization failed") + else: + logger.info("ID photo background removal feature not enabled or unavailable") + +# 初始化RVM抠图处理器 +rvm_processor = None +if RVM_AVAILABLE and AUTO_INIT_RVM: + try: + from rvm_processor import RVMProcessor + t_start = time.perf_counter() + rvm_processor = RVMProcessor() + init_time = time.perf_counter() - t_start + if rvm_processor.is_available(): + logger.info(f"RVM background removal processor initialized successfully, time: {init_time:.3f}s") + else: + rvm_processor = None + logger.info(f"RVM background removal processor initialization completed but not available, time: {init_time:.3f}s") + except Exception as e: + init_time = time.perf_counter() - t_start + logger.error(f"Failed to initialize RVM background removal processor, time: {init_time:.3f}s, error: {e}") + rvm_processor = None +else: + logger.info("RVM background removal processor is set to lazy initialization or unavailable") + +if rvm_processor is None: + if RVM_AVAILABLE: + logger.warning("RVM background removal feature unavailable: initialization failed") + else: + logger.info("RVM background removal feature not enabled or unavailable") + +# 初始化动漫风格化处理器 +anime_stylizer = None +if ANIME_STYLE_AVAILABLE and AUTO_INIT_ANIME_STYLE: + try: + from anime_stylizer import AnimeStylizer + t_start = time.perf_counter() + anime_stylizer = AnimeStylizer() + init_time = time.perf_counter() - t_start + if anime_stylizer.is_available(): + logger.info(f"Anime stylization processor initialized successfully, time: {init_time:.3f}s") + else: + anime_stylizer = None + logger.info(f"Anime stylization processor initialization completed but not available, time: {init_time:.3f}s") + except Exception as e: + init_time = time.perf_counter() - t_start + logger.error(f"Failed to initialize anime stylization processor, time: {init_time:.3f}s, error: {e}") + anime_stylizer = None +else: + logger.info("Anime stylization processor is set to lazy initialization or unavailable") + +if anime_stylizer is None: + if ANIME_STYLE_AVAILABLE: + logger.warning("Anime stylization feature unavailable: AnimeStylizer initialization failed") + else: + logger.info("Anime stylization feature not enabled or unavailable") + +def _ensure_analyzer(): + global analyzer + if analyzer is None: + try: + analyzer = EnhancedFaceAnalyzer() + logger.info("Face analyzer delayed initialization successful") + except Exception as e: + logger.error(f"Failed to initialize analyzer: {e}") + analyzer = None + +# 初始化分析器(可配置是否在启动时自动初始化) +analyzer = None +if AUTO_INIT_ANALYZER: + t_start = time.perf_counter() + _ensure_analyzer() + init_time = time.perf_counter() - t_start + if analyzer is not None: + logger.info(f"Face analyzer initialized successfully, time: {init_time:.3f}s") + else: + logger.info(f"Face analyzer initialization completed but not available, time: {init_time:.3f}s") + +# 创建路由 +api_router = APIRouter(prefix="/facescore", tags=["Face API"]) +logger.info("API router initialization completed") + + +# 延迟初始化工具函数 +def _ensure_photo_restorer(): + global photo_restorer, restorer_type + if photo_restorer is None and GFPGAN_AVAILABLE: + try: + from gfpgan_restorer import GFPGANRestorer + photo_restorer = GFPGANRestorer() + if photo_restorer.is_available(): + restorer_type = "gfpgan" + logger.info("GFPGAN restorer delayed initialization successful") + except Exception as e: + logger.error(f"GFPGAN restorer delayed initialization failed: {e}") + +def _ensure_ddcolor(): + global ddcolor_colorizer + if ddcolor_colorizer is None and DDCOLOR_AVAILABLE: + try: + from ddcolor_colorizer import DDColorColorizer + ddcolor_colorizer = DDColorColorizer() + if ddcolor_colorizer.is_available(): + logger.info("DDColor colorizer delayed initialization successful") + except Exception as e: + logger.error(f"DDColor colorizer delayed initialization failed: {e}") + +def _ensure_realesrgan(): + global realesrgan_upscaler + if realesrgan_upscaler is None and REALESRGAN_AVAILABLE: + try: + from realesrgan_upscaler import get_upscaler + realesrgan_upscaler = get_upscaler() + if realesrgan_upscaler.is_available(): + logger.info("Real-ESRGAN super resolution processor delayed initialization successful") + except Exception as e: + logger.error(f"Real-ESRGAN super resolution processor delayed initialization failed: {e}") + +def _ensure_rembg(): + global rembg_processor + if rembg_processor is None and REMBG_AVAILABLE: + try: + from rembg_processor import RembgProcessor + rembg_processor = RembgProcessor() + if rembg_processor.is_available(): + logger.info("rembg background removal processor delayed initialization successful") + except Exception as e: + logger.error(f"rembg background removal processor delayed initialization failed: {e}") + +def _ensure_rvm(): + global rvm_processor + if rvm_processor is None and RVM_AVAILABLE: + try: + from rvm_processor import RVMProcessor + rvm_processor = RVMProcessor() + if rvm_processor.is_available(): + logger.info("RVM background removal processor delayed initialization successful") + except Exception as e: + logger.error(f"RVM background removal processor delayed initialization failed: {e}") + +def _ensure_anime_stylizer(): + global anime_stylizer + if anime_stylizer is None and ANIME_STYLE_AVAILABLE: + try: + from anime_stylizer import AnimeStylizer + anime_stylizer = AnimeStylizer() + if anime_stylizer.is_available(): + logger.info("Anime stylization processor delayed initialization successful") + except Exception as e: + logger.error(f"Anime stylization processor delayed initialization failed: {e}") + + +async def handle_image_vector_async(file_path: str, image_name: str): + """异步处理图片向量化""" + try: + # 检查图像是否已经存在于向量库中 + t_check = time.perf_counter() + exists = await asyncio.get_event_loop().run_in_executor( + executor, check_image_exists, image_name + ) + logger.info(f"[Async] Time to check if image exists: {time.perf_counter() - t_check:.3f}s") + + if exists: + logger.info(f"[Async] Image {image_name} already exists in vector library, skipping vectorization") + return + + t1 = time.perf_counter() + # 把 encode_image 放进线程池执行 + img_vector = await asyncio.get_event_loop().run_in_executor( + executor, clip_encode_image, file_path + ) + logger.info(f"[Async] Image vectorization time: {time.perf_counter() - t1:.3f}s") + + # 同样,把 add_image_vector 也放进线程池执行 + t2 = time.perf_counter() + await asyncio.get_event_loop().run_in_executor( + executor, add_image_vector, image_name, img_vector + ) + logger.info(f"[Async] Vectorization storage time: {time.perf_counter() - t2:.3f}s") + except Exception as e: + import traceback + logger.error(f"[Async] Image vector processing failed: {str(e)}") + traceback.print_exc() + + +def _encode_basename(name: str) -> str: + encoded = base64.urlsafe_b64encode(name.encode("utf-8")).decode("ascii") + return encoded.rstrip("=") + + +def _decode_basename(encoded: str) -> str: + padding = "=" * ((4 - len(encoded) % 4) % 4) + try: + return base64.urlsafe_b64decode( + (encoded + padding).encode("ascii")).decode("utf-8") + except Exception: + return encoded + + +def _iter_celebrity_images(base_dir: str) -> List[str]: + allowed_extensions = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} + images = [] + for root, _, files in os.walk(base_dir): + for filename in files: + if filename.startswith('.'): + continue + if not any( + filename.lower().endswith(ext) for ext in allowed_extensions): + continue + images.append(os.path.join(root, filename)) + return images + + +CATEGORY_ALIAS_MAP = { + "face": "face", + "original": "original", + "restore": "restore", + "upcolor": "upcolor", + "compress": "compress", + "upscale": "upscale", + "anime_style": "anime_style", + "animestyle": "anime_style", + "anime-style": "anime_style", + "grayscale": "grayscale", + "gray": "grayscale", + "id_photo": "id_photo", + "idphoto": "id_photo", + "grid": "grid", + "rvm": "rvm", + "celebrity": "celebrity", + "all": "all", + "other": "other", +} + +CATEGORY_DISPLAY_NAMES = { + "face": "人脸", + "original": "评分原图", + "restore": "修复", + "upcolor": "上色", + "compress": "压缩", + "upscale": "超清", + "anime_style": "动漫风格", + "grayscale": "黑白", + "id_photo": "证件照", + "grid": "宫格", + "rvm": "RVM抠图", + "celebrity": "明星识别", + "other": "其他", + "unknown": "未知", +} + +CATEGORY_DISPLAY_ORDER = [ + "face", + "original", + "celebrity", + "restore", + "upcolor", + "compress", + "upscale", + "anime_style", + "grayscale", + "id_photo", + "grid", + "rvm", + "other", + "unknown", +] + + +def _normalize_search_category(search_type: Optional[str]) -> Optional[str]: + """将前端传入的 searchType 映射为数据库中的类别""" + if not search_type: + return None + search_type = search_type.lower() + return CATEGORY_ALIAS_MAP.get(search_type, "other") + + +async def _record_output_file( + file_path: str, + nickname: Optional[str], + *, + category: Optional[str] = None, + bos_uploaded: bool = False, + score: Optional[float] = None, + extra: Optional[Dict[str, Any]] = None, +) -> None: + """封装的图片记录写入,避免影响主流程""" + try: + score_value = float(score) if score is not None else 0.0 + except (TypeError, ValueError): + logger.warning("score 转换失败,已回退为 0,file=%s raw_score=%r", + file_path, score) + score_value = 0.0 + + async def _write_record() -> None: + start_time = time.perf_counter() + try: + await record_image_creation( + file_path=file_path, + nickname=nickname, + category=category, + bos_uploaded=bos_uploaded, + score=score_value, + extra_metadata=extra, + ) + duration = time.perf_counter() - start_time + logger.info( + "MySQL记录完成 file=%s category=%s nickname=%s score=%.4f bos_uploaded=%s cost=%.3fs", + os.path.basename(file_path), + category or "auto", + nickname or "", + score_value, + bos_uploaded, + duration, + ) + except Exception as exc: + logger.warning(f"记录图片到数据库失败: {exc}") + + asyncio.create_task(_write_record()) + + +async def _refresh_celebrity_cache(sample_image_path: str, + db_path: str) -> None: + """刷新DeepFace数据库缓存""" + if not DEEPFACE_AVAILABLE or deepface_module is None: + return + + if not os.path.exists(sample_image_path): + return + + if not os.path.isdir(db_path): + return + + lock = _ensure_deepface_lock() + async with lock: + try: + await process_cpu_intensive_task( + deepface_module.find, + img_path=sample_image_path, + db_path=db_path, + model_name="ArcFace", + detector_backend="yolov11n", + distance_metric="cosine", + enforce_detection=True, + silent=True, + refresh_database=True, + ) + except (AttributeError, RuntimeError) as attr_exc: + if "numpy" in str(attr_exc) or "SymbolicTensor" in str(attr_exc): + logger.warning( + f"刷新明星向量缓存遇到 numpy/SymbolicTensor 异常,尝试恢复后重试: {attr_exc}") + _recover_deepface_model() + try: + await process_cpu_intensive_task( + deepface_module.find, + img_path=sample_image_path, + db_path=db_path, + model_name="ArcFace", + detector_backend="yolov11n", + distance_metric="cosine", + enforce_detection=True, + silent=True, + refresh_database=True, + ) + except Exception as retry_exc: + logger.warning(f"恢复后重新刷新明星缓存仍失败: {retry_exc}") + else: + raise + except ValueError as exc: + logger.warning( + f"刷新明星向量缓存遇到模型状态异常,尝试恢复后重试: {exc}") + _recover_deepface_model() + try: + await process_cpu_intensive_task( + deepface_module.find, + img_path=sample_image_path, + db_path=db_path, + model_name="ArcFace", + detector_backend="yolov11n", + distance_metric="cosine", + enforce_detection=True, + silent=True, + refresh_database=True, + ) + except Exception as retry_exc: + logger.warning(f"恢复后重新刷新明星缓存仍失败: {retry_exc}") + except Exception as e: + logger.warning(f"Refresh celebrity cache failed: {e}") + + +async def _log_progress(task_name: str, + start_time: float, + stop_event: asyncio.Event, + interval: float = 5.0) -> None: + """周期性输出进度日志,避免长时间无输出""" + try: + while True: + try: + await asyncio.wait_for(stop_event.wait(), timeout=interval) + break + except asyncio.TimeoutError: + elapsed = time.perf_counter() - start_time + logger.info(f"{task_name}进行中... 已耗时 {elapsed:.1f}秒") + elapsed = time.perf_counter() - start_time + logger.info(f"{task_name}完成,总耗时 {elapsed:.1f}秒") + except Exception as exc: + logger.warning(f"进度日志任务异常: {exc}") + + +# 通用入参日志装饰器:记录所有接口的入参;若为文件,记录文件名和大小 +def log_api_params(func): + sig = inspect.signature(func) + is_coro = inspect.iscoroutinefunction(func) + + def _is_upload_file(obj: Any) -> bool: + try: + if obj is None: + return False + if isinstance(obj, (bytes, bytearray, str)): + return False + if isinstance(obj, UploadFile): + return True + if StarletteUploadFile is not None and isinstance(obj, + StarletteUploadFile): + return True + # Duck typing: 具备文件相关属性即视为上传文件 + return hasattr(obj, "filename") and hasattr(obj, "file") + except Exception: + return False + + def _upload_file_info(f: UploadFile): + try: + size = getattr(f, "size", None) + if size is None and hasattr(f, "file") and hasattr(f.file, + "tell") and hasattr( + f.file, "seek"): + try: + pos = f.file.tell() + f.file.seek(0, io.SEEK_END) + size = f.file.tell() + f.file.seek(pos, io.SEEK_SET) + except Exception: + size = None + except Exception: + size = None + return { + "type": "file", + "filename": getattr(f, "filename", None), + "size": size, + "content_type": getattr(f, "content_type", None), + } + + def _sanitize_val(name: str, val: Any): + try: + if _is_upload_file(val): + return _upload_file_info(val) + if isinstance(val, (list, tuple)) and ( + len(val) == 0 or _is_upload_file(val[0])): + files = [] + for f in val or []: + files.append( + _upload_file_info(f) if _is_upload_file(f) else str(f)) + return {"type": "files", "count": len(val or []), + "files": files} + if isinstance(val, Request): + # 不记录任何 header/url/client 等潜在敏感信息 + return {"type": "request"} + if val is None: + return None + if hasattr(val, "model_dump"): + data = val.model_dump() + return convert_numpy_types(data) + if hasattr(val, "dict") and callable(getattr(val, "dict")): + data = val.dict() + return convert_numpy_types(data) + if isinstance(val, (bytes, bytearray)): + return f"" + if isinstance(val, (str, int, float, bool)): + if isinstance(val, str) and len(val) > 200: + return val[:200] + "...(truncated)" + return val + # 兜底转换 + return json.loads(json.dumps(val, default=str)) + except Exception as e: + return f"" + + async def _async_wrapper(*args, **kwargs): + try: + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + payload = {name: _sanitize_val(name, val) for name, val in + bound.arguments.items()} + logger.info( + f"==> http {json.dumps(convert_numpy_types(payload), ensure_ascii=False)}") + except Exception as e: + logger.warning(f"Failed to log params for {func.__name__}: {e}") + return await func(*args, **kwargs) + + def _sync_wrapper(*args, **kwargs): + try: + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + payload = {name: _sanitize_val(name, val) for name, val in + bound.arguments.items()} + logger.info( + f"==> http {json.dumps(convert_numpy_types(payload), ensure_ascii=False)}") + except Exception as e: + logger.warning(f"Failed to log params for {func.__name__}: {e}") + return func(*args, **kwargs) + + if is_coro: + return functools.wraps(func)(_async_wrapper) + else: + return functools.wraps(func)(_sync_wrapper) + + +@api_router.post(path="/upload_file", tags=["文件上传"]) +@log_api_params +async def upload_file( + file: UploadFile = File(...), + fileType: str = Form( + None, + description="文件类型,如 'idphoto' 表示证件照上传" + ), + nickname: str = Form( + None, + description="操作者昵称,用于记录到数据库" + ), +): + """ + 文件上传接口:接收上传的文件,保存到本地并返回文件名。 + - 文件名规则:{uuid}_save_id_photo.{ext} + - 保存目录:IMAGES_DIR + - 如果 fileType='idphoto',则调用图片修复接口 + """ + if not file: + raise HTTPException(status_code=400, detail="请上传文件") + + try: + contents = await file.read() + if not contents: + raise HTTPException(status_code=400, detail="文件内容为空") + + # 获取原始文件扩展名 + _, file_extension = os.path.splitext(file.filename) + # 如果没有扩展名,使用空扩展名(保持用户上传文件的原始格式) + + # 生成唯一ID + unique_id = str(uuid.uuid4()).replace('-', '') + extra_meta_base = { + "source": "upload_file", + "file_type": fileType, + "original_filename": file.filename, + } + + # 特殊处理:证件照类型,先做老照片修复再保存 + if fileType == 'idphoto': + try: + # 解码图片 + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException(status_code=400, + detail="无法解析图片文件") + + # 确保修复器可用 + _ensure_photo_restorer() + restored_with_model = ( + photo_restorer is not None and photo_restorer.is_available() + ) + if not restored_with_model: + logger.warning( + "GFPGAN 修复器不可用,跳过修复,按原样保存证件照") + # 按原样保存 + saved_filename = f"{unique_id}_save_id_photo{file_extension}" + saved_path = os.path.join(IMAGES_DIR, saved_filename) + with open(saved_path, "wb") as f: + f.write(contents) + # bos_uploaded = upload_file_to_bos(saved_path) + else: + t1 = time.perf_counter() + logger.info( + "Start restoring uploaded ID photo before saving...") + # 执行修复 + restored_image = await process_cpu_intensive_task( + photo_restorer.restore_image, image) + # 以 webp 高质量保存,命名与证件照区分 + saved_filename = f"{unique_id}_save_id_photo_restore.webp" + saved_path = os.path.join(IMAGES_DIR, saved_filename) + if not save_image_high_quality(restored_image, saved_path, + quality=SAVE_QUALITY): + raise HTTPException(status_code=500, + detail="保存修复后图像失败") + logger.info( + f"ID photo restored and saved: {saved_filename}, time: {time.perf_counter() - t1:.3f}s") + # bos_uploaded = upload_file_to_bos(saved_path) + + # 可选:向量化入库(与其他接口保持一致) + if CLIP_AVAILABLE: + asyncio.create_task( + handle_image_vector_async(saved_path, saved_filename)) + + await _record_output_file( + file_path=saved_path, + nickname=nickname, + category="id_photo", + bos_uploaded=True, + extra={ + **{k: v for k, v in extra_meta_base.items() if v}, + "restored_with_model": restored_with_model, + }, + ) + + return { + "success": True, + "message": "上传成功(已修复)" if photo_restorer is not None and photo_restorer.is_available() else "上传成功", + "filename": saved_filename, + } + except HTTPException: + raise + except Exception as e: + logger.error(f"证件照上传修复流程失败,改为直接保存: {e}") + # 失败兜底:直接保存原文件 + saved_filename = f"{unique_id}_save_id_photo{file_extension}" + saved_path = os.path.join(IMAGES_DIR, saved_filename) + try: + with open(saved_path, "wb") as f: + f.write(contents) + await _record_output_file( + file_path=saved_path, + nickname=nickname, + category="id_photo", + bos_uploaded=True, + extra={ + **{k: v for k, v in extra_meta_base.items() if v}, + "restored_with_model": False, + "fallback": True, + }, + ) + except Exception as se: + logger.error(f"保存文件失败: {se}") + raise HTTPException(status_code=500, detail="保存文件失败") + return { + "success": True, + "message": "上传成功(修复失败,已原样保存)", + "filename": saved_filename, + } + + # 默认:普通文件直接保存原始内容 + saved_filename = f"{unique_id}_save_file{file_extension}" + saved_path = os.path.join(IMAGES_DIR, saved_filename) + try: + with open(saved_path, "wb") as f: + f.write(contents) + bos_uploaded = upload_file_to_bos(saved_path) + logger.info(f"文件上传成功: {saved_filename}") + await _record_output_file( + file_path=saved_path, + nickname=nickname, + bos_uploaded=bos_uploaded, + extra={ + **{k: v for k, v in extra_meta_base.items() if v}, + "restored_with_model": False, + }, + ) + except Exception as e: + logger.error(f"保存文件失败: {str(e)}") + raise HTTPException(status_code=500, detail="保存文件失败") + + return {"success": True, "message": "上传成功", + "filename": saved_filename} + except HTTPException: + raise + except Exception as e: + logger.error(f"文件上传失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}") + + +@api_router.post(path="/check_image_security") +@log_api_params +async def analyze_face( + file: UploadFile = File(...), + nickname: str = Form(None, description="操作者昵称") +): + contents = await file.read() + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + original_md5_hash = str(uuid.uuid4()).replace('-', '') + original_image_filename = f"{original_md5_hash}_original.webp" + original_image_path = os.path.join(IMAGES_DIR, original_image_filename) + save_image_high_quality(image, original_image_path, quality=SAVE_QUALITY, upload_to_bos=False) + try: + with open(original_image_path, "rb") as f: + security_payload = f.read() + except Exception: + security_payload = contents + # 🔥 添加图片安全检测 + t1 = time.perf_counter() + is_safe = await wx_access_token.check_image_security(security_payload) + logger.info(f"Checking image content safety, time: {time.perf_counter() - t1:.3f}s") + if not is_safe: + await _record_output_file( + file_path=original_image_path, + nickname=nickname, + category="original", + score=0.0, + extra={ + "source": "security", + "role": "annotated", + "model": "wx", + }, + ) + return { + "success": False, + "code": 400, + "message": "图片内容不合规! 请更换其他图片", + "filename": file.filename, + } + else: + return { + "success": True, + "code": 0, + "message": "图片内容合规", + "filename": file.filename, + } + + +@api_router.post("/detect_faces", tags=["Face API"]) +@log_api_params +async def detect_faces_endpoint( + file: UploadFile = File(..., description="需要进行人脸检测的图片"), +): + """ + 上传单张图片,调用 YOLO(_detect_faces)做人脸检测并返回耗时。 + """ + if not file or not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传有效的图片文件") + + image_bytes = await file.read() + if not image_bytes: + raise HTTPException(status_code=400, detail="图片内容为空") + + np_arr = np.frombuffer(image_bytes, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException(status_code=400, detail="无法解析图片文件,请确认格式正确") + + if analyzer is None: + _ensure_analyzer() + if analyzer is None: + raise HTTPException(status_code=500, detail="人脸检测模型尚未就绪,请稍后再试") + + detect_start = time.perf_counter() + try: + face_boxes = analyzer._detect_faces(image) + except Exception as exc: + logger.error(f"Face detection failed: {exc}") + raise HTTPException(status_code=500, detail="调用人脸检测失败") from exc + detect_duration = time.perf_counter() - detect_start + + return { + "success": True, + "face_count": len(face_boxes), + "boxes": face_boxes, + "elapsed_ms": round(detect_duration * 1000, 3), + "elapsed_seconds": round(detect_duration, 4), + "hostname": SERVER_HOSTNAME, + } + + +@api_router.post(path="/analyze") +@log_api_params +async def analyze_face( + request: Request, + file: UploadFile = File(None), # 保持原有的单文件上传参数(可选) + files: list[UploadFile] = File(None), # 新增的多文件上传参数(可选) + images: str = Form(None), # 可选的base64图片列表 + nickname: str = Form(None, description="操作者昵称"), + model: ModelType = Query( + ModelType.HYBRID, description="选择使用的模型: howcuteami, deepface 或 hybrid" + ), +): + """ + 分析上传的图片(支持单文件上传、多文件上传或base64编码) + :param file: 单个上传的图片文件(保持向后兼容) + :param files: 多个上传的图片文件列表 + :param images: 上传的图片base64编码列表(JSON字符串) + :param model: 选择使用的模型类型 + :return: 分析结果,包含所有图片的五官评分和标注后图片的下载文件名 + """ + # 不读取或记录任何 header 信息 + + # 获取图片数据 + image_data_list = [] + + # 处理单文件上传(保持向后兼容) + if file: + logger.info( + f"--------> Start processing model={model.value}, single file upload --------" + ) + contents = await file.read() + image_data_list.append(contents) + + # 处理多文件上传 + elif files and len(files) > 0: + logger.info( + f"--------> Start processing model={model.value}, file_count={len(files)} --------" + ) + for file_item in files: + if len(image_data_list) >= FACE_SCORE_MAX_IMAGES: # 使用配置项限制图片数量 + break + contents = await file_item.read() + image_data_list.append(contents) + + # 处理base64编码图片 + elif images: + logger.info( + f"--------> Start processing model={model.value}, image_count={len(images)} --------" + ) + try: + images_list = json.loads(images) + for image_b64 in images_list[:FACE_SCORE_MAX_IMAGES]: # 使用配置项限制图片数量 + image_data = base64.b64decode(image_b64) + image_data_list.append(image_data) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="图片数据格式错误") + + else: + raise HTTPException(status_code=400, detail="请上传至少一张图片") + + if analyzer is None: + _ensure_analyzer() + if analyzer is None: + raise HTTPException( + status_code=500, + detail="人脸分析器未初始化,请检查模型文件是否缺失或损坏。", + ) + + # 验证图片数量 + if len(image_data_list) == 0: + raise HTTPException(status_code=400, detail="请上传至少一张图片") + + if len(image_data_list) > FACE_SCORE_MAX_IMAGES: # 使用配置项限制图片数量 + raise HTTPException(status_code=400, detail=f"最多只能上传{FACE_SCORE_MAX_IMAGES}张图片") + + all_results = [] + valid_image_count = 0 + + try: + overall_start = time.perf_counter() + + # 处理每张图片 + for idx, image_data in enumerate(image_data_list): + image_start = time.perf_counter() + try: + image_size_kb = len(image_data) / 1024 if image_data else 0 + decode_start = time.perf_counter() + np_arr = np.frombuffer(image_data, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + _log_stage_duration( + "图片解码", + decode_start, + f"image_index={idx+1}, size={image_size_kb:.2f}KB, success={image is not None}", + ) + + if image is None: + logger.warning(f"无法解析第{idx+1}张图片") + continue + + # 生成MD5哈希 + original_md5_hash = str(uuid.uuid4()).replace("-", "") + original_image_filename = f"{original_md5_hash}_original.webp" + + logger.info( + f"Processing image {idx+1}/{len(image_data_list)}, md5={original_md5_hash}, size={image_size_kb:.2f} KB" + ) + + analysis_start = time.perf_counter() + # 使用指定模型进行分析 + result = analyzer.analyze_faces(image, original_md5_hash, model) + _log_stage_duration( + "模型推理", + analysis_start, + f"image_index={idx+1}, model={model.value}, faces={result.get('face_count', 0)}", + ) + + # 如果该图片没有人脸,跳过 + if not result.get("success") or result.get("face_count", 0) == 0: + logger.info(f"第{idx+1}张图片未检测到人脸,跳过处理") + continue + + annotated_image_np = result.pop("annotated_image", None) + result["annotated_image_filename"] = None + + if result.get("success") and annotated_image_np is not None: + original_image_path = os.path.join(OUTPUT_DIR, original_image_filename) + save_start = time.perf_counter() + save_success = save_image_high_quality( + annotated_image_np, original_image_path, quality=SAVE_QUALITY + ) + _log_stage_duration( + "标注图保存", + save_start, + f"image_index={idx+1}, path={original_image_path}, success={save_success}", + ) + + if save_success: + result["annotated_image_filename"] = original_image_filename + faces = result["faces"] + + try: + beauty_scores: List[float] = [] + age_models: List[Any] = [] + gender_models: List[Any] = [] + genders: List[Any] = [] + ages: List[Any] = [] + + for face_idx, face_info in enumerate(faces, start=1): + beauty_value = float(face_info.get("beauty_score") or 0.0) + beauty_scores.append(beauty_value) + age_models.append(face_info.get("age_model_used")) + gender_models.append(face_info.get("gender_model_used")) + genders.append(face_info.get("gender")) + ages.append(face_info.get("age")) + + cropped_filename = face_info.get("cropped_face_filename") + if cropped_filename: + cropped_path = os.path.join(IMAGES_DIR, cropped_filename) + if os.path.exists(cropped_path): + upload_start = time.perf_counter() + bos_face = upload_file_to_bos(cropped_path) + _log_stage_duration( + "BOS 上传(人脸)", + upload_start, + f"image_index={idx+1}, face_index={face_idx}, file={cropped_filename}, uploaded={bos_face}", + ) + record_face_start = time.perf_counter() + await _record_output_file( + file_path=cropped_path, + nickname=nickname, + category="face", + bos_uploaded=bos_face, + score=beauty_value, + extra={ + "source": "analyze", + "role": "face_crop", + "model": model.value, + "face_id": face_info.get("face_id"), + "gender": face_info.get("gender"), + "age": face_info.get("age"), + }, + ) + _log_stage_duration( + "记录人脸文件", + record_face_start, + f"image_index={idx+1}, face_index={face_idx}, file={cropped_filename}", + ) + + max_beauty_score = max(beauty_scores) if beauty_scores else 0.0 + + record_annotated_start = time.perf_counter() + await _record_output_file( + file_path=original_image_path, + nickname=nickname, + category="original", + score=max_beauty_score, + extra={ + "source": "analyze", + "role": "annotated", + "model": model.value, + }, + ) + _log_stage_duration( + "记录标注文件", + record_annotated_start, + f"image_index={idx+1}, file={original_image_filename}", + ) + + # 异步执行图片向量化并入库,不阻塞主流程 + if CLIP_AVAILABLE: + # 先保存原始图片到IMAGES_DIR供向量化使用 + original_input_path = os.path.join(IMAGES_DIR, original_image_filename) + save_input_start = time.perf_counter() + input_save_success = save_image_high_quality( + image, original_input_path, quality=SAVE_QUALITY + ) + _log_stage_duration( + "原图保存(CLIP)", + save_input_start, + f"image_index={idx+1}, success={input_save_success}", + ) + if input_save_success: + record_input_start = time.perf_counter() + await _record_output_file( + file_path=original_input_path, + nickname=nickname, + category="original", + score=max_beauty_score, + extra={ + "source": "analyze", + "role": "original_input", + "model": model.value, + }, + ) + _log_stage_duration( + "记录原图文件", + record_input_start, + f"image_index={idx+1}, file={original_image_filename}", + ) + vector_schedule_start = time.perf_counter() + asyncio.create_task( + handle_image_vector_async( + original_input_path, original_image_filename + ) + ) + _log_stage_duration( + "调度向量化任务", + vector_schedule_start, + f"image_index={idx+1}, file={original_image_filename}", + ) + + image_elapsed = time.perf_counter() - image_start + logger.info( + f"<-------- Image {idx+1} processing completed, elapsed: {image_elapsed:.3f}s, faces={len(faces)}, beauty={beauty_scores}, age={ages} via {age_models}, gender={genders} via {gender_models} --------" + ) + + # 添加到结果列表 + all_results.append(result) + valid_image_count += 1 + except Exception as e: + logger.error(f"Error processing image {idx+1}: {str(e)}") + continue + + except Exception as e: + logger.error(f"Error processing image {idx+1}: {str(e)}") + continue + + # 如果没有有效图片,返回错误 + if valid_image_count == 0: + logger.info("<-------- All images processing completed, no faces detected in any image --------") + return JSONResponse( + content={ + "success": False, + "message": "请尝试上传清晰、无遮挡的正面照片", + "face_count": 0, + "faces": [], + } + ) + + # 合并所有结果 + combined_result = { + "success": True, + "message": "分析完成", + "face_count": sum(result["face_count"] for result in all_results), + "faces": [ + { + "face": face, + "annotated_image_filename": result.get("annotated_image_filename"), + } + for result in all_results + for face in result["faces"] + ], + } + + # 保底:对女性年龄进行调整(如果年龄大于阈值且尚未调整) + for face_entry in combined_result["faces"]: + face = face_entry["face"] + gender = face.get("gender", "") + age_str = face.get("age", "") + + if str(gender) != "Female" or face.get("age_adjusted"): + continue + + try: + # 处理年龄范围格式,如 "25-32" + if "-" in str(age_str): + age = int(str(age_str).split("-")[0].strip("() ")) + else: + age = int(str(age_str).strip()) + + if age >= FEMALE_AGE_ADJUSTMENT_THRESHOLD and FEMALE_AGE_ADJUSTMENT > 0: + adjusted_age = max(0, age - FEMALE_AGE_ADJUSTMENT) + face["age"] = str(adjusted_age) + face["age_adjusted"] = True + face["age_adjustment_value"] = FEMALE_AGE_ADJUSTMENT + logger.info(f"Adjusted age for female (fallback): {age} -> {adjusted_age}") + except (ValueError, TypeError): + pass + + # 转换所有 numpy 类型为原生 Python 类型 + cleaned_result = convert_numpy_types(combined_result) + total_elapsed = time.perf_counter() - overall_start + logger.info( + f"<-------- All images processing completed, total time: {total_elapsed:.3f}s, valid images: {valid_image_count} --------" + ) + return JSONResponse(content=cleaned_result) + + except Exception as e: + import traceback + + traceback.print_exc() + logger.error(f"Internal error occurred during analysis: {str(e)}") + raise HTTPException(status_code=500, detail=f"分析过程中出现内部错误: {str(e)}") + + +@api_router.post("/image_search", response_model=ImageFileList, tags=["图像搜索"]) +@log_api_params +async def search_by_image( + file: UploadFile = File(None), + searchType: str = Query("face"), + top_k: int = Query(5), + score_threshold: float = Query(0.28) +): + """使用图片进行相似图像搜索""" + # 检查CLIP是否可用 + if not CLIP_AVAILABLE: + raise HTTPException(status_code=500, detail="CLIP功能未启用或初始化失败") + + try: + # 获取图片数据 + if not file: + raise HTTPException(status_code=400, detail="请提供要搜索的图片") + + # 读取图片数据 + image_data = await file.read() + + # 保存临时图片文件 + temp_image_path = f"/tmp/search_image_{uuid.uuid4().hex}.webp" + try: + # 解码图片 + np_arr = np.frombuffer(image_data, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException(status_code=400, detail="无法解析图片文件") + + # 保存为临时文件 + cv2.imwrite(temp_image_path, image, [cv2.IMWRITE_WEBP_QUALITY, 100]) + + # 使用CLIP编码图片 + image_vector = clip_encode_image(temp_image_path) + + # 执行搜索 + search_results = search_text_vector(image_vector, top_k) + + # 根据score_threshold过滤结果 + filtered_results = [ + item for item in search_results + if item[1] >= score_threshold + ] + + # 从数据库获取元数据 + records_map = {} + try: + records_map = await fetch_records_by_paths( + file_path for file_path, _ in filtered_results + ) + except Exception as exc: + logger.warning(f"Fetch image records by path failed: {exc}") + + category = _normalize_search_category(searchType) + # 构建返回结果 + all_files = [] + for file_path, score in filtered_results: + record = records_map.get(file_path) + record_category = ( + record.get( + "category") if record else infer_category_from_filename( + file_path) + ) + if category not in ( + None, "all") and record_category != category: + continue + + size_bytes = 0 + is_cropped = False + nickname_value = record.get("nickname") if record else None + last_modified_dt = None + + if record: + size_bytes = int(record.get("size_bytes") or 0) + is_cropped = bool(record.get("is_cropped_face")) + last_modified_dt = record.get("last_modified") + if isinstance(last_modified_dt, str): + try: + last_modified_dt = datetime.fromisoformat( + last_modified_dt) + except ValueError: + last_modified_dt = None + + if last_modified_dt is None or size_bytes == 0: + full_path = os.path.join(IMAGES_DIR, file_path) + if not os.path.isfile(full_path): + continue + stat = os.stat(full_path) + size_bytes = stat.st_size + last_modified_dt = datetime.fromtimestamp(stat.st_mtime) + is_cropped = "_face_" in file_path and file_path.count("_") >= 2 + + last_modified_str = ( + last_modified_dt.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(last_modified_dt, datetime) + else "" + ) + file_info = { + "file_path": file_path, + "score": round(score, 4), + "is_cropped_face": is_cropped, + "size_bytes": size_bytes, + "size_str": human_readable_size(size_bytes), + "last_modified": last_modified_str, + "nickname": nickname_value, + } + all_files.append(file_info) + + return ImageFileList(results=all_files, count=len(all_files)) + + finally: + # 清理临时文件 + if os.path.exists(temp_image_path): + os.remove(temp_image_path) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Image search failed: {str(e)}") + raise HTTPException(status_code=500, detail=f"图片搜索失败: {str(e)}") + + +@api_router.get( + "/daily_category_stats", + response_model=CategoryStatsResponse, + tags=["统计"] +) +@log_api_params +async def get_daily_category_stats(): + """查询当日各分类数量""" + try: + rows = await fetch_today_category_counts() + except Exception as exc: + logger.error("Fetch today category counts failed: %s", exc) + raise HTTPException(status_code=500, + detail="查询今日分类统计失败") from exc + + counts_map: Dict[str, int] = { + str(item.get("category") or "unknown"): int(item.get("count") or 0) + for item in rows + } + total = sum(counts_map.values()) + + remaining = counts_map.copy() + stats: List[CategoryStatItem] = [] + + for category in CATEGORY_DISPLAY_ORDER: + count = remaining.pop(category, 0) + stats.append( + CategoryStatItem( + category=category, + display_name=CATEGORY_DISPLAY_NAMES.get(category, category), + count=count, + ) + ) + + for category in sorted(remaining.keys()): + stats.append( + CategoryStatItem( + category=category, + display_name=CATEGORY_DISPLAY_NAMES.get(category, category), + count=remaining[category], + ) + ) + + return CategoryStatsResponse(stats=stats, total=total) + + +@api_router.post("/outputs", response_model=PagedImageFileList, tags=["检测列表"]) +@log_api_params +async def list_outputs( + request: SearchRequest, + page: int = Query(1, ge=1, description="页码(从1开始)"), + page_size: int = Query(20, ge=1, le=100, description="每页数量(最大100)") +): + search_type = request.searchType + category = _normalize_search_category(search_type) + keyword = request.keyword.strip() if getattr(request, "keyword", + None) else "" + nickname_filter = request.nickname.strip() if getattr(request, "nickname", + None) else None + + try: + # 如果有关键词且CLIP可用,进行向量搜索 + if keyword and CLIP_AVAILABLE: + logger.info(f"Performing vector search, keyword: {keyword}") + try: + # 编码搜索文本 + text_vector = clip_encode_text(keyword) + + # 搜索相似图片 - 使用更大的top_k以支持分页 + search_results = search_text_vector(text_vector, request.top_k if hasattr(request, 'top_k') else 1000) + + # 根据score_threshold过滤结果 + filtered_results = [ + item for item in search_results + if item[1] >= request.score_threshold + ] + + logger.info(f"Vector search found {len(filtered_results)} similar results") + + # 从数据库中批量获取图片元数据 + records_map = {} + try: + records_map = await fetch_records_by_paths( + file_path for file_path, _ in filtered_results + ) + except Exception as exc: + logger.warning(f"Fetch image records by path failed: {exc}") + + # 构建返回结果 + all_files = [] + for file_path, score in filtered_results: + record = records_map.get(file_path) + record_category = ( + record.get( + "category") if record else infer_category_from_filename( + file_path) + ) + + if category not in ( + None, "all") and record_category != category: + continue + if nickname_filter and ( + record is None or ( + record.get("nickname") or "").strip() != nickname_filter + ): + continue + + size_bytes = 0 + is_cropped = False + nickname_value = record.get("nickname") if record else None + last_modified_dt = None + + if record: + size_bytes = int(record.get("size_bytes") or 0) + is_cropped = bool(record.get("is_cropped_face")) + last_modified_dt = record.get("last_modified") + if isinstance(last_modified_dt, str): + try: + last_modified_dt = datetime.fromisoformat( + last_modified_dt) + except ValueError: + last_modified_dt = None + + if last_modified_dt is None or size_bytes == 0: + full_path = os.path.join(IMAGES_DIR, file_path) + if not os.path.isfile(full_path): + continue + stat = os.stat(full_path) + size_bytes = stat.st_size + last_modified_dt = datetime.fromtimestamp(stat.st_mtime) + is_cropped = "_face_" in file_path and file_path.count("_") >= 2 + + last_modified_str = ( + last_modified_dt.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(last_modified_dt, datetime) + else "" + ) + file_info = { + "file_path": file_path, + "score": round(score, 4), + "is_cropped_face": is_cropped, + "size_bytes": size_bytes, + "size_str": human_readable_size(size_bytes), + "last_modified": last_modified_str, + "nickname": nickname_value, + } + all_files.append(file_info) + + # 应用分页 + total_count = len(all_files) + start_index = (page - 1) * page_size + end_index = start_index + page_size + paged_results = all_files[start_index:end_index] + + total_pages = (total_count + page_size - 1) // page_size # 向上取整 + return PagedImageFileList( + results=paged_results, + count=total_count, + page=page, + page_size=page_size, + total_pages=total_pages + ) + + except Exception as e: + logger.error(f"Vector search failed: {str(e)}") + # 如果向量搜索失败,降级到普通文件列表 + + # 普通文件列表模式(无关键词或CLIP不可用) + logger.info("Returning regular file list") + try: + total_count = await count_image_records( + category=category, + nickname=nickname_filter, + ) + if total_count > 0: + offset = (page - 1) * page_size + rows = await fetch_paged_image_records( + category=category, + nickname=nickname_filter, + offset=offset, + limit=page_size, + ) + paged_results = [] + for row in rows: + last_modified = row.get("last_modified") + if isinstance(last_modified, str): + try: + last_modified_dt = datetime.fromisoformat( + last_modified) + except ValueError: + last_modified_dt = None + else: + last_modified_dt = last_modified + size_bytes = int(row.get("size_bytes") or 0) + paged_results.append({ + "file_path": row.get("file_path"), + "score": float(row.get("score") or 0.0), + "is_cropped_face": bool(row.get("is_cropped_face")), + "size_bytes": size_bytes, + "size_str": human_readable_size(size_bytes), + "last_modified": last_modified_dt.strftime( + "%Y-%m-%d %H:%M:%S") if last_modified_dt else "", + "nickname": row.get("nickname"), + }) + total_pages = (total_count + page_size - 1) // page_size + return PagedImageFileList( + results=paged_results, + count=total_count, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + except Exception as exc: + logger.error( + f"Query image records from MySQL failed: {exc}, fallback to filesystem scan") + + if nickname_filter: + # 没有数据库结果且需要按昵称过滤,直接返回空列表以避免返回其他用户数据 + return PagedImageFileList( + results=[], + count=0, + page=page, + page_size=page_size, + total_pages=0, + ) + + # 文件系统兜底逻辑 + all_files = [] + for f in os.listdir(IMAGES_DIR): + if not f.lower().endswith((".jpg", ".jpeg", ".png", ".webp")): + continue + + file_category = infer_category_from_filename(f) + if category not in (None, "all") and file_category != category: + continue + + full_path = os.path.join(IMAGES_DIR, f) + if os.path.isfile(full_path): + stat = os.stat(full_path) + is_cropped = "_face_" in f and f.count("_") >= 2 + file_info = { + "file_path": f, + "score": 0.0, + "is_cropped_face": is_cropped, + "size_bytes": stat.st_size, + "size_str": human_readable_size(stat.st_size), + "last_modified": datetime.fromtimestamp( + stat.st_mtime).strftime( + "%Y-%m-%d %H:%M:%S" + ), + "nickname": None, + } + all_files.append(file_info) + + all_files.sort(key=lambda x: x["last_modified"], reverse=True) + + # 应用分页 + total_count = len(all_files) + start_index = (page - 1) * page_size + end_index = start_index + page_size + paged_results = all_files[start_index:end_index] + + total_pages = (total_count + page_size - 1) // page_size # 向上取整 + return PagedImageFileList( + results=paged_results, + count=total_count, + page=page, + page_size=page_size, + total_pages=total_pages + ) + except Exception as e: + logger.error(f"Failed to get detection result list: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@api_router.get("/preview/{filename}", tags=["文件预览"]) +@log_api_params +async def download_result(filename: str): + file_path = os.path.join(IMAGES_DIR, filename) + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="文件不存在") + + # 根据文件扩展名确定媒体类型 + if filename.lower().endswith('.png'): + media_type = "image/png" + elif filename.lower().endswith('.webp'): + media_type = "image/webp" + else: + media_type = "image/jpeg" + return FileResponse(path=file_path, filename=filename, media_type=media_type) + + +@api_router.get("/download/{filename}", tags=["文件下载"]) +@log_api_params +async def preview_result(filename: str): + file_path = os.path.join(OUTPUT_DIR, filename) + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="文件不存在") + + # 根据文件扩展名确定媒体类型 + if filename.lower().endswith('.png'): + media_type = "image/png" + elif filename.lower().endswith('.webp'): + media_type = "image/webp" + else: + media_type = "image/jpeg" + return FileResponse( + path=file_path, + filename=filename, + media_type=media_type, + # background=BackgroundTask(move_file_to_archive, file_path), + ) + + +@api_router.get("/models", tags=["模型信息"]) +@log_api_params +async def get_available_models(): + """获取可用的模型列表""" + models = { + "howcuteami": { + "name": "HowCuteAmI", + "description": "基于OpenCV DNN的颜值、年龄、性别预测模型", + "available": analyzer is not None, + "features": [ + "face_detection", + "age_prediction", + "gender_prediction", + "beauty_scoring", + ], + }, + "deepface": { + "name": "DeepFace", + "description": "Facebook开源的人脸分析框架,支持年龄、性别、情绪识别", + "available": DEEPFACE_AVAILABLE, + "features": ["age_prediction", "gender_prediction", "emotion_analysis"], + }, + "hybrid": { + "name": "Hybrid Model", + "description": "混合模型:HowCuteAmI(颜值+性别)+ DeepFace(年龄+情绪)", + "available": analyzer is not None and DEEPFACE_AVAILABLE, + "features": [ + "beauty_scoring", + "gender_prediction", + "age_prediction", + "emotion_analysis", + ], + }, + } + + facial_analysis = { + "name": "Facial Feature Analysis", + "description": "基于MediaPipe的五官特征分析", + "available": DLIB_AVAILABLE, + "features": [ + "eyes_scoring", + "nose_scoring", + "mouth_scoring", + "eyebrows_scoring", + "jawline_scoring", + "harmony_analysis", + ], + } + + return { + "prediction_models": models, + "facial_analysis": facial_analysis, + "recommended_combination": ( + "hybrid + facial_analysis" + if analyzer is not None and DEEPFACE_AVAILABLE and DLIB_AVAILABLE + else "howcuteami + basic_analysis" + ), + } + + +@api_router.post("/sync_resources", tags=["系统维护"]) +@log_api_params +async def sync_bos_resources( + force_download: bool = Query(False, description="是否强制重新下载已存在的文件"), + include_background: bool = Query( + False, description="是否同步配置中标记为后台的资源" + ), + bos_prefix: str | None = Query( + None, description="自定义 BOS 前缀,例如 20220620/models" + ), + destination_dir: str | None = Query( + None, description="自定义本地目录,例如 /opt/models/custom" + ), + background: bool = Query( + False, description="与自定义前缀搭配使用时,是否在后台异步下载" + ), +): + """ + 手动触发 BOS 资源同步。 + - 若提供 bos_prefix 与 destination_dir,则按指定路径同步; + - 否则根据配置的 BOS_DOWNLOAD_TARGETS 执行批量同步。 + """ + start_time = time.perf_counter() + + if (bos_prefix and not destination_dir) or (destination_dir and not bos_prefix): + raise HTTPException(status_code=400, detail="bos_prefix 和 destination_dir 需要同时提供") + + if bos_prefix and destination_dir: + dest_path = os.path.abspath(os.path.expanduser(destination_dir.strip())) + + async def _sync_single(): + return await asyncio.to_thread( + download_bos_directory, + bos_prefix.strip(), + dest_path, + force_download=force_download, + ) + + if background: + async def _background_task(): + success = await _sync_single() + if success: + logger.info( + "后台 BOS 下载完成: prefix=%s -> %s", bos_prefix, dest_path + ) + else: + logger.warning( + "后台 BOS 下载失败: prefix=%s -> %s", bos_prefix, dest_path + ) + + asyncio.create_task(_background_task()) + elapsed = time.perf_counter() - start_time + return { + "success": True, + "force_download": force_download, + "include_background": False, + "bos_prefix": bos_prefix, + "destination_dir": dest_path, + "elapsed_seconds": round(elapsed, 3), + "message": "后台下载任务已启动", + } + + success = await _sync_single() + elapsed = time.perf_counter() - start_time + return { + "success": bool(success), + "force_download": force_download, + "include_background": False, + "bos_prefix": bos_prefix, + "destination_dir": dest_path, + "elapsed_seconds": round(elapsed, 3), + "message": "资源同步完成" if success else "资源同步失败,请查看日志", + } + + # 未指定前缀时,按配置批量同步 + success = await asyncio.to_thread( + ensure_bos_resources, + force_download, + include_background, + ) + elapsed = time.perf_counter() - start_time + message = ( + "后台下载任务已启动,将在后台继续运行" + if not include_background + else "资源同步完成" + ) + return { + "success": bool(success), + "force_download": force_download, + "include_background": include_background, + "elapsed_seconds": round(elapsed, 3), + "message": message, + "bos_prefix": None, + "destination_dir": None, + } + + +@api_router.post("/restore") +@log_api_params +async def restore_old_photo( + file: UploadFile = File(...), + md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"), + colorize: bool = Query(False, description="是否对黑白照片进行上色"), + nickname: str = Form(None, description="操作者昵称"), +): + """ + 老照片修复接口 + :param file: 上传的老照片文件 + :param md5: 前端传递的文件md5,如果未传递则使用original_md5_hash + :param colorize: 是否对黑白照片进行上色,默认为False + :return: 修复结果,包含修复后图片的文件名 + """ + _ensure_photo_restorer() + if photo_restorer is None or not photo_restorer.is_available(): + raise HTTPException( + status_code=500, + detail="照片修复器未初始化,请检查服务状态。" + ) + + # 验证文件类型 + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件") + + try: + contents = await file.read() + original_md5_hash = str(uuid.uuid4()).replace('-', '') + # 如果前端传递了md5参数则使用,否则使用original_md5_hash + actual_md5 = md5 if md5 else original_md5_hash + restored_filename = f"{actual_md5}_restore.webp" + + logger.info(f"Starting to restore old photo: {file.filename}, size={file.size}, colorize={colorize}, md5={original_md5_hash}") + t1 = time.perf_counter() + + # 解码图像 + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException( + status_code=400, detail="无法解析图片文件,请确保文件格式正确。" + ) + + # 获取原图信息 + original_height, original_width = image.shape[:2] + original_size = file.size + + # 调整后的处理流程:先修复再上色 + # 步骤1: 使用GFPGAN修复图像 + logger.info("Step 1: Starting to restore the original image...") + processing_steps = [] + + try: + restored_image = await process_cpu_intensive_task(photo_restorer.restore_image, image) + final_image = restored_image + processing_steps.append(f"使用{restorer_type}修复器修复") + logger.info("Restoration processing completed") + except Exception as e: + logger.error(f"Restoration processing failed: {e}, continuing with original image") + final_image = image + + # 步骤2: 如果用户选择上色,对修复后的图像进行上色 + if colorize and ddcolor_colorizer is not None and ddcolor_colorizer.is_available(): + logger.info("Step 2: Starting to colorize the restored image...") + try: + # 检查修复后的图像是否为灰度 + restored_is_grayscale = ddcolor_colorizer.is_grayscale(final_image) + logger.info(f"Is restored image grayscale: {restored_is_grayscale}") + + if restored_is_grayscale: + # 对灰度图进行上色 + logger.info("Colorizing the restored grayscale image...") + colorized_image = await process_cpu_intensive_task(ddcolor_colorizer.colorize_image_direct, final_image) + final_image = colorized_image + processing_steps.append("使用DDColor对修复后图像上色") + logger.info("Colorization processing completed") + else: + # 对于彩色图像,可以选择强制上色或跳过 + logger.info("Restored image is already colored, performing forced colorization...") + colorized_image = await process_cpu_intensive_task(ddcolor_colorizer.colorize_image_direct, final_image) + final_image = colorized_image + processing_steps.append("强制使用DDColor上色") + logger.info("Forced colorization processing completed") + + except Exception as e: + logger.error(f"Colorization processing failed: {e}, using restored image") + elif colorize: + if DDCOLOR_AVAILABLE: + logger.warning("Colorization feature unavailable: DDColor not properly initialized") + else: + logger.info("Colorization feature disabled or DDColor unavailable, skipping colorization step") + + # 获取处理后图像信息 + processed_height, processed_width = final_image.shape[:2] + + # 保存最终处理后的图像到IMAGES_DIR(与人脸评分使用相同路径) + restored_path = os.path.join(IMAGES_DIR, restored_filename) + save_success = save_image_high_quality( + final_image, restored_path, quality=SAVE_QUALITY + ) + + if save_success: + total_time = time.perf_counter() - t1 + + # 获取处理后文件大小 + processed_size = os.path.getsize(restored_path) + + logger.info(f"Old photo processing completed: {restored_filename}, time: {total_time:.3f}s") + + # 异步执行图片向量化并入库,不阻塞主流程 + if CLIP_AVAILABLE: + asyncio.create_task(handle_image_vector_async(restored_path, restored_filename)) + + # bos_uploaded = upload_file_to_bos(restored_path) + await _record_output_file( + file_path=restored_path, + nickname=nickname, + category="restore", + bos_uploaded=True, + extra={ + "source": "restore", + "colorize": colorize, + "processing_steps": processing_steps, + "md5": actual_md5, + }, + ) + + return { + "success": True, + "message": "成功", + "original_filename": file.filename, + "restored_filename": restored_filename, + "processing_time": f"{total_time:.3f}s", + "original_size": original_size, + "processed_size": processed_size, + "size_increase_ratio": round(processed_size / original_size, 2), + "original_dimensions": f"{original_width} × {original_height}", + "processed_dimensions": f"{processed_width} × {processed_height}", + } + else: + raise HTTPException(status_code=500, detail="保存修复后图像失败") + + except Exception as e: + logger.error(f"Error occurred during old photo restoration: {str(e)}") + raise HTTPException(status_code=500, detail=f"修复过程中出现错误: {str(e)}") + + +@api_router.post("/upcolor") +@log_api_params +async def colorize_photo( + file: UploadFile = File(...), + md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"), + nickname: str = Form(None, description="操作者昵称"), +): + """ + 照片上色接口 + :param file: 上传的照片文件 + :param md5: 前端传递的文件md5,如果未传递则使用original_md5_hash + :return: 上色结果,包含上色后图片的文件名 + """ + _ensure_ddcolor() + if ddcolor_colorizer is None or not ddcolor_colorizer.is_available(): + raise HTTPException( + status_code=500, + detail="照片上色器未初始化,请检查服务状态。" + ) + + # 验证文件类型 + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件") + + try: + contents = await file.read() + original_md5_hash = str(uuid.uuid4()).replace('-', '') + # 如果前端传递了md5参数则使用,否则使用original_md5_hash + actual_md5 = md5 if md5 else original_md5_hash + colored_filename = f"{actual_md5}_upcolor.webp" + + logger.info(f"Starting to colorize photo: {file.filename}, size={file.size}, md5={original_md5_hash}") + t1 = time.perf_counter() + + # 解码图像 + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException( + status_code=400, detail="无法解析图片文件,请确保文件格式正确。" + ) + + # 获取原图信息 + original_height, original_width = image.shape[:2] + original_size = file.size + + # 使用DDColor对图像进行上色 + logger.info("Starting to colorize the image...") + try: + colorized_image = await process_cpu_intensive_task(ddcolor_colorizer.colorize_image_direct, image) + logger.info("Colorization processing completed") + except Exception as e: + logger.error(f"Colorization processing failed: {e}") + raise HTTPException(status_code=500, detail=f"上色处理失败: {str(e)}") + + # 获取处理后图像信息 + processed_height, processed_width = colorized_image.shape[:2] + + # 保存上色后的图像到IMAGES_DIR + colored_path = os.path.join(IMAGES_DIR, colored_filename) + save_success = save_image_high_quality( + colorized_image, colored_path, quality=SAVE_QUALITY + ) + + if save_success: + total_time = time.perf_counter() - t1 + + # 获取处理后文件大小 + processed_size = os.path.getsize(colored_path) + + logger.info(f"Photo colorization completed: {colored_filename}, time: {total_time:.3f}s") + + # 异步执行图片向量化并入库,不阻塞主流程 + if CLIP_AVAILABLE: + asyncio.create_task(handle_image_vector_async(colored_path, colored_filename)) + + # bos_uploaded = upload_file_to_bos(colored_path) + await _record_output_file( + file_path=colored_path, + nickname=nickname, + category="upcolor", + bos_uploaded=True, + extra={ + "source": "upcolor", + "md5": actual_md5, + }, + ) + + return { + "success": True, + "message": "成功", + "original_filename": file.filename, + "colored_filename": colored_filename, + "processing_time": f"{total_time:.3f}s", + "original_size": original_size, + "processed_size": processed_size, + "size_increase_ratio": round(processed_size / original_size, 2), + "original_dimensions": f"{original_width} × {original_height}", + "processed_dimensions": f"{processed_width} × {processed_height}", + } + else: + raise HTTPException(status_code=500, detail="保存上色后图像失败") + + except Exception as e: + logger.error(f"Error occurred during photo colorization: {str(e)}") + raise HTTPException(status_code=500, detail=f"上色过程中出现错误: {str(e)}") + + +@api_router.get("/anime_style/status", tags=["动漫风格化"]) +@log_api_params +async def get_anime_style_status(): + """ + 获取动漫风格化模型状态 + :return: 模型状态信息,包括已加载的模型和预加载状态 + """ + _ensure_anime_stylizer() + if anime_stylizer is None or not anime_stylizer.is_available(): + raise HTTPException( + status_code=500, + detail="动漫风格化处理器未初始化,请检查服务状态。" + ) + + try: + # 获取预加载状态 + preload_status = anime_stylizer.get_preload_status() + available_styles = anime_stylizer.get_available_styles() + + return { + "success": True, + "message": "获取动漫风格化状态成功", + "preload_status": preload_status, + "available_styles": available_styles, + "service_available": True + } + except Exception as e: + logger.error(f"Failed to get anime stylization status: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取状态失败: {str(e)}") + + +@api_router.post("/anime_style/preload", tags=["动漫风格化"]) +@log_api_params +async def preload_anime_models( + style_types: list = Query(None, description="要预加载的风格类型列表,如果为空则预加载所有模型") +): + """ + 预加载动漫风格化模型 + :param style_types: 要预加载的风格类型列表,支持: handdrawn, disney, illustration, artstyle, anime, sketch + :return: 预加载结果 + """ + _ensure_anime_stylizer() + if anime_stylizer is None or not anime_stylizer.is_available(): + raise HTTPException( + status_code=500, + detail="动漫风格化处理器未初始化,请检查服务状态。" + ) + + try: + logger.info(f"API request to preload anime style models: {style_types}") + + # 开始预加载 + start_time = time.perf_counter() + anime_stylizer.preload_models(style_types) + preload_time = time.perf_counter() - start_time + + # 获取预加载后的状态 + preload_status = anime_stylizer.get_preload_status() + + return { + "success": True, + "message": f"模型预加载完成,耗时: {preload_time:.3f}s", + "preload_time": f"{preload_time:.3f}s", + "preload_status": preload_status, + "requested_styles": style_types, + } + except Exception as e: + logger.error(f"Anime style model preloading failed: {str(e)}") + raise HTTPException(status_code=500, detail=f"预加载失败: {str(e)}") + + +@api_router.post("/anime_style") +@log_api_params +async def anime_stylize_photo( + file: UploadFile = File(...), + style_type: str = Form("handdrawn", + description="动漫风格类型: handdrawn=手绘风格, disney=迪士尼风格, illustration=插画风格, artstyle=艺术风格, anime=二次元风格, sketch=素描风格"), + nickname: str = Form(None, description="操作者昵称"), +): + """ + 图片动漫风格化接口 + :param file: 上传的照片文件 + :param style_type: 动漫风格类型,默认为"disney"(迪士尼风格) + :return: 动漫风格化结果,包含风格化后图片的文件名 + """ + _ensure_anime_stylizer() + if anime_stylizer is None or not anime_stylizer.is_available(): + raise HTTPException( + status_code=500, + detail="动漫风格化处理器未初始化,请检查服务状态。" + ) + + # 验证文件类型 + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件") + + # 验证风格类型 + valid_styles = ["handdrawn", "disney", "illustration", "artstyle", "anime", "sketch"] + if style_type not in valid_styles: + raise HTTPException(status_code=400, detail=f"不支持的风格类型,请选择: {valid_styles}") + + try: + contents = await file.read() + if not contents: + raise HTTPException(status_code=400, detail="文件内容为空") + + original_md5_hash = hashlib.md5(contents).hexdigest() + + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException( + status_code=400, detail="无法解析图片文件,请确保文件格式正确。" + ) + + def _save_webp_and_upload(image_array: np.ndarray, output_path: str, + log_prefix: str): + success, encoded_img = cv2.imencode( + ".webp", image_array, + [cv2.IMWRITE_WEBP_QUALITY, SAVE_QUALITY] + ) + if not success: + logger.error(f"{log_prefix}编码失败: {output_path}") + return False, False + try: + with open(output_path, "wb") as output_file: + output_file.write(encoded_img) + except Exception as save_exc: + logger.error( + f"{log_prefix}保存失败: {output_path}, error: {save_exc}") + return False, False + logger.info( + f"{log_prefix}保存成功: {output_path}, size: {len(encoded_img) / 1024:.2f} KB" + ) + bos_uploaded_flag = upload_file_to_bos(output_path) + return True, bos_uploaded_flag + + original_filename = f"{original_md5_hash}_anime_style.webp" + original_path = os.path.join(IMAGES_DIR, original_filename) + if not os.path.exists(original_path): + original_saved, original_bos_uploaded = _save_webp_and_upload( + image, original_path, "动漫风格原图" + ) + if not original_saved: + raise HTTPException(status_code=500, detail="保存原图失败") + else: + logger.info( + f"Original image already exists for anime style: {original_filename}") + original_bos_uploaded = False + + styled_uuid = uuid.uuid4().hex + styled_filename = f"{styled_uuid}_anime_style_{style_type}.webp" + + # 获取风格描述 + style_descriptions = anime_stylizer.get_available_styles() + style_description = style_descriptions.get(style_type, "未知风格") + + logger.info(f"Starting anime stylization processing: {file.filename}, size={file.size}, style={style_type}({style_description}), md5={original_md5_hash}") + t1 = time.perf_counter() + + await _record_output_file( + file_path=original_path, + nickname=nickname, + category="anime_style", + bos_uploaded=original_bos_uploaded, + extra={ + "source": "anime_style", + "style_type": style_type, + "style_description": style_description, + "md5": original_md5_hash, + "role": "original", + "original_filename": original_filename, + }, + ) + + # 使用AnimeStylizer对图像进行动漫风格化 + logger.info(f"Starting to stylize image with anime style, style: {style_description}...") + try: + stylized_image = await process_cpu_intensive_task(anime_stylizer.stylize_image, image, style_type) + logger.info("Anime stylization processing completed") + except Exception as e: + logger.error(f"Anime stylization processing failed: {e}") + raise HTTPException(status_code=500, detail=f"动漫风格化处理失败: {str(e)}") + + # 保存风格化后的图像到IMAGES_DIR + styled_path = os.path.join(IMAGES_DIR, styled_filename) + save_success, bos_uploaded = _save_webp_and_upload( + stylized_image, styled_path, "动漫风格结果图" + ) + + if save_success: + total_time = time.perf_counter() - t1 + logger.info(f"Anime stylization completed: {styled_filename}, time: {total_time:.3f}s") + + # 异步执行图片向量化并入��,不阻塞主流程 + if CLIP_AVAILABLE: + asyncio.create_task(handle_image_vector_async(styled_path, styled_filename)) + + await _record_output_file( + file_path=styled_path, + nickname=nickname, + category="anime_style", + bos_uploaded=bos_uploaded, + extra={ + "source": "anime_style", + "style_type": style_type, + "style_description": style_description, + "md5": original_md5_hash, + "role": "styled", + "original_filename": original_filename, + "styled_uuid": styled_uuid, + }, + ) + + return { + "success": True, + "message": "成功", + "original_filename": file.filename, + "styled_filename": styled_filename, + "style_type": style_type, + # "style_description": style_description, + # "available_styles": style_descriptions, + "processing_time": f"{total_time:.3f}s" + } + else: + raise HTTPException(status_code=500, detail="保存动漫风格化后图像失败") + + except Exception as e: + logger.error(f"Error occurred during anime stylization: {str(e)}") + raise HTTPException(status_code=500, detail=f"动漫风格化过程中出现错误: {str(e)}") + + +@api_router.post("/grayscale") +@log_api_params +async def grayscale_photo( + file: UploadFile = File(...), + nickname: str = Form(None, description="操作者昵称"), +): + """ + 图像黑白化接口 + :param file: 上传的照片文件 + :return: 黑白化结果,包含黑白化后图片的文件名 + """ + # 验证文件类型 + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件") + + try: + contents = await file.read() + original_md5_hash = str(uuid.uuid4()).replace('-', '') + grayscale_filename = f"{original_md5_hash}_grayscale.webp" + + logger.info(f"Starting image grayscale conversion: {file.filename}, size={file.size}, md5={original_md5_hash}") + t1 = time.perf_counter() + + # 解码图像 + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException( + status_code=400, detail="无法解析图片文件,请确保文件格式正确。" + ) + + # 获取原图信息 + original_height, original_width = image.shape[:2] + original_size = file.size + + # 进行图像黑白化处理 + logger.info("Starting to convert image to grayscale...") + try: + # 转换为灰度图像 + gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + # 转换回3通道格式以便保存为彩色图像格式 + grayscale_image = cv2.cvtColor(gray_image, cv2.COLOR_GRAY2BGR) + logger.info("Grayscale processing completed") + except Exception as e: + logger.error(f"Grayscale processing failed: {e}") + raise HTTPException(status_code=500, detail=f"黑白化处理失败: {str(e)}") + + # 保存黑白化后的图像到IMAGES_DIR + grayscale_path = os.path.join(IMAGES_DIR, grayscale_filename) + save_success = save_image_high_quality( + grayscale_image, grayscale_path, quality=SAVE_QUALITY + ) + + if save_success: + total_time = time.perf_counter() - t1 + + # 获取处理后文件大小 + processed_size = os.path.getsize(grayscale_path) + + logger.info(f"Image grayscale conversion completed: {grayscale_filename}, time: {total_time:.3f}s") + + # 异步执行图片向量化并入库,不阻塞主流程 + if CLIP_AVAILABLE: + asyncio.create_task(handle_image_vector_async(grayscale_path, grayscale_filename)) + + # bos_uploaded = upload_file_to_bos(grayscale_path) + await _record_output_file( + file_path=grayscale_path, + nickname=nickname, + category="grayscale", + bos_uploaded=True, + extra={ + "source": "grayscale", + "md5": original_md5_hash, + }, + ) + + return { + "success": True, + "message": "成功", + "original_filename": file.filename, + "grayscale_filename": grayscale_filename, + "processing_time": f"{total_time:.3f}s", + "original_size": original_size, + "processed_size": processed_size, + "size_increase_ratio": round(processed_size / original_size, 2), + "original_dimensions": f"{original_width} × {original_height}", + "processed_dimensions": f"{original_width} × {original_height}", + } + else: + raise HTTPException(status_code=500, detail="保存黑白化后图像失败") + + except Exception as e: + logger.error(f"Error occurred during image grayscale conversion: {str(e)}") + raise HTTPException(status_code=500, detail=f"黑白化过程中出现错误: {str(e)}") + + +@api_router.post("/upscale") +@log_api_params +async def upscale_photo( + file: UploadFile = File(...), + md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"), + scale: int = Query(UPSCALE_SIZE, description="放大倍数,支持2或4倍"), + model_name: str = Query(REALESRGAN_MODEL, + description="模型名称,推荐使用RealESRGAN_x2plus以提高CPU性能"), + nickname: str = Form(None, description="操作者昵称"), +): + """ + 照片超清放大接口 + :param file: 上传的照片文件 + :param md5: 前端传递的文件md5,如果未传递则使用original_md5_hash + :param scale: 放大倍数,默认4倍 + :param model_name: 使用的模型名称 + :return: 超清结果,包含超清后图片的文件名和相关信息 + """ + _ensure_realesrgan() + if realesrgan_upscaler is None or not realesrgan_upscaler.is_available(): + raise HTTPException( + status_code=500, + detail="照片超清处理器未初始化,请检查服务状态。" + ) + + # 验证文件类型 + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件") + + # 验证放大倍数 + if scale not in [2, 4]: + raise HTTPException(status_code=400, detail="放大倍数只支持2倍或4倍") + + try: + contents = await file.read() + original_md5_hash = str(uuid.uuid4()).replace('-', '') + # 如果前端传递了md5参数则使用,否则使用original_md5_hash + actual_md5 = md5 if md5 else original_md5_hash + upscaled_filename = f"{actual_md5}_upscale.webp" + + logger.info(f"Starting photo super resolution processing: {file.filename}, size={file.size}, scale={scale}x, model={model_name}, md5={original_md5_hash}") + t1 = time.perf_counter() + + # 解码图像 + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException( + status_code=400, detail="无法解析图片文件,请确保文件格式正确。" + ) + + # 获取原图信息 + original_height, original_width = image.shape[:2] + original_size = file.size + + # 使用Real-ESRGAN对图像进行超清处理 + logger.info(f"Starting Real-ESRGAN super resolution processing, original image size: {original_width}x{original_height}") + try: + upscaled_image = await process_cpu_intensive_task(realesrgan_upscaler.upscale_image, image, scale=scale) + logger.info("Super resolution processing completed") + except Exception as e: + logger.error(f"Super resolution processing failed: {e}") + raise HTTPException(status_code=500, detail=f"超清处理失败: {str(e)}") + + # 获取处理后图像信息 + upscaled_height, upscaled_width = upscaled_image.shape[:2] + + # 保存超清后的图像到IMAGES_DIR(与其他接口保持一致) + upscaled_path = os.path.join(IMAGES_DIR, upscaled_filename) + save_success = save_image_high_quality( + upscaled_image, upscaled_path, quality=SAVE_QUALITY + ) + + if save_success: + total_time = time.perf_counter() - t1 + + # 获取处理后文件大小 + upscaled_size = os.path.getsize(upscaled_path) + + logger.info(f"Photo super resolution processing completed: {upscaled_filename}, time: {total_time:.3f}s") + + # 异步执行图片向量化并入库,不阻塞主流程 + if CLIP_AVAILABLE: + asyncio.create_task(handle_image_vector_async(upscaled_path, upscaled_filename)) + + # bos_uploaded = upload_file_to_bos(upscaled_path) + await _record_output_file( + file_path=upscaled_path, + nickname=nickname, + category="upscale", + bos_uploaded=True, + extra={ + "source": "upscale", + "md5": actual_md5, + "scale": scale, + "model_name": model_name, + }, + ) + + return { + "success": True, + "message": "成功", + "original_filename": file.filename, + "upscaled_filename": upscaled_filename, + "processing_time": f"{total_time:.3f}s", + "original_size": original_size, + "upscaled_size": upscaled_size, + "size_increase_ratio": round(upscaled_size / original_size, 2), + "original_dimensions": f"{original_width} × {original_height}", + "upscaled_dimensions": f"{upscaled_width} × {upscaled_height}", + "scale_factor": f"{scale}x" + } + else: + raise HTTPException(status_code=500, detail="保存超清后图像失败") + + except HTTPException: + # 重新抛出HTTP异常 + raise + except Exception as e: + logger.error(f"Error occurred during photo super resolution: {str(e)}") + raise HTTPException(status_code=500, detail=f"超清过程中出现错误: {str(e)}") + + +@api_router.post("/remove_background") +@log_api_params +async def remove_background( + file: UploadFile = File(...), + background_color: str = Form("None", description="背景颜色,格式:r,g,b,如 255,255,255 为白色,None为透明背景"), + model: str = Form("robustVideoMatting", description="使用的rembg模型: u2net, u2net_human_seg, silueta, isnet-general-use, robustVideoMatting"), + output_format: str = Form("webp", description="输出格式: png, webp"), + nickname: str = Form(None, description="操作者昵称"), +): + """ + 证件照抠图接口 + :param file: 上传的图片文件 + :param background_color: 背景颜色,格式:r,g,b 或 None + :param model: 使用的模型: u2net, u2net_human_seg, silueta, isnet-general-use, robustVideoMatting + :param output_format: 输出格式: png, webp + :return: 抠图结果,包含抠图后图片的文件名 + """ + # 验证文件类型 + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件") + + # 验证输出格式 + if output_format not in ["png", "webp"]: + raise HTTPException(status_code=400, detail="输出格式只支持png或webp") + + try: + contents = await file.read() + # 解码图像 + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException( + status_code=400, detail="无法解析图片文件,请确保文件格式正确。" + ) + + # 检查图片中是否存在人脸 + has_face = False + if analyzer is not None: + try: + face_boxes = analyzer._detect_faces(image) + has_face = len(face_boxes) > 0 + except Exception as e: + logger.warning(f"Face detection failed: {e}") + has_face = False + + # 如果图片存在人脸并且模型是robustVideoMatting,则使用RVM处理器 + if has_face and model == "robustVideoMatting": + # 重新设置文件指针,因为上面已经读取了内容 + file.file = io.BytesIO(contents) + # 尝试使用RVM处理器,如果失败则回滚到rembg + try: + return await rvm_remove_background( + file, + background_color, + output_format, + nickname=nickname, + ) + except Exception as rvm_error: + logger.warning(f"RVM background removal failed: {rvm_error}, rolling back to rembg background removal") + # 重置文件指针 + file.file = io.BytesIO(contents) + + # 否则使用rembg处理器 + _ensure_rembg() + if rembg_processor is None or not rembg_processor.is_available(): + raise HTTPException( + status_code=500, + detail="证件照抠图处理器未初始化,请检查服务状态。" + ) + + # 如果用户选择了robustVideoMatting但图片中没有人脸,则使用isnet-general-use模型 + if model == "robustVideoMatting": + model = "isnet-general-use" + logger.info(f"User selected robustVideoMatting model but no face detected in image, switching to {model} model") + + # 生成唯一ID + unique_id = str(uuid.uuid4()).replace('-', '') # 32位UUID + + # 根据是否有透明背景决定文件扩展名 + if background_color and background_color.lower() != "none": + processed_filename = f"{unique_id}_id_photo.webp" + else: + processed_filename = f"{unique_id}_id_photo.{output_format}" # 透明背景使用指定格式 + + logger.info(f"Starting ID photo background removal processing: {file.filename}, size={file.size}, model={model}, bg_color={background_color}, uuid={unique_id}") + t1 = time.perf_counter() + + # 获取原图信息 + original_height, original_width = image.shape[:2] + original_size = file.size + + # 切换模型(如果需要) + if model != rembg_processor.model_name: + if not rembg_processor.switch_model(model): + logger.warning(f"Failed to switch to model {model}, using default model {rembg_processor.model_name}") + + # 解析背景颜色 + bg_color = None + if background_color and background_color.lower() != "none": + try: + # 解析 r,g,b 格式,转换为 BGR 格式 + rgb_values = [int(x.strip()) for x in background_color.split(",")] + if len(rgb_values) == 3: + bg_color = (rgb_values[2], rgb_values[1], rgb_values[0]) # RGB转BGR + logger.info(f"Using background color: RGB{tuple(rgb_values)} -> BGR{bg_color}") + else: + raise ValueError("背景颜色格式错误") + except (ValueError, IndexError) as e: + logger.warning(f"Failed to parse background color parameter: {e}, using default white background") + bg_color = (255, 255, 255) # 默认白色背景 + + # 执行抠图处理 + logger.info("Starting rembg background removal processing...") + try: + if bg_color is not None: + processed_image = await process_cpu_intensive_task(rembg_processor.create_id_photo, image, bg_color) + processing_info = f"使用{model}模型抠图并添加纯色背景" + else: + processed_image = await process_cpu_intensive_task(rembg_processor.remove_background, image) + processing_info = f"使用{model}模型抠图保持透明背景" + + logger.info("Background removal processing completed") + except Exception as e: + logger.error(f"Background removal processing failed: {e}") + raise HTTPException(status_code=500, detail=f"抠图处理失败: {str(e)}") + + # 获取处理后图像信息 + processed_height, processed_width = processed_image.shape[:2] + + # 保存抠图后的图像到IMAGES_DIR(与facescore保持一致) + processed_path = os.path.join(IMAGES_DIR, processed_filename) + bos_uploaded = False + + # 根据是否有透明背景选择保存方式 + if bg_color is not None: + # 有背景色,保存为JPEG + save_success = save_image_high_quality(processed_image, processed_path, quality=SAVE_QUALITY) + # if save_success: + # bos_uploaded = upload_file_to_bos(processed_path) + else: + # 透明背景,保存为指定格式 + if output_format == "webp": + # 使用OpenCV保存为WebP格式 + success, encoded_img = cv2.imencode(".webp", processed_image, [cv2.IMWRITE_WEBP_QUALITY, 100]) + if success: + with open(processed_path, "wb") as f: + f.write(encoded_img) + bos_uploaded = upload_file_to_bos(processed_path) + save_success = True + else: + save_success = False + else: + # 保存为PNG格式 + save_success = save_image_with_transparency(processed_image, processed_path) + # if save_success: + # bos_uploaded = upload_file_to_bos(processed_path) + + if save_success: + total_time = time.perf_counter() - t1 + + # 获取处理后文件大小 + processed_size = os.path.getsize(processed_path) + + logger.info(f"ID photo background removal processing completed: {processed_filename}, time: {total_time:.3f}s") + + # 异步执行图片向量化并入库,不阻塞主流程 + if CLIP_AVAILABLE: + asyncio.create_task(handle_image_vector_async(processed_path, processed_filename)) + + if not bos_uploaded: + bos_uploaded = upload_file_to_bos(processed_path) + + await _record_output_file( + file_path=processed_path, + nickname=nickname, + category="id_photo", + bos_uploaded=bos_uploaded, + extra={ + "source": "remove_background", + "background_color": background_color, + "model_used": model, + "output_format": output_format, + "has_face": has_face, + }, + ) + + # 确定输出格式 + final_output_format = "PNG" if bg_color is None and output_format == "png" else \ + "WEBP" if bg_color is None and output_format == "webp" else "JPEG" + has_transparency = bg_color is None + + return { + "success": True, + "message": "抠图成功", + "original_filename": file.filename, + "processed_filename": processed_filename, + "processing_time": f"{total_time:.3f}s", + "processing_info": processing_info, + "original_size": original_size, + "processed_size": processed_size, + "size_change_ratio": round(processed_size / original_size, 2) if original_size > 0 else 1.0, + "original_dimensions": f"{original_width} × {original_height}", + "processed_dimensions": f"{processed_width} × {processed_height}", + "model_used": model, + "background_color": background_color, + "output_format": final_output_format, + "has_transparency": has_transparency + } + else: + raise HTTPException(status_code=500, detail="保存抠图后图像失败") + + except HTTPException: + # 重新抛出HTTP异常 + raise + except Exception as e: + logger.error(f"Error occurred during ID photo background removal: {str(e)}") + raise HTTPException(status_code=500, detail=f"抠图过程中出现错误: {str(e)}") + + +@api_router.post("/rvm") +@log_api_params +async def rvm_remove_background( + file: UploadFile = File(...), + background_color: str = Form("None", description="背景颜色,格式:r,g,b,如 255,255,255 为白色,None为透明背景"), + output_format: str = Form("webp", description="输出格式: png, webp"), + nickname: str = Form(None, description="操作者昵称"), +): + """ + RVM证件照抠图接口 + :param file: 上传的图片文件 + :param background_color: 背景颜色,格式:r,g,b 或 None + :param output_format: 输出格式: png, webp + :return: 抠图结果,包含抠图后图片的文件名 + """ + _ensure_rvm() + if rvm_processor is None or not rvm_processor.is_available(): + raise HTTPException( + status_code=500, + detail="RVM抠图处理器未初始化,请检查服务状态。" + ) + + # 验证文件类型 + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件") + + # 验证输出格式 + if output_format not in ["png", "webp"]: + raise HTTPException(status_code=400, detail="输出格式只支持png或webp") + + try: + contents = await file.read() + unique_id = str(uuid.uuid4()).replace('-', '') # 32位UUID + + # 根据是否有透明背景决定文件扩展名 + if background_color and background_color.lower() != "none": + processed_filename = f"{unique_id}_rvm_id_photo.webp" + else: + processed_filename = f"{unique_id}_rvm_id_photo.{output_format}" # 透明背景使用指定格式 + + logger.info(f"Starting RVM ID photo background removal processing: {file.filename}, size={file.size}, bg_color={background_color}, uuid={unique_id}") + t1 = time.perf_counter() + + # 解码图像 + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException( + status_code=400, detail="无法解析图片文件,请确保文件格式正确。" + ) + + # 获取原图信息 + original_height, original_width = image.shape[:2] + original_size = file.size + + # 解析背景颜色 + bg_color = None + if background_color and background_color.lower() != "none": + try: + # 解析 r,g,b 格式,转换为 BGR 格式 + rgb_values = [int(x.strip()) for x in background_color.split(",")] + if len(rgb_values) == 3: + bg_color = (rgb_values[2], rgb_values[1], rgb_values[0]) # RGB转BGR + logger.info(f"Using background color: RGB{tuple(rgb_values)} -> BGR{bg_color}") + else: + raise ValueError("背景颜色格式错误") + except (ValueError, IndexError) as e: + logger.warning(f"Failed to parse background color parameter: {e}, using default white background") + bg_color = (255, 255, 255) # 默认白色背景 + + # 执行RVM抠图处理 + logger.info("Starting RVM background removal processing...") + try: + if bg_color is not None: + processed_image = await process_cpu_intensive_task(rvm_processor.create_id_photo, image, bg_color) + processing_info = "使用RVM模型抠图并添加纯色背景" + else: + processed_image = await process_cpu_intensive_task(rvm_processor.remove_background, image) + processing_info = "使用RVM模型抠图保持透明背景" + + logger.info("RVM background removal processing completed") + except Exception as e: + logger.error(f"RVM background removal processing failed: {e}") + raise Exception(f"RVM抠图处理失败: {str(e)}") + + # 获取处理后图像信息 + processed_height, processed_width = processed_image.shape[:2] + + # 保存抠图后的图像到IMAGES_DIR(与facescore保持一致) + processed_path = os.path.join(IMAGES_DIR, processed_filename) + bos_uploaded = False + + # 根据是否有透明背景选择保存方式 + if bg_color is not None: + # 有背景色,保存为JPEG + save_success = save_image_high_quality(processed_image, processed_path, quality=SAVE_QUALITY) + # if save_success: + # bos_uploaded = upload_file_to_bos(processed_path) + else: + # 透明背景,保存为指定格式 + if output_format == "webp": + # 使用OpenCV保存为WebP格式 + success, encoded_img = cv2.imencode(".webp", processed_image, [cv2.IMWRITE_WEBP_QUALITY, 100]) + if success: + with open(processed_path, "wb") as f: + f.write(encoded_img) + bos_uploaded = upload_file_to_bos(processed_path) + save_success = True + else: + save_success = False + else: + # 保存为PNG格式 + save_success = save_image_with_transparency(processed_image, processed_path) + # if save_success: + # bos_uploaded = upload_file_to_bos(processed_path) + + if save_success: + total_time = time.perf_counter() - t1 + + # 获取处理后文件大小 + processed_size = os.path.getsize(processed_path) + + logger.info(f"RVM ID photo background removal processing completed: {processed_filename}, time: {total_time:.3f}s") + + # 异步执行图片向量化并入库,不阻塞主流程 + if CLIP_AVAILABLE: + asyncio.create_task(handle_image_vector_async(processed_path, processed_filename)) + + if not bos_uploaded: + bos_uploaded = upload_file_to_bos(processed_path) + + await _record_output_file( + file_path=processed_path, + nickname=nickname, + category="rvm", + bos_uploaded=bos_uploaded, + extra={ + "source": "rvm_remove_background", + "background_color": background_color, + "output_format": output_format, + }, + ) + + # 确定输出格式 + final_output_format = "PNG" if bg_color is None and output_format == "png" else \ + "WEBP" if bg_color is None and output_format == "webp" else "JPEG" + has_transparency = bg_color is None + + return { + "success": True, + "message": "RVM抠图成功", + "original_filename": file.filename, + "processed_filename": processed_filename, + "processing_time": f"{total_time:.3f}s", + "processing_info": processing_info, + "original_size": original_size, + "processed_size": processed_size, + "size_change_ratio": round(processed_size / original_size, 2) if original_size > 0 else 1.0, + "original_dimensions": f"{original_width} × {original_height}", + "processed_dimensions": f"{processed_width} × {processed_height}", + "background_color": background_color, + "output_format": final_output_format, + "has_transparency": has_transparency + } + else: + raise HTTPException(status_code=500, detail="保存RVM抠图后图像失败") + + except HTTPException: + # 重新抛出HTTP异常 + raise + except Exception as e: + logger.error(f"Error occurred during RVM ID photo background removal: {str(e)}") + raise Exception(f"RVM抠图过程中出现错误: {str(e)}") + + +@api_router.get("/keep_alive", tags=["系统维护"]) +@log_api_params +async def keep_cpu_alive( + duration: float = Query( + 0.01, ge=0.001, le=60.0, description="需要保持CPU繁忙的持续时间(秒)" + ), + intensity: int = Query( + 1, ge=1, le=500000, description="控制CPU占用强度的内部循环次数" + ), +): + """ + 手动触发CPU保持活跃,避免云服务因空闲进入休眠。 + """ + t_start = time.perf_counter() + result = await process_cpu_intensive_task(_keep_cpu_busy, duration, intensity) + total_elapsed = time.perf_counter() - t_start + + logger.info( + "Keep-alive task completed | duration=%.2fs intensity=%d iterations=%d checksum=%d cpu_elapsed=%.3fs total=%.3fs", + duration, + intensity, + result["iterations"], + result["checksum"], + result["elapsed"], + total_elapsed, + ) + + return { + "status": "ok", + "requested_duration": duration, + "requested_intensity": intensity, + "cpu_elapsed": round(result["elapsed"], 3), + "total_elapsed": round(total_elapsed, 3), + "iterations": result["iterations"], + "checksum": result["checksum"], + "message": "CPU保持活跃任务已完成", + "hostname": SERVER_HOSTNAME, + } + + +@api_router.get("/health") +@log_api_params +async def health_check(): + """健康检查接口""" + return { + "status": "healthy", + "analyzer_ready": analyzer is not None, + "deepface_available": DEEPFACE_AVAILABLE, + "mediapipe_available": DLIB_AVAILABLE, + "photo_restorer_available": photo_restorer is not None and photo_restorer.is_available(), + "restorer_type": restorer_type, + "ddcolor_available": ddcolor_colorizer is not None and ddcolor_colorizer.is_available(), + "colorization_supported": DDCOLOR_AVAILABLE, + "realesrgan_available": realesrgan_upscaler is not None and realesrgan_upscaler.is_available(), + "upscale_supported": REALESRGAN_AVAILABLE, + "rembg_available": rembg_processor is not None and rembg_processor.is_available(), + "rvm_available": rvm_processor is not None and rvm_processor.is_available(), + "id_photo_supported": REMBG_AVAILABLE, + "clip_available": CLIP_AVAILABLE, + "vector_search_supported": CLIP_AVAILABLE, + "anime_stylizer_available": anime_stylizer is not None and anime_stylizer.is_available(), + "anime_style_supported": ANIME_STYLE_AVAILABLE, + "rvm_supported": RVM_AVAILABLE, + "message": "Enhanced FaceScore API is running with photo restoration, colorization, upscale, ID photo generation and vector search support", + "version": "3.2.0", + } + + +@api_router.get("/", response_class=HTMLResponse) +@log_api_params +async def index(): + """主页面""" + file_path = os.path.join(os.path.dirname(__file__), "facescore.html") + try: + with open(file_path, "r", encoding="utf-8") as f: + html_content = f.read() + return HTMLResponse(content=html_content) + except FileNotFoundError: + return HTMLResponse( + content="

facescore.html not found

", status_code=404 + ) + + +@api_router.post("/split_grid") +@log_api_params +async def split_grid_image( + file: UploadFile = File(...), + grid_type: int = Form(9, + description="宫格类型: 4表示2x2四宫格, 9表示3x3九宫格"), + nickname: str = Form(None, description="操作者昵称"), +): + """ + 图片分层宫格接口 + :param file: 上传的图片文件 + :param grid_type: 宫格类型,4表示2x2四宫格,9表示3x3九宫格 + :return: 分层结果,包含分割后的图片文件名列表 + """ + # 验证文件类型 + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件") + + # 验证宫格类型 + if grid_type not in [4, 9]: + raise HTTPException(status_code=400, detail="宫格类型只支持4(2x2)或9(3x3)") + + try: + contents = await file.read() + original_md5_hash = str(uuid.uuid4()).replace('-', '') + + # 根据宫格类型确定行列数 + if grid_type == 4: + rows, cols = 2, 2 + grid_name = "2x2" + else: # grid_type == 9 + rows, cols = 3, 3 + grid_name = "3x3" + + logger.info(f"Starting to split image into {grid_name} grid: {file.filename}, size={file.size}, md5={original_md5_hash}") + t1 = time.perf_counter() + + # 解码图像 + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException( + status_code=400, detail="无法解析图片文件,请确保文件格式正确。" + ) + + # 获取图像尺寸 + height, width = image.shape[:2] + + # 智能分割算法:确保朋友圈拼接不变形 + logger.info(f"Original image size: {width}×{height}, grid type: {grid_name}") + + # 计算图片长宽比 + aspect_ratio = width / height + logger.info(f"Image aspect ratio: {aspect_ratio:.2f}") + + # 使用更简单可靠的策略:总是取较小的边作为基准 + # 这样确保不管是4宫格还是9宫格都能正确处理 + min_dimension = min(width, height) + + # 计算每个格子的尺寸(正方形) + # 为了确保完整分割,我们使用最大的行列数作为除数 + square_size = min_dimension // max(rows, cols) + + # 重新计算实际使用的图片区域(正方形区域) + actual_width = square_size * cols + actual_height = square_size * rows + + # 计算居中裁剪的起始位置 + start_x = (width - actual_width) // 2 + start_y = (height - actual_height) // 2 + + logger.info(f"Calculation result - Grid size: {square_size}×{square_size}, usage area: {actual_width}×{actual_height}, starting position: ({start_x}, {start_y})") + + # 分割图片并保存每个格子 + grid_filenames = [] + + for row in range(rows): + for col in range(cols): + # 计算当前正方形格子的坐标 + y1 = start_y + row * square_size + y2 = start_y + (row + 1) * square_size + x1 = start_x + col * square_size + x2 = start_x + (col + 1) * square_size + + # 裁剪当前格子(正方形) + grid_image = image[y1:y2, x1:x2] + + # 生成格子文件名 + grid_index = row * cols + col + 1 # 从1开始编号 + grid_filename = f"{original_md5_hash}_grid_{grid_name}_{grid_index:02d}.webp" + grid_path = os.path.join(IMAGES_DIR, grid_filename) + + # 保存格子图片 + save_success = save_image_high_quality(grid_image, grid_path, quality=SAVE_QUALITY) + + if save_success: + grid_filenames.append(grid_filename) + else: + logger.error(f"Failed to save grid image: {grid_filename}") + if save_success: + await _record_output_file( + file_path=grid_path, + nickname=nickname, + category="grid", + extra={ + "source": "split_grid", + "grid_type": grid_type, + "index": grid_index, + }, + ) + + # 同时保存原图到IMAGES_DIR供向量化使用 + original_filename = f"{original_md5_hash}_original.webp" + original_path = os.path.join(IMAGES_DIR, original_filename) + if save_image_high_quality(image, original_path, quality=SAVE_QUALITY): + await _record_output_file( + file_path=original_path, + nickname=nickname, + category="original", + extra={ + "source": "split_grid", + "grid_type": grid_type, + "role": "original", + }, + ) + + # 异步执行原图向量化并入库 + if CLIP_AVAILABLE: + asyncio.create_task(handle_image_vector_async(original_path, original_filename)) + + total_time = time.perf_counter() - t1 + logger.info(f"Image splitting completed: {len(grid_filenames)} grids, time: {total_time:.3f}s") + + return { + "success": True, + "message": "分割成功", + "original_filename": file.filename, + "original_saved_filename": original_filename, + "grid_type": grid_type, + "grid_layout": f"{rows}x{cols}", + "grid_count": len(grid_filenames), + "grid_filenames": grid_filenames, + "processing_time": f"{total_time:.3f}s", + "image_dimensions": f"{width} × {height}", + "grid_dimensions": f"{square_size} × {square_size}", + "actual_used_area": f"{actual_width} × {actual_height}" + } + + except Exception as e: + logger.error(f"Error occurred during image splitting: {str(e)}") + raise HTTPException(status_code=500, detail=f"分割过程中出现错误: {str(e)}") + + +@api_router.post("/compress") +@log_api_params +async def compress_image( + file: UploadFile = File(...), + compressType: str = Form(...), + outputFormat: str = Form(default="webp"), + quality: int = Form(default=100), + targetSize: float = Form(default=None), + width: int = Form(default=None), + height: int = Form(default=None), + nickname: str = Form(None, description="操作者昵称"), +): + """ + 图像压缩接口 + :param file: 上传的图片文件 + :param compressType: 压缩类型 ('quality', 'dimension', 'size', 'format') + :param outputFormat: 输出格式 ('jpg', 'png', 'webp') + :param quality: 压缩质量 (10-100) + :param targetSize: 目标文件大小 (bytes,仅用于按大小压缩) + :param width: 目标宽度 (仅用于按尺寸压缩) + :param height: 目标高度 (仅用于按尺寸压缩) + :return: 压缩结果,包含压缩后图片的文件名和统计信息 + """ + # 验证文件类型 + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件") + + try: + contents = await file.read() + unique_id = str(uuid.uuid4()).replace('-', '')[:32] # 12位随机ID + compressed_filename = f"{unique_id}_compress.{outputFormat.lower()}" + logger.info( + f"Starting to compress image: {file.filename}, " + f"type: {compressType}, " + f"format: {outputFormat}, " + f"quality: {quality}, " + f"target size: {targetSize}, " + f"target width: {width}, " + f"target height: {height}" + ) + t1 = time.perf_counter() + + # 解码图像 + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException( + status_code=400, detail="无法解析图片文件,请确保文件格式正确。" + ) + + # 获取原图信息 + original_height, original_width = image.shape[:2] + original_size = file.size + + # 根据压缩类型调用相应的压缩函数 + try: + if compressType == 'quality': + # 按质量压缩 + if not (10 <= quality <= 100): + raise HTTPException(status_code=400, detail="质量参数必须在10-100之间") + compressed_bytes, compress_info = compress_image_by_quality(image, quality, outputFormat) + + elif compressType == 'dimension': + # 按尺寸压缩 + if not width or not height: + raise HTTPException(status_code=400, detail="按尺寸压缩需要提供宽度和高度参数") + if not (50 <= width <= 4096) or not (50 <= height <= 4096): + raise HTTPException(status_code=400, detail="尺寸参数必须在50-4096之间") + # 按尺寸压缩时使用100质量(不压缩质量) + compressed_bytes, compress_info = compress_image_by_dimensions( + image, width, height, 100, outputFormat + ) + + elif compressType == 'size': + # 按大小压缩 + if not targetSize or targetSize <= 0: + raise HTTPException(status_code=400, detail="按大小压缩需要提供有效的目标大小") + if targetSize > 50: # 限制最大50MB + raise HTTPException(status_code=400, detail="目标大小不能超过50MB") + target_size_kb = targetSize * 1024 # 转换为KB + compressed_bytes, compress_info = compress_image_by_file_size( + image, target_size_kb, outputFormat + ) + + elif compressType == 'format': + # 格式转换 + compressed_bytes, compress_info = convert_image_format(image, outputFormat, quality) + + else: + raise HTTPException(status_code=400, detail="不支持的压缩类型") + + except Exception as e: + logger.error(f"Image compression processing failed: {e}") + raise HTTPException(status_code=500, detail=f"压缩处理失败: {str(e)}") + + # 保存压缩后的图像到IMAGES_DIR + compressed_path = os.path.join(IMAGES_DIR, compressed_filename) + try: + with open(compressed_path, "wb") as f: + f.write(compressed_bytes) + bos_uploaded = upload_file_to_bos(compressed_path) + logger.info(f"Compressed image saved successfully: {compressed_path}") + + # 异步执行图片向量化并入库,不阻塞主流程 + if CLIP_AVAILABLE: + asyncio.create_task(handle_image_vector_async(compressed_path, compressed_filename)) + await _record_output_file( + file_path=compressed_path, + nickname=nickname, + category="compress", + bos_uploaded=bos_uploaded, + extra={ + "source": "compress", + "compress_type": compressType, + "output_format": outputFormat, + }, + ) + + except Exception as e: + logger.error(f"Failed to save compressed image: {e}") + raise HTTPException(status_code=500, detail="保存压缩后图像失败") + + # 计算压缩统计信息 + processing_time = time.perf_counter() - t1 + compressed_size = len(compressed_bytes) + compression_ratio = ((original_size - compressed_size) / original_size) * 100 if original_size > 0 else 0 + + # 构建返回结果 + result = { + "success": True, + "message": "压缩成功", + "original_filename": file.filename, + "compressed_filename": compressed_filename, + "original_size": original_size, + "compressed_size": compressed_size, + "compression_ratio": round(compression_ratio, 1), + "original_dimensions": f"{original_width} × {original_height}", + "compressed_dimensions": compress_info.get('compressed_dimensions', f"{original_width} × {original_height}"), + "processing_time": f"{processing_time:.3f}s", + "output_format": compress_info.get('format', outputFormat.upper()), + "compress_type": compressType, + "quality_used": compress_info.get('quality', quality), + "attempts": compress_info.get('attempts', 1) + } + + logger.info( + f"Image compression completed: {compressed_filename}, time: {processing_time:.3f}s, " + f"original size: {human_readable_size(original_size)}, " + f"compressed: {human_readable_size(compressed_size)}, " + f"compression ratio: {compression_ratio:.1f}%" + ) + + return JSONResponse(content=convert_numpy_types(result)) + + except HTTPException: + # 重新抛出HTTP异常 + raise + except Exception as e: + logger.error(f"Error occurred during image compression: {str(e)}") + raise HTTPException(status_code=500, detail=f"压缩过程中出现错误: {str(e)}") + + +@api_router.get("/cleanup/status", tags=["系统管理"]) +@log_api_params +async def get_cleanup_scheduler_status(): + """ + 获取图片清理定时任务状态 + :return: 清理任务的状态信息 + """ + try: + status = get_cleanup_status() + return { + "success": True, + "status": status, + "message": "获取清理任务状态成功" + } + except Exception as e: + logger.error(f"Failed to get cleanup task status: {e}") + raise HTTPException(status_code=500, detail=f"获取清理任务状态失败: {str(e)}") + + +@api_router.post("/cleanup/manual", tags=["系统管理"]) +@log_api_params +async def manual_cleanup_images(): + """ + 手动执行一次图片清理任务 + 清理IMAGES_DIR目录中1小时以前的图片文件 + :return: 清理结果统计 + """ + try: + logger.info("Manually executing image cleanup task...") + result = manual_cleanup() + + if result['success']: + # Chinese message for API response + message = f"清理完成! 删除了 {result['deleted_count']} 个文件" + if result['deleted_count'] > 0: + message += f", 总大小: {result.get('deleted_size', 0) / 1024 / 1024:.2f} MB" + # English log for readability + en_message = f"Cleanup completed! Deleted {result['deleted_count']} files" + if result['deleted_count'] > 0: + en_message += f", total size: {result.get('deleted_size', 0) / 1024 / 1024:.2f} MB" + logger.info(en_message) + else: + # Chinese message for API response + error_str = result.get('error', '未知错误') + message = f"清理任务执行失败: {error_str}" + # English log for readability + logger.error(f"Cleanup task failed: {error_str}") + + return { + "success": result['success'], + "message": message, + "result": result + } + + except Exception as e: + logger.error(f"Manual cleanup task execution failed: {e}") + raise HTTPException(status_code=500, detail=f"手动清理任务执行失败: {str(e)}") + + +def _extract_tar_archive(archive_path: str, target_dir: str) -> Dict[str, str]: + """在独立线程中执行tar命令,避免阻塞事件循环。""" + cmd = ["tar", "-xzf", archive_path, "-C", target_dir] + cmd_display = " ".join(cmd) + logger.info(f"开始执行解压命令: {cmd_display}") + completed = subprocess.run( + cmd, capture_output=True, text=True, check=False + ) + if completed.returncode != 0: + stderr = (completed.stderr or "").strip() + raise RuntimeError(f"tar命令执行失败: {stderr or '未知错误'}") + logger.info(f"解压命令执行成功: {cmd_display}") + return { + "command": cmd_display, + "stdout": (completed.stdout or "").strip(), + "stderr": (completed.stderr or "").strip(), + } + + +def _flatten_chinese_celeb_dataset_dir(target_dir: str) -> bool: + """ + 若解压后出现 /opt/data/... 的嵌套结构,将内容提升到 target_dir 根目录,避免重复嵌套。 + """ + nested_root = os.path.join(target_dir, "opt", "data", "chinese_celeb_dataset") + if not os.path.isdir(nested_root): + return False + + for name in os.listdir(nested_root): + src = os.path.join(nested_root, name) + dst = os.path.join(target_dir, name) + shutil.move(src, dst) + + # 清理多余的 opt/data 目录 + try: + shutil.rmtree(os.path.join(target_dir, "opt")) + except FileNotFoundError: + pass + return True + + +def _cleanup_chinese_celeb_hidden_files(target_dir: str) -> int: + """ + 删除解压后遗留的 macOS 资源分叉文件(._*),避免污染后续处理。 + """ + pattern = os.path.join(target_dir, "._*") + removed = 0 + for hidden_path in glob.glob(pattern): + try: + if os.path.isdir(hidden_path): + shutil.rmtree(hidden_path, ignore_errors=True) + else: + os.remove(hidden_path) + removed += 1 + except FileNotFoundError: + continue + except OSError as exc: + logger.warning("清理隐藏文件失败: %s (%s)", hidden_path, exc) + if removed: + logger.info("已清理 chinese_celeb_dataset 隐藏文件 %d 个 (pattern=%s)", removed, pattern) + return removed + + +def extract_chinese_celeb_dataset_sync() -> Dict[str, Any]: + """ + 同步执行 chinese_celeb_dataset 解压操作,供启动流程或其他同步场景复用。 + """ + archive_path = os.path.join(MODELS_PATH, "chinese_celeb_dataset.tar.gz") + target_dir = "/opt/data/chinese_celeb_dataset" + + if not os.path.isfile(archive_path): + raise FileNotFoundError(f"数据集文件不存在: {archive_path}") + + try: + if os.path.isdir(target_dir): + shutil.rmtree(target_dir) + os.makedirs(target_dir, exist_ok=True) + except OSError as exc: + logger.error(f"创建目标目录失败: {target_dir}, {exc}") + raise RuntimeError(f"创建目标目录失败: {exc}") from exc + + extract_result = _extract_tar_archive(archive_path, target_dir) + flattened = _flatten_chinese_celeb_dataset_dir(target_dir) + hidden_removed = _cleanup_chinese_celeb_hidden_files(target_dir) + + return { + "success": True, + "message": "chinese_celeb_dataset 解压完成", + "archive_path": archive_path, + "target_dir": target_dir, + "command": extract_result.get("command"), + "stdout": extract_result.get("stdout"), + "stderr": extract_result.get("stderr"), + "normalized": flattened, + "hidden_removed": hidden_removed, + } + + +def _run_shell_command(command: str, timeout: int = 300) -> Dict[str, Any]: + """执行外部命令并返回输出。""" + logger.info(f"准备执行系统命令: {command}") + try: + completed = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + logger.error(f"命令执行超时({timeout}s): {command}") + raise RuntimeError(f"命令执行超时({timeout}s): {exc}") from exc + + return { + "returncode": completed.returncode, + "stdout": (completed.stdout or "").strip(), + "stderr": (completed.stderr or "").strip(), + } + + +@api_router.post("/datasets/chinese-celeb/extract", tags=["系统管理"]) +@log_api_params +async def extract_chinese_celeb_dataset(): + """ + 解压 MODELS_PATH 下的 chinese_celeb_dataset.tar.gz 到 /opt/data/chinese_celeb_dataset。 + """ + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + executor, extract_chinese_celeb_dataset_sync + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: + logger.error(f"解压 chinese_celeb_dataset 失败: {exc}") + raise HTTPException(status_code=500, detail=f"解压失败: {exc}") + + return result + + +@api_router.post("/files/upload", tags=["文件管理"]) +@log_api_params +async def upload_file_to_directory( + directory: str = Form(..., description="目标目录,支持绝对路径"), + file: UploadFile = File(..., description="要上传的文件"), +): + """上传文件到指定目录。""" + if not directory.strip(): + raise HTTPException(status_code=400, detail="目录参数不能为空") + + target_dir = os.path.abspath(os.path.expanduser(directory.strip())) + try: + os.makedirs(target_dir, exist_ok=True) + except OSError as exc: + logger.error(f"创建目录失败: {target_dir}, {exc}") + raise HTTPException(status_code=500, detail=f"创建目录失败: {exc}") + + original_name = file.filename or "uploaded_file" + filename = os.path.basename(original_name) or f"upload_{int(time.time())}" + target_path = os.path.join(target_dir, filename) + + bytes_written = 0 + try: + with open(target_path, "wb") as out_file: + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + out_file.write(chunk) + bytes_written += len(chunk) + except Exception as exc: + logger.error(f"保存上传文件失败: {exc}") + raise HTTPException(status_code=500, detail=f"保存文件失败: {exc}") + + return { + "success": True, + "message": "文件上传成功", + "saved_path": target_path, + "filename": filename, + "size": bytes_written, + } + + +@api_router.get("/files/download", tags=["文件管理"]) +@log_api_params +async def download_file( + file_path: str = Query(..., description="要下载的文件路径,支持绝对路径"), +): + """根据给定路径下载文件。""" + if not file_path.strip(): + raise HTTPException(status_code=400, detail="文件路径不能为空") + + resolved_path = os.path.abspath(os.path.expanduser(file_path.strip())) + if not os.path.isfile(resolved_path): + raise HTTPException(status_code=404, detail=f"文件不存在: {resolved_path}") + + filename = os.path.basename(resolved_path) or "download" + return FileResponse( + resolved_path, + filename=filename, + media_type="application/octet-stream", + ) + + +@api_router.post("/system/command", tags=["系统管理"]) +@log_api_params +async def execute_system_command(payload: Dict[str, Any]): + """ + 执行Linux命令并返回stdout/stderr。 + payload示例: {"command": "ls -l", "timeout": 120} + """ + command = (payload or {}).get("command") + if not command or not isinstance(command, str): + raise HTTPException(status_code=400, detail="必须提供command字符串") + + timeout = payload.get("timeout", 300) + try: + timeout_val = int(timeout) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="timeout必须为整数") + if timeout_val <= 0: + raise HTTPException(status_code=400, detail="timeout必须为正整数") + + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + executor, _run_shell_command, command, timeout_val + ) + except Exception as exc: + logger.error(f"命令执行失败: {exc}") + raise HTTPException(status_code=500, detail=f"命令执行失败: {exc}") + + success = result.get("returncode", 1) == 0 + return { + "success": success, + "command": command, + "returncode": result.get("returncode"), + "stdout": result.get("stdout"), + "stderr": result.get("stderr"), + } + + + +@api_router.post("/celebrity/keep_alive", tags=["系统维护"]) +@log_api_params +async def celebrity_keep_cpu_alive( + duration: float = Query( + 0.01, ge=0.001, le=60.0, description="需要保持CPU繁忙的持续时间(秒)" + ), + intensity: int = Query( + 1, ge=1, le=50000, description="控制CPU占用强度的内部循环次数" + ), +): + """ + 手动触发CPU保持活跃,避免云服务因空闲进入休眠。 + """ + t_start = time.perf_counter() + result = await process_cpu_intensive_task(_keep_cpu_busy, duration, intensity) + total_elapsed = time.perf_counter() - t_start + + logger.info( + "Keep-alive task completed | duration=%.2fs intensity=%d iterations=%d checksum=%d cpu_elapsed=%.3fs total=%.3fs", + duration, + intensity, + result["iterations"], + result["checksum"], + result["elapsed"], + total_elapsed, + ) + + return { + "status": "ok", + "requested_duration": duration, + "requested_intensity": intensity, + "cpu_elapsed": round(result["elapsed"], 3), + "total_elapsed": round(total_elapsed, 3), + "iterations": result["iterations"], + "checksum": result["checksum"], + "message": "CPU保持活跃任务已完成", + "hostname": SERVER_HOSTNAME, + } + + +@api_router.post("/celebrity/load", tags=["Face Recognition"]) +@log_api_params +async def load_celebrity_database(): + """刷新DeepFace明星人脸库缓存""" + if not DEEPFACE_AVAILABLE or deepface_module is None: + raise HTTPException(status_code=500, + detail="DeepFace模块未初始化,请检查服务状态。") + + folder_path = CELEBRITY_SOURCE_DIR + if not folder_path: + raise HTTPException(status_code=500, + detail="未配置明星图库目录,请设置环境变量 CELEBRITY_SOURCE_DIR。") + + folder_path = os.path.abspath(os.path.expanduser(folder_path)) + if not os.path.isdir(folder_path): + raise HTTPException(status_code=400, + detail=f"文件夹不存在: {folder_path}") + + image_files = _iter_celebrity_images(folder_path) + if not image_files: + raise HTTPException(status_code=400, + detail="明星图库目录中未找到有效图片。") + + encoded_files = [] + renamed = [] + + for src_path in image_files: + directory, original_name = os.path.split(src_path) + base_name, ext = os.path.splitext(original_name) + + suffix_part = "" + base_core = base_name + if "__" in base_name: + base_core, suffix_part = base_name.split("__", 1) + suffix_part = f"__{suffix_part}" + + decoded_core = _decode_basename(base_core) + if _encode_basename(decoded_core) == base_core: + encoded_base = base_core + else: + encoded_base = _encode_basename(base_name) + suffix_part = "" + + candidate_name = f"{encoded_base}{suffix_part}{ext.lower()}" + target_path = os.path.join(directory, candidate_name) + + if os.path.normcase(src_path) != os.path.normcase(target_path): + suffix = 1 + while os.path.exists(target_path): + candidate_name = f"{encoded_base}__{suffix}{ext.lower()}" + target_path = os.path.join(directory, candidate_name) + suffix += 1 + try: + os.rename(src_path, target_path) + renamed.append({"old": src_path, "new": target_path}) + except Exception as err: + logger.error( + f"Failed to rename celebrity image {src_path}: {err}") + continue + + encoded_files.append(target_path) + + if not encoded_files: + raise HTTPException(status_code=400, + detail="明星图片重命名失败,请检查目录内容。") + + sample_image = encoded_files[0] + start_time = time.perf_counter() + logger.info( + f"开始刷新明星人脸向量缓存,样本图片: {sample_image}, 总数: {len(encoded_files)}") + + stop_event = asyncio.Event() + progress_task = asyncio.create_task( + _log_progress("刷新明星人脸缓存", start_time, stop_event, interval=5.0)) + + try: + await _refresh_celebrity_cache(sample_image, folder_path) + finally: + stop_event.set() + try: + await progress_task + except Exception: + pass + + total_time = time.perf_counter() - start_time + + logger.info( + f"Celebrity library refreshed. total_images={len(encoded_files)} renamed={len(renamed)} sample={sample_image} elapsed={total_time:.1f}s" + ) + + return { + "success": True, + "message": "明星图库缓存刷新成功", + "data": { + "total_images": len(encoded_files), + "renamed": renamed, + "sample_image": sample_image, + "source": folder_path, + "processing_time": total_time, + }, + } + + +@api_router.post("/celebrity/match", tags=["Face Recognition"]) +@log_api_params +async def match_celebrity_face( + file: UploadFile = File(..., description="待匹配的用户图片"), + nickname: str = Form(None, description="操作者昵称"), +): + """ + 上传图片与明星人脸库比对 + :param file: 上传图片 + :return: 最相似的明星文件及分数 + """ + if not DEEPFACE_AVAILABLE or deepface_module is None: + raise HTTPException(status_code=500, + detail="DeepFace模块未初始化,请检查服务状态。") + + primary_dir = CELEBRITY_SOURCE_DIR + if not primary_dir: + raise HTTPException(status_code=500, + detail="未配置明星图库目录,请设置环境变量 CELEBRITY_SOURCE_DIR。") + + db_path = os.path.abspath(os.path.expanduser(primary_dir)) + if not os.path.isdir(db_path): + raise HTTPException(status_code=400, + detail=f"明星图库目录不存在: {db_path}") + + existing_files = _iter_celebrity_images(db_path) + if not existing_files: + raise HTTPException(status_code=400, + detail="明星人脸库为空,请先调用导入接口。") + + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件。") + + temp_filename: Optional[str] = None + temp_path: Optional[str] = None + cleanup_temp_file = False + annotated_filename: Optional[str] = None + + try: + contents = await file.read() + np_arr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) + if image is None: + raise HTTPException(status_code=400, + detail="无法解析上传的图片,请确认格式。") + + if analyzer is None: + _ensure_analyzer() + + faces: List[List[int]] = [] + if analyzer is not None: + faces = analyzer._detect_faces(image) + if not faces: + raise HTTPException(status_code=400, + detail="图片中未检测到人脸,请重新上传。") + + temp_filename = f"{uuid.uuid4().hex}_celebrity_query.webp" + temp_path = os.path.join(IMAGES_DIR, temp_filename) + if not save_image_high_quality(image, temp_path, quality=SAVE_QUALITY): + raise HTTPException(status_code=500, + detail="保存临时图片失败,请稍后重试。") + cleanup_temp_file = True + await _record_output_file( + file_path=temp_path, + nickname=nickname, + category="celebrity", + extra={ + "source": "celebrity_match", + "role": "query", + }, + ) + + def _build_find_kwargs(refresh: bool) -> dict: + kwargs = dict( + img_path=temp_path, + db_path=db_path, + model_name="ArcFace", + detector_backend="yolov11n", + distance_metric="cosine", + enforce_detection=True, + silent=True, + refresh_database=refresh, + ) + if CELEBRITY_FIND_THRESHOLD is not None: + kwargs["threshold"] = CELEBRITY_FIND_THRESHOLD + return kwargs + + lock = _ensure_deepface_lock() + async with lock: + try: + find_result = await process_cpu_intensive_task( + deepface_module.find, + **_build_find_kwargs(refresh=False), + ) + except (AttributeError, RuntimeError) as attr_err: + if "numpy" in str(attr_err) or "SymbolicTensor" in str(attr_err): + logger.warning( + f"DeepFace find encountered numpy/SymbolicTensor error, 尝试清理模型后刷新缓存: {attr_err}") + _recover_deepface_model() + find_result = await process_cpu_intensive_task( + deepface_module.find, + **_build_find_kwargs(refresh=True), + ) + else: + raise + except ValueError as ve: + logger.warning( + f"DeepFace find failed without refresh: {ve}, 尝试清理模型后刷新缓存。") + _recover_deepface_model() + find_result = await process_cpu_intensive_task( + deepface_module.find, + **_build_find_kwargs(refresh=True), + ) + + if not find_result: + raise HTTPException(status_code=404, detail="未找到相似的人脸。") + + result_df = find_result[0] + best_record = None + if hasattr(result_df, "empty"): + if result_df.empty: + raise HTTPException(status_code=404, detail="未找到相似的人脸。") + best_record = result_df.iloc[0] + elif isinstance(result_df, list) and result_df: + best_record = result_df[0] + else: + raise HTTPException(status_code=500, + detail="明星人脸库返回格式异常。") + + # Pandas Series 转 dict,确保后续访问统一 + if hasattr(best_record, "to_dict"): + best_record_data = best_record.to_dict() + else: + best_record_data = dict(best_record) + + identity_path = str(best_record_data.get("identity", "")) + if not identity_path: + raise HTTPException(status_code=500, + detail="识别结果缺少identity字段。") + + distance = float(best_record_data.get("distance", 0.0)) + similarity = max(0.0, min(100.0, (1 - distance / 2) * 100)) + confidence_raw = best_record_data.get("confidence") + confidence = float( + confidence_raw) if confidence_raw is not None else similarity + filename = os.path.basename(identity_path) + base, ext = os.path.splitext(filename) + encoded_part = base.split("__", 1)[0] if "__" in base else base + display_name = _decode_basename(encoded_part) + + def _parse_coord(value): + try: + if value is None: + return None + if isinstance(value, (np.integer, int)): + return int(value) + if isinstance(value, (np.floating, float)): + if np.isnan(value): + return None + return int(round(float(value))) + if isinstance(value, str) and value.strip(): + return int(round(float(value))) + except Exception: + return None + return None + + img_height, img_width = image.shape[:2] + crop = None + + matched_box = None + + sx = _parse_coord(best_record_data.get("source_x")) + sy = _parse_coord(best_record_data.get("source_y")) + sw = _parse_coord(best_record_data.get("source_w")) + sh = _parse_coord(best_record_data.get("source_h")) + + if ( + sx is not None + and sy is not None + and sw is not None + and sh is not None + and sw > 0 + and sh > 0 + ): + x1 = max(0, sx) + y1 = max(0, sy) + x2 = min(img_width, x1 + sw) + y2 = min(img_height, y1 + sh) + if x2 > x1 and y2 > y1: + crop = image[y1:y2, x1:x2] + matched_box = (x1, y1, x2, y2) + + if (crop is None or crop.size == 0) and faces: + def _area(box): + if not box or len(box) < 4: + return 0 + return max(0, box[2] - box[0]) * max(0, box[3] - box[1]) + + largest_face = max(faces, key=_area) + if largest_face and len(largest_face) >= 4: + fx1, fy1, fx2, fy2 = [int(max(0, v)) for v in largest_face[:4]] + fx1 = min(fx1, img_width - 1) + fy1 = min(fy1, img_height - 1) + fx2 = min(max(fx1 + 1, fx2), img_width) + fy2 = min(max(fy1 + 1, fy2), img_height) + if fx2 > fx1 and fy2 > fy1: + crop = image[fy1:fy2, fx1:fx2] + matched_box = (fx1, fy1, fx2, fy2) + + face_filename = None + if crop is not None and crop.size > 0: + face_filename = f"{uuid.uuid4().hex}_face_1.webp" + face_path = os.path.join(IMAGES_DIR, face_filename) + if not save_image_high_quality(crop, face_path, + quality=SAVE_QUALITY): + logger.error(f"Failed to save cropped face image: {face_path}") + face_filename = None + else: + await _record_output_file( + file_path=face_path, + nickname=nickname, + category="face", + extra={ + "source": "celebrity_match", + "role": "face_crop", + }, + ) + if matched_box is not None and temp_path: + annotated_image = image.copy() + x1, y1, x2, y2 = matched_box + thickness = max(2, int(round(min(img_height, img_width) / 200))) + thickness = max(thickness, 2) + cv2.rectangle(annotated_image, (x1, y1), (x2, y2), + color=(0, 255, 0), thickness=thickness) + if save_image_high_quality(annotated_image, temp_path, + quality=SAVE_QUALITY): + annotated_filename = temp_filename + cleanup_temp_file = False + await _record_output_file( + file_path=temp_path, + nickname=nickname, + category="celebrity", + extra={ + "source": "celebrity_match", + "role": "annotated", + }, + ) + else: + logger.error( + f"Failed to save annotated celebrity image: {temp_path}") + elif temp_path: + # 未拿到匹配框,保持原图但仍保留文件供返回 + annotated_filename = temp_filename + cleanup_temp_file = False + + result_payload = CelebrityMatchResponse( + filename=filename, + display_name=display_name, + distance=distance, + similarity=similarity, + confidence=confidence, + face_filename=face_filename, + ) + + return { + "success": True, + "filename": result_payload.filename, + "display_name": result_payload.display_name, + "distance": result_payload.distance, + "similarity": result_payload.similarity, + "confidence": result_payload.confidence, + "face_filename": result_payload.face_filename, + "annotated_filename": annotated_filename, + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Celebrity match failed: {e}") + raise HTTPException(status_code=500, + detail=f"明星人脸匹配失败: {str(e)}") + finally: + if cleanup_temp_file and temp_path: + try: + os.remove(temp_path) + except Exception: + pass + + +@api_router.post("/face_verify") +@log_api_params +async def face_similarity_verification( + file1: UploadFile = File(..., description="第一张人脸图片"), + file2: UploadFile = File(..., description="第二张人脸图片"), + nickname: str = Form(None, description="操作者昵称"), +): + """ + 人脸相似度比对接口 + :param file1: 第一张人脸图片文件 + :param file2: 第二张人脸图片文件 + :return: 人脸比对结果,包括相似度分值和裁剪后的人脸图片 + """ + # 检查DeepFace是否可用 + if not DEEPFACE_AVAILABLE or deepface_module is None: + raise HTTPException( + status_code=500, + detail="DeepFace模块未初始化,请检查服务状态。" + ) + + # 验证文件类型 + if not file1.content_type.startswith("image/") or not file2.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="请上传图片文件") + + try: + # 读取两张图片 + contents1 = await file1.read() + contents2 = await file2.read() + + # 生成唯一标识符 + md5_hash1 = str(uuid.uuid4()).replace('-', '') + md5_hash2 = str(uuid.uuid4()).replace('-', '') + + # 生成文件名 + original_filename1 = f"{md5_hash1}_original1.webp" + original_filename2 = f"{md5_hash2}_original2.webp" + face_filename1 = f"{md5_hash1}_face1.webp" + face_filename2 = f"{md5_hash2}_face2.webp" + + logger.info(f"Starting face similarity verification: {file1.filename} vs {file2.filename}") + t1 = time.perf_counter() + + # 解码图像 + np_arr1 = np.frombuffer(contents1, np.uint8) + image1 = cv2.imdecode(np_arr1, cv2.IMREAD_COLOR) + if image1 is None: + raise HTTPException(status_code=400, detail="无法解析第一张图片文件,请确保文件格式正确。") + + np_arr2 = np.frombuffer(contents2, np.uint8) + image2 = cv2.imdecode(np_arr2, cv2.IMREAD_COLOR) + if image2 is None: + raise HTTPException(status_code=400, detail="无法解析第二张图片文件,请确保文件格式正确。") + + # 检查图片中是否包含人脸 + if analyzer is None: + _ensure_analyzer() + + if analyzer is not None: + # 检查第一张图片是否包含人脸 + logger.info("detect 1 image...") + face_boxes1 = analyzer._detect_faces(image1) + if not face_boxes1: + raise HTTPException(status_code=400, detail="第一张图片中未检测到人脸,请上传包含清晰人脸的图片") + + # 检查第二张图片是否包含人脸 + logger.info("detect 2 image...") + face_boxes2 = analyzer._detect_faces(image2) + if not face_boxes2: + raise HTTPException(status_code=400, detail="第二张图片中未检测到人脸,请上传包含清晰人脸的图片") + + # 保存原始图片到IMAGES_DIR(先不上传 BOS,供 DeepFace 使用) + original_path1 = os.path.join(IMAGES_DIR, original_filename1) + if not save_image_high_quality( + image1, + original_path1, + quality=SAVE_QUALITY, + upload_to_bos=False, + ): + raise HTTPException(status_code=500, detail="保存第一张原始图片失败") + + original_path2 = os.path.join(IMAGES_DIR, original_filename2) + if not save_image_high_quality( + image2, + original_path2, + quality=SAVE_QUALITY, + upload_to_bos=False, + ): + raise HTTPException(status_code=500, detail="保存第二张原始图片失败") + + # 调用DeepFace.verify进行人脸比对 + logger.info("Starting DeepFace verification...") + lock = _ensure_deepface_lock() + async with lock: + try: + # 使用ArcFace模型进行人脸比对 + verification_result = await process_cpu_intensive_task( + deepface_module.verify, + img1_path=original_path1, + img2_path=original_path2, + model_name="ArcFace", + detector_backend="yolov11n", + distance_metric="cosine" + ) + logger.info( + f"DeepFace verification completed result:{json.dumps(verification_result, ensure_ascii=False)}") + except (AttributeError, RuntimeError) as attr_err: + if "numpy" in str(attr_err) or "SymbolicTensor" in str(attr_err): + logger.warning( + f"DeepFace verification 遇到 numpy/SymbolicTensor 异常,尝试恢复后重试: {attr_err}") + _recover_deepface_model() + try: + verification_result = await process_cpu_intensive_task( + deepface_module.verify, + img1_path=original_path1, + img2_path=original_path2, + model_name="ArcFace", + detector_backend="yolov11n", + distance_metric="cosine" + ) + logger.info( + f"DeepFace verification completed after recovery: {json.dumps(verification_result, ensure_ascii=False)}") + except Exception as retry_error: + logger.error( + f"DeepFace verification failed after recovery attempt: {retry_error}") + raise HTTPException(status_code=500, + detail=f"人脸比对失败: {str(retry_error)}") from retry_error + else: + raise + except ValueError as ve: + logger.warning( + f"DeepFace verification 遇到模型状态异常,尝试恢复后重试: {ve}") + _recover_deepface_model() + try: + verification_result = await process_cpu_intensive_task( + deepface_module.verify, + img1_path=original_path1, + img2_path=original_path2, + model_name="ArcFace", + detector_backend="yolov11n", + distance_metric="cosine" + ) + logger.info( + f"DeepFace verification completed after recovery: {json.dumps(verification_result, ensure_ascii=False)}") + except Exception as retry_error: + logger.error( + f"DeepFace verification failed after recovery attempt: {retry_error}") + raise HTTPException(status_code=500, + detail=f"人脸比对失败: {str(retry_error)}") from retry_error + except Exception as e: + logger.error(f"DeepFace verification failed: {e}") + raise HTTPException(status_code=500, + detail=f"人脸比对失败: {str(e)}") from e + + # 提取比对结果 + verified = verification_result["verified"] + distance = verification_result["distance"] + + # 将距离转换为相似度百分比 (距离越小相似度越高) + # cosine距离范围[0,2],转换为百分比 + similarity_percentage = (1 - distance / 2) * 100 + + # 从验证结果中获取人脸框信息 + facial_areas = verification_result.get("facial_areas", {}) + img1_region = facial_areas.get("img1", {}) + img2_region = facial_areas.get("img2", {}) + + # 确保分析器已初始化,用于绘制特征点 + if analyzer is None: + _ensure_analyzer() + + def _apply_landmarks_on_original( + source_image: np.ndarray, + region: dict, + label: str, + ) -> Tuple[np.ndarray, bool]: + if analyzer is None or not region: + return source_image, False + try: + x = max(0, region.get("x", 0)) + y = max(0, region.get("y", 0)) + w = region.get("w", 0) + h = region.get("h", 0) + x_end = min(source_image.shape[1], x + w) + y_end = min(source_image.shape[0], y + h) + if x_end <= x or y_end <= y: + return source_image, False + result_img = source_image.copy() + face_region = result_img[y:y_end, x:x_end] + face_with_landmarks = analyzer.facial_analyzer.draw_facial_landmarks(face_region) + result_img[y:y_end, x:x_end] = face_with_landmarks + return result_img, True + except Exception as exc: + logger.warning(f"Failed to draw facial landmarks on original image {label}: {exc}") + return source_image, False + + original_output_img1, original1_has_landmarks = _apply_landmarks_on_original(image1, img1_region, "1") + original_output_img2, original2_has_landmarks = _apply_landmarks_on_original(image2, img2_region, "2") + + if save_image_high_quality(original_output_img1, original_path1, quality=SAVE_QUALITY): + await _record_output_file( + file_path=original_path1, + nickname=nickname, + category="original", + extra={ + "source": "face_verify", + "role": "original1_landmarks" if original1_has_landmarks else "original1", + "with_landmarks": original1_has_landmarks, + }, + ) + if save_image_high_quality(original_output_img2, original_path2, quality=SAVE_QUALITY): + await _record_output_file( + file_path=original_path2, + nickname=nickname, + category="original", + extra={ + "source": "face_verify", + "role": "original2_landmarks" if original2_has_landmarks else "original2", + "with_landmarks": original2_has_landmarks, + }, + ) + + # 如果有区域信息,则裁剪人脸 + if img1_region and img2_region: + try: + # 裁剪人脸区域 + x1, y1, w1, h1 = img1_region.get("x", 0), img1_region.get("y", 0), img1_region.get("w", 0), img1_region.get("h", 0) + x2, y2, w2, h2 = img2_region.get("x", 0), img2_region.get("y", 0), img2_region.get("w", 0), img2_region.get("h", 0) + + # 确保坐标在图像范围内 + x1, y1 = max(0, x1), max(0, y1) + x2, y2 = max(0, x2), max(0, y2) + x1_end, y1_end = min(image1.shape[1], x1 + w1), min(image1.shape[0], y1 + h1) + x2_end, y2_end = min(image2.shape[1], x2 + w2), min(image2.shape[0], y2 + h2) + + # 裁剪人脸 + face_img1 = image1[y1:y1_end, x1:x1_end] + face_img2 = image2[y2:y2_end, x2:x2_end] + + face_path1 = os.path.join(IMAGES_DIR, face_filename1) + face_path2 = os.path.join(IMAGES_DIR, face_filename2) + # 根据分析器可用性决定是否绘制特征点,仅保存最终版本一次 + def _prepare_face_image(face_img, face_index): + if analyzer is None: + return face_img, False + try: + return analyzer.facial_analyzer.draw_facial_landmarks(face_img.copy()), True + except Exception as exc: + logger.warning(f"Failed to draw facial landmarks on face{face_index}: {exc}") + return face_img, False + + face_output_img1, face1_has_landmarks = _prepare_face_image(face_img1, 1) + face_output_img2, face2_has_landmarks = _prepare_face_image(face_img2, 2) + + if save_image_high_quality(face_output_img1, face_path1, quality=SAVE_QUALITY): + await _record_output_file( + file_path=face_path1, + nickname=nickname, + category="face", + extra={ + "source": "face_verify", + "role": "face1_landmarks" if face1_has_landmarks else "face1", + "with_landmarks": face1_has_landmarks, + }, + ) + if save_image_high_quality(face_output_img2, face_path2, quality=SAVE_QUALITY): + await _record_output_file( + file_path=face_path2, + nickname=nickname, + category="face", + extra={ + "source": "face_verify", + "role": "face2_landmarks" if face2_has_landmarks else "face2", + "with_landmarks": face2_has_landmarks, + }, + ) + except Exception as e: + logger.warning(f"Failed to crop faces: {e}") + else: + # 如果没有区域信息,使用原始图像 + logger.info("No face regions found in verification result, using original images") + + total_time = time.perf_counter() - t1 + logger.info(f"Face similarity verification completed: time={total_time:.3f}s, similarity={similarity_percentage:.2f}%") + + # 返回结果 + return { + "success": True, + "message": "人脸比对完成", + "verified": verified, + "similarity_percentage": round(similarity_percentage, 2), + "distance": distance, + "processing_time": f"{total_time:.3f}s", + "original_filename1": original_filename1, + "original_filename2": original_filename2, + "face_filename1": face_filename1, + "face_filename2": face_filename2, + "model_used": "ArcFace", + "detector_backend": "retinaface", + "distance_metric": "cosine" + } + + except HTTPException: + # 重新抛出HTTP异常 + raise + except Exception as e: + logger.error(f"Error occurred during face similarity verification: {str(e)}") + raise HTTPException(status_code=500, detail=f"人脸比对过程中出现错误: {str(e)}")