初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+298
View File
@@ -0,0 +1,298 @@
"""
感知服务编排。
职责:
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