- 拆分 tencent_cloud_client.py(1311行)为 api_operations.py / image_processor.py / retry.py - 更新 HA 废弃 API: SupportsResponse.OPTIONAL, async_unload_platforms, native_value, 移除 CONNECTION_CLASS - 新增动态人员传感器管理(自动增/删) - 新增 detect_face 服务定义(services.yaml) - 移除 secret_key 持久化存储,增强安全性 - 完善错误映射前缀匹配 + 异常类型安全退避 - 修复 Coordinator 双重人员 ID 跟踪死代码 - 添加 _sanitize_error 脱敏,ImageCache LRU 缓存 - manifest.json 版本 2.1.0 + Pillow 依赖
349 lines
12 KiB
Python
349 lines
12 KiB
Python
"""腾讯云人脸识别客户端(精简门面)。
|
||
|
||
原 1311 行的客户端已拆分为:
|
||
- ``retry.py``: 重试配置与执行器
|
||
- ``image_processor.py``: 图片获取/压缩/编码
|
||
- ``api_operations.py``: 各 API 操作的实现(搜索/检测/属性/人员/人脸/库信息/列表)
|
||
|
||
本文件仅保留:凭据创建、HTTP Session 管理、错误脱敏、以及对外暴露的
|
||
``TencentCloudClient`` 门面,委托给上述模块。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
import requests
|
||
from tencentcloud.common import credential
|
||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import (
|
||
TencentCloudSDKException,
|
||
)
|
||
from tencentcloud.iai.v20200303 import iai_client
|
||
|
||
from . import api_operations
|
||
from .errors import (
|
||
ApiError,
|
||
AuthenticationError,
|
||
log_and_raise_error,
|
||
validate_config,
|
||
)
|
||
from .image_processor import ImageCache, get_image_base64
|
||
from .retry import RetryConfig
|
||
from .const import CONF_REGION, CONF_SECRET_ID, CONF_SECRET_KEY
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
|
||
class TencentCloudClient:
|
||
"""腾讯云人脸识别客户端门面。"""
|
||
|
||
def __init__(
|
||
self,
|
||
secret_id: str,
|
||
secret_key: str,
|
||
region: str = "ap-shanghai",
|
||
retry_config: Optional[RetryConfig] = None,
|
||
) -> None:
|
||
validate_config({
|
||
CONF_SECRET_ID: secret_id,
|
||
CONF_SECRET_KEY: secret_key,
|
||
CONF_REGION: region,
|
||
})
|
||
|
||
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()
|
||
|
||
# HTTP 连接池化
|
||
self._session = requests.Session()
|
||
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,
|
||
)
|
||
|
||
# --- 资源管理 --------------------------------------------------------
|
||
|
||
def close(self) -> None:
|
||
"""释放 HTTP Session 等资源。"""
|
||
if self._session:
|
||
self._session.close()
|
||
self._image_cache.clear()
|
||
|
||
# --- 客户端创建 -------------------------------------------------------
|
||
|
||
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:
|
||
_LOGGER.error("创建腾讯云客户端失败: %s", self._sanitize_error(str(ex)))
|
||
log_and_raise_error(
|
||
AuthenticationError,
|
||
"创建腾讯云客户端失败",
|
||
{"region": self.region, "error_type": type(ex).__name__},
|
||
)
|
||
|
||
# --- 凭据验证 ---------------------------------------------------------
|
||
|
||
def verify_credentials(self) -> bool:
|
||
"""验证凭据(轻量 GetGroupList)。失败抛出对应错误。"""
|
||
return api_operations.verify_credentials(
|
||
self.client, retry_config=self.retry_config
|
||
)
|
||
|
||
def verify_credentials_lightweight(self) -> bool:
|
||
"""测试 API 连接(轻量验证)。失败返回 False,不抛异常。"""
|
||
try:
|
||
self.verify_credentials()
|
||
return True
|
||
except Exception as ex: # noqa: BLE001
|
||
_LOGGER.error("API连接测试失败: %s", self._sanitize_error(str(ex)))
|
||
return False
|
||
|
||
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
|
||
|
||
# 兼容旧调用名
|
||
_test_api_connection = test_api_connection
|
||
|
||
# --- 图片 ------------------------------------------------------------
|
||
|
||
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"},
|
||
)
|
||
|
||
# --- 业务 API(委托 api_operations) ----------------------------------
|
||
|
||
def search_faces(
|
||
self,
|
||
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,
|
||
max_face_num: int = 1,
|
||
min_face_size: int = 34,
|
||
max_user_num: int = 5,
|
||
quality_control: int = 1,
|
||
need_rotate_check: int = 1,
|
||
face_match_threshold: float = 60.0,
|
||
) -> Dict[str, Any]:
|
||
if image_base64 is None:
|
||
image_base64 = self._get_image_base64(image_url, image_path, image_file)
|
||
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,
|
||
)
|
||
|
||
def detect_faces(
|
||
self,
|
||
image_url: Optional[str] = None,
|
||
image_path: Optional[str] = None,
|
||
image_file: Optional[Any] = None,
|
||
image_base64: Optional[str] = None,
|
||
max_face_num: int = 1,
|
||
min_face_size: int = 34,
|
||
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)
|
||
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,
|
||
)
|
||
|
||
def get_face_attributes(
|
||
self,
|
||
image_url: Optional[str] = None,
|
||
image_path: Optional[str] = None,
|
||
image_file: Optional[Any] = None,
|
||
image_base64: Optional[str] = None,
|
||
max_face_num: 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)
|
||
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,
|
||
)
|
||
|
||
def create_person(
|
||
self,
|
||
person_id: str,
|
||
person_name: str,
|
||
group_id: str,
|
||
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,
|
||
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)
|
||
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,
|
||
)
|
||
|
||
def delete_person(self, person_id: str) -> Dict[str, Any]:
|
||
return api_operations.delete_person(
|
||
self.client,
|
||
person_id=person_id,
|
||
retry_config=self.retry_config,
|
||
sanitize=self._sanitize_error,
|
||
)
|
||
|
||
def create_face(
|
||
self,
|
||
person_id: str,
|
||
image_url: Optional[str] = None,
|
||
image_path: Optional[str] = None,
|
||
image_file: Optional[Any] = None,
|
||
image_base64: Optional[str] = None,
|
||
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)
|
||
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,
|
||
)
|
||
|
||
def delete_face(self, person_id: str, face_id: str) -> Dict[str, Any]:
|
||
return api_operations.delete_face(
|
||
self.client,
|
||
person_id=person_id,
|
||
face_id=face_id,
|
||
retry_config=self.retry_config,
|
||
sanitize=self._sanitize_error,
|
||
)
|
||
|
||
def get_group_info(self, group_id: str) -> Dict[str, Any]:
|
||
return api_operations.get_group_info(
|
||
self.client,
|
||
group_id=group_id,
|
||
retry_config=self.retry_config,
|
||
sanitize=self._sanitize_error,
|
||
)
|
||
|
||
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,
|
||
)
|
||
|
||
def get_person_list_all(self, group_id: str) -> Dict[str, Any]:
|
||
return api_operations.get_person_list_all(
|
||
self.client,
|
||
group_id=group_id,
|
||
retry_config=self.retry_config,
|
||
sanitize=self._sanitize_error,
|
||
)
|
||
|
||
# --- 辅助 ------------------------------------------------------------
|
||
|
||
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]}***"
|
||
)
|
||
if self.secret_key and self.secret_key in error_msg:
|
||
error_msg = error_msg.replace(self.secret_key, "***")
|
||
return error_msg
|
||
|
||
# 兼容旧代码:暴露 TencentCloudSDKException 别名
|
||
@staticmethod
|
||
def is_sdk_exception(ex: Exception) -> bool:
|
||
return isinstance(ex, TencentCloudSDKException)
|