Files
HuiBrain/app/services/ocr_service.py
EduBrain Dev 4d5665d369 fix issue
2026-04-14 21:25:41 +08:00

541 lines
19 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.
"""
OCR 识别服务模块
支持多种 OCR 提供商PaddleOCR本地/ 阿里云 / 腾讯云
auto 模式下本地优先 + 云端 fallback
"""
from __future__ import annotations
import abc
import asyncio
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
from app.config import OCRProvider, settings
logger = logging.getLogger(__name__)
# ──────────────────────────── 数据结构 ────────────────────────────
@dataclass
class OCRBlock:
"""OCR 识别的文本块"""
text: str
bbox: Optional[List[float]] = None # [x1, y1, x2, y2]
confidence: float = 0.0
@dataclass
class OCRResult:
"""OCR 识别结果"""
text: str
blocks: List[OCRBlock] = field(default_factory=list)
confidence: float = 0.0
provider: str = ""
keywords: List[str] = field(default_factory=list) # OCR 时提取的关键词标签
# ──────────────────────────── 抽象基类 ────────────────────────────
class OCRProviderBase(abc.ABC):
"""OCR 提供商抽象基类"""
@property
@abc.abstractmethod
def name(self) -> str:
"""提供商名称"""
...
@abc.abstractmethod
async def recognize(self, image_path: str) -> OCRResult:
"""
识别图片中的文字。
Args:
image_path: 图片文件路径
Returns:
OCRResult 包含识别文本、文本块和置信度
Raises:
Exception: 识别失败时抛出异常
"""
...
# ──────────────────────────── PaddleOCR 实现(本地) ────────────────────────────
class PaddleOCRProvider(OCRProviderBase):
"""
PaddleOCR 本地识别
优点:免费、离线可用、中文识别效果好
缺点:需要较多内存、首次加载模型较慢
"""
def __init__(self) -> None:
self._ocr = None
@property
def name(self) -> str:
return "paddleocr"
def _init_ocr(self):
"""延迟初始化 PaddleOCR 引擎"""
if self._ocr is not None:
return
logger.info("正在初始化 PaddleOCR 引擎...")
from paddleocr import PaddleOCR
self._ocr = PaddleOCR(
use_angle_cls=True,
lang="ch",
use_gpu=False, # 默认 CPU生产环境可通过环境变量控制
show_log=False,
enable_mkldnn=True, # 启用 MKLDNN 加速
)
logger.info("PaddleOCR 引擎初始化完成")
async def recognize(self, image_path: str) -> OCRResult:
"""使用 PaddleOCR 识别图片"""
self._init_ocr()
loop = asyncio.get_running_loop()
def _run_ocr():
result = self._ocr.ocr(image_path, cls=True)
return result
raw_result = await loop.run_in_executor(None, _run_ocr)
# 解析 PaddleOCR 返回结果
blocks: List[OCRBlock] = []
all_text_parts: List[str] = []
total_confidence = 0.0
if raw_result and raw_result[0]:
for line in raw_result[0]:
bbox_points = line[0]
text = line[1][0]
confidence = float(line[1][1])
# 将四个角点转为 [x1, y1, x2, y2]
xs = [p[0] for p in bbox_points]
ys = [p[1] for p in bbox_points]
bbox = [min(xs), min(ys), max(xs), max(ys)]
blocks.append(OCRBlock(text=text, bbox=bbox, confidence=confidence))
all_text_parts.append(text)
total_confidence += confidence
full_text = "\n".join(all_text_parts)
avg_confidence = total_confidence / len(blocks) if blocks else 0.0
return OCRResult(
text=full_text,
blocks=blocks,
confidence=avg_confidence,
provider=self.name,
)
# ──────────────────────────── 阿里云 OCR 实现 ────────────────────────────
class AliyunOCRProvider(OCRProviderBase):
"""
阿里云 OCR 识别
使用阿里云文字识别 API通用文字识别
"""
def __init__(self) -> None:
if not settings.ALIYUN_OCR_ACCESS_KEY or not settings.ALIYUN_OCR_SECRET:
raise ValueError("阿里云 OCR 需要配置 ALIYUN_OCR_ACCESS_KEY 和 ALIYUN_OCR_SECRET")
@property
def name(self) -> str:
return "aliyun"
async def recognize(self, image_path: str) -> OCRResult:
"""调用阿里云 OCR API 识别图片"""
import base64
import json
import asyncio
loop = asyncio.get_running_loop()
def _call_api():
from alibabacloud_tea_openapi.models import Config
from alibabacloud_ocr_api20210707.client import Client
from alibabacloud_ocr_api20210707.models import RecognizeGeneralRequest
from alibabacloud_tea_util.models import RuntimeOptions
config = Config(
access_key_id=settings.ALIYUN_OCR_ACCESS_KEY,
access_key_secret=settings.ALIYUN_OCR_SECRET,
endpoint="ocr-api.cn-hangzhou.aliyuncs.com",
)
client = Client(config)
# 读取图片并 Base64 编码
with open(image_path, "rb") as f:
image_bytes = f.read()
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
request = RecognizeGeneralRequest(
body=json.dumps({"url": f"data:image/png;base64,{image_b64}"}),
)
runtime = RuntimeOptions()
response = client.recognize_general_with_options(request, runtime)
return response
response = await loop.run_in_executor(None, _call_api)
if response.body and response.body.data:
data = json.loads(response.body.data)
blocks: List[OCRBlock] = []
all_text_parts: List[str] = []
total_confidence = 0.0
# 阿里云返回格式可能包含多个文本块
if isinstance(data, dict) and "content" in data:
text = data["content"]
all_text_parts.append(text)
blocks.append(OCRBlock(text=text, confidence=1.0))
total_confidence = 1.0
elif isinstance(data, list):
for item in data:
text = item.get("text", item.get("word", ""))
score = float(item.get("score", item.get("confidence", 1.0)))
blocks.append(OCRBlock(text=text, confidence=score))
all_text_parts.append(text)
total_confidence += score
full_text = "\n".join(all_text_parts)
avg_confidence = total_confidence / len(blocks) if blocks else 0.0
return OCRResult(
text=full_text,
blocks=blocks,
confidence=avg_confidence,
provider=self.name,
)
return OCRResult(text="", blocks=[], confidence=0.0, provider=self.name)
# ──────────────────────────── 腾讯云 OCR 实现 ────────────────────────────
class TencentOCRProvider(OCRProviderBase):
"""
腾讯云 OCR 识别
使用腾讯云文字识别 API通用印刷体识别
"""
def __init__(self) -> None:
if not settings.TENCENT_OCR_SECRET_ID or not settings.TENCENT_OCR_SECRET_KEY:
raise ValueError("腾讯云 OCR 需要配置 TENCENT_OCR_SECRET_ID 和 TENCENT_OCR_SECRET_KEY")
@property
def name(self) -> str:
return "tencent"
async def recognize(self, image_path: str) -> OCRResult:
"""调用腾讯云 OCR API 识别图片"""
import asyncio
import base64
import json
loop = asyncio.get_running_loop()
def _call_api():
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.ocr.v20181119 import ocr_client, models
cred = credential.Credential(
settings.TENCENT_OCR_SECRET_ID,
settings.TENCENT_OCR_SECRET_KEY,
)
http_profile = HttpProfile()
http_profile.endpoint = "ocr.tencentcloudapi.com"
client_profile = ClientProfile()
client_profile.httpProfile = http_profile
client = ocr_client.OcrClient(cred, "", client_profile)
# 读取图片并 Base64 编码
with open(image_path, "rb") as f:
image_bytes = f.read()
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
req = models.GeneralBasicOCRRequest()
req.ImageBase64 = image_b64
resp = client.GeneralBasicOCR(req)
return resp
resp = await loop.run_in_executor(None, _call_api)
blocks: List[OCRBlock] = []
all_text_parts: List[str] = []
total_confidence = 0.0
if resp.TextDetections:
for item in resp.TextDetections:
text = item.DetectedText or ""
confidence = item.Confidence / 100.0 if item.Confidence else 0.0
# 解析多边形顶点
bbox = None
if item.Polygon:
xs = [p.X for p in item.Polygon]
ys = [p.Y for p in item.Polygon]
bbox = [min(xs), min(ys), max(xs), max(ys)]
blocks.append(OCRBlock(text=text, bbox=bbox, confidence=confidence))
all_text_parts.append(text)
total_confidence += confidence
full_text = "\n".join(all_text_parts)
avg_confidence = total_confidence / len(blocks) if blocks else 0.0
return OCRResult(
text=full_text,
blocks=blocks,
confidence=avg_confidence,
provider=self.name,
)
# ──────────────────────────── DeepSeek OCR 实现 ────────────────────────────
class DeepSeekOCRProvider(OCRProviderBase):
"""
DeepSeek Vision OCR
通过 OpenAI 兼容接口发送图片 base64让视觉模型识别文字。
支持 DeepSeek 官方 API 和 Ollama 自部署(如 deepseek-ocr 模型)。
"""
def __init__(self) -> None:
if not settings.DEEPSEEK_API_KEY:
raise ValueError("DeepSeek OCR 需要配置 DEEPSEEK_API_KEY")
import httpx
from openai import AsyncOpenAI
self._client = AsyncOpenAI(
api_key=settings.DEEPSEEK_API_KEY,
base_url=settings.DEEPSEEK_BASE_URL,
http_client=httpx.AsyncClient(timeout=120.0), # OCR 较慢,超时 120 秒
)
self._model = settings.DEEPSEEK_OCR_MODEL
# 判断是否为 Ollama 部署
self._is_ollama = "ollama" in settings.DEEPSEEK_API_KEY.lower() or \
"11434" in settings.DEEPSEEK_BASE_URL
@property
def name(self) -> str:
return "deepseek"
async def recognize(self, image_path: str) -> OCRResult:
"""使用 DeepSeek Vision 模型识别图片中的文字"""
import base64
# 读取图片并编码为 base64
with open(image_path, "rb") as f:
image_bytes = f.read()
base64_image = base64.b64encode(image_bytes).decode("utf-8")
# 根据图片格式确定 MIME 类型
suffix = Path(image_path).suffix.lower()
mime_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".bmp": "image/bmp",
".webp": "image/webp",
}
mime_type = mime_map.get(suffix, "image/png")
system_prompt = (
"你是一个 OCR 文字识别工具。"
"请识别图片中的所有文字,逐行输出,不要添加任何解释。"
)
response = await self._client.chat.completions.create(
model=self._model,
messages=[
{
"role": "system",
"content": system_prompt,
},
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{base64_image}"
},
},
{
"type": "text",
"text": "请识别这张图片中的所有文字内容。",
},
],
},
],
max_tokens=4096,
temperature=0.0,
)
text = response.choices[0].message.content or ""
# 清理特殊 tokendeepseek-ocr 模型可能输出 <begin of sentence> 等)
import re
text = re.sub(r'<[^>]*>', '', text)
text = re.sub(r'<\|[^>]*\|>', '', text)
# 清理可能的 markdown 包裹
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[-1]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
lines = [line for line in text.split("\n") if line.strip()]
blocks = [OCRBlock(text=line, confidence=1.0) for line in lines]
return OCRResult(
text=text,
blocks=blocks,
confidence=1.0,
provider=self.name,
)
# ──────────────────────────── 工厂类 ────────────────────────────
class OCRService:
"""
OCR 服务工厂类
根据配置自动选择 OCR 提供商。
auto 模式下:本地 PaddleOCR 优先,失败后 fallback 到云端 OCR。
"""
_instance: Optional[OCRProviderBase] = None
@classmethod
def _create_provider(cls, provider: OCRProvider) -> OCRProviderBase:
"""根据提供商枚举创建对应的 OCR 实例"""
provider_map = {
OCRProvider.PADDLEOCR: PaddleOCRProvider,
OCRProvider.ALIYUN: AliyunOCRProvider,
OCRProvider.TENCENT: TencentOCRProvider,
OCRProvider.DEEPSEEK: DeepSeekOCRProvider,
}
provider_cls = provider_map.get(provider)
if provider_cls is None:
raise ValueError(f"不支持的 OCR 提供商: {provider}")
return provider_cls()
@classmethod
def get_instance(cls) -> OCRProviderBase:
"""获取 OCR 服务单例实例"""
if cls._instance is not None:
return cls._instance
if settings.OCR_PROVIDER == OCRProvider.AUTO:
# auto 模式返回 PaddleOCRfallback 逻辑在 recognize_auto 中处理
cls._instance = PaddleOCRProvider()
else:
cls._instance = cls._create_provider(settings.OCR_PROVIDER)
logger.info("OCR 服务初始化完成: provider=%s", cls._instance.name)
return cls._instance
@classmethod
async def recognize(cls, image_path: str) -> OCRResult:
"""
识别图片文字。
auto 模式下PaddleOCR 优先,失败后依次尝试阿里云、腾讯云。
"""
if settings.OCR_PROVIDER == OCRProvider.AUTO:
return await cls._recognize_auto(image_path)
instance = cls.get_instance()
return await instance.recognize(image_path)
@classmethod
async def _recognize_auto(cls, image_path: str) -> OCRResult:
"""
auto 模式识别逻辑:
1. 先尝试本地 PaddleOCR
2. 如果失败且配置了阿里云,尝试阿里云 OCR
3. 如果仍失败且配置了腾讯云,尝试腾讯云 OCR
"""
# 第一步:本地 PaddleOCR
try:
provider = PaddleOCRProvider()
result = await provider.recognize(image_path)
if result.text.strip():
logger.info("PaddleOCR 识别成功,文本长度: %d", len(result.text))
return result
logger.warning("PaddleOCR 返回空文本,尝试 fallback")
except Exception as exc:
logger.warning("PaddleOCR 识别失败: %s,尝试 fallback", exc)
# 第二步DeepSeek OCR如果配置了
if settings.DEEPSEEK_API_KEY:
try:
provider = DeepSeekOCRProvider()
result = await provider.recognize(image_path)
if result.text.strip():
logger.info("DeepSeek OCR 识别成功,文本长度: %d", len(result.text))
return result
logger.warning("DeepSeek OCR 返回空文本,尝试 fallback")
except Exception as exc:
logger.warning("DeepSeek OCR 识别失败: %s,尝试 fallback", exc)
# 第三步:阿里云 OCR
if settings.ALIYUN_OCR_ACCESS_KEY and settings.ALIYUN_OCR_SECRET:
try:
provider = AliyunOCRProvider()
result = await provider.recognize(image_path)
if result.text.strip():
logger.info("阿里云 OCR 识别成功,文本长度: %d", len(result.text))
return result
logger.warning("阿里云 OCR 返回空文本,继续 fallback")
except Exception as exc:
logger.warning("阿里云 OCR 识别失败: %s,继续 fallback", exc)
# 第四步:腾讯云 OCR
if settings.TENCENT_OCR_SECRET_ID and settings.TENCENT_OCR_SECRET_KEY:
try:
provider = TencentOCRProvider()
result = await provider.recognize(image_path)
if result.text.strip():
logger.info("腾讯云 OCR 识别成功,文本长度: %d", len(result.text))
return result
logger.warning("腾讯云 OCR 返回空文本")
except Exception as exc:
logger.warning("腾讯云 OCR 识别失败: %s", exc)
# 所有 OCR 都失败
return OCRResult(
text="",
blocks=[],
confidence=0.0,
provider="none",
)
@classmethod
def reset(cls) -> None:
"""重置单例(用于测试或切换配置)"""
cls._instance = None