|
|
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 |
|
|
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_module = None |
|
|
if DEEPFACE_AVAILABLE: |
|
|
t_start = time.perf_counter() |
|
|
|
|
|
t_start = time.perf_counter() |
|
|
|
|
|
try: |
|
|
from deepface import DeepFace |
|
|
deepface_module = DeepFace |
|
|
|
|
|
|
|
|
_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)): |
|
|
|
|
|
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: |
|
|
|
|
|
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_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 |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
photo_restorer = None |
|
|
restorer_type = "none" |
|
|
|
|
|
|
|
|
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_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") |
|
|
|
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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_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_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() |
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
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): |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
@api_router.post(path="/upload_file", tags=["文件上传"]) |
|
|
@log_api_params |
|
|
async def upload_file( |
|
|
file: UploadFile = File(...), |
|
|
fileType: str = Form( |
|
|
None, |
|
|
description="文件类型,如 'idphoto' 表示证件照上传" |
|
|
), |
|
|
nickname: str = Form( |
|
|
None, |
|
|
description="操作者昵称,用于记录到数据库" |
|
|
), |
|
|
): |
|
|
""" |
|
|
文件上传接口:接收上传的文件,保存到本地并返回文件名。 |
|
|
- 文件名规则:{uuid}_save_id_photo.{ext} |
|
|
- 保存目录:IMAGES_DIR |
|
|
- 如果 fileType='idphoto',则调用图片修复接口 |
|
|
""" |
|
|
if not file: |
|
|
raise HTTPException(status_code=400, detail="请上传文件") |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
if not contents: |
|
|
raise HTTPException(status_code=400, detail="文件内容为空") |
|
|
|
|
|
|
|
|
_, file_extension = os.path.splitext(file.filename) |
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
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) |
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
|
|
|
if CLIP_AVAILABLE: |
|
|
asyncio.create_task( |
|
|
handle_image_vector_async(saved_path, saved_filename)) |
|
|
|
|
|
await _record_output_file( |
|
|
file_path=saved_path, |
|
|
nickname=nickname, |
|
|
category="id_photo", |
|
|
bos_uploaded=True, |
|
|
extra={ |
|
|
**{k: v for k, v in extra_meta_base.items() if v}, |
|
|
"restored_with_model": restored_with_model, |
|
|
}, |
|
|
) |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"message": "上传成功(已修复)" if photo_restorer is not None and photo_restorer.is_available() else "上传成功", |
|
|
"filename": saved_filename, |
|
|
} |
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"证件照上传修复流程失败,改为直接保存: {e}") |
|
|
|
|
|
saved_filename = f"{unique_id}_save_id_photo{file_extension}" |
|
|
saved_path = os.path.join(IMAGES_DIR, saved_filename) |
|
|
try: |
|
|
with open(saved_path, "wb") as f: |
|
|
f.write(contents) |
|
|
await _record_output_file( |
|
|
file_path=saved_path, |
|
|
nickname=nickname, |
|
|
category="id_photo", |
|
|
bos_uploaded=True, |
|
|
extra={ |
|
|
**{k: v for k, v in extra_meta_base.items() if v}, |
|
|
"restored_with_model": False, |
|
|
"fallback": True, |
|
|
}, |
|
|
) |
|
|
except Exception as se: |
|
|
logger.error(f"保存文件失败: {se}") |
|
|
raise HTTPException(status_code=500, detail="保存文件失败") |
|
|
return { |
|
|
"success": True, |
|
|
"message": "上传成功(修复失败,已原样保存)", |
|
|
"filename": saved_filename, |
|
|
} |
|
|
|
|
|
|
|
|
saved_filename = f"{unique_id}_save_file{file_extension}" |
|
|
saved_path = os.path.join(IMAGES_DIR, saved_filename) |
|
|
try: |
|
|
with open(saved_path, "wb") as f: |
|
|
f.write(contents) |
|
|
bos_uploaded = upload_file_to_bos(saved_path) |
|
|
logger.info(f"文件上传成功: {saved_filename}") |
|
|
await _record_output_file( |
|
|
file_path=saved_path, |
|
|
nickname=nickname, |
|
|
bos_uploaded=bos_uploaded, |
|
|
extra={ |
|
|
**{k: v for k, v in extra_meta_base.items() if v}, |
|
|
"restored_with_model": False, |
|
|
}, |
|
|
) |
|
|
except Exception as e: |
|
|
logger.error(f"保存文件失败: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail="保存文件失败") |
|
|
|
|
|
return {"success": True, "message": "上传成功", |
|
|
"filename": saved_filename} |
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"文件上传失败: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post(path="/check_image_security") |
|
|
@log_api_params |
|
|
async def analyze_face( |
|
|
file: UploadFile = File(...), |
|
|
nickname: str = Form(None, description="操作者昵称") |
|
|
): |
|
|
contents = await file.read() |
|
|
np_arr = np.frombuffer(contents, np.uint8) |
|
|
image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) |
|
|
original_md5_hash = str(uuid.uuid4()).replace('-', '') |
|
|
original_image_filename = f"{original_md5_hash}_original.webp" |
|
|
original_image_path = os.path.join(IMAGES_DIR, original_image_filename) |
|
|
save_image_high_quality(image, original_image_path, quality=SAVE_QUALITY, upload_to_bos=False) |
|
|
try: |
|
|
with open(original_image_path, "rb") as f: |
|
|
security_payload = f.read() |
|
|
except Exception: |
|
|
security_payload = contents |
|
|
|
|
|
t1 = time.perf_counter() |
|
|
is_safe = await wx_access_token.check_image_security(security_payload) |
|
|
logger.info(f"Checking image content safety, time: {time.perf_counter() - t1:.3f}s") |
|
|
if not is_safe: |
|
|
upload_file_to_bos(original_image_path) |
|
|
await _record_output_file( |
|
|
file_path=original_image_path, |
|
|
nickname=nickname, |
|
|
category="original", |
|
|
score=0.0, |
|
|
bos_uploaded=True, |
|
|
extra={ |
|
|
"source": "security", |
|
|
"role": "annotated", |
|
|
"model": "wx", |
|
|
}, |
|
|
) |
|
|
return { |
|
|
"success": False, |
|
|
"code": 400, |
|
|
"message": "图片内容不合规! 请更换其他图片", |
|
|
"filename": file.filename, |
|
|
} |
|
|
else: |
|
|
return { |
|
|
"success": True, |
|
|
"code": 0, |
|
|
"message": "图片内容合规", |
|
|
"filename": file.filename, |
|
|
} |
|
|
|
|
|
|
|
|
@api_router.post("/detect_faces", tags=["Face API"]) |
|
|
@log_api_params |
|
|
async def detect_faces_endpoint( |
|
|
file: UploadFile = File(..., description="需要进行人脸检测的图片"), |
|
|
): |
|
|
""" |
|
|
上传单张图片,调用 YOLO(_detect_faces)做人脸检测并返回耗时。 |
|
|
""" |
|
|
if not file or not file.content_type or not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传有效的图片文件") |
|
|
|
|
|
image_bytes = await file.read() |
|
|
if not image_bytes: |
|
|
raise HTTPException(status_code=400, detail="图片内容为空") |
|
|
|
|
|
np_arr = np.frombuffer(image_bytes, np.uint8) |
|
|
image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) |
|
|
if image is None: |
|
|
raise HTTPException(status_code=400, detail="无法解析图片文件,请确认格式正确") |
|
|
|
|
|
if analyzer is None: |
|
|
_ensure_analyzer() |
|
|
if analyzer is None: |
|
|
raise HTTPException(status_code=500, detail="人脸检测模型尚未就绪,请稍后再试") |
|
|
|
|
|
detect_start = time.perf_counter() |
|
|
try: |
|
|
face_boxes = analyzer._detect_faces(image) |
|
|
except Exception as exc: |
|
|
logger.error(f"Face detection failed: {exc}") |
|
|
raise HTTPException(status_code=500, detail="调用人脸检测失败") from exc |
|
|
detect_duration = time.perf_counter() - detect_start |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"face_count": len(face_boxes), |
|
|
"boxes": face_boxes, |
|
|
"elapsed_ms": round(detect_duration * 1000, 3), |
|
|
"elapsed_seconds": round(detect_duration, 4), |
|
|
"hostname": SERVER_HOSTNAME, |
|
|
} |
|
|
|
|
|
|
|
|
@api_router.post(path="/analyze") |
|
|
@log_api_params |
|
|
async def analyze_face( |
|
|
request: Request, |
|
|
file: UploadFile = File(None), |
|
|
files: list[UploadFile] = File(None), |
|
|
images: str = Form(None), |
|
|
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: 分析结果,包含所有图片的五官评分和标注后图片的下载文件名 |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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: |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
cleaned_result = convert_numpy_types(combined_result) |
|
|
total_elapsed = time.perf_counter() - overall_start |
|
|
logger.info( |
|
|
f"<-------- All images processing completed, total time: {total_elapsed:.3f}s, valid images: {valid_image_count} --------" |
|
|
) |
|
|
return JSONResponse(content=cleaned_result) |
|
|
|
|
|
except Exception as e: |
|
|
import traceback |
|
|
|
|
|
traceback.print_exc() |
|
|
logger.error(f"Internal error occurred during analysis: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"分析过程中出现内部错误: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post("/image_search", response_model=ImageFileList, tags=["图像搜索"]) |
|
|
@log_api_params |
|
|
async def search_by_image( |
|
|
file: UploadFile = File(None), |
|
|
searchType: str = Query("face"), |
|
|
top_k: int = Query(5), |
|
|
score_threshold: float = Query(0.28) |
|
|
): |
|
|
"""使用图片进行相似图像搜索""" |
|
|
|
|
|
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]) |
|
|
|
|
|
|
|
|
image_vector = clip_encode_image(temp_image_path) |
|
|
|
|
|
|
|
|
search_results = search_text_vector(image_vector, top_k) |
|
|
|
|
|
|
|
|
filtered_results = [ |
|
|
item for item in search_results |
|
|
if item[1] >= score_threshold |
|
|
] |
|
|
|
|
|
|
|
|
records_map = {} |
|
|
try: |
|
|
records_map = await fetch_records_by_paths( |
|
|
file_path for file_path, _ in filtered_results |
|
|
) |
|
|
except Exception as exc: |
|
|
logger.warning(f"Fetch image records by path failed: {exc}") |
|
|
|
|
|
category = _normalize_search_category(searchType) |
|
|
|
|
|
all_files = [] |
|
|
for file_path, score in filtered_results: |
|
|
record = records_map.get(file_path) |
|
|
record_category = ( |
|
|
record.get( |
|
|
"category") if record else infer_category_from_filename( |
|
|
file_path) |
|
|
) |
|
|
if category not in ( |
|
|
None, "all") and record_category != category: |
|
|
continue |
|
|
|
|
|
size_bytes = 0 |
|
|
is_cropped = False |
|
|
nickname_value = record.get("nickname") if record else None |
|
|
last_modified_dt = None |
|
|
|
|
|
if record: |
|
|
size_bytes = int(record.get("size_bytes") or 0) |
|
|
is_cropped = bool(record.get("is_cropped_face")) |
|
|
last_modified_dt = record.get("last_modified") |
|
|
if isinstance(last_modified_dt, str): |
|
|
try: |
|
|
last_modified_dt = datetime.fromisoformat( |
|
|
last_modified_dt) |
|
|
except ValueError: |
|
|
last_modified_dt = None |
|
|
|
|
|
if last_modified_dt is None or size_bytes == 0: |
|
|
full_path = os.path.join(IMAGES_DIR, file_path) |
|
|
if not os.path.isfile(full_path): |
|
|
continue |
|
|
stat = os.stat(full_path) |
|
|
size_bytes = stat.st_size |
|
|
last_modified_dt = datetime.fromtimestamp(stat.st_mtime) |
|
|
is_cropped = "_face_" in file_path and file_path.count("_") >= 2 |
|
|
|
|
|
last_modified_str = ( |
|
|
last_modified_dt.strftime("%Y-%m-%d %H:%M:%S") |
|
|
if isinstance(last_modified_dt, datetime) |
|
|
else "" |
|
|
) |
|
|
file_info = { |
|
|
"file_path": file_path, |
|
|
"score": round(score, 4), |
|
|
"is_cropped_face": is_cropped, |
|
|
"size_bytes": size_bytes, |
|
|
"size_str": human_readable_size(size_bytes), |
|
|
"last_modified": last_modified_str, |
|
|
"nickname": nickname_value, |
|
|
} |
|
|
all_files.append(file_info) |
|
|
|
|
|
return ImageFileList(results=all_files, count=len(all_files)) |
|
|
|
|
|
finally: |
|
|
|
|
|
if os.path.exists(temp_image_path): |
|
|
os.remove(temp_image_path) |
|
|
|
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Image search failed: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"图片搜索失败: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.get( |
|
|
"/daily_category_stats", |
|
|
response_model=CategoryStatsResponse, |
|
|
tags=["统计"] |
|
|
) |
|
|
@log_api_params |
|
|
async def get_daily_category_stats(): |
|
|
"""查询当日各分类数量""" |
|
|
try: |
|
|
rows = await fetch_today_category_counts() |
|
|
except Exception as exc: |
|
|
logger.error("Fetch today category counts failed: %s", exc) |
|
|
raise HTTPException(status_code=500, |
|
|
detail="查询今日分类统计失败") from exc |
|
|
|
|
|
counts_map: Dict[str, int] = { |
|
|
str(item.get("category") or "unknown"): int(item.get("count") or 0) |
|
|
for item in rows |
|
|
} |
|
|
total = sum(counts_map.values()) |
|
|
|
|
|
remaining = counts_map.copy() |
|
|
stats: List[CategoryStatItem] = [] |
|
|
|
|
|
for category in CATEGORY_DISPLAY_ORDER: |
|
|
count = remaining.pop(category, 0) |
|
|
stats.append( |
|
|
CategoryStatItem( |
|
|
category=category, |
|
|
display_name=CATEGORY_DISPLAY_NAMES.get(category, category), |
|
|
count=count, |
|
|
) |
|
|
) |
|
|
|
|
|
for category in sorted(remaining.keys()): |
|
|
stats.append( |
|
|
CategoryStatItem( |
|
|
category=category, |
|
|
display_name=CATEGORY_DISPLAY_NAMES.get(category, category), |
|
|
count=remaining[category], |
|
|
) |
|
|
) |
|
|
|
|
|
return CategoryStatsResponse(stats=stats, total=total) |
|
|
|
|
|
|
|
|
@api_router.post("/outputs", response_model=PagedImageFileList, tags=["检测列表"]) |
|
|
@log_api_params |
|
|
async def list_outputs( |
|
|
request: SearchRequest, |
|
|
page: int = Query(1, ge=1, description="页码(从1开始)"), |
|
|
page_size: int = Query(20, ge=1, le=100, description="每页数量(最大100)") |
|
|
): |
|
|
search_type = request.searchType |
|
|
category = _normalize_search_category(search_type) |
|
|
keyword = request.keyword.strip() if getattr(request, "keyword", |
|
|
None) else "" |
|
|
nickname_filter = request.nickname.strip() if getattr(request, "nickname", |
|
|
None) else None |
|
|
|
|
|
try: |
|
|
|
|
|
if keyword and CLIP_AVAILABLE: |
|
|
logger.info(f"Performing vector search, keyword: {keyword}") |
|
|
try: |
|
|
|
|
|
text_vector = clip_encode_text(keyword) |
|
|
|
|
|
|
|
|
search_results = search_text_vector(text_vector, request.top_k if hasattr(request, 'top_k') else 1000) |
|
|
|
|
|
|
|
|
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)}") |
|
|
|
|
|
|
|
|
|
|
|
logger.info("Returning regular file list") |
|
|
try: |
|
|
total_count = await count_image_records( |
|
|
category=category, |
|
|
nickname=nickname_filter, |
|
|
) |
|
|
if total_count > 0: |
|
|
offset = (page - 1) * page_size |
|
|
rows = await fetch_paged_image_records( |
|
|
category=category, |
|
|
nickname=nickname_filter, |
|
|
offset=offset, |
|
|
limit=page_size, |
|
|
) |
|
|
paged_results = [] |
|
|
for row in rows: |
|
|
last_modified = row.get("last_modified") |
|
|
if isinstance(last_modified, str): |
|
|
try: |
|
|
last_modified_dt = datetime.fromisoformat( |
|
|
last_modified) |
|
|
except ValueError: |
|
|
last_modified_dt = None |
|
|
else: |
|
|
last_modified_dt = last_modified |
|
|
size_bytes = int(row.get("size_bytes") or 0) |
|
|
paged_results.append({ |
|
|
"file_path": row.get("file_path"), |
|
|
"score": float(row.get("score") or 0.0), |
|
|
"is_cropped_face": bool(row.get("is_cropped_face")), |
|
|
"size_bytes": size_bytes, |
|
|
"size_str": human_readable_size(size_bytes), |
|
|
"last_modified": last_modified_dt.strftime( |
|
|
"%Y-%m-%d %H:%M:%S") if last_modified_dt else "", |
|
|
"nickname": row.get("nickname"), |
|
|
}) |
|
|
total_pages = (total_count + page_size - 1) // page_size |
|
|
return PagedImageFileList( |
|
|
results=paged_results, |
|
|
count=total_count, |
|
|
page=page, |
|
|
page_size=page_size, |
|
|
total_pages=total_pages, |
|
|
) |
|
|
except Exception as exc: |
|
|
logger.error( |
|
|
f"Query image records from MySQL failed: {exc}, fallback to filesystem scan") |
|
|
|
|
|
if nickname_filter: |
|
|
|
|
|
return PagedImageFileList( |
|
|
results=[], |
|
|
count=0, |
|
|
page=page, |
|
|
page_size=page_size, |
|
|
total_pages=0, |
|
|
) |
|
|
|
|
|
|
|
|
all_files = [] |
|
|
for f in os.listdir(IMAGES_DIR): |
|
|
if not f.lower().endswith((".jpg", ".jpeg", ".png", ".webp")): |
|
|
continue |
|
|
|
|
|
file_category = infer_category_from_filename(f) |
|
|
if category not in (None, "all") and file_category != category: |
|
|
continue |
|
|
|
|
|
full_path = os.path.join(IMAGES_DIR, f) |
|
|
if os.path.isfile(full_path): |
|
|
stat = os.stat(full_path) |
|
|
is_cropped = "_face_" in f and f.count("_") >= 2 |
|
|
file_info = { |
|
|
"file_path": f, |
|
|
"score": 0.0, |
|
|
"is_cropped_face": is_cropped, |
|
|
"size_bytes": stat.st_size, |
|
|
"size_str": human_readable_size(stat.st_size), |
|
|
"last_modified": datetime.fromtimestamp( |
|
|
stat.st_mtime).strftime( |
|
|
"%Y-%m-%d %H:%M:%S" |
|
|
), |
|
|
"nickname": None, |
|
|
} |
|
|
all_files.append(file_info) |
|
|
|
|
|
all_files.sort(key=lambda x: x["last_modified"], reverse=True) |
|
|
|
|
|
|
|
|
total_count = len(all_files) |
|
|
start_index = (page - 1) * page_size |
|
|
end_index = start_index + page_size |
|
|
paged_results = all_files[start_index:end_index] |
|
|
|
|
|
total_pages = (total_count + page_size - 1) // page_size |
|
|
return PagedImageFileList( |
|
|
results=paged_results, |
|
|
count=total_count, |
|
|
page=page, |
|
|
page_size=page_size, |
|
|
total_pages=total_pages |
|
|
) |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to get detection result list: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
|
|
|
@api_router.get("/preview/{filename}", tags=["文件预览"]) |
|
|
@log_api_params |
|
|
async def download_result(filename: str): |
|
|
file_path = os.path.join(IMAGES_DIR, filename) |
|
|
if not os.path.exists(file_path): |
|
|
raise HTTPException(status_code=404, detail="文件不存在") |
|
|
|
|
|
|
|
|
if filename.lower().endswith('.png'): |
|
|
media_type = "image/png" |
|
|
elif filename.lower().endswith('.webp'): |
|
|
media_type = "image/webp" |
|
|
else: |
|
|
media_type = "image/jpeg" |
|
|
return FileResponse(path=file_path, filename=filename, media_type=media_type) |
|
|
|
|
|
|
|
|
@api_router.get("/download/{filename}", tags=["文件下载"]) |
|
|
@log_api_params |
|
|
async def preview_result(filename: str): |
|
|
file_path = os.path.join(OUTPUT_DIR, filename) |
|
|
if not os.path.exists(file_path): |
|
|
raise HTTPException(status_code=404, detail="文件不存在") |
|
|
|
|
|
|
|
|
if filename.lower().endswith('.png'): |
|
|
media_type = "image/png" |
|
|
elif filename.lower().endswith('.webp'): |
|
|
media_type = "image/webp" |
|
|
else: |
|
|
media_type = "image/jpeg" |
|
|
return FileResponse( |
|
|
path=file_path, |
|
|
filename=filename, |
|
|
media_type=media_type, |
|
|
|
|
|
) |
|
|
|
|
|
|
|
|
@api_router.get("/models", tags=["模型信息"]) |
|
|
@log_api_params |
|
|
async def get_available_models(): |
|
|
"""获取可用的模型列表""" |
|
|
models = { |
|
|
"howcuteami": { |
|
|
"name": "HowCuteAmI", |
|
|
"description": "基于OpenCV DNN的颜值、年龄、性别预测模型", |
|
|
"available": analyzer is not None, |
|
|
"features": [ |
|
|
"face_detection", |
|
|
"age_prediction", |
|
|
"gender_prediction", |
|
|
"beauty_scoring", |
|
|
], |
|
|
}, |
|
|
"deepface": { |
|
|
"name": "DeepFace", |
|
|
"description": "Facebook开源的人脸分析框架,支持年龄、性别、情绪识别", |
|
|
"available": DEEPFACE_AVAILABLE, |
|
|
"features": ["age_prediction", "gender_prediction", "emotion_analysis"], |
|
|
}, |
|
|
"hybrid": { |
|
|
"name": "Hybrid Model", |
|
|
"description": "混合模型:HowCuteAmI(颜值+性别)+ DeepFace(年龄+情绪)", |
|
|
"available": analyzer is not None and DEEPFACE_AVAILABLE, |
|
|
"features": [ |
|
|
"beauty_scoring", |
|
|
"gender_prediction", |
|
|
"age_prediction", |
|
|
"emotion_analysis", |
|
|
], |
|
|
}, |
|
|
} |
|
|
|
|
|
facial_analysis = { |
|
|
"name": "Facial Feature Analysis", |
|
|
"description": "基于MediaPipe的五官特征分析", |
|
|
"available": DLIB_AVAILABLE, |
|
|
"features": [ |
|
|
"eyes_scoring", |
|
|
"nose_scoring", |
|
|
"mouth_scoring", |
|
|
"eyebrows_scoring", |
|
|
"jawline_scoring", |
|
|
"harmony_analysis", |
|
|
], |
|
|
} |
|
|
|
|
|
return { |
|
|
"prediction_models": models, |
|
|
"facial_analysis": facial_analysis, |
|
|
"recommended_combination": ( |
|
|
"hybrid + facial_analysis" |
|
|
if analyzer is not None and DEEPFACE_AVAILABLE and DLIB_AVAILABLE |
|
|
else "howcuteami + basic_analysis" |
|
|
), |
|
|
} |
|
|
|
|
|
|
|
|
@api_router.post("/sync_resources", tags=["系统维护"]) |
|
|
@log_api_params |
|
|
async def sync_bos_resources( |
|
|
force_download: bool = Query(False, description="是否强制重新下载已存在的文件"), |
|
|
include_background: bool = Query( |
|
|
False, description="是否同步配置中标记为后台的资源" |
|
|
), |
|
|
bos_prefix: str | None = Query( |
|
|
None, description="自定义 BOS 前缀,例如 20220620/models" |
|
|
), |
|
|
destination_dir: str | None = Query( |
|
|
None, description="自定义本地目录,例如 /opt/models/custom" |
|
|
), |
|
|
background: bool = Query( |
|
|
False, description="与自定义前缀搭配使用时,是否在后台异步下载" |
|
|
), |
|
|
): |
|
|
""" |
|
|
手动触发 BOS 资源同步。 |
|
|
- 若提供 bos_prefix 与 destination_dir,则按指定路径同步; |
|
|
- 否则根据配置的 BOS_DOWNLOAD_TARGETS 执行批量同步。 |
|
|
""" |
|
|
start_time = time.perf_counter() |
|
|
|
|
|
if (bos_prefix and not destination_dir) or (destination_dir and not bos_prefix): |
|
|
raise HTTPException(status_code=400, detail="bos_prefix 和 destination_dir 需要同时提供") |
|
|
|
|
|
if bos_prefix and destination_dir: |
|
|
dest_path = os.path.abspath(os.path.expanduser(destination_dir.strip())) |
|
|
|
|
|
async def _sync_single(): |
|
|
return await asyncio.to_thread( |
|
|
download_bos_directory, |
|
|
bos_prefix.strip(), |
|
|
dest_path, |
|
|
force_download=force_download, |
|
|
) |
|
|
|
|
|
if background: |
|
|
async def _background_task(): |
|
|
success = await _sync_single() |
|
|
if success: |
|
|
logger.info( |
|
|
"后台 BOS 下载完成: prefix=%s -> %s", bos_prefix, dest_path |
|
|
) |
|
|
else: |
|
|
logger.warning( |
|
|
"后台 BOS 下载失败: prefix=%s -> %s", bos_prefix, dest_path |
|
|
) |
|
|
|
|
|
asyncio.create_task(_background_task()) |
|
|
elapsed = time.perf_counter() - start_time |
|
|
return { |
|
|
"success": True, |
|
|
"force_download": force_download, |
|
|
"include_background": False, |
|
|
"bos_prefix": bos_prefix, |
|
|
"destination_dir": dest_path, |
|
|
"elapsed_seconds": round(elapsed, 3), |
|
|
"message": "后台下载任务已启动", |
|
|
} |
|
|
|
|
|
success = await _sync_single() |
|
|
elapsed = time.perf_counter() - start_time |
|
|
return { |
|
|
"success": bool(success), |
|
|
"force_download": force_download, |
|
|
"include_background": False, |
|
|
"bos_prefix": bos_prefix, |
|
|
"destination_dir": dest_path, |
|
|
"elapsed_seconds": round(elapsed, 3), |
|
|
"message": "资源同步完成" if success else "资源同步失败,请查看日志", |
|
|
} |
|
|
|
|
|
|
|
|
success = await asyncio.to_thread( |
|
|
ensure_bos_resources, |
|
|
force_download, |
|
|
include_background, |
|
|
) |
|
|
elapsed = time.perf_counter() - start_time |
|
|
message = ( |
|
|
"后台下载任务已启动,将在后台继续运行" |
|
|
if not include_background |
|
|
else "资源同步完成" |
|
|
) |
|
|
return { |
|
|
"success": bool(success), |
|
|
"force_download": force_download, |
|
|
"include_background": include_background, |
|
|
"elapsed_seconds": round(elapsed, 3), |
|
|
"message": message, |
|
|
"bos_prefix": None, |
|
|
"destination_dir": None, |
|
|
} |
|
|
|
|
|
|
|
|
@api_router.post("/restore") |
|
|
@log_api_params |
|
|
async def restore_old_photo( |
|
|
file: UploadFile = File(...), |
|
|
md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"), |
|
|
colorize: bool = Query(False, description="是否对黑白照片进行上色"), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
老照片修复接口 |
|
|
:param file: 上传的老照片文件 |
|
|
:param md5: 前端传递的文件md5,如果未传递则使用original_md5_hash |
|
|
:param colorize: 是否对黑白照片进行上色,默认为False |
|
|
:return: 修复结果,包含修复后图片的文件名 |
|
|
""" |
|
|
_ensure_photo_restorer() |
|
|
if photo_restorer is None or not photo_restorer.is_available(): |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail="照片修复器未初始化,请检查服务状态。" |
|
|
) |
|
|
|
|
|
|
|
|
if not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传图片文件") |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
original_md5_hash = str(uuid.uuid4()).replace('-', '') |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
await _record_output_file( |
|
|
file_path=restored_path, |
|
|
nickname=nickname, |
|
|
category="restore", |
|
|
bos_uploaded=True, |
|
|
extra={ |
|
|
"source": "restore", |
|
|
"colorize": colorize, |
|
|
"processing_steps": processing_steps, |
|
|
"md5": actual_md5, |
|
|
}, |
|
|
) |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"message": "成功", |
|
|
"original_filename": file.filename, |
|
|
"restored_filename": restored_filename, |
|
|
"processing_time": f"{total_time:.3f}s", |
|
|
"original_size": original_size, |
|
|
"processed_size": processed_size, |
|
|
"size_increase_ratio": round(processed_size / original_size, 2), |
|
|
"original_dimensions": f"{original_width} × {original_height}", |
|
|
"processed_dimensions": f"{processed_width} × {processed_height}", |
|
|
} |
|
|
else: |
|
|
raise HTTPException(status_code=500, detail="保存修复后图像失败") |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Error occurred during old photo restoration: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"修复过程中出现错误: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post("/upcolor") |
|
|
@log_api_params |
|
|
async def colorize_photo( |
|
|
file: UploadFile = File(...), |
|
|
md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
照片上色接口 |
|
|
:param file: 上传的照片文件 |
|
|
:param md5: 前端传递的文件md5,如果未传递则使用original_md5_hash |
|
|
:return: 上色结果,包含上色后图片的文件名 |
|
|
""" |
|
|
_ensure_ddcolor() |
|
|
if ddcolor_colorizer is None or not ddcolor_colorizer.is_available(): |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail="照片上色器未初始化,请检查服务状态。" |
|
|
) |
|
|
|
|
|
|
|
|
if not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传图片文件") |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
original_md5_hash = str(uuid.uuid4()).replace('-', '') |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
await _record_output_file( |
|
|
file_path=colored_path, |
|
|
nickname=nickname, |
|
|
category="upcolor", |
|
|
bos_uploaded=True, |
|
|
extra={ |
|
|
"source": "upcolor", |
|
|
"md5": actual_md5, |
|
|
}, |
|
|
) |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"message": "成功", |
|
|
"original_filename": file.filename, |
|
|
"colored_filename": colored_filename, |
|
|
"processing_time": f"{total_time:.3f}s", |
|
|
"original_size": original_size, |
|
|
"processed_size": processed_size, |
|
|
"size_increase_ratio": round(processed_size / original_size, 2), |
|
|
"original_dimensions": f"{original_width} × {original_height}", |
|
|
"processed_dimensions": f"{processed_width} × {processed_height}", |
|
|
} |
|
|
else: |
|
|
raise HTTPException(status_code=500, detail="保存上色后图像失败") |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Error occurred during photo colorization: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"上色过程中出现错误: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.get("/anime_style/status", tags=["动漫风格化"]) |
|
|
@log_api_params |
|
|
async def get_anime_style_status(): |
|
|
""" |
|
|
获取动漫风格化模型状态 |
|
|
:return: 模型状态信息,包括已加载的模型和预加载状态 |
|
|
""" |
|
|
_ensure_anime_stylizer() |
|
|
if anime_stylizer is None or not anime_stylizer.is_available(): |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail="动漫风格化处理器未初始化,请检查服务状态。" |
|
|
) |
|
|
|
|
|
try: |
|
|
|
|
|
preload_status = anime_stylizer.get_preload_status() |
|
|
available_styles = anime_stylizer.get_available_styles() |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"message": "获取动漫风格化状态成功", |
|
|
"preload_status": preload_status, |
|
|
"available_styles": available_styles, |
|
|
"service_available": True |
|
|
} |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to get anime stylization status: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"获取状态失败: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post("/anime_style/preload", tags=["动漫风格化"]) |
|
|
@log_api_params |
|
|
async def preload_anime_models( |
|
|
style_types: list = Query(None, description="要预加载的风格类型列表,如果为空则预加载所有模型") |
|
|
): |
|
|
""" |
|
|
预加载动漫风格化模型 |
|
|
:param style_types: 要预加载的风格类型列表,支持: handdrawn, disney, illustration, artstyle, anime, sketch |
|
|
:return: 预加载结果 |
|
|
""" |
|
|
_ensure_anime_stylizer() |
|
|
if anime_stylizer is None or not anime_stylizer.is_available(): |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail="动漫风格化处理器未初始化,请检查服务状态。" |
|
|
) |
|
|
|
|
|
try: |
|
|
logger.info(f"API request to preload anime style models: {style_types}") |
|
|
|
|
|
|
|
|
start_time = time.perf_counter() |
|
|
anime_stylizer.preload_models(style_types) |
|
|
preload_time = time.perf_counter() - start_time |
|
|
|
|
|
|
|
|
preload_status = anime_stylizer.get_preload_status() |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"message": f"模型预加载完成,耗时: {preload_time:.3f}s", |
|
|
"preload_time": f"{preload_time:.3f}s", |
|
|
"preload_status": preload_status, |
|
|
"requested_styles": style_types, |
|
|
} |
|
|
except Exception as e: |
|
|
logger.error(f"Anime style model preloading failed: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"预加载失败: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post("/anime_style") |
|
|
@log_api_params |
|
|
async def anime_stylize_photo( |
|
|
file: UploadFile = File(...), |
|
|
style_type: str = Form("handdrawn", |
|
|
description="动漫风格类型: handdrawn=手绘风格, disney=迪士尼风格, illustration=插画风格, artstyle=艺术风格, anime=二次元风格, sketch=素描风格"), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
图片动漫风格化接口 |
|
|
:param file: 上传的照片文件 |
|
|
:param style_type: 动漫风格类型,默认为"disney"(迪士尼风格) |
|
|
:return: 动漫风格化结果,包含风格化后图片的文件名 |
|
|
""" |
|
|
_ensure_anime_stylizer() |
|
|
if anime_stylizer is None or not anime_stylizer.is_available(): |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail="动漫风格化处理器未初始化,请检查服务状态。" |
|
|
) |
|
|
|
|
|
|
|
|
if not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传图片文件") |
|
|
|
|
|
|
|
|
valid_styles = ["handdrawn", "disney", "illustration", "artstyle", "anime", "sketch"] |
|
|
if style_type not in valid_styles: |
|
|
raise HTTPException(status_code=400, detail=f"不支持的风格类型,请选择: {valid_styles}") |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
if not contents: |
|
|
raise HTTPException(status_code=400, detail="文件内容为空") |
|
|
|
|
|
original_md5_hash = hashlib.md5(contents).hexdigest() |
|
|
|
|
|
np_arr = np.frombuffer(contents, np.uint8) |
|
|
image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) |
|
|
if image is None: |
|
|
raise HTTPException( |
|
|
status_code=400, detail="无法解析图片文件,请确保文件格式正确。" |
|
|
) |
|
|
|
|
|
def _save_webp_and_upload(image_array: np.ndarray, output_path: str, |
|
|
log_prefix: str): |
|
|
success, encoded_img = cv2.imencode( |
|
|
".webp", image_array, |
|
|
[cv2.IMWRITE_WEBP_QUALITY, SAVE_QUALITY] |
|
|
) |
|
|
if not success: |
|
|
logger.error(f"{log_prefix}编码失败: {output_path}") |
|
|
return False, False |
|
|
try: |
|
|
with open(output_path, "wb") as output_file: |
|
|
output_file.write(encoded_img) |
|
|
except Exception as save_exc: |
|
|
logger.error( |
|
|
f"{log_prefix}保存失败: {output_path}, error: {save_exc}") |
|
|
return False, False |
|
|
logger.info( |
|
|
f"{log_prefix}保存成功: {output_path}, size: {len(encoded_img) / 1024:.2f} KB" |
|
|
) |
|
|
bos_uploaded_flag = upload_file_to_bos(output_path) |
|
|
return True, bos_uploaded_flag |
|
|
|
|
|
original_filename = f"{original_md5_hash}_anime_style.webp" |
|
|
original_path = os.path.join(IMAGES_DIR, original_filename) |
|
|
if not os.path.exists(original_path): |
|
|
original_saved, original_bos_uploaded = _save_webp_and_upload( |
|
|
image, original_path, "动漫风格原图" |
|
|
) |
|
|
if not original_saved: |
|
|
raise HTTPException(status_code=500, detail="保存原图失败") |
|
|
else: |
|
|
logger.info( |
|
|
f"Original image already exists for anime style: {original_filename}") |
|
|
original_bos_uploaded = False |
|
|
|
|
|
styled_uuid = uuid.uuid4().hex |
|
|
styled_filename = f"{styled_uuid}_anime_style_{style_type}.webp" |
|
|
|
|
|
|
|
|
style_descriptions = anime_stylizer.get_available_styles() |
|
|
style_description = style_descriptions.get(style_type, "未知风格") |
|
|
|
|
|
logger.info(f"Starting anime stylization processing: {file.filename}, size={file.size}, style={style_type}({style_description}), md5={original_md5_hash}") |
|
|
t1 = time.perf_counter() |
|
|
|
|
|
await _record_output_file( |
|
|
file_path=original_path, |
|
|
nickname=nickname, |
|
|
category="anime_style", |
|
|
bos_uploaded=original_bos_uploaded, |
|
|
extra={ |
|
|
"source": "anime_style", |
|
|
"style_type": style_type, |
|
|
"style_description": style_description, |
|
|
"md5": original_md5_hash, |
|
|
"role": "original", |
|
|
"original_filename": original_filename, |
|
|
}, |
|
|
) |
|
|
|
|
|
|
|
|
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)}") |
|
|
|
|
|
|
|
|
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, |
|
|
|
|
|
|
|
|
"processing_time": f"{total_time:.3f}s" |
|
|
} |
|
|
else: |
|
|
raise HTTPException(status_code=500, detail="保存动漫风格化后图像失败") |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Error occurred during anime stylization: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"动漫风格化过程中出现错误: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post("/grayscale") |
|
|
@log_api_params |
|
|
async def grayscale_photo( |
|
|
file: UploadFile = File(...), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
图像黑白化接口 |
|
|
:param file: 上传的照片文件 |
|
|
:return: 黑白化结果,包含黑白化后图片的文件名 |
|
|
""" |
|
|
|
|
|
if not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传图片文件") |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
original_md5_hash = str(uuid.uuid4()).replace('-', '') |
|
|
grayscale_filename = f"{original_md5_hash}_grayscale.webp" |
|
|
|
|
|
logger.info(f"Starting image grayscale conversion: {file.filename}, size={file.size}, md5={original_md5_hash}") |
|
|
t1 = time.perf_counter() |
|
|
|
|
|
|
|
|
np_arr = np.frombuffer(contents, np.uint8) |
|
|
image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) |
|
|
if image is None: |
|
|
raise HTTPException( |
|
|
status_code=400, detail="无法解析图片文件,请确保文件格式正确。" |
|
|
) |
|
|
|
|
|
|
|
|
original_height, original_width = image.shape[:2] |
|
|
original_size = file.size |
|
|
|
|
|
|
|
|
logger.info("Starting to convert image to grayscale...") |
|
|
try: |
|
|
|
|
|
gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) |
|
|
|
|
|
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)}") |
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
await _record_output_file( |
|
|
file_path=grayscale_path, |
|
|
nickname=nickname, |
|
|
category="grayscale", |
|
|
bos_uploaded=True, |
|
|
extra={ |
|
|
"source": "grayscale", |
|
|
"md5": original_md5_hash, |
|
|
}, |
|
|
) |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"message": "成功", |
|
|
"original_filename": file.filename, |
|
|
"grayscale_filename": grayscale_filename, |
|
|
"processing_time": f"{total_time:.3f}s", |
|
|
"original_size": original_size, |
|
|
"processed_size": processed_size, |
|
|
"size_increase_ratio": round(processed_size / original_size, 2), |
|
|
"original_dimensions": f"{original_width} × {original_height}", |
|
|
"processed_dimensions": f"{original_width} × {original_height}", |
|
|
} |
|
|
else: |
|
|
raise HTTPException(status_code=500, detail="保存黑白化后图像失败") |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Error occurred during image grayscale conversion: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"黑白化过程中出现错误: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post("/upscale") |
|
|
@log_api_params |
|
|
async def upscale_photo( |
|
|
file: UploadFile = File(...), |
|
|
md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"), |
|
|
scale: int = Query(UPSCALE_SIZE, description="放大倍数,支持2或4倍"), |
|
|
model_name: str = Query(REALESRGAN_MODEL, |
|
|
description="模型名称,推荐使用RealESRGAN_x2plus以提高CPU性能"), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
照片超清放大接口 |
|
|
:param file: 上传的照片文件 |
|
|
:param md5: 前端传递的文件md5,如果未传递则使用original_md5_hash |
|
|
:param scale: 放大倍数,默认4倍 |
|
|
:param model_name: 使用的模型名称 |
|
|
:return: 超清结果,包含超清后图片的文件名和相关信息 |
|
|
""" |
|
|
_ensure_realesrgan() |
|
|
if realesrgan_upscaler is None or not realesrgan_upscaler.is_available(): |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail="照片超清处理器未初始化,请检查服务状态。" |
|
|
) |
|
|
|
|
|
|
|
|
if not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传图片文件") |
|
|
|
|
|
|
|
|
if scale not in [2, 4]: |
|
|
raise HTTPException(status_code=400, detail="放大倍数只支持2倍或4倍") |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
original_md5_hash = str(uuid.uuid4()).replace('-', '') |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Error occurred during photo super resolution: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"超清过程中出现错误: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post("/remove_background") |
|
|
@log_api_params |
|
|
async def remove_background( |
|
|
file: UploadFile = File(...), |
|
|
background_color: str = Form("None", description="背景颜色,格式:r,g,b,如 255,255,255 为白色,None为透明背景"), |
|
|
model: str = Form("robustVideoMatting", description="使用的rembg模型: u2net, u2net_human_seg, silueta, isnet-general-use, robustVideoMatting"), |
|
|
output_format: str = Form("webp", description="输出格式: png, webp"), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
证件照抠图接口 |
|
|
:param file: 上传的图片文件 |
|
|
:param background_color: 背景颜色,格式:r,g,b 或 None |
|
|
:param model: 使用的模型: u2net, u2net_human_seg, silueta, isnet-general-use, robustVideoMatting |
|
|
:param output_format: 输出格式: png, webp |
|
|
:return: 抠图结果,包含抠图后图片的文件名 |
|
|
""" |
|
|
|
|
|
if not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传图片文件") |
|
|
|
|
|
|
|
|
if output_format not in ["png", "webp"]: |
|
|
raise HTTPException(status_code=400, detail="输出格式只支持png或webp") |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
|
|
|
np_arr = np.frombuffer(contents, np.uint8) |
|
|
image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) |
|
|
if image is None: |
|
|
raise HTTPException( |
|
|
status_code=400, detail="无法解析图片文件,请确保文件格式正确。" |
|
|
) |
|
|
|
|
|
|
|
|
has_face = False |
|
|
if analyzer is not None: |
|
|
try: |
|
|
face_boxes = analyzer._detect_faces(image) |
|
|
has_face = len(face_boxes) > 0 |
|
|
except Exception as e: |
|
|
logger.warning(f"Face detection failed: {e}") |
|
|
has_face = False |
|
|
|
|
|
|
|
|
if has_face and model == "robustVideoMatting": |
|
|
|
|
|
file.file = io.BytesIO(contents) |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
_ensure_rembg() |
|
|
if rembg_processor is None or not rembg_processor.is_available(): |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail="证件照抠图处理器未初始化,请检查服务状态。" |
|
|
) |
|
|
|
|
|
|
|
|
if model == "robustVideoMatting": |
|
|
model = "isnet-general-use" |
|
|
logger.info(f"User selected robustVideoMatting model but no face detected in image, switching to {model} model") |
|
|
|
|
|
|
|
|
unique_id = str(uuid.uuid4()).replace('-', '') |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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]) |
|
|
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] |
|
|
|
|
|
|
|
|
processed_path = os.path.join(IMAGES_DIR, processed_filename) |
|
|
bos_uploaded = False |
|
|
|
|
|
|
|
|
if bg_color is not None: |
|
|
|
|
|
save_success = save_image_high_quality(processed_image, processed_path, quality=SAVE_QUALITY) |
|
|
|
|
|
|
|
|
else: |
|
|
|
|
|
if output_format == "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: |
|
|
|
|
|
save_success = save_image_with_transparency(processed_image, 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: |
|
|
|
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Error occurred during ID photo background removal: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"抠图过程中出现错误: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post("/rvm") |
|
|
@log_api_params |
|
|
async def rvm_remove_background( |
|
|
file: UploadFile = File(...), |
|
|
background_color: str = Form("None", description="背景颜色,格式:r,g,b,如 255,255,255 为白色,None为透明背景"), |
|
|
output_format: str = Form("webp", description="输出格式: png, webp"), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
RVM证件照抠图接口 |
|
|
:param file: 上传的图片文件 |
|
|
:param background_color: 背景颜色,格式:r,g,b 或 None |
|
|
:param output_format: 输出格式: png, webp |
|
|
:return: 抠图结果,包含抠图后图片的文件名 |
|
|
""" |
|
|
_ensure_rvm() |
|
|
if rvm_processor is None or not rvm_processor.is_available(): |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail="RVM抠图处理器未初始化,请检查服务状态。" |
|
|
) |
|
|
|
|
|
|
|
|
if not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传图片文件") |
|
|
|
|
|
|
|
|
if output_format not in ["png", "webp"]: |
|
|
raise HTTPException(status_code=400, detail="输出格式只支持png或webp") |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
unique_id = str(uuid.uuid4()).replace('-', '') |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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]) |
|
|
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 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] |
|
|
|
|
|
|
|
|
processed_path = os.path.join(IMAGES_DIR, processed_filename) |
|
|
bos_uploaded = False |
|
|
|
|
|
|
|
|
if bg_color is not None: |
|
|
|
|
|
save_success = save_image_high_quality(processed_image, processed_path, quality=SAVE_QUALITY) |
|
|
|
|
|
|
|
|
else: |
|
|
|
|
|
if output_format == "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: |
|
|
|
|
|
save_success = save_image_with_transparency(processed_image, 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: |
|
|
|
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Error occurred during RVM ID photo background removal: {str(e)}") |
|
|
raise Exception(f"RVM抠图过程中出现错误: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.get("/keep_alive", tags=["系统维护"]) |
|
|
@log_api_params |
|
|
async def keep_cpu_alive( |
|
|
duration: float = Query( |
|
|
0.01, ge=0.001, le=60.0, description="需要保持CPU繁忙的持续时间(秒)" |
|
|
), |
|
|
intensity: int = Query( |
|
|
1, ge=1, le=500000, description="控制CPU占用强度的内部循环次数" |
|
|
), |
|
|
): |
|
|
""" |
|
|
手动触发CPU保持活跃,避免云服务因空闲进入休眠。 |
|
|
""" |
|
|
t_start = time.perf_counter() |
|
|
result = await process_cpu_intensive_task(_keep_cpu_busy, duration, intensity) |
|
|
total_elapsed = time.perf_counter() - t_start |
|
|
|
|
|
logger.info( |
|
|
"Keep-alive task completed | duration=%.2fs intensity=%d iterations=%d checksum=%d cpu_elapsed=%.3fs total=%.3fs", |
|
|
duration, |
|
|
intensity, |
|
|
result["iterations"], |
|
|
result["checksum"], |
|
|
result["elapsed"], |
|
|
total_elapsed, |
|
|
) |
|
|
|
|
|
return { |
|
|
"status": "ok", |
|
|
"requested_duration": duration, |
|
|
"requested_intensity": intensity, |
|
|
"cpu_elapsed": round(result["elapsed"], 3), |
|
|
"total_elapsed": round(total_elapsed, 3), |
|
|
"iterations": result["iterations"], |
|
|
"checksum": result["checksum"], |
|
|
"message": "CPU保持活跃任务已完成", |
|
|
"hostname": SERVER_HOSTNAME, |
|
|
} |
|
|
|
|
|
|
|
|
@api_router.get("/health") |
|
|
@log_api_params |
|
|
async def health_check(): |
|
|
"""健康检查接口""" |
|
|
return { |
|
|
"status": "healthy", |
|
|
"analyzer_ready": analyzer is not None, |
|
|
"deepface_available": DEEPFACE_AVAILABLE, |
|
|
"mediapipe_available": DLIB_AVAILABLE, |
|
|
"photo_restorer_available": photo_restorer is not None and photo_restorer.is_available(), |
|
|
"restorer_type": restorer_type, |
|
|
"ddcolor_available": ddcolor_colorizer is not None and ddcolor_colorizer.is_available(), |
|
|
"colorization_supported": DDCOLOR_AVAILABLE, |
|
|
"realesrgan_available": realesrgan_upscaler is not None and realesrgan_upscaler.is_available(), |
|
|
"upscale_supported": REALESRGAN_AVAILABLE, |
|
|
"rembg_available": rembg_processor is not None and rembg_processor.is_available(), |
|
|
"rvm_available": rvm_processor is not None and rvm_processor.is_available(), |
|
|
"id_photo_supported": REMBG_AVAILABLE, |
|
|
"clip_available": CLIP_AVAILABLE, |
|
|
"vector_search_supported": CLIP_AVAILABLE, |
|
|
"anime_stylizer_available": anime_stylizer is not None and anime_stylizer.is_available(), |
|
|
"anime_style_supported": ANIME_STYLE_AVAILABLE, |
|
|
"rvm_supported": RVM_AVAILABLE, |
|
|
"message": "Enhanced FaceScore API is running with photo restoration, colorization, upscale, ID photo generation and vector search support", |
|
|
"version": "3.2.0", |
|
|
} |
|
|
|
|
|
|
|
|
@api_router.get("/", response_class=HTMLResponse) |
|
|
@log_api_params |
|
|
async def index(): |
|
|
"""主页面""" |
|
|
file_path = os.path.join(os.path.dirname(__file__), "facescore.html") |
|
|
try: |
|
|
with open(file_path, "r", encoding="utf-8") as f: |
|
|
html_content = f.read() |
|
|
return HTMLResponse(content=html_content) |
|
|
except FileNotFoundError: |
|
|
return HTMLResponse( |
|
|
content="<h1>facescore.html not found</h1>", status_code=404 |
|
|
) |
|
|
|
|
|
|
|
|
@api_router.post("/split_grid") |
|
|
@log_api_params |
|
|
async def split_grid_image( |
|
|
file: UploadFile = File(...), |
|
|
grid_type: int = Form(9, |
|
|
description="宫格类型: 4表示2x2四宫格, 9表示3x3九宫格"), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
图片分层宫格接口 |
|
|
:param file: 上传的图片文件 |
|
|
:param grid_type: 宫格类型,4表示2x2四宫格,9表示3x3九宫格 |
|
|
:return: 分层结果,包含分割后的图片文件名列表 |
|
|
""" |
|
|
|
|
|
if not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传图片文件") |
|
|
|
|
|
|
|
|
if grid_type not in [4, 9]: |
|
|
raise HTTPException(status_code=400, detail="宫格类型只支持4(2x2)或9(3x3)") |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
original_md5_hash = str(uuid.uuid4()).replace('-', '') |
|
|
|
|
|
|
|
|
if grid_type == 4: |
|
|
rows, cols = 2, 2 |
|
|
grid_name = "2x2" |
|
|
else: |
|
|
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}") |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
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, |
|
|
}, |
|
|
) |
|
|
|
|
|
|
|
|
original_filename = f"{original_md5_hash}_original.webp" |
|
|
original_path = os.path.join(IMAGES_DIR, original_filename) |
|
|
if save_image_high_quality(image, original_path, quality=SAVE_QUALITY): |
|
|
await _record_output_file( |
|
|
file_path=original_path, |
|
|
nickname=nickname, |
|
|
category="original", |
|
|
extra={ |
|
|
"source": "split_grid", |
|
|
"grid_type": grid_type, |
|
|
"role": "original", |
|
|
}, |
|
|
) |
|
|
|
|
|
|
|
|
if CLIP_AVAILABLE: |
|
|
asyncio.create_task(handle_image_vector_async(original_path, original_filename)) |
|
|
|
|
|
total_time = time.perf_counter() - t1 |
|
|
logger.info(f"Image splitting completed: {len(grid_filenames)} grids, time: {total_time:.3f}s") |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"message": "分割成功", |
|
|
"original_filename": file.filename, |
|
|
"original_saved_filename": original_filename, |
|
|
"grid_type": grid_type, |
|
|
"grid_layout": f"{rows}x{cols}", |
|
|
"grid_count": len(grid_filenames), |
|
|
"grid_filenames": grid_filenames, |
|
|
"processing_time": f"{total_time:.3f}s", |
|
|
"image_dimensions": f"{width} × {height}", |
|
|
"grid_dimensions": f"{square_size} × {square_size}", |
|
|
"actual_used_area": f"{actual_width} × {actual_height}" |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Error occurred during image splitting: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"分割过程中出现错误: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post("/compress") |
|
|
@log_api_params |
|
|
async def compress_image( |
|
|
file: UploadFile = File(...), |
|
|
compressType: str = Form(...), |
|
|
outputFormat: str = Form(default="webp"), |
|
|
quality: int = Form(default=100), |
|
|
targetSize: float = Form(default=None), |
|
|
width: int = Form(default=None), |
|
|
height: int = Form(default=None), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
图像压缩接口 |
|
|
:param file: 上传的图片文件 |
|
|
:param compressType: 压缩类型 ('quality', 'dimension', 'size', 'format') |
|
|
:param outputFormat: 输出格式 ('jpg', 'png', 'webp') |
|
|
:param quality: 压缩质量 (10-100) |
|
|
:param targetSize: 目标文件大小 (bytes,仅用于按大小压缩) |
|
|
:param width: 目标宽度 (仅用于按尺寸压缩) |
|
|
:param height: 目标高度 (仅用于按尺寸压缩) |
|
|
:return: 压缩结果,包含压缩后图片的文件名和统计信息 |
|
|
""" |
|
|
|
|
|
if not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传图片文件") |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
unique_id = str(uuid.uuid4()).replace('-', '')[:32] |
|
|
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之间") |
|
|
|
|
|
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: |
|
|
raise HTTPException(status_code=400, detail="目标大小不能超过50MB") |
|
|
target_size_kb = targetSize * 1024 |
|
|
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)}") |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Error occurred during image compression: {str(e)}") |
|
|
raise HTTPException(status_code=500, detail=f"压缩过程中出现错误: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.get("/cleanup/status", tags=["系统管理"]) |
|
|
@log_api_params |
|
|
async def get_cleanup_scheduler_status(): |
|
|
""" |
|
|
获取图片清理定时任务状态 |
|
|
:return: 清理任务的状态信息 |
|
|
""" |
|
|
try: |
|
|
status = get_cleanup_status() |
|
|
return { |
|
|
"success": True, |
|
|
"status": status, |
|
|
"message": "获取清理任务状态成功" |
|
|
} |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to get cleanup task status: {e}") |
|
|
raise HTTPException(status_code=500, detail=f"获取清理任务状态失败: {str(e)}") |
|
|
|
|
|
|
|
|
@api_router.post("/cleanup/manual", tags=["系统管理"]) |
|
|
@log_api_params |
|
|
async def manual_cleanup_images(): |
|
|
""" |
|
|
手动执行一次图片清理任务 |
|
|
清理IMAGES_DIR目录中1小时以前的图片文件 |
|
|
:return: 清理结果统计 |
|
|
""" |
|
|
try: |
|
|
logger.info("Manually executing image cleanup task...") |
|
|
result = manual_cleanup() |
|
|
|
|
|
if result['success']: |
|
|
|
|
|
message = f"清理完成! 删除了 {result['deleted_count']} 个文件" |
|
|
if result['deleted_count'] > 0: |
|
|
message += f", 总大小: {result.get('deleted_size', 0) / 1024 / 1024:.2f} MB" |
|
|
|
|
|
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: |
|
|
|
|
|
error_str = result.get('error', '未知错误') |
|
|
message = f"清理任务执行失败: {error_str}" |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
try: |
|
|
shutil.rmtree(os.path.join(target_dir, "opt")) |
|
|
except FileNotFoundError: |
|
|
pass |
|
|
return True |
|
|
|
|
|
|
|
|
def _cleanup_chinese_celeb_hidden_files(target_dir: str) -> int: |
|
|
""" |
|
|
删除解压后遗留的 macOS 资源分叉文件(._*),避免污染后续处理。 |
|
|
""" |
|
|
pattern = os.path.join(target_dir, "._*") |
|
|
removed = 0 |
|
|
for hidden_path in glob.glob(pattern): |
|
|
try: |
|
|
if os.path.isdir(hidden_path): |
|
|
shutil.rmtree(hidden_path, ignore_errors=True) |
|
|
else: |
|
|
os.remove(hidden_path) |
|
|
removed += 1 |
|
|
except FileNotFoundError: |
|
|
continue |
|
|
except OSError as exc: |
|
|
logger.warning("清理隐藏文件失败: %s (%s)", hidden_path, exc) |
|
|
if removed: |
|
|
logger.info("已清理 chinese_celeb_dataset 隐藏文件 %d 个 (pattern=%s)", removed, pattern) |
|
|
return removed |
|
|
|
|
|
|
|
|
def extract_chinese_celeb_dataset_sync() -> Dict[str, Any]: |
|
|
""" |
|
|
同步执行 chinese_celeb_dataset 解压操作,供启动流程或其他同步场景复用。 |
|
|
""" |
|
|
archive_path = os.path.join(MODELS_PATH, "chinese_celeb_dataset.tar.gz") |
|
|
target_dir = "/opt/data/chinese_celeb_dataset" |
|
|
|
|
|
if not os.path.isfile(archive_path): |
|
|
raise FileNotFoundError(f"数据集文件不存在: {archive_path}") |
|
|
|
|
|
try: |
|
|
if os.path.isdir(target_dir): |
|
|
shutil.rmtree(target_dir) |
|
|
os.makedirs(target_dir, exist_ok=True) |
|
|
except OSError as exc: |
|
|
logger.error(f"创建目标目录失败: {target_dir}, {exc}") |
|
|
raise RuntimeError(f"创建目标目录失败: {exc}") from exc |
|
|
|
|
|
extract_result = _extract_tar_archive(archive_path, target_dir) |
|
|
flattened = _flatten_chinese_celeb_dataset_dir(target_dir) |
|
|
hidden_removed = _cleanup_chinese_celeb_hidden_files(target_dir) |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"message": "chinese_celeb_dataset 解压完成", |
|
|
"archive_path": archive_path, |
|
|
"target_dir": target_dir, |
|
|
"command": extract_result.get("command"), |
|
|
"stdout": extract_result.get("stdout"), |
|
|
"stderr": extract_result.get("stderr"), |
|
|
"normalized": flattened, |
|
|
"hidden_removed": hidden_removed, |
|
|
} |
|
|
|
|
|
|
|
|
def _run_shell_command(command: str, timeout: int = 300) -> Dict[str, Any]: |
|
|
"""执行外部命令并返回输出。""" |
|
|
logger.info(f"准备执行系统命令: {command}") |
|
|
try: |
|
|
completed = subprocess.run( |
|
|
command, |
|
|
shell=True, |
|
|
capture_output=True, |
|
|
text=True, |
|
|
timeout=timeout, |
|
|
) |
|
|
except subprocess.TimeoutExpired as exc: |
|
|
logger.error(f"命令执行超时({timeout}s): {command}") |
|
|
raise RuntimeError(f"命令执行超时({timeout}s): {exc}") from exc |
|
|
|
|
|
return { |
|
|
"returncode": completed.returncode, |
|
|
"stdout": (completed.stdout or "").strip(), |
|
|
"stderr": (completed.stderr or "").strip(), |
|
|
} |
|
|
|
|
|
|
|
|
@api_router.post("/datasets/chinese-celeb/extract", tags=["系统管理"]) |
|
|
@log_api_params |
|
|
async def extract_chinese_celeb_dataset(): |
|
|
""" |
|
|
解压 MODELS_PATH 下的 chinese_celeb_dataset.tar.gz 到 /opt/data/chinese_celeb_dataset。 |
|
|
""" |
|
|
loop = asyncio.get_event_loop() |
|
|
try: |
|
|
result = await loop.run_in_executor( |
|
|
executor, extract_chinese_celeb_dataset_sync |
|
|
) |
|
|
except FileNotFoundError as exc: |
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc |
|
|
except Exception as exc: |
|
|
logger.error(f"解压 chinese_celeb_dataset 失败: {exc}") |
|
|
raise HTTPException(status_code=500, detail=f"解压失败: {exc}") |
|
|
|
|
|
return result |
|
|
|
|
|
|
|
|
@api_router.post("/files/upload", tags=["文件管理"]) |
|
|
@log_api_params |
|
|
async def upload_file_to_directory( |
|
|
directory: str = Form(..., description="目标目录,支持绝对路径"), |
|
|
file: UploadFile = File(..., description="要上传的文件"), |
|
|
): |
|
|
"""上传文件到指定目录。""" |
|
|
if not directory.strip(): |
|
|
raise HTTPException(status_code=400, detail="目录参数不能为空") |
|
|
|
|
|
target_dir = os.path.abspath(os.path.expanduser(directory.strip())) |
|
|
try: |
|
|
os.makedirs(target_dir, exist_ok=True) |
|
|
except OSError as exc: |
|
|
logger.error(f"创建目录失败: {target_dir}, {exc}") |
|
|
raise HTTPException(status_code=500, detail=f"创建目录失败: {exc}") |
|
|
|
|
|
original_name = file.filename or "uploaded_file" |
|
|
filename = os.path.basename(original_name) or f"upload_{int(time.time())}" |
|
|
target_path = os.path.join(target_dir, filename) |
|
|
|
|
|
bytes_written = 0 |
|
|
try: |
|
|
with open(target_path, "wb") as out_file: |
|
|
while True: |
|
|
chunk = await file.read(1024 * 1024) |
|
|
if not chunk: |
|
|
break |
|
|
out_file.write(chunk) |
|
|
bytes_written += len(chunk) |
|
|
except Exception as exc: |
|
|
logger.error(f"保存上传文件失败: {exc}") |
|
|
raise HTTPException(status_code=500, detail=f"保存文件失败: {exc}") |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"message": "文件上传成功", |
|
|
"saved_path": target_path, |
|
|
"filename": filename, |
|
|
"size": bytes_written, |
|
|
} |
|
|
|
|
|
|
|
|
@api_router.get("/files/download", tags=["文件管理"]) |
|
|
@log_api_params |
|
|
async def download_file( |
|
|
file_path: str = Query(..., description="要下载的文件路径,支持绝对路径"), |
|
|
): |
|
|
"""根据给定路径下载文件。""" |
|
|
if not file_path.strip(): |
|
|
raise HTTPException(status_code=400, detail="文件路径不能为空") |
|
|
|
|
|
resolved_path = os.path.abspath(os.path.expanduser(file_path.strip())) |
|
|
if not os.path.isfile(resolved_path): |
|
|
raise HTTPException(status_code=404, detail=f"文件不存在: {resolved_path}") |
|
|
|
|
|
filename = os.path.basename(resolved_path) or "download" |
|
|
return FileResponse( |
|
|
resolved_path, |
|
|
filename=filename, |
|
|
media_type="application/octet-stream", |
|
|
) |
|
|
|
|
|
|
|
|
@api_router.post("/system/command", tags=["系统管理"]) |
|
|
@log_api_params |
|
|
async def execute_system_command(payload: Dict[str, Any]): |
|
|
""" |
|
|
执行Linux命令并返回stdout/stderr。 |
|
|
payload示例: {"command": "ls -l", "timeout": 120} |
|
|
""" |
|
|
command = (payload or {}).get("command") |
|
|
if not command or not isinstance(command, str): |
|
|
raise HTTPException(status_code=400, detail="必须提供command字符串") |
|
|
|
|
|
timeout = payload.get("timeout", 300) |
|
|
try: |
|
|
timeout_val = int(timeout) |
|
|
except (TypeError, ValueError): |
|
|
raise HTTPException(status_code=400, detail="timeout必须为整数") |
|
|
if timeout_val <= 0: |
|
|
raise HTTPException(status_code=400, detail="timeout必须为正整数") |
|
|
|
|
|
loop = asyncio.get_event_loop() |
|
|
try: |
|
|
result = await loop.run_in_executor( |
|
|
executor, _run_shell_command, command, timeout_val |
|
|
) |
|
|
except Exception as exc: |
|
|
logger.error(f"命令执行失败: {exc}") |
|
|
raise HTTPException(status_code=500, detail=f"命令执行失败: {exc}") |
|
|
|
|
|
success = result.get("returncode", 1) == 0 |
|
|
return { |
|
|
"success": success, |
|
|
"command": command, |
|
|
"returncode": result.get("returncode"), |
|
|
"stdout": result.get("stdout"), |
|
|
"stderr": result.get("stderr"), |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@api_router.post("/celebrity/keep_alive", tags=["系统维护"]) |
|
|
@log_api_params |
|
|
async def celebrity_keep_cpu_alive( |
|
|
duration: float = Query( |
|
|
0.01, ge=0.001, le=60.0, description="需要保持CPU繁忙的持续时间(秒)" |
|
|
), |
|
|
intensity: int = Query( |
|
|
1, ge=1, le=50000, description="控制CPU占用强度的内部循环次数" |
|
|
), |
|
|
): |
|
|
""" |
|
|
手动触发CPU保持活跃,避免云服务因空闲进入休眠。 |
|
|
""" |
|
|
t_start = time.perf_counter() |
|
|
result = await process_cpu_intensive_task(_keep_cpu_busy, duration, intensity) |
|
|
total_elapsed = time.perf_counter() - t_start |
|
|
|
|
|
logger.info( |
|
|
"Keep-alive task completed | duration=%.2fs intensity=%d iterations=%d checksum=%d cpu_elapsed=%.3fs total=%.3fs", |
|
|
duration, |
|
|
intensity, |
|
|
result["iterations"], |
|
|
result["checksum"], |
|
|
result["elapsed"], |
|
|
total_elapsed, |
|
|
) |
|
|
|
|
|
return { |
|
|
"status": "ok", |
|
|
"requested_duration": duration, |
|
|
"requested_intensity": intensity, |
|
|
"cpu_elapsed": round(result["elapsed"], 3), |
|
|
"total_elapsed": round(total_elapsed, 3), |
|
|
"iterations": result["iterations"], |
|
|
"checksum": result["checksum"], |
|
|
"message": "CPU保持活跃任务已完成", |
|
|
"hostname": SERVER_HOSTNAME, |
|
|
} |
|
|
|
|
|
|
|
|
@api_router.post("/celebrity/load", tags=["Face Recognition"]) |
|
|
@log_api_params |
|
|
async def load_celebrity_database(): |
|
|
"""刷新DeepFace明星人脸库缓存""" |
|
|
if not DEEPFACE_AVAILABLE or deepface_module is None: |
|
|
raise HTTPException(status_code=500, |
|
|
detail="DeepFace模块未初始化,请检查服务状态。") |
|
|
|
|
|
folder_path = CELEBRITY_SOURCE_DIR |
|
|
if not folder_path: |
|
|
raise HTTPException(status_code=500, |
|
|
detail="未配置明星图库目录,请设置环境变量 CELEBRITY_SOURCE_DIR。") |
|
|
|
|
|
folder_path = os.path.abspath(os.path.expanduser(folder_path)) |
|
|
if not os.path.isdir(folder_path): |
|
|
raise HTTPException(status_code=400, |
|
|
detail=f"文件夹不存在: {folder_path}") |
|
|
|
|
|
image_files = _iter_celebrity_images(folder_path) |
|
|
if not image_files: |
|
|
raise HTTPException(status_code=400, |
|
|
detail="明星图库目录中未找到有效图片。") |
|
|
|
|
|
encoded_files = [] |
|
|
renamed = [] |
|
|
|
|
|
for src_path in image_files: |
|
|
directory, original_name = os.path.split(src_path) |
|
|
base_name, ext = os.path.splitext(original_name) |
|
|
|
|
|
suffix_part = "" |
|
|
base_core = base_name |
|
|
if "__" in base_name: |
|
|
base_core, suffix_part = base_name.split("__", 1) |
|
|
suffix_part = f"__{suffix_part}" |
|
|
|
|
|
decoded_core = _decode_basename(base_core) |
|
|
if _encode_basename(decoded_core) == base_core: |
|
|
encoded_base = base_core |
|
|
else: |
|
|
encoded_base = _encode_basename(base_name) |
|
|
suffix_part = "" |
|
|
|
|
|
candidate_name = f"{encoded_base}{suffix_part}{ext.lower()}" |
|
|
target_path = os.path.join(directory, candidate_name) |
|
|
|
|
|
if os.path.normcase(src_path) != os.path.normcase(target_path): |
|
|
suffix = 1 |
|
|
while os.path.exists(target_path): |
|
|
candidate_name = f"{encoded_base}__{suffix}{ext.lower()}" |
|
|
target_path = os.path.join(directory, candidate_name) |
|
|
suffix += 1 |
|
|
try: |
|
|
os.rename(src_path, target_path) |
|
|
renamed.append({"old": src_path, "new": target_path}) |
|
|
except Exception as err: |
|
|
logger.error( |
|
|
f"Failed to rename celebrity image {src_path}: {err}") |
|
|
continue |
|
|
|
|
|
encoded_files.append(target_path) |
|
|
|
|
|
if not encoded_files: |
|
|
raise HTTPException(status_code=400, |
|
|
detail="明星图片重命名失败,请检查目录内容。") |
|
|
|
|
|
sample_image = encoded_files[0] |
|
|
start_time = time.perf_counter() |
|
|
logger.info( |
|
|
f"开始刷新明星人脸向量缓存,样本图片: {sample_image}, 总数: {len(encoded_files)}") |
|
|
|
|
|
stop_event = asyncio.Event() |
|
|
progress_task = asyncio.create_task( |
|
|
_log_progress("刷新明星人脸缓存", start_time, stop_event, interval=5.0)) |
|
|
|
|
|
try: |
|
|
await _refresh_celebrity_cache(sample_image, folder_path) |
|
|
finally: |
|
|
stop_event.set() |
|
|
try: |
|
|
await progress_task |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
total_time = time.perf_counter() - start_time |
|
|
|
|
|
logger.info( |
|
|
f"Celebrity library refreshed. total_images={len(encoded_files)} renamed={len(renamed)} sample={sample_image} elapsed={total_time:.1f}s" |
|
|
) |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"message": "明星图库缓存刷新成功", |
|
|
"data": { |
|
|
"total_images": len(encoded_files), |
|
|
"renamed": renamed, |
|
|
"sample_image": sample_image, |
|
|
"source": folder_path, |
|
|
"processing_time": total_time, |
|
|
}, |
|
|
} |
|
|
|
|
|
|
|
|
@api_router.post("/celebrity/match", tags=["Face Recognition"]) |
|
|
@log_api_params |
|
|
async def match_celebrity_face( |
|
|
file: UploadFile = File(..., description="待匹配的用户图片"), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
上传图片与明星人脸库比对 |
|
|
:param file: 上传图片 |
|
|
:return: 最相似的明星文件及分数 |
|
|
""" |
|
|
if not DEEPFACE_AVAILABLE or deepface_module is None: |
|
|
raise HTTPException(status_code=500, |
|
|
detail="DeepFace模块未初始化,请检查服务状态。") |
|
|
|
|
|
primary_dir = CELEBRITY_SOURCE_DIR |
|
|
if not primary_dir: |
|
|
raise HTTPException(status_code=500, |
|
|
detail="未配置明星图库目录,请设置环境变量 CELEBRITY_SOURCE_DIR。") |
|
|
|
|
|
db_path = os.path.abspath(os.path.expanduser(primary_dir)) |
|
|
if not os.path.isdir(db_path): |
|
|
raise HTTPException(status_code=400, |
|
|
detail=f"明星图库目录不存在: {db_path}") |
|
|
|
|
|
existing_files = _iter_celebrity_images(db_path) |
|
|
if not existing_files: |
|
|
raise HTTPException(status_code=400, |
|
|
detail="明星人脸库为空,请先调用导入接口。") |
|
|
|
|
|
if not file.content_type or not file.content_type.startswith("image/"): |
|
|
raise HTTPException(status_code=400, detail="请上传图片文件。") |
|
|
|
|
|
temp_filename: Optional[str] = None |
|
|
temp_path: Optional[str] = None |
|
|
cleanup_temp_file = False |
|
|
annotated_filename: Optional[str] = None |
|
|
|
|
|
try: |
|
|
contents = await file.read() |
|
|
np_arr = np.frombuffer(contents, np.uint8) |
|
|
image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) |
|
|
if image is None: |
|
|
raise HTTPException(status_code=400, |
|
|
detail="无法解析上传的图片,请确认格式。") |
|
|
|
|
|
if analyzer is None: |
|
|
_ensure_analyzer() |
|
|
|
|
|
faces: List[List[int]] = [] |
|
|
if analyzer is not None: |
|
|
faces = analyzer._detect_faces(image) |
|
|
if not faces: |
|
|
raise HTTPException(status_code=400, |
|
|
detail="图片中未检测到人脸,请重新上传。") |
|
|
|
|
|
temp_filename = f"{uuid.uuid4().hex}_celebrity_query.webp" |
|
|
temp_path = os.path.join(IMAGES_DIR, temp_filename) |
|
|
if not save_image_high_quality(image, temp_path, quality=SAVE_QUALITY): |
|
|
raise HTTPException(status_code=500, |
|
|
detail="保存临时图片失败,请稍后重试。") |
|
|
cleanup_temp_file = True |
|
|
await _record_output_file( |
|
|
file_path=temp_path, |
|
|
nickname=nickname, |
|
|
category="celebrity", |
|
|
extra={ |
|
|
"source": "celebrity_match", |
|
|
"role": "query", |
|
|
}, |
|
|
) |
|
|
|
|
|
def _build_find_kwargs(refresh: bool) -> dict: |
|
|
kwargs = dict( |
|
|
img_path=temp_path, |
|
|
db_path=db_path, |
|
|
model_name="ArcFace", |
|
|
detector_backend="yolov11n", |
|
|
distance_metric="cosine", |
|
|
enforce_detection=True, |
|
|
silent=True, |
|
|
refresh_database=refresh, |
|
|
) |
|
|
if CELEBRITY_FIND_THRESHOLD is not None: |
|
|
kwargs["threshold"] = CELEBRITY_FIND_THRESHOLD |
|
|
return kwargs |
|
|
|
|
|
lock = _ensure_deepface_lock() |
|
|
async with lock: |
|
|
try: |
|
|
find_result = await process_cpu_intensive_task( |
|
|
deepface_module.find, |
|
|
**_build_find_kwargs(refresh=False), |
|
|
) |
|
|
except (AttributeError, RuntimeError) as attr_err: |
|
|
if "numpy" in str(attr_err) or "SymbolicTensor" in str(attr_err): |
|
|
logger.warning( |
|
|
f"DeepFace find encountered numpy/SymbolicTensor error, 尝试清理模型后刷新缓存: {attr_err}") |
|
|
_recover_deepface_model() |
|
|
find_result = await process_cpu_intensive_task( |
|
|
deepface_module.find, |
|
|
**_build_find_kwargs(refresh=True), |
|
|
) |
|
|
else: |
|
|
raise |
|
|
except ValueError as ve: |
|
|
logger.warning( |
|
|
f"DeepFace find failed without refresh: {ve}, 尝试清理模型后刷新缓存。") |
|
|
_recover_deepface_model() |
|
|
find_result = await process_cpu_intensive_task( |
|
|
deepface_module.find, |
|
|
**_build_find_kwargs(refresh=True), |
|
|
) |
|
|
|
|
|
if not find_result: |
|
|
raise HTTPException(status_code=404, detail="未找到相似的人脸。") |
|
|
|
|
|
result_df = find_result[0] |
|
|
best_record = None |
|
|
if hasattr(result_df, "empty"): |
|
|
if result_df.empty: |
|
|
raise HTTPException(status_code=404, detail="未找到相似的人脸。") |
|
|
best_record = result_df.iloc[0] |
|
|
elif isinstance(result_df, list) and result_df: |
|
|
best_record = result_df[0] |
|
|
else: |
|
|
raise HTTPException(status_code=500, |
|
|
detail="明星人脸库返回格式异常。") |
|
|
|
|
|
|
|
|
if hasattr(best_record, "to_dict"): |
|
|
best_record_data = best_record.to_dict() |
|
|
else: |
|
|
best_record_data = dict(best_record) |
|
|
|
|
|
identity_path = str(best_record_data.get("identity", "")) |
|
|
if not identity_path: |
|
|
raise HTTPException(status_code=500, |
|
|
detail="识别结果缺少identity字段。") |
|
|
|
|
|
distance = float(best_record_data.get("distance", 0.0)) |
|
|
similarity = max(0.0, min(100.0, (1 - distance / 2) * 100)) |
|
|
confidence_raw = best_record_data.get("confidence") |
|
|
confidence = float( |
|
|
confidence_raw) if confidence_raw is not None else similarity |
|
|
filename = os.path.basename(identity_path) |
|
|
base, ext = os.path.splitext(filename) |
|
|
encoded_part = base.split("__", 1)[0] if "__" in base else base |
|
|
display_name = _decode_basename(encoded_part) |
|
|
|
|
|
def _parse_coord(value): |
|
|
try: |
|
|
if value is None: |
|
|
return None |
|
|
if isinstance(value, (np.integer, int)): |
|
|
return int(value) |
|
|
if isinstance(value, (np.floating, float)): |
|
|
if np.isnan(value): |
|
|
return None |
|
|
return int(round(float(value))) |
|
|
if isinstance(value, str) and value.strip(): |
|
|
return int(round(float(value))) |
|
|
except Exception: |
|
|
return None |
|
|
return None |
|
|
|
|
|
img_height, img_width = image.shape[:2] |
|
|
crop = None |
|
|
|
|
|
matched_box = None |
|
|
|
|
|
sx = _parse_coord(best_record_data.get("source_x")) |
|
|
sy = _parse_coord(best_record_data.get("source_y")) |
|
|
sw = _parse_coord(best_record_data.get("source_w")) |
|
|
sh = _parse_coord(best_record_data.get("source_h")) |
|
|
|
|
|
if ( |
|
|
sx is not None |
|
|
and sy is not None |
|
|
and sw is not None |
|
|
and sh is not None |
|
|
and sw > 0 |
|
|
and sh > 0 |
|
|
): |
|
|
x1 = max(0, sx) |
|
|
y1 = max(0, sy) |
|
|
x2 = min(img_width, x1 + sw) |
|
|
y2 = min(img_height, y1 + sh) |
|
|
if x2 > x1 and y2 > y1: |
|
|
crop = image[y1:y2, x1:x2] |
|
|
matched_box = (x1, y1, x2, y2) |
|
|
|
|
|
if (crop is None or crop.size == 0) and faces: |
|
|
def _area(box): |
|
|
if not box or len(box) < 4: |
|
|
return 0 |
|
|
return max(0, box[2] - box[0]) * max(0, box[3] - box[1]) |
|
|
|
|
|
largest_face = max(faces, key=_area) |
|
|
if largest_face and len(largest_face) >= 4: |
|
|
fx1, fy1, fx2, fy2 = [int(max(0, v)) for v in largest_face[:4]] |
|
|
fx1 = min(fx1, img_width - 1) |
|
|
fy1 = min(fy1, img_height - 1) |
|
|
fx2 = min(max(fx1 + 1, fx2), img_width) |
|
|
fy2 = min(max(fy1 + 1, fy2), img_height) |
|
|
if fx2 > fx1 and fy2 > fy1: |
|
|
crop = image[fy1:fy2, fx1:fx2] |
|
|
matched_box = (fx1, fy1, fx2, fy2) |
|
|
|
|
|
face_filename = None |
|
|
if crop is not None and crop.size > 0: |
|
|
face_filename = f"{uuid.uuid4().hex}_face_1.webp" |
|
|
face_path = os.path.join(IMAGES_DIR, face_filename) |
|
|
if not save_image_high_quality(crop, face_path, |
|
|
quality=SAVE_QUALITY): |
|
|
logger.error(f"Failed to save cropped face image: {face_path}") |
|
|
face_filename = None |
|
|
else: |
|
|
await _record_output_file( |
|
|
file_path=face_path, |
|
|
nickname=nickname, |
|
|
category="face", |
|
|
extra={ |
|
|
"source": "celebrity_match", |
|
|
"role": "face_crop", |
|
|
}, |
|
|
) |
|
|
if matched_box is not None and temp_path: |
|
|
annotated_image = image.copy() |
|
|
x1, y1, x2, y2 = matched_box |
|
|
thickness = max(2, int(round(min(img_height, img_width) / 200))) |
|
|
thickness = max(thickness, 2) |
|
|
cv2.rectangle(annotated_image, (x1, y1), (x2, y2), |
|
|
color=(0, 255, 0), thickness=thickness) |
|
|
if save_image_high_quality(annotated_image, temp_path, |
|
|
quality=SAVE_QUALITY): |
|
|
annotated_filename = temp_filename |
|
|
cleanup_temp_file = False |
|
|
await _record_output_file( |
|
|
file_path=temp_path, |
|
|
nickname=nickname, |
|
|
category="celebrity", |
|
|
extra={ |
|
|
"source": "celebrity_match", |
|
|
"role": "annotated", |
|
|
}, |
|
|
) |
|
|
else: |
|
|
logger.error( |
|
|
f"Failed to save annotated celebrity image: {temp_path}") |
|
|
elif temp_path: |
|
|
|
|
|
annotated_filename = temp_filename |
|
|
cleanup_temp_file = False |
|
|
|
|
|
result_payload = CelebrityMatchResponse( |
|
|
filename=filename, |
|
|
display_name=display_name, |
|
|
distance=distance, |
|
|
similarity=similarity, |
|
|
confidence=confidence, |
|
|
face_filename=face_filename, |
|
|
) |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"filename": result_payload.filename, |
|
|
"display_name": result_payload.display_name, |
|
|
"distance": result_payload.distance, |
|
|
"similarity": result_payload.similarity, |
|
|
"confidence": result_payload.confidence, |
|
|
"face_filename": result_payload.face_filename, |
|
|
"annotated_filename": annotated_filename, |
|
|
} |
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Celebrity match failed: {e}") |
|
|
raise HTTPException(status_code=500, |
|
|
detail=f"明星人脸匹配失败: {str(e)}") |
|
|
finally: |
|
|
if cleanup_temp_file and temp_path: |
|
|
try: |
|
|
os.remove(temp_path) |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
|
|
|
@api_router.post("/face_verify") |
|
|
@log_api_params |
|
|
async def face_similarity_verification( |
|
|
file1: UploadFile = File(..., description="第一张人脸图片"), |
|
|
file2: UploadFile = File(..., description="第二张人脸图片"), |
|
|
nickname: str = Form(None, description="操作者昵称"), |
|
|
): |
|
|
""" |
|
|
人脸相似度比对接口 |
|
|
:param file1: 第一张人脸图片文件 |
|
|
:param file2: 第二张人脸图片文件 |
|
|
:return: 人脸比对结果,包括相似度分值和裁剪后的人脸图片 |
|
|
""" |
|
|
|
|
|
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="第二张图片中未检测到人脸,请上传包含清晰人脸的图片") |
|
|
|
|
|
|
|
|
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="保存第二张原始图片失败") |
|
|
|
|
|
|
|
|
logger.info("Starting DeepFace verification...") |
|
|
lock = _ensure_deepface_lock() |
|
|
async with lock: |
|
|
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 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"] |
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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)}") |
|
|
|