Spaces:
Paused
Paused
| 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"<bytes length={len(val)}>" | |
| 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"<error logging param '{name}': {e}>" | |
| 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) | |
| 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)}") | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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)}") | |
| 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)}") | |
| 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) | |
| 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)) | |
| 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) | |
| 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), | |
| ) | |
| 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" | |
| ), | |
| } | |
| 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, | |
| } | |
| 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)}") | |
| 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)}") | |
| 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)}") | |
| 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)}") | |
| 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)}") | |
| 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)}") | |
| 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)}") | |
| 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)}") | |
| 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)}") | |
| 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, | |
| } | |
| 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", | |
| } | |
| 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="<h1>facescore.html not found</h1>", status_code=404 | |
| ) | |
| 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)}") | |
| 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)}") | |
| 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)}") | |
| 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(), | |
| } | |
| 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 | |
| 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, | |
| } | |
| 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", | |
| ) | |
| 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"), | |
| } | |
| 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, | |
| } | |
| 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, | |
| }, | |
| } | |
| 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 | |
| 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)}") | |