2026-07-07 02:33:38 +08:00
|
|
|
|
"""腾讯云人脸识别客户端(精简门面)。
|
|
|
|
|
|
|
|
|
|
|
|
原 1311 行的客户端已拆分为:
|
|
|
|
|
|
- ``retry.py``: 重试配置与执行器
|
|
|
|
|
|
- ``image_processor.py``: 图片获取/压缩/编码
|
|
|
|
|
|
- ``api_operations.py``: 各 API 操作的实现(搜索/检测/属性/人员/人脸/库信息/列表)
|
|
|
|
|
|
|
|
|
|
|
|
本文件仅保留:凭据创建、HTTP Session 管理、错误脱敏、以及对外暴露的
|
|
|
|
|
|
``TencentCloudClient`` 门面,委托给上述模块。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
2025-09-07 21:48:21 +08:00
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-07-07 02:33:38 +08:00
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
2025-09-07 21:48:21 +08:00
|
|
|
|
import requests
|
|
|
|
|
|
from tencentcloud.common import credential
|
2026-07-07 02:33:38 +08:00
|
|
|
|
from tencentcloud.common.exception.tencent_cloud_sdk_exception import (
|
|
|
|
|
|
TencentCloudSDKException,
|
2025-09-07 21:48:21 +08:00
|
|
|
|
)
|
2026-07-07 02:33:38 +08:00
|
|
|
|
from tencentcloud.iai.v20200303 import iai_client
|
|
|
|
|
|
|
|
|
|
|
|
from . import api_operations
|
2025-09-07 21:48:21 +08:00
|
|
|
|
from .errors import (
|
2026-07-07 02:33:38 +08:00
|
|
|
|
ApiError,
|
|
|
|
|
|
AuthenticationError,
|
2025-09-07 21:48:21 +08:00
|
|
|
|
log_and_raise_error,
|
|
|
|
|
|
validate_config,
|
|
|
|
|
|
)
|
2026-07-07 02:33:38 +08:00
|
|
|
|
from .image_processor import ImageCache, get_image_base64
|
|
|
|
|
|
from .retry import RetryConfig
|
|
|
|
|
|
from .const import CONF_REGION, CONF_SECRET_ID, CONF_SECRET_KEY
|
2025-09-07 21:48:21 +08:00
|
|
|
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TencentCloudClient:
|
2026-07-07 02:33:38 +08:00
|
|
|
|
"""腾讯云人脸识别客户端门面。"""
|
2025-09-07 21:48:21 +08:00
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
secret_id: str,
|
|
|
|
|
|
secret_key: str,
|
|
|
|
|
|
region: str = "ap-shanghai",
|
2026-07-07 02:33:38 +08:00
|
|
|
|
retry_config: Optional[RetryConfig] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
validate_config({
|
|
|
|
|
|
CONF_SECRET_ID: secret_id,
|
|
|
|
|
|
CONF_SECRET_KEY: secret_key,
|
|
|
|
|
|
CONF_REGION: region,
|
|
|
|
|
|
})
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
2025-09-07 21:48:21 +08:00
|
|
|
|
self.secret_id = secret_id
|
|
|
|
|
|
self.secret_key = secret_key
|
|
|
|
|
|
self.region = region
|
|
|
|
|
|
self.retry_config = retry_config or RetryConfig()
|
|
|
|
|
|
self.client = self._create_client()
|
2026-07-07 02:33:38 +08:00
|
|
|
|
|
|
|
|
|
|
# HTTP 连接池化
|
2026-05-02 00:18:57 +08:00
|
|
|
|
self._session = requests.Session()
|
2026-07-07 02:33:38 +08:00
|
|
|
|
self._session.mount(
|
|
|
|
|
|
"http://", requests.adapters.HTTPAdapter(max_retries=2)
|
|
|
|
|
|
)
|
|
|
|
|
|
self._session.mount(
|
|
|
|
|
|
"https://", requests.adapters.HTTPAdapter(max_retries=2)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 图片缓存(url/path),通过 session 下载
|
|
|
|
|
|
self._image_cache = ImageCache(
|
|
|
|
|
|
maxsize=32,
|
|
|
|
|
|
download_fn=lambda url: get_image_base64(
|
|
|
|
|
|
image_url=url, session=self._session
|
|
|
|
|
|
),
|
|
|
|
|
|
read_fn=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# --- 资源管理 --------------------------------------------------------
|
2026-05-02 00:18:57 +08:00
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
2026-07-07 02:33:38 +08:00
|
|
|
|
"""释放 HTTP Session 等资源。"""
|
2026-05-02 00:18:57 +08:00
|
|
|
|
if self._session:
|
|
|
|
|
|
self._session.close()
|
2026-07-07 02:33:38 +08:00
|
|
|
|
self._image_cache.clear()
|
|
|
|
|
|
|
|
|
|
|
|
# --- 客户端创建 -------------------------------------------------------
|
2025-09-07 21:48:21 +08:00
|
|
|
|
|
|
|
|
|
|
def _create_client(self) -> iai_client.IaiClient:
|
|
|
|
|
|
try:
|
|
|
|
|
|
_LOGGER.debug("创建腾讯云客户端,区域: %s", self.region)
|
|
|
|
|
|
cred = credential.Credential(self.secret_id, self.secret_key)
|
|
|
|
|
|
client = iai_client.IaiClient(cred, self.region)
|
|
|
|
|
|
_LOGGER.debug("腾讯云客户端创建成功")
|
|
|
|
|
|
return client
|
|
|
|
|
|
except Exception as ex:
|
2026-05-01 23:14:39 +08:00
|
|
|
|
_LOGGER.error("创建腾讯云客户端失败: %s", self._sanitize_error(str(ex)))
|
2025-09-07 21:48:21 +08:00
|
|
|
|
log_and_raise_error(
|
|
|
|
|
|
AuthenticationError,
|
2026-05-01 23:14:39 +08:00
|
|
|
|
"创建腾讯云客户端失败",
|
2026-07-07 02:33:38 +08:00
|
|
|
|
{"region": self.region, "error_type": type(ex).__name__},
|
2025-09-07 21:48:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-07 02:33:38 +08:00
|
|
|
|
# --- 凭据验证 ---------------------------------------------------------
|
|
|
|
|
|
|
2026-05-01 23:14:39 +08:00
|
|
|
|
def verify_credentials(self) -> bool:
|
2026-07-07 02:33:38 +08:00
|
|
|
|
"""验证凭据(轻量 GetGroupList)。失败抛出对应错误。"""
|
|
|
|
|
|
return api_operations.verify_credentials(
|
|
|
|
|
|
self.client, retry_config=self.retry_config
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def verify_credentials_lightweight(self) -> bool:
|
|
|
|
|
|
"""测试 API 连接(轻量验证)。失败返回 False,不抛异常。"""
|
2026-05-01 23:14:39 +08:00
|
|
|
|
try:
|
2026-07-07 02:33:38 +08:00
|
|
|
|
self.verify_credentials()
|
2026-05-01 23:14:39 +08:00
|
|
|
|
return True
|
2026-07-07 02:33:38 +08:00
|
|
|
|
except Exception as ex: # noqa: BLE001
|
|
|
|
|
|
_LOGGER.error("API连接测试失败: %s", self._sanitize_error(str(ex)))
|
|
|
|
|
|
return False
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
2026-07-07 02:33:38 +08:00
|
|
|
|
def test_api_connection(self, group_id: str) -> bool:
|
|
|
|
|
|
"""测试与指定人员库的连接。失败返回 False。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
_LOGGER.info("开始测试API连接: group_id=%s", group_id)
|
|
|
|
|
|
result = self.get_group_info(group_id)
|
|
|
|
|
|
if result.get("success", False):
|
|
|
|
|
|
_LOGGER.info("API连接测试成功")
|
|
|
|
|
|
return True
|
|
|
|
|
|
_LOGGER.error("API连接测试失败: %s", result.get("error_message"))
|
|
|
|
|
|
return False
|
|
|
|
|
|
except Exception as ex: # noqa: BLE001
|
|
|
|
|
|
_LOGGER.error("API连接测试失败: %s", self._sanitize_error(str(ex)))
|
|
|
|
|
|
return False
|
2025-09-07 21:48:21 +08:00
|
|
|
|
|
2026-07-07 02:33:38 +08:00
|
|
|
|
# 兼容旧调用名
|
|
|
|
|
|
_test_api_connection = test_api_connection
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
2026-07-07 02:33:38 +08:00
|
|
|
|
# --- 图片 ------------------------------------------------------------
|
2026-05-02 00:18:57 +08:00
|
|
|
|
|
2026-07-07 02:33:38 +08:00
|
|
|
|
def _get_image_base64(
|
|
|
|
|
|
self,
|
|
|
|
|
|
image_url: Optional[str] = None,
|
|
|
|
|
|
image_path: Optional[str] = None,
|
|
|
|
|
|
image_file: Optional[Any] = None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""获取图片 base64(带 url/path 缓存)。"""
|
|
|
|
|
|
if image_url:
|
|
|
|
|
|
return self._image_cache.get_url(image_url)
|
|
|
|
|
|
if image_path:
|
|
|
|
|
|
return self._image_cache.get_path(image_path)
|
|
|
|
|
|
if image_file:
|
|
|
|
|
|
return self._image_cache.get(image_file=image_file)
|
|
|
|
|
|
log_and_raise_error(
|
|
|
|
|
|
ApiError,
|
|
|
|
|
|
"必须提供 image_url、image_path 或 image_file",
|
|
|
|
|
|
{"reason": "no_image_source"},
|
2025-09-07 21:48:21 +08:00
|
|
|
|
)
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
2026-07-07 02:33:38 +08:00
|
|
|
|
# --- 业务 API(委托 api_operations) ----------------------------------
|
2025-09-07 21:48:21 +08:00
|
|
|
|
|
|
|
|
|
|
def search_faces(
|
|
|
|
|
|
self,
|
2026-07-07 02:33:38 +08:00
|
|
|
|
image_url: Optional[str] = None,
|
|
|
|
|
|
image_path: Optional[str] = None,
|
|
|
|
|
|
image_file: Optional[Any] = None,
|
|
|
|
|
|
image_base64: Optional[str] = None,
|
|
|
|
|
|
group_ids: Optional[List[str]] = None,
|
2025-09-07 21:48:21 +08:00
|
|
|
|
max_face_num: int = 1,
|
|
|
|
|
|
min_face_size: int = 34,
|
|
|
|
|
|
max_user_num: int = 5,
|
|
|
|
|
|
quality_control: int = 1,
|
|
|
|
|
|
need_rotate_check: int = 1,
|
2026-07-07 02:33:38 +08:00
|
|
|
|
face_match_threshold: float = 60.0,
|
2025-09-07 23:21:24 +08:00
|
|
|
|
) -> Dict[str, Any]:
|
2026-05-01 23:14:39 +08:00
|
|
|
|
if image_base64 is None:
|
|
|
|
|
|
image_base64 = self._get_image_base64(image_url, image_path, image_file)
|
2026-07-07 02:33:38 +08:00
|
|
|
|
if not group_ids:
|
|
|
|
|
|
_LOGGER.warning("未提供人员库ID,搜索可能失败")
|
|
|
|
|
|
return api_operations.search_faces(
|
|
|
|
|
|
self.client,
|
|
|
|
|
|
image_base64=image_base64,
|
|
|
|
|
|
group_ids=group_ids or [],
|
|
|
|
|
|
max_face_num=max_face_num,
|
|
|
|
|
|
min_face_size=min_face_size,
|
|
|
|
|
|
max_user_num=max_user_num,
|
|
|
|
|
|
quality_control=quality_control,
|
|
|
|
|
|
need_rotate_check=need_rotate_check,
|
|
|
|
|
|
face_match_threshold=face_match_threshold,
|
|
|
|
|
|
retry_config=self.retry_config,
|
|
|
|
|
|
sanitize=self._sanitize_error,
|
|
|
|
|
|
)
|
2025-09-07 21:48:21 +08:00
|
|
|
|
|
|
|
|
|
|
def detect_faces(
|
|
|
|
|
|
self,
|
2026-07-07 02:33:38 +08:00
|
|
|
|
image_url: Optional[str] = None,
|
|
|
|
|
|
image_path: Optional[str] = None,
|
|
|
|
|
|
image_file: Optional[Any] = None,
|
|
|
|
|
|
image_base64: Optional[str] = None,
|
2025-09-07 21:48:21 +08:00
|
|
|
|
max_face_num: int = 1,
|
|
|
|
|
|
min_face_size: int = 34,
|
2026-07-07 02:33:38 +08:00
|
|
|
|
need_rotate_check: int = 1,
|
2025-09-07 23:21:24 +08:00
|
|
|
|
) -> Dict[str, Any]:
|
2026-05-01 23:14:39 +08:00
|
|
|
|
if image_base64 is None:
|
|
|
|
|
|
image_base64 = self._get_image_base64(image_url, image_path, image_file)
|
2026-07-07 02:33:38 +08:00
|
|
|
|
return api_operations.detect_faces(
|
|
|
|
|
|
self.client,
|
|
|
|
|
|
image_base64=image_base64,
|
|
|
|
|
|
max_face_num=max_face_num,
|
|
|
|
|
|
min_face_size=min_face_size,
|
|
|
|
|
|
need_rotate_check=need_rotate_check,
|
|
|
|
|
|
retry_config=self.retry_config,
|
|
|
|
|
|
sanitize=self._sanitize_error,
|
|
|
|
|
|
)
|
2025-09-07 21:48:21 +08:00
|
|
|
|
|
|
|
|
|
|
def get_face_attributes(
|
|
|
|
|
|
self,
|
2026-07-07 02:33:38 +08:00
|
|
|
|
image_url: Optional[str] = None,
|
|
|
|
|
|
image_path: Optional[str] = None,
|
|
|
|
|
|
image_file: Optional[Any] = None,
|
|
|
|
|
|
image_base64: Optional[str] = None,
|
2025-09-07 21:48:21 +08:00
|
|
|
|
max_face_num: int = 1,
|
2026-07-07 02:33:38 +08:00
|
|
|
|
need_rotate_check: int = 1,
|
2025-09-07 23:21:24 +08:00
|
|
|
|
) -> Dict[str, Any]:
|
2026-05-01 23:14:39 +08:00
|
|
|
|
if image_base64 is None:
|
|
|
|
|
|
image_base64 = self._get_image_base64(image_url, image_path, image_file)
|
2026-07-07 02:33:38 +08:00
|
|
|
|
return api_operations.get_face_attributes(
|
|
|
|
|
|
self.client,
|
|
|
|
|
|
image_base64=image_base64,
|
|
|
|
|
|
max_face_num=max_face_num,
|
|
|
|
|
|
need_rotate_check=need_rotate_check,
|
|
|
|
|
|
retry_config=self.retry_config,
|
|
|
|
|
|
sanitize=self._sanitize_error,
|
|
|
|
|
|
)
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
|
|
|
|
|
def create_person(
|
|
|
|
|
|
self,
|
|
|
|
|
|
person_id: str,
|
|
|
|
|
|
person_name: str,
|
|
|
|
|
|
group_id: str,
|
2026-07-07 02:33:38 +08:00
|
|
|
|
image_url: Optional[str] = None,
|
|
|
|
|
|
image_path: Optional[str] = None,
|
|
|
|
|
|
image_file: Optional[Any] = None,
|
|
|
|
|
|
image_base64: Optional[str] = None,
|
|
|
|
|
|
gender: Optional[int] = None,
|
|
|
|
|
|
person_tag: Optional[str] = None,
|
2026-05-01 23:14:39 +08:00
|
|
|
|
quality_control: int = 1,
|
|
|
|
|
|
need_rotate_check: int = 1,
|
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
|
if image_base64 is None and any([image_url, image_path, image_file]):
|
|
|
|
|
|
image_base64 = self._get_image_base64(image_url, image_path, image_file)
|
2026-07-07 02:33:38 +08:00
|
|
|
|
return api_operations.create_person(
|
|
|
|
|
|
self.client,
|
|
|
|
|
|
person_id=person_id,
|
|
|
|
|
|
person_name=person_name,
|
|
|
|
|
|
group_id=group_id,
|
|
|
|
|
|
image_base64=image_base64,
|
|
|
|
|
|
gender=gender,
|
|
|
|
|
|
person_tag=person_tag,
|
|
|
|
|
|
quality_control=quality_control,
|
|
|
|
|
|
need_rotate_check=need_rotate_check,
|
|
|
|
|
|
retry_config=self.retry_config,
|
|
|
|
|
|
sanitize=self._sanitize_error,
|
|
|
|
|
|
)
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
|
|
|
|
|
def delete_person(self, person_id: str) -> Dict[str, Any]:
|
2026-07-07 02:33:38 +08:00
|
|
|
|
return api_operations.delete_person(
|
|
|
|
|
|
self.client,
|
|
|
|
|
|
person_id=person_id,
|
|
|
|
|
|
retry_config=self.retry_config,
|
|
|
|
|
|
sanitize=self._sanitize_error,
|
|
|
|
|
|
)
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
|
|
|
|
|
def create_face(
|
|
|
|
|
|
self,
|
|
|
|
|
|
person_id: str,
|
2026-07-07 02:33:38 +08:00
|
|
|
|
image_url: Optional[str] = None,
|
|
|
|
|
|
image_path: Optional[str] = None,
|
|
|
|
|
|
image_file: Optional[Any] = None,
|
|
|
|
|
|
image_base64: Optional[str] = None,
|
2026-05-01 23:14:39 +08:00
|
|
|
|
quality_control: int = 1,
|
|
|
|
|
|
need_rotate_check: int = 1,
|
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
|
if image_base64 is None:
|
|
|
|
|
|
image_base64 = self._get_image_base64(image_url, image_path, image_file)
|
2026-07-07 02:33:38 +08:00
|
|
|
|
return api_operations.create_face(
|
|
|
|
|
|
self.client,
|
|
|
|
|
|
person_id=person_id,
|
|
|
|
|
|
image_base64=image_base64,
|
|
|
|
|
|
quality_control=quality_control,
|
|
|
|
|
|
need_rotate_check=need_rotate_check,
|
|
|
|
|
|
retry_config=self.retry_config,
|
|
|
|
|
|
sanitize=self._sanitize_error,
|
|
|
|
|
|
)
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
|
|
|
|
|
def delete_face(self, person_id: str, face_id: str) -> Dict[str, Any]:
|
2026-07-07 02:33:38 +08:00
|
|
|
|
return api_operations.delete_face(
|
|
|
|
|
|
self.client,
|
|
|
|
|
|
person_id=person_id,
|
|
|
|
|
|
face_id=face_id,
|
|
|
|
|
|
retry_config=self.retry_config,
|
|
|
|
|
|
sanitize=self._sanitize_error,
|
|
|
|
|
|
)
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
2025-09-07 21:48:21 +08:00
|
|
|
|
def get_group_info(self, group_id: str) -> Dict[str, Any]:
|
2026-07-07 02:33:38 +08:00
|
|
|
|
return api_operations.get_group_info(
|
|
|
|
|
|
self.client,
|
|
|
|
|
|
group_id=group_id,
|
|
|
|
|
|
retry_config=self.retry_config,
|
|
|
|
|
|
sanitize=self._sanitize_error,
|
|
|
|
|
|
)
|
2025-09-07 21:48:21 +08:00
|
|
|
|
|
2026-07-07 02:33:38 +08:00
|
|
|
|
def get_person_list(
|
|
|
|
|
|
self, group_id: str, limit: int = 100, offset: int = 0
|
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
|
return api_operations.get_person_list(
|
|
|
|
|
|
self.client,
|
|
|
|
|
|
group_id=group_id,
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
offset=offset,
|
|
|
|
|
|
retry_config=self.retry_config,
|
|
|
|
|
|
sanitize=self._sanitize_error,
|
|
|
|
|
|
)
|
2025-09-07 21:48:21 +08:00
|
|
|
|
|
2026-05-02 00:18:57 +08:00
|
|
|
|
def get_person_list_all(self, group_id: str) -> Dict[str, Any]:
|
2026-07-07 02:33:38 +08:00
|
|
|
|
return api_operations.get_person_list_all(
|
|
|
|
|
|
self.client,
|
|
|
|
|
|
group_id=group_id,
|
|
|
|
|
|
retry_config=self.retry_config,
|
|
|
|
|
|
sanitize=self._sanitize_error,
|
2025-09-07 21:48:21 +08:00
|
|
|
|
)
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
2026-07-07 02:33:38 +08:00
|
|
|
|
# --- 辅助 ------------------------------------------------------------
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
2026-07-07 02:33:38 +08:00
|
|
|
|
def _sanitize_error(self, error_msg: str) -> str:
|
|
|
|
|
|
"""脱敏错误信息中的凭据。"""
|
|
|
|
|
|
if not isinstance(error_msg, str):
|
|
|
|
|
|
return str(error_msg)
|
|
|
|
|
|
if self.secret_id and self.secret_id in error_msg:
|
|
|
|
|
|
error_msg = error_msg.replace(
|
|
|
|
|
|
self.secret_id, f"{self.secret_id[:8]}***"
|
2025-09-07 21:48:21 +08:00
|
|
|
|
)
|
2026-07-07 02:33:38 +08:00
|
|
|
|
if self.secret_key and self.secret_key in error_msg:
|
|
|
|
|
|
error_msg = error_msg.replace(self.secret_key, "***")
|
|
|
|
|
|
return error_msg
|
2026-05-01 23:14:39 +08:00
|
|
|
|
|
2026-07-07 02:33:38 +08:00
|
|
|
|
# 兼容旧代码:暴露 TencentCloudSDKException 别名
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def is_sdk_exception(ex: Exception) -> bool:
|
|
|
|
|
|
return isinstance(ex, TencentCloudSDKException)
|