Files
2026-08-21 16:00:13 +08:00

299 lines
9.4 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.
"""
感知服务编排。
职责:
1. 接收图片 → 预处理(灰度增强/对比度调整)
2. 调用天目 OCR(带缓存和重试)
3. 后处理 → 结构化
4. 写入事件系统(可选)
使用示例::
service = PerceptionService()
result = await service.recognize(image_bytes)
if result.success:
print(f"识别结果: {result.text}")
await service.close()
"""
from __future__ import annotations
import hashlib
import io
from collections import OrderedDict
from pathlib import Path
from typing import Any
from app.core.logging import logger
from app.integrations.ocr import OCRClient, OCRResult
class LRUCache:
"""线程安全的 LRU 缓存(基于 OrderedDict)。"""
def __init__(self, maxsize: int = 128) -> None:
self._data: OrderedDict[str, Any] = OrderedDict()
self._maxsize = maxsize
def get(self, key: str) -> Any | None:
if key not in self._data:
return None
self._data.move_to_end(key)
return self._data[key]
def put(self, key: str, value: Any) -> None:
self._data[key] = value
self._data.move_to_end(key)
if len(self._data) > self._maxsize:
self._data.popitem(last=False)
def clear(self) -> None:
self._data.clear()
class PerceptionService:
"""感知服务:图片处理、OCR 识别、结构化输出。
支持缓存(相同图片 hash 命中时跳过重复识别)、
图片预处理(灰度增强有助于提高 OCR 精度)、
自动重试(主引擎失败时降级到备选引擎)。
"""
def __init__(
self,
ocr_client: OCRClient | None = None,
cache_size: int = 128,
enable_preprocessing: bool = True,
enable_cache: bool = True,
) -> None:
self._ocr = ocr_client or OCRClient()
self._cache = LRUCache(maxsize=cache_size) if enable_cache else None
self._enable_preprocessing = enable_preprocessing
async def close(self) -> None:
"""释放 OCR 客户端和缓存。"""
await self._ocr.close()
if self._cache:
self._cache.clear()
# ── 通用 OCR ────────────────────────────────────────
async def recognize(
self,
image: bytes | str | Path,
*,
engine: str = "auto",
save_md: bool = False,
use_cache: bool = True,
) -> OCRResult:
"""通用 OCR 识别(带缓存和自动降级)。
Args:
image: 图片数据(bytes)、文件路径(str/Path)、HTTP URL。
engine: 引擎选择。
save_md: 是否保存 Markdown。
use_cache: 是否使用缓存(相同图片跳过重复识别)。
Returns:
OCRResult: 识别结果。
"""
image_data = self._resolve_image(image)
# 缓存命中
cache_key = self._cache_key(image_data) if use_cache and self._cache else None
if cache_key:
cached = self._cache.get(cache_key) if self._cache else None
if cached is not None:
logger.info("OCR 缓存命中 | key={}", cache_key[:12])
return cached
# 预处理
if self._enable_preprocessing and isinstance(image_data, bytes):
preprocessed = self._preprocess_image(image_data)
if preprocessed is not None:
image_data = preprocessed
# 主引擎识别(自动降级)
result = await self._recognize_with_fallback(image_data, engine, save_md)
# 写入缓存
if cache_key and self._cache and result.success:
self._cache.put(cache_key, result)
if result.success:
logger.info(
"OCR 识别完成 | engine={} blocks={} ms={}",
result.engine,
len(result.blocks),
result.duration_ms,
)
else:
logger.warning("OCR 识别失败 | engine={} error={}", engine, result.error)
return result
# ── 复杂文档解析 ────────────────────────────────────
async def parse_document(
self,
image: bytes | str | Path,
*,
output_format: str = "markdown",
save_md: bool = False,
) -> OCRResult:
"""复杂文档解析(PaddleOCR-VL)。
Args:
image: 图片数据(bytes)、文件路径(str/Path)、HTTP URL。
output_format: 输出格式。
save_md: 是否保存 Markdown。
Returns:
OCRResult: 结构化解析结果。
"""
image_data = self._resolve_image(image)
result = await self._ocr.ocr_vl(
image_data,
output_format=output_format,
save_md=save_md,
)
if result.success:
logger.info(
"文档解析完成 | engine={} format={} ms={}",
result.engine,
output_format,
result.duration_ms,
)
else:
logger.warning("文档解析失败 | error={}", result.error)
return result
# ── 批处理 ───────────────────────────────────────────
async def recognize_batch(
self,
images: list[bytes | str | Path],
*,
engine: str = "auto",
concurrency: int = 3,
) -> list[OCRResult]:
"""批量 OCR 识别(限制并发数)。
Args:
images: 图片列表。
engine: 引擎选择。
concurrency: 最大并发数。
Returns:
OCRResult 列表,顺序与输入一致。
"""
import asyncio
semaphore = asyncio.Semaphore(concurrency)
async def _limited(img: bytes | str | Path) -> OCRResult:
async with semaphore:
return await self.recognize(img, engine=engine)
tasks = [_limited(img) for img in images]
return await asyncio.gather(*tasks)
# ── 辅助 ─────────────────────────────────────────────
@staticmethod
def _resolve_image(image: bytes | str | Path) -> bytes | str:
"""将图片输入统一为 bytes 或 URL 字符串。
Args:
image: bytes 数据、文件路径或 URL。
Returns:
bytes(文件/二进制)或 str(URL)。
"""
if isinstance(image, Path):
return image.read_bytes()
if isinstance(image, str):
if image.startswith(("http://", "https://")):
return image
return Path(image).read_bytes()
return image
@staticmethod
def _cache_key(image_data: bytes | str) -> str | None:
"""生成缓存键(bytes 用 SHA256,URL 直接作为键)。"""
if isinstance(image_data, bytes):
return hashlib.sha256(image_data).hexdigest()
if isinstance(image_data, str) and image_data.startswith(("http://", "https://")):
return f"url:{image_data}"
return None
@staticmethod
def _preprocess_image(image_data: bytes) -> bytes | None:
"""图片预处理:灰度化 + 对比度增强。
对某些低对比度或偏色图片,预处理能显著提高 OCR 精度。
仅对 bytes 输入生效(URL 跳过)。
Returns:
预处理后的图片 bytes,失败时返回 None。
"""
try:
from PIL import Image, ImageEnhance, ImageFilter
img = Image.open(io.BytesIO(image_data))
# 1. 转灰度
if img.mode != "L":
img = img.convert("L")
# 2. 增强对比度
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(1.5)
# 3. 轻微锐化
img = img.filter(ImageFilter.SHARPEN)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
except Exception as e:
logger.warning("图片预处理失败(不影响原图识别): {}", e)
return None
async def _recognize_with_fallback(
self,
image_data: bytes | str,
engine: str,
save_md: bool,
) -> OCRResult:
"""主引擎识别 + 降级逻辑。
ppocrv6 失败时自动降级到 paddleocr-vl(如果配置了懒加载),
paddleocr-vl 失败时不再降级。
"""
result = await self._ocr.ocr(
image_data,
engine=engine,
save_md=save_md,
)
# 主引擎成功 → 直接返回
if result.success or engine != "auto":
return result
# auto 模式下 ppocrv6 失败 → 尝试 paddleocr-vl 降级
# 只对某些可恢复的错误降级(空结果而非服务器错误)
if result.error and ("引擎未加载" in result.error or "未识别到文字" in result.error):
logger.info("ppocrv6 结果为空,降级到 paddleocr-vl")
fallback = await self._ocr.ocr(
image_data,
engine="paddleocr-vl",
save_md=save_md,
)
if fallback.success:
return fallback
return result