初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user