Files
Hermes 18a6a8ca9b refactor: 全面重构腾讯云人脸识别插件
- 拆分 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 依赖
2026-07-07 02:33:38 +08:00

359 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""图片处理模块。
负责从 URL / 本地路径 / 上传文件 / data URL 获取并编码图片为 base64
并在需要时压缩、缩放、转码为 JPEG。原 ``tencent_cloud_client.py`` 中
分散的图片逻辑在此集中、简化。
"""
from __future__ import annotations
import base64
import io
import logging
import os
import re
from functools import lru_cache
from typing import Optional
import requests
from .errors import ImageNotFoundError, NetworkError, log_and_raise_error
_LOGGER = logging.getLogger(__name__)
# 图片限制
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
MAX_IMAGE_DIMENSION = 4096
SUPPORTED_IMAGE_FORMATS = (".jpg", ".jpeg", ".png", ".bmp", ".gif")
IMAGE_COMPRESSION_QUALITY = 85
COMPRESS_TARGET_RATIO = 0.8 # 压缩目标 = MAX_IMAGE_SIZE * 0.8
_URL_PATTERN = re.compile(
r"^https?://"
r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|"
r"localhost|"
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
r"(?::\d+)?"
r"(?:/?|[/?]\S*)$",
re.IGNORECASE,
)
def is_valid_url(url: str) -> bool:
"""验证 URL 格式是否有效。"""
return bool(_URL_PATTERN.match(url or ""))
def _pil_available() -> bool:
try:
import PIL # noqa: F401
return True
except ImportError:
return False
def _open_image(data: bytes):
"""打开图片,返回 PIL Image 对象;PIL 不可用时抛出 ImageNotFoundError。"""
try:
from PIL import Image
except ImportError as ex:
_LOGGER.warning(
"PIL库(Pillow)未安装,无法进行图片处理。建议安装: pip install Pillow>=10.0.0"
)
raise ImageNotFoundError(
"Pillow 未安装,无法处理图片",
error_key="image_decode_failed",
error_details={"reason": "pillow_missing"},
) from ex
return Image.open(io.BytesIO(data))
def process_image_data(image_data: bytes) -> bytes:
"""处理图片数据:尺寸校验、缩放、格式转换、压缩。
PIL 不可用时直接返回原始数据(但仍受调用方的尺寸约束)。
"""
if not image_data:
return image_data
# 超过限制先压缩
if len(image_data) > MAX_IMAGE_SIZE:
_LOGGER.warning("图片大小超过限制,尝试压缩: %d 字节", len(image_data))
if not _pil_available():
# 无法压缩时只能按原样返回,由服务端兜底校验
return image_data
try:
img = _open_image(image_data)
width, height = img.size
# 尺寸过大则缩放
if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
_LOGGER.warning("图片尺寸过大,尝试缩放: %dx%d", width, height)
scale = min(
MAX_IMAGE_DIMENSION / width,
MAX_IMAGE_DIMENSION / height,
)
new_size = (int(width * scale), int(height * scale))
img = img.resize(new_size, _get_resample_filter())
_LOGGER.info(
"图片缩放完成: %dx%d -> %dx%d",
width, height, new_size[0], new_size[1],
)
# 统一转 JPEG
output = io.BytesIO()
if img.mode in ("RGBA", "LA", "P"):
img = img.convert("RGB")
img.save(output, format="JPEG", quality=IMAGE_COMPRESSION_QUALITY, optimize=True)
result = output.getvalue()
# 仍然过大则进一步压缩
if len(result) > MAX_IMAGE_SIZE:
result = _compress_to_fit(img, MAX_IMAGE_SIZE * COMPRESS_TARGET_RATIO)
_LOGGER.debug("图片处理完成: %d -> %d 字节", len(image_data), len(result))
return result
except ImageNotFoundError:
raise
except Exception as ex: # noqa: BLE001
_LOGGER.warning("图片处理失败,使用原始数据: %s", ex)
return image_data
def _get_resample_filter():
from PIL import Image
# LANCZOS 在不同 Pillow 版本中名称一致
return Image.LANCZOS
def _compress_to_fit(img, target_size: int) -> bytes:
"""逐步降低质量以将图片压缩到目标大小。"""
quality = IMAGE_COMPRESSION_QUALITY
last_data = b""
while quality >= 30:
output = io.BytesIO()
out = img
if out.mode in ("RGBA", "LA", "P"):
out = out.convert("RGB")
out.save(output, format="JPEG", quality=quality, optimize=True)
data = output.getvalue()
last_data = data
if len(data) <= target_size:
_LOGGER.info(
"图片压缩完成,最终质量: %d, 大小: %d 字节", quality, len(data)
)
return data
quality -= 5
_LOGGER.warning("图片压缩已达最低质量,仍超过目标大小: %d 字节", len(last_data))
return last_data
def download_image_as_base64(image_url: str, session: Optional[requests.Session] = None) -> str:
"""下载图片并转换为 base64 编码。"""
_LOGGER.debug("开始下载图片: %s", image_url)
if not is_valid_url(image_url):
log_and_raise_error(
ImageNotFoundError,
f"无效的图片URL: {image_url}",
{"url": image_url},
)
sess = session or requests
headers = {"User-Agent": "Mozilla/5.0 (HomeAssistant TencentFaceRecognition)"}
try:
response = sess.get(image_url, timeout=15, headers=headers)
response.raise_for_status()
except requests.exceptions.Timeout as ex:
log_and_raise_error(
NetworkError,
f"下载图片超时: {image_url}",
{"url": image_url, "error": "timeout"},
)
raise # log_and_raise_error 抛出,此行仅为类型提示
except requests.exceptions.ConnectionError as ex:
log_and_raise_error(
NetworkError,
f"下载图片连接错误: {image_url}",
{"url": image_url, "error": "connection_error"},
)
raise
except requests.exceptions.RequestException as ex:
log_and_raise_error(
NetworkError,
f"下载图片失败: {image_url}",
{"url": image_url, "error": str(ex)},
)
raise
content_type = response.headers.get("content-type", "").lower()
if content_type and not content_type.startswith("image/"):
log_and_raise_error(
ImageNotFoundError,
f"URL不是有效的图片: {image_url}, 内容类型: {content_type}",
{"url": image_url, "content_type": content_type},
)
image_data = process_image_data(response.content)
return base64.b64encode(image_data).decode("utf-8")
def read_local_image_as_base64(image_path: str) -> str:
"""读取本地图片并转换为 base64 编码。"""
if not os.path.exists(image_path):
log_and_raise_error(
ImageNotFoundError,
f"图片文件不存在: {image_path}",
{"path": image_path},
)
if not os.path.isfile(image_path):
log_and_raise_error(
ImageNotFoundError,
f"路径不是文件: {image_path}",
{"path": image_path},
)
file_ext = os.path.splitext(image_path)[1].lower()
if file_ext not in SUPPORTED_IMAGE_FORMATS:
log_and_raise_error(
ImageNotFoundError,
f"不支持的图片格式: {file_ext}",
{"path": image_path, "supported_formats": list(SUPPORTED_IMAGE_FORMATS)},
)
file_size = os.path.getsize(image_path)
if file_size > MAX_IMAGE_SIZE:
log_and_raise_error(
ImageNotFoundError,
f"图片文件过大: {image_path}",
{
"path": image_path,
"size": file_size,
"max_size": f"{MAX_IMAGE_SIZE // (1024 * 1024)}MB",
},
)
try:
with open(image_path, "rb") as img_file:
image_data = img_file.read()
except PermissionError:
log_and_raise_error(
ImageNotFoundError,
f"没有权限读取图片文件: {image_path}",
{"path": image_path, "error": "permission_denied"},
)
raise
processed = process_image_data(image_data)
return base64.b64encode(processed).decode("utf-8")
def encode_uploaded_image_as_base64(image_file) -> str:
"""处理上传的图片(data URL / base64 字符串 / 文件对象)并返回 base64。"""
if isinstance(image_file, str):
if image_file.startswith("data:image"):
_LOGGER.debug("处理 data URL 格式图片")
try:
_, encoded = image_file.split(",", 1)
image_data = base64.b64decode(encoded)
except ValueError:
log_and_raise_error(
ImageNotFoundError,
"无效的 data URL 格式",
{"data_url_length": len(image_file)},
)
raise
processed = process_image_data(image_data)
return base64.b64encode(processed).decode("utf-8")
# 视为 base64 字符串
_LOGGER.debug("使用 base64 编码图片,长度: %d", len(image_file))
try:
image_data = base64.b64decode(image_file)
except Exception as ex: # noqa: BLE001
log_and_raise_error(
ImageNotFoundError,
"base64 解码失败",
{"error": str(ex), "data_length": len(image_file)},
)
raise
processed = process_image_data(image_data)
return base64.b64encode(processed).decode("utf-8")
# 文件对象
_LOGGER.debug("处理文件对象")
image_data = image_file.read()
processed = process_image_data(image_data)
return base64.b64encode(processed).decode("utf-8")
def get_image_base64(
image_url: Optional[str] = None,
image_path: Optional[str] = None,
image_file=None,
session: Optional[requests.Session] = None,
) -> str:
"""根据提供的来源获取图片 base64。
三者任选其一;优先级 url > path > file。
"""
if not any([image_url, image_path, image_file]):
log_and_raise_error(
ImageNotFoundError,
"必须提供 image_url、image_path 或 image_file",
{},
)
if image_url:
return download_image_as_base64(image_url, session=session)
if image_path:
return read_local_image_as_base64(image_path)
return encode_uploaded_image_as_base64(image_file)
class ImageCache:
"""简单的 LRU 图片缓存,避免短时间内重复下载/读取同一图片。
下载使用的 ``requests.Session`` 通过 ``download_fn`` 注入,不参与缓存键。
"""
def __init__(
self,
maxsize: int = 32,
download_fn=None,
read_fn=None,
) -> None:
self._download = lru_cache(maxsize=maxsize)(
download_fn or download_image_as_base64
)
self._read_local = lru_cache(maxsize=maxsize)(
read_fn or read_local_image_as_base64
)
def get_url(self, image_url: str) -> str:
return self._download(image_url)
def get_path(self, image_path: str) -> str:
return self._read_local(image_path)
def get(self, image_url=None, image_path=None, image_file=None) -> str:
if image_url:
return self.get_url(image_url)
if image_path:
return self.get_path(image_path)
if image_file:
return encode_uploaded_image_as_base64(image_file)
log_and_raise_error(
ImageNotFoundError,
"必须提供 image_url、image_path 或 image_file",
{},
)
def clear(self) -> None:
self._download.cache_clear()
self._read_local.cache_clear()