初始化提交

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

+2
View File
@@ -0,0 +1,2 @@
"""天目"""
__version__ = "0.2.0"
+136
View File
@@ -0,0 +1,136 @@
"""
天目配置管理。
从环境变量加载配置,遵循魂镜统一配置规范。
模型文件统一存放在项目根目录 models/ 下,各引擎从对应子目录加载。
"""
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_DEFAULT_MODELS_DIR = _PROJECT_ROOT / "models"
def detect_device(preferred: str = "auto") -> str:
"""检测当前可用的最佳推理设备。
使用 PyTorch 检测(torch 在 vl 组中,未安装时回退 CPU)。
检测顺序:CUDA → MPS(Apple Silicon)→ CPU
Args:
preferred: 用户偏好(auto / cuda / mps / cpu)。
Returns:
实际可用的设备名称。
"""
if preferred == "cpu":
return "cpu"
# 使用 PyTorch 检测(vl 组可选,未安装时回退 CPU)
try:
import torch
except ImportError:
return "cpu"
if preferred in ("auto", "cuda") and torch.cuda.is_available():
cuda_ver = torch.version.cuda or "unknown"
return f"cuda ({cuda_ver})"
if preferred in ("auto", "mps") and hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
class Settings(BaseSettings):
"""天目应用配置。"""
# 服务
host: str = "0.0.0.0"
port: int = 8101
# ── 模型文件路径 ────────────────────────────────────
models_dir: str = str(_DEFAULT_MODELS_DIR)
# PP-OCRv6 模型:det / rec / cls 子目录或具体 ONNX 路径
# 为空时自动扫描 $models_dir/ocr/ppocrv6/{det,rec,cls}/
ppocrv6_det_model: str = ""
ppocrv6_rec_model: str = ""
ppocrv6_cls_model: str = ""
# 识别词表路径(默认 auto 在 rec 目录下查找 ppocrv6_dict.txt)
ppocrv6_dict_path: str = ""
# PaddleOCR-VL 模型目录(默认 $models_dir/ocr/paddleocr-vl/)
paddleocr_vl_model_dir: str = ""
# ── 输出路径 ────────────────────────────────────────
# OCR 结果输出目录(Markdown 文件等),默认项目根目录 output/
output_dir: str = ""
# 推理设备:auto / cuda / mps / cpu
device: str = "auto"
# ── 网络与超时 ──────────────────────────────────────
# 下载远端图片的 HTTP 超时(秒)
http_timeout: int = 30
# ── PaddleOCR-VL 推理参数 ───────────────────────────
# 最大生成 tokens(复杂文档可能需要更长)
vl_max_new_tokens: int = 1024
# 图像最大像素数(影响内存占用,越大越吃显存)
# 默认 1280*28*28 ≈ 1,003,520,即长边约 1280px
vl_max_pixels: int = 1280 * 28 * 28
# 日志级别
log_level: str = "info"
# ── 运行时属性(非配置项)──
_detected_device: str | None = None
@property
def detected_device(self) -> str:
"""实际检测到的设备(懒加载)。"""
if self._detected_device is None:
self._detected_device = detect_device(self.device)
return self._detected_device
@property
def models_dir_resolved(self) -> Path:
return Path(self.models_dir).resolve()
@property
def ppocrv6_dir(self) -> Path:
"""PP-OCRv6 模型根目录。"""
return self.models_dir_resolved / "ocr" / "ppocrv6"
@property
def paddleocr_vl_dir(self) -> Path:
"""PaddleOCR-VL 模型目录。"""
if self.paddleocr_vl_model_dir:
return Path(self.paddleocr_vl_model_dir).resolve()
return self.models_dir_resolved / "ocr" / "paddleocr-vl"
@property
def output_dir_resolved(self) -> Path:
"""OCR 输出目录(Markdown 文件等)。
优先使用 ``TIANMU_OUTPUT_DIR`` 环境变量,
否则默认为项目根目录的 ``output/``。
"""
if self.output_dir:
return Path(self.output_dir).resolve()
# 项目根目录 = models_dir 的父目录
return self.models_dir_resolved.parent / "output"
model_config = SettingsConfigDict(
env_prefix="tianmu_",
env_file=None,
)
settings = Settings()
+1
View File
@@ -0,0 +1 @@
"""天目 API 端点。"""
+55
View File
@@ -0,0 +1,55 @@
"""健康检查端点。"""
from __future__ import annotations
from fastapi import APIRouter
from src import __version__
from src.engine.rapidocr import RapidOCREngine
from src.engine.paddleocr_vl import PaddleOCRVLEngine
from src.schemas.ocr import EngineStatus, HealthResponse
router = APIRouter()
# 全局引擎实例(由 main.py startup 注入)
rapidocr: RapidOCREngine | None = None
paddleocr_vl: PaddleOCRVLEngine | None = None
def init(
rapidocr_engine: RapidOCREngine,
paddleocr_vl_engine: PaddleOCRVLEngine,
) -> None:
"""注入引擎实例。"""
global rapidocr, paddleocr_vl
rapidocr = rapidocr_engine
paddleocr_vl = paddleocr_vl_engine
@router.get("/healthz", response_model=HealthResponse)
async def health() -> HealthResponse:
engines_status: dict = {}
for name, engine in [("ppocrv6", rapidocr), ("paddleocr-vl", paddleocr_vl)]:
if engine is None:
engines_status[name] = EngineStatus(status="unavailable")
else:
h = await engine.health()
engines_status[name] = EngineStatus(status=h.get("status", "error"))
# 整体状态
ok_count = sum(1 for e in engines_status.values() if e.status == "ok")
unavailable_count = sum(1 for e in engines_status.values() if e.status == "unavailable")
total = len(engines_status)
if ok_count == total:
overall = "ok"
elif unavailable_count == total:
overall = "error"
else:
overall = "degraded"
return HealthResponse(
status=overall,
engines=engines_status,
version=__version__,
)
+287
View File
@@ -0,0 +1,287 @@
"""
OCR 推理端点。
职责:
1. 接收 OCR / VL 请求
2. 解析图片输入(base64 / URL / 文件路径)
3. 按需懒加载引擎
4. 执行推理并返回结构化结果
注意:
- PP-OCRv6 引擎在 startup 时加载(有模型目录则加载,失败不阻塞)
- PaddleOCR-VL 引擎在首次请求时懒加载(模型大,启动慢)
- 两条入口(/v1/ocr?engine=paddleocr-vl 和 /v1/ocr/vl)走同一懒加载逻辑
"""
from __future__ import annotations
import base64
import os
import time
import uuid
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException
from src.config import settings
from src.engine.rapidocr import RapidOCREngine
from src.engine.paddleocr_vl import PaddleOCRVLEngine
from src.schemas.ocr import OCRRequest, OCRResponse, OCRVLRequest, OCRVLResponse
from src.utils.logging import logger
router = APIRouter()
# 全局引擎实例(由 main.py startup 注入)
rapidocr: RapidOCREngine | None = None
paddleocr_vl: PaddleOCRVLEngine | None = None
def init(
rapidocr_engine: RapidOCREngine,
paddleocr_vl_engine: PaddleOCRVLEngine,
) -> None:
"""注入引擎实例。"""
global rapidocr, paddleocr_vl
rapidocr = rapidocr_engine
paddleocr_vl = paddleocr_vl_engine
async def _ensure_paddleocr_vl() -> bool:
"""确保 PaddleOCR-VL 引擎已加载(懒加载)。
该引擎模型约 1.8 GB,启动慢,所以在首次请求时加载而非 startup。
Returns:
True 表示引擎就绪,False 表示加载失败或不可用。
"""
global paddleocr_vl
if paddleocr_vl is None:
logger.warning("PaddleOCR-VL 引擎实例未初始化")
return False
if paddleocr_vl.loaded:
return True
try:
await paddleocr_vl.load()
logger.info("PaddleOCR-VL 引擎懒加载完成")
return True
except Exception as e:
logger.exception("PaddleOCR-VL 引擎加载失败")
return False
@router.post("/v1/ocr", response_model=OCRResponse)
async def ocr(req: OCRRequest) -> OCRResponse:
"""通用文字识别。
支持 base64 编码图片和 HTTP(s) URL。
默认使用 PP-OCRv6 引擎,速度快、精度高。
如有复杂文档需求,可使用 /v1/ocr/vl 端点。
"""
engine_name = req.engine
if engine_name == "auto":
engine_name = "ppocrv6" # 默认走 RapidOCR
# 解析图片
try:
image_data = await _resolve_image(req.image)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if req.resize:
image_data = _resize_image(image_data, req.resize)
# 选择引擎(统一走懒加载)
if engine_name == "ppocrv6":
result = await _run_rapidocr(image_data, req)
elif engine_name == "paddleocr-vl":
result = await _run_paddleocr_vl_with_lazy_load(image_data, req)
else:
raise HTTPException(status_code=400, detail=f"未知引擎: {engine_name}")
# 保存 Markdown 文件
if req.save_md and result.text:
md_path = _save_md(result.text, "ppocrv6" if engine_name == "ppocrv6" else "paddleocr-vl")
result.saved_md_path = str(md_path)
return result
@router.post("/v1/ocr/vl", response_model=OCRVLResponse)
async def ocr_vl(req: OCRVLRequest) -> OCRVLResponse:
"""复杂文档解析(PaddleOCR-VL)。
适用于表格、公式、古籍、印章等复杂场景。
输出 Markdown / JSON 结构化文本。
"""
try:
image_data = await _resolve_image(req.image)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# 懒加载 PaddleOCR-VL
ready = await _ensure_paddleocr_vl()
if not ready:
return OCRVLResponse(
engine="paddleocr-vl",
text="",
format=req.output_format,
duration_ms=0,
error="PaddleOCR-VL 引擎不可用或加载失败",
)
if paddleocr_vl is None:
return OCRVLResponse(
engine="paddleocr-vl",
text="",
format=req.output_format,
duration_ms=0,
error="引擎不可用",
)
result = await paddleocr_vl.parse_document(image_data, req.output_format)
# 保存 Markdown 文件
md_path = None
if req.save_md and result.text:
md_path = str(_save_md(result.text, "paddleocr-vl"))
logger.info("VL 结果已保存 | path={} chars={}", md_path, len(result.text))
# 重建响应以包含 saved_md_path(防止 Pydantic 序列化问题)
return OCRVLResponse(
engine=result.engine,
text=result.text,
format=result.format,
duration_ms=result.duration_ms,
error=result.error,
saved_md_path=md_path,
)
# ── 辅助函数 ──────────────────────────────────────────────
async def _resolve_image(image: str) -> bytes:
"""解析图片输入:支持 base64 或 HTTP URL。
Args:
image: base64 编码的图片数据或 HTTP(s) URL。
Returns:
图片的二进制数据。
"""
# URL 下载
if image.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=settings.http_timeout) as client:
resp = await client.get(image)
resp.raise_for_status()
return resp.content
# 本地文件路径
if os.path.isfile(image):
with open(image, "rb") as f:
return f.read()
# Base64 解码(支持 data:image/...;base64,... 格式)
if "," in image:
image = image.split(",", 1)[1]
try:
return base64.b64decode(image)
except Exception:
raise ValueError("图片输入格式无效:请提供 base64、HTTP URL 或文件路径")
def _resize_image(data: bytes, max_side: int) -> bytes:
"""等比缩放图片,确保最长边不超过 max_side(px)。
Args:
data: 原图二进制数据。
max_side: 最大边长(像素)。
Returns:
缩放后的图片二进制数据。
"""
try:
from PIL import Image
except ImportError:
logger.warning("PIL 不可用,跳过图片缩放")
return data
import io
try:
img = Image.open(io.BytesIO(data))
w, h = img.size
if max(w, h) <= max_side:
return data
if w >= h:
new_w = max_side
new_h = int(h * max_side / w)
else:
new_h = max_side
new_w = int(w * max_side / h)
img = img.resize((new_w, new_h), Image.LANCZOS)
buf = io.BytesIO()
fmt = img.format or "JPEG"
img.save(buf, format=fmt)
return buf.getvalue()
except Exception as e:
logger.warning("图片缩放失败: {}", e)
return data
async def _run_rapidocr(image_data: bytes, req: OCRRequest) -> OCRResponse:
"""使用 PP-OCRv6 引擎执行 OCR。"""
if rapidocr is None or not rapidocr.loaded:
return OCRResponse(engine="ppocrv6", error="PP-OCRv6 引擎未加载")
result = await rapidocr.recognize(image_data)
return result
async def _run_paddleocr_vl_with_lazy_load(image_data: bytes, req: OCRRequest) -> OCRResponse:
"""使用 PaddleOCR-VL 引擎(含懒加载)。"""
ready = await _ensure_paddleocr_vl()
if not ready:
return OCRResponse(engine="paddleocr-vl", error="PaddleOCR-VL 引擎不可用或加载失败")
if paddleocr_vl is None:
return OCRResponse(engine="paddleocr-vl", error="引擎不可用")
vl_result = await paddleocr_vl.parse_document(image_data, "text")
return OCRResponse(
engine="paddleocr-vl",
text=vl_result.text,
blocks=[],
duration_ms=vl_result.duration_ms,
error=vl_result.error,
)
def _save_md(text: str, engine_prefix: str = "ocr") -> Path:
"""将 OCR 结果保存为 Markdown 文件。
文件保存在 ``{TIANMU_OUTPUT_DIR}/md/`` 下(默认项目根目录 output/md/),
文件名格式为 ``{engine_prefix}_{datetime}_{uuid}.md``。
使用 uuid4 保证并发不冲突,自动创建目录(如不存在)。
Args:
text: OCR 识别文本(已按布局排序)。
engine_prefix: 引擎前缀标识(如 ocr / ppocrv6 / paddleocr-vl),用于文件名。
Returns:
保存的文件路径。
"""
output_dir = settings.output_dir_resolved / "md"
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y%m%d_%H%M%S")
file_id = uuid.uuid4().hex[:12]
safe_prefix = engine_prefix.replace("/", "-").replace("_", "-")
file_path = output_dir / f"{safe_prefix}_{timestamp}_{file_id}.md"
file_path.write_text(text, encoding="utf-8")
logger.info("OCR 结果已保存到 {}", file_path)
return file_path
+49
View File
@@ -0,0 +1,49 @@
"""
天目引擎协议(Protocol)。
定义统一的引擎接口,两个引擎实现(RapidOCR / PaddleOCR-VL)均遵循此协议。
调用方只需依赖协议,不依赖具体实现。
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class OCREngine(Protocol):
"""OCR 引擎统一协议。"""
loaded: bool
async def load(self) -> None:
"""初始化并加载模型。"""
...
async def close(self) -> None:
"""释放模型资源。"""
...
async def health(self) -> dict:
"""返回引擎健康状态。"""
...
class RapidOCRProtocol(OCREngine, Protocol):
"""RapidOCR 引擎协议(含 recognize 方法)。"""
async def recognize(self, image_data: bytes | str) -> object:
"""执行 OCR 识别,返回结构化响应。"""
...
class PaddleOCRVlProtocol(OCREngine, Protocol):
"""PaddleOCR-VL 引擎协议(含 parse_document 方法)。"""
async def parse_document(
self,
image_data: bytes | str,
output_format: str = "markdown",
) -> object:
"""解析文档图片,返回结构化文本。"""
...
+489
View File
@@ -0,0 +1,489 @@
"""
OCR 结果布局后处理(纯逻辑,无 IO)。
对 RapidOCR 检测到的文本框做阅读顺序修复和段落分组,
将扁平 blocks 转换为结构化文本(Markdown 兼容)。
能力范围(Phase 2,简单排版):
1. 阅读顺序排序:Y→X 排序
2. 行归组:Y 坐标容差内归为同一行
3. 段落分组:行间距 > 阈值时分段
4. 基础格式推断:全数字行、短标题行标记
5. 多栏检测:X 范围不重叠的 blocks 按栏分组排序
6. 标题层级:基于 block 高度相对中位高度的倍数推断
依赖:
- 无 IO,无第三方库(仅标准库)
- 可独立测试,不依赖引擎实例
"""
from __future__ import annotations
import re
import statistics
from typing import Any
from src.schemas.ocr import BBox, TextBlock
def _detect_columns(blocks: list[TextBlock], page_width: float = 800) -> list[list[TextBlock]]:
"""多栏检测:将 blocks 按水平范围分成左右栏。
检测策略:
1. 计算所有 block 的 X 范围(左边界、右边界)
2. 如果存在明显的水平间隙(gap > page_width * 0.1),则按间隙分栏
3. 每栏内部按 Y 排序
4. 按左 → 右顺序返回
Args:
blocks: 原始 OCR blocks。
page_width: 页面参考宽度。
Returns:
分栏后的 blocks 列表(左栏 → 右栏,每栏内部 Y 排序)。
如果未检测到多栏,返回 [blocks](单栏)。
"""
if len(blocks) < 4:
return [blocks]
# 收集所有 block 的 X 范围
x_ranges: list[tuple[float, float, float]] = [] # (left, right, center)
for b in blocks:
pts = b.bbox.points
if len(pts) >= 2:
xs = [p[0] for p in pts]
x_ranges.append((min(xs), max(xs), statistics.mean(xs)))
else:
x_ranges.append((0, page_width, page_width / 2))
# 计算中位宽度,用于检测间隙
widths = [r[1] - r[0] for r in x_ranges]
median_width = statistics.median(widths) if widths else page_width
# 对所有右边界排序,找间隙
right_edges = sorted(set(r[1] for r in x_ranges))
left_edges = sorted(set(r[0] for r in x_ranges))
# 找大间隙:如果某列右边界与下一列左边界的间隙 > median_width
split_x: list[float] = []
for re in right_edges:
candidates = [le for le in left_edges if le > re + median_width * 1.5]
if candidates:
gap = candidates[0] - re
if gap > page_width * 0.08: # 间隙 > 页面 8% 宽度
split_x.append(re + gap / 2)
if not split_x:
return [blocks]
# 按间隙分栏(取最大间隙位置)
split_pos = max(split_x)
col_left = [b for b, r in zip(blocks, x_ranges) if r[0] < split_pos]
col_right = [b for b, r in zip(blocks, x_ranges) if r[0] >= split_pos]
# 每栏内按 Y 排序
col_left.sort(key=_block_center_y)
col_right.sort(key=_block_center_y)
result = []
if col_left:
result.append(col_left)
if col_right:
result.append(col_right)
return result
def sort_and_group(blocks: list[TextBlock], page_width: float = 800) -> list[list[TextBlock]]:
"""对 OCR blocks 做阅读顺序排序和行归组。
支持多栏检测:如果 blocks 可以按水平范围分成左右栏,
则先按栏分组,每栏内部 Y→X 排序,再左→右合并。
Args:
blocks: 原始 OCR 结果块。
page_width: 页面参考宽度,用于多栏检测和居中判断。
Returns:
按阅读顺序排列的行列表,每行为该行内已按 X 排序的 blocks。
"""
if not blocks:
return []
# 多栏检测(有间隙时分栏,每栏独立 Y 排序)
columns = _detect_columns(blocks, page_width)
if len(columns) > 1:
# 多栏:逐栏处理行归组,再左→右合并行
all_lines: list[list[TextBlock]] = []
for col in columns:
col_lines = _sort_and_group_single(col)
all_lines.extend(col_lines)
return all_lines
return _sort_and_group_single(blocks)
def _sort_and_group_single(blocks: list[TextBlock]) -> list[list[TextBlock]]:
"""单栏场景的 Y→X 排序和行归组。"""
if not blocks:
return []
# 1. 计算 Y 容差(基于所有 bbox 的中位高度)
heights = [_block_height(b) for b in blocks]
if not heights:
return [[b] for b in blocks]
median_height = statistics.median(heights) if len(heights) > 1 else heights[0]
y_tolerance = median_height * 0.5 # 半行高以内视为同一行
# 2. 按 Y 中心点排序(全局从上到下)
sorted_blocks = sorted(blocks, key=_block_center_y)
# 3. 行归组
lines: list[list[TextBlock]] = []
current_line: list[TextBlock] = [sorted_blocks[0]]
current_y = _block_center_y(sorted_blocks[0])
for block in sorted_blocks[1:]:
y_center = _block_center_y(block)
if abs(y_center - current_y) <= y_tolerance:
current_line.append(block)
else:
lines.append(current_line)
current_line = [block]
current_y = y_center
if current_line:
lines.append(current_line)
# 4. 每行内按 X 从左到右排序
for line in lines:
line.sort(key=_block_center_x)
return lines
def group_paragraphs(lines: list[list[TextBlock]], page_width: float = 800) -> list[dict[str, Any]]:
"""将行归组为段落。
基于行间距判断段落边界。行间距明显大于中位行距时视为新段落。
Args:
lines: 已按阅读顺序排列的行列表。
page_width: 页面参考宽度(px),用于居中检测,默认 800。
Returns:
段落列表,每段包含文本和类型。
"""
if not lines:
return []
# 计算中位行间距
if len(lines) > 1:
spacings: list[float] = []
for i in range(len(lines) - 1):
bottom = max(_block_bottom(b) for b in lines[i])
top = min(_block_top(b) for b in lines[i + 1])
spacings.append(top - bottom)
median_spacing = statistics.median(spacings) if spacings else 8.0
paragraph_threshold = median_spacing * 1.8 # 1.8 倍以上视为段落分隔
else:
paragraph_threshold = 20.0 # 默认值
paragraphs: list[dict[str, Any]] = []
current_para: list[str] = []
current_type = "text"
current_level: int = 3 # 当前标题级别(用于连续标题的场景)
# 所有行 block 的高度(用于计算中位高度比例,辅助标题检测)
all_heights = [max(_block_height(b) for b in line) for line in lines if line]
median_line_height = statistics.median(all_heights) if len(all_heights) > 1 else (all_heights[0] if all_heights else 16.0)
for i, line_blocks in enumerate(lines):
line_text = "".join(b.text for b in line_blocks)
# 计算行信息(居中 + 相对高度)
avg_center_x = statistics.mean(
_block_center_x(b) for b in line_blocks
) if line_blocks else 0
center_x_ratio = avg_center_x / page_width if page_width > 0 and avg_center_x > 0 else None
line_height = max(_block_height(b) for b in line_blocks) if line_blocks else 0
rel_height = line_height / median_line_height if median_line_height > 0 and line_height > 0 else None
is_centered = center_x_ratio is not None and 0.25 <= center_x_ratio <= 0.75
line_type = _detect_line_type(
line_text,
center_x_ratio=center_x_ratio,
rel_height=rel_height,
)
# 标题级别推断
level = _detect_heading_level(
rel_height=rel_height,
is_centered=is_centered,
text=line_text,
) if line_type == "heading" else 3
# 判断是否分段
is_new_paragraph = False
# 空行强制分段
if not line_text.strip():
is_new_paragraph = True
else:
# 行间距大于阈值(仅正间距,防止重叠/同 Y 误分段)
if i > 0:
prev_bottom = max(_block_bottom(b) for b in lines[i - 1])
curr_top = min(_block_top(b) for b in line_blocks)
gap = curr_top - prev_bottom
if gap > 0 and gap > paragraph_threshold:
is_new_paragraph = True
# 行类型变化(如文字→标题,独立于间距判断)
if current_para and line_type != current_type:
is_new_paragraph = True
if is_new_paragraph and current_para:
paragraphs.append({
"type": current_type,
"text": "\n".join(current_para),
"level": current_level,
})
current_para = []
current_type = "text"
current_level = 3
if line_text.strip():
current_para.append(line_text)
current_type = line_type
current_level = level
if current_para:
paragraphs.append({
"type": current_type,
"text": "\n".join(current_para),
"level": current_level,
})
return paragraphs
def build_markdown_text(
blocks: list[TextBlock],
page_width: float = 800,
) -> tuple[str, list[list[TextBlock]], list[dict[str, Any]]]:
"""将 OCR blocks 转换为 markdown 格式文本。
支持段落类型:
- heading:标题(## 前缀)
- list_item:无序列表(- 前缀)
- numbered:有序列表
- code:代码块(``` 包裹)
- separator:分隔线(---)
- text:普通段落
Args:
blocks: 原始 OCR 识别结果块。
page_width: 页面参考宽度(px),用于居中检测。
Returns:
(markdown 文本, 行列表, 段落列表)
"""
lines = sort_and_group(blocks)
paragraphs = group_paragraphs(lines, page_width=page_width)
# 构建 markdown
md_parts: list[str] = []
code_block_open = False
for para in paragraphs:
para_type = para["type"]
para_text = para["text"]
if para_type == "heading":
level = para.get("level", 2)
level = max(1, min(level, 3)) # 只输出 1~3 级
prefix = "#" * level
md_parts.append(f"\n{prefix} {para_text.strip()}\n")
elif para_type == "list_item":
# 每行加 - 前缀
items = para_text.strip().split("\n")
md_parts.append("\n".join(f"- {line.strip()}" for line in items))
elif para_type == "numbered":
md_parts.append(f"\n{para_text.strip()}\n")
elif para_type == "code":
md_parts.append(f"\n```\n{para_text.strip()}\n```\n")
elif para_type == "separator":
md_parts.append("\n---\n")
else:
md_parts.append(para_text)
markdown = "\n\n".join(p for p in md_parts if p.strip())
return markdown, lines, paragraphs
# ── 辅助函数 ──────────────────────────────────────────────
def _block_height(block: TextBlock) -> float:
"""计算文本框高度。"""
try:
pts = block.bbox.points
return abs(pts[2][1] - pts[0][1]) if len(pts) >= 4 else 0.0
except (IndexError, TypeError, AttributeError):
return 0.0
def _block_center_y(block: TextBlock) -> float:
"""文本框 Y 中心点。"""
try:
pts = block.bbox.points
ys = [p[1] for p in pts]
return statistics.mean(ys)
except (IndexError, TypeError, AttributeError, statistics.StatisticsError):
return 0.0
def _block_center_x(block: TextBlock) -> float:
"""文本框 X 中心点。"""
try:
pts = block.bbox.points
xs = [p[0] for p in pts]
return statistics.mean(xs)
except (IndexError, TypeError, AttributeError, statistics.StatisticsError):
return 0.0
def _block_bottom(block: TextBlock) -> float:
"""文本框底部 Y 坐标。"""
try:
pts = block.bbox.points
return max(p[1] for p in pts)
except (IndexError, TypeError, AttributeError):
return 0.0
def _block_top(block: TextBlock) -> float:
"""文本框顶部 Y 坐标。"""
try:
pts = block.bbox.points
return min(p[1] for p in pts)
except (IndexError, TypeError, AttributeError):
return 0.0
def _detect_line_type(
text: str,
center_x_ratio: float | None = None,
rel_height: float | None = None,
) -> str:
"""检测行类型。
Args:
text: 行文本。
center_x_ratio: 文本框中心 X 相对页面宽度的比例(0~1),
用于检测居中对齐(标题候选)。
rel_height: 文本框高度相对中位高度的比例,
用于检测标题级别(更大的字 → 更高级别标题)。
Returns:
类型:heading / list_item / numbered / code / separator / text
"""
stripped = text.strip()
if not stripped:
return "text"
# 分隔线(纯符号)
if re.match(r'^[-=*_]{3,}$', stripped):
return "separator"
# 代码行(检查原文以保留前导空格,stripped 会去除它们)
if text.startswith((" ", "\t")):
return "code"
# 列表项(以 - * • ▪ ▸ → · 开头)
if stripped.startswith(("- ", "* ", "• ", "▪ ", "▸ ", "→ ", "· ")):
return "list_item"
# 独立 bullet 字符(单个符号作为独立行)
if stripped in ("•", "▪", "▸", "→", "·", "-", "*"):
return "list_item"
# 编号项(支持 1.内容 / 1)内容 / (1) 等,点后可不带空格)
if re.match(r'^\d+[\.\)]\s*', stripped):
return "numbered"
if re.match(r'^\(\d+\)\s*', stripped):
return "numbered"
if re.match(r'^[①②③④⑤⑥⑦⑧⑨⑩]', stripped):
return "numbered"
# TOC 目录行:"第一章 .............. 1" — 含连续点号的行
# 特征:短行(10-50 char) + 含连续 3+ 点号 + 非标题
if 10 <= len(stripped) < 60 and "..." in stripped and "。" not in stripped:
return "numbered"
# 标题候选:短行 + 居中或短文本特征
# 标题通常满足:较短 + 不以句号/逗号/分号/冒号结尾
is_short = len(stripped) < 40
no_punct_end = not stripped.endswith(("。", ",", ";", ":", "、", "!", "?", "…"))
# 居中检测:如果 center_x_ratio 在 0.25~0.75 之间,视为居中
is_centered = center_x_ratio is not None and 0.25 <= center_x_ratio <= 0.75
# 字体较大:rel_height > 1.3 说明字号明显大于中位文本
is_larger_font = rel_height is not None and rel_height > 1.3
if is_short and no_punct_end:
# 全大写英文短行 → 标题
if stripped.isupper() and stripped.replace(" ", "").isalpha():
return "heading"
# 居中短行 → 标题
if is_centered:
return "heading"
# 大字短行 → 标题
if is_larger_font:
return "heading"
# 极短的中文行(≤ 8 字符)→ 标题(如"第一章"、"摘要")
cn_chars = sum(1 for c in stripped if '一' <= c <= '鿿')
cn_ratio = cn_chars / len(stripped) if len(stripped) > 0 else 0
if cn_ratio > 0.6 and len(stripped) <= 8:
return "heading"
# 纯数字短行(页码、编号)
if stripped.isdigit():
return "numbered"
return "text"
def _detect_heading_level(
rel_height: float | None = None,
is_centered: bool = False,
text: str = "",
) -> int:
"""推断标题级别。
级别规则:
- h1(1):字体很大(rel_height ≥ 1.8) 或 居中 + 大字
- h2(2):字体中等(rel_height ≥ 1.3) 或 居中
- h3(3):其他标题
Args:
rel_height: 相对中位字体高度的比例。
is_centered: 是否居中。
text: 标题文本(用于额外判断)。
Returns:
1 / 2 / 3——对应 markdown 的 # / ## / ###。
"""
# 居中的大字 → h1
if is_centered and rel_height is not None and rel_height >= 1.5:
return 1
# 大字 → h2
if rel_height is not None and rel_height >= 1.8:
return 1
if rel_height is not None and rel_height >= 1.3:
return 2
# 居中 → h2
if is_centered:
return 2
return 3
+252
View File
@@ -0,0 +1,252 @@
"""
PaddleOCR-VL 引擎封装。
基于 PaddleOCR-VL-1.6(via HuggingFace Transformers)的端到端文档解析引擎。
支持表格/公式/古籍/印章等复杂文档的 Markdown 结构化输出。
依赖:
- torch >= 2.0(推理后端)
- transformers >= 4.40(模型加载,内置 PaddleOCR-VL 支持)
- Pillow(图片处理)
设备:MPS (Apple Silicon) / CUDA / CPU。
注意:CPU 推理极慢(~数分钟/张),强烈建议在有 GPU 的环境使用。
"""
from __future__ import annotations
import io
import time
from pathlib import Path
from typing import Any
from PIL import Image
from src.config import settings
from src.schemas.ocr import OCRVLResponse
from src.utils.logging import logger
class PaddleOCRVLEngine:
"""PaddleOCR-VL-1.6 文档解析引擎。
通过 HuggingFace Transformers 加载,使用 chat template 消息格式
进行结构化文档解析,输出 Markdown / JSON / 纯文本。
"""
def __init__(self) -> None:
self._processor: Any | None = None
self._model: Any | None = None
self._device: str = ""
self.loaded = False
async def load(self) -> None:
"""加载 PaddleOCR-VL 模型和处理器。
从本地路径加载,不使用 trust_remote_code(transformers 5.13 已内置)。
"""
model_path = str(settings.paddleocr_vl_dir)
logger.info("加载 PaddleOCR-VL 模型 | dir={}", model_path)
if not Path(model_path).exists():
raise FileNotFoundError(f"PaddleOCR-VL 模型目录不存在: {model_path}")
# 检测设备(尊重 TIANMU_DEVICE 显式设置)
import torch
user_pref = settings.device # "auto" / "cuda" / "mps" / "cpu"
if user_pref == "cpu":
self._device = "cpu"
elif user_pref in ("auto", "cuda") and torch.cuda.is_available():
self._device = "cuda"
elif user_pref in ("auto", "mps") and hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
self._device = "mps"
else:
self._device = "cpu"
logger.info(
" device={} | TIANMU_DEVICE={} | CUDA={} MPS={}",
self._device, user_pref,
torch.cuda.is_available(),
torch.backends.mps.is_available() if hasattr(torch.backends, "mps") else False,
)
from transformers import AutoModelForImageTextToText, AutoProcessor
# 加载 processor(需 trust_remote_code,因为处理有自定义逻辑)
self._processor = AutoProcessor.from_pretrained(
model_path,
trust_remote_code=True,
)
# 加载模型(使用内置 transformers 实现,不 trust_remote_code)
self._model = AutoModelForImageTextToText.from_pretrained(
model_path,
dtype=torch.bfloat16,
).to(self._device).eval()
self.loaded = True
logger.info("PaddleOCR-VL 引擎就绪 | device={}", self._device)
async def close(self) -> None:
"""释放模型和处理器。"""
self._model = None
self._processor = None
self.loaded = False
logger.info("PaddleOCR-VL 引擎已关闭")
import gc, torch
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
async def health(self) -> dict:
"""返回引擎健康状态。"""
return {
"status": "ok" if self.loaded else "unavailable",
"model": "paddleocr-vl-1.6",
"device": self._device if self.loaded else "N/A",
}
async def parse_document(
self,
image_data: bytes | str | Path,
output_format: str = "markdown",
) -> OCRVLResponse:
"""解析文档图片,返回结构化文本。
Args:
image_data: 图片二进制数据或文件路径。
output_format: 输出格式(markdown / json / text)。
Returns:
OCRVLResponse: 结构化解析结果。
"""
if not self.loaded or self._model is None or self._processor is None:
return OCRVLResponse(
engine="paddleocr-vl",
text="",
format=output_format,
error="引擎未加载",
)
# 解析图片
try:
img = self._resolve_image(image_data)
except Exception as e:
logger.exception("图片解析失败")
return OCRVLResponse(
engine="paddleocr-vl", text="", format=output_format, error=f"图片解析失败: {e}"
)
# 构建消息(官方格式)
task = self._task_from_format(output_format)
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": img},
{"type": "text", "text": task},
],
},
]
# 使用官方的 apply_chat_template
max_pixels = getattr(
self._processor.image_processor, "max_pixels", settings.vl_max_pixels
)
inputs = self._processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
processor_kwargs={
"images_kwargs": {
"size": {
"shortest_edge": self._processor.image_processor.min_pixels,
"longest_edge": max_pixels,
},
},
},
).to(self._device)
# 推理
import torch
logger.info("PaddleOCR-VL 推理开始 | img={} outputs={}", img.size, output_format)
start = time.perf_counter()
try:
with torch.no_grad():
outputs = self._model.generate(
**inputs,
max_new_tokens=settings.vl_max_new_tokens,
do_sample=False,
temperature=None,
top_p=None,
)
except Exception as e:
logger.exception("PaddleOCR-VL 推理失败")
return OCRVLResponse(
engine="paddleocr-vl", text="", format=output_format, error=f"推理失败: {e}"
)
elapsed = (time.perf_counter() - start) * 1000
result_text = self._processor.decode(
outputs[0][inputs["input_ids"].shape[-1]:-1]
).strip()
logger.info("PaddleOCR-VL 推理完成 | {}ms | chars={}", round(elapsed), len(result_text))
return OCRVLResponse(
engine="paddleocr-vl",
text=result_text,
format=output_format,
duration_ms=round(elapsed, 1),
error=None,
)
# ── 辅助方法 ──────────────────────────────────────────
# 官方任务提示词
_TASK_PROMPTS: dict[str, str] = {
"markdown": "OCR:",
"json": "OCR:",
"text": "OCR:",
"table": "Table Recognition:",
"formula": "Formula Recognition:",
"chart": "Chart Recognition:",
"spotting": "Spotting:",
"seal": "Seal Recognition:",
}
def _task_from_format(self, output_format: str) -> str:
"""将 tianmu 的 output_format 映射为官方任务提示词。
Args:
output_format: markdown / json / text / table / formula / chart / spotting / seal。
Returns:
官方任务提示词字符串。
"""
return self._TASK_PROMPTS.get(output_format, "OCR:")
@staticmethod
def _resolve_image(data: bytes | str | Path) -> Image.Image:
"""将各种输入格式解析为 PIL Image。
Args:
data: 图片数据(bytes)、文件路径(str/Path)。
Returns:
RGB 模式的 PIL Image。
"""
if isinstance(data, (str, Path)):
path = Path(data)
if path.is_file():
return Image.open(path).convert("RGB")
# bytes → PIL
return Image.open(io.BytesIO(data if isinstance(data, bytes) else data.encode())).convert("RGB")
+185
View File
@@ -0,0 +1,185 @@
"""
RapidOCR 引擎封装。
基于 RapidOCR(PP-OCRv6)的本地 ONNX 推理引擎,用于快速文字提取。
默认使用 small 模型组合,可通过环境变量切换模型尺寸。
"""
from __future__ import annotations
import time
from pathlib import Path
from src.config import settings
from src.engine.layout import build_markdown_text
from src.schemas.ocr import BBox, OCRResponse, TextBlock
from src.utils.logging import logger
class RapidOCREngine:
"""RapidOCR(PP-OCRv6)推理引擎。
使用 ONNX Runtime 进行 CPU/GPU 推理,支持自定义模型路径。
"""
def __init__(self) -> None:
self._engine: object | None = None
self.loaded = False
self._model_name = ""
self._use_coreml = False
async def load(self) -> None:
"""初始化 RapidOCR 引擎,加载 ONNX 模型。"""
import numpy as np # noqa: F401 — rapidocr 依赖 numpy
from rapidocr import RapidOCR
models_dir = settings.ppocrv6_dir
logger.info("加载 PP-OCRv6 模型 | dir={}", models_dir)
# RapidOCR 的 params 使用 "section.key" 平铺键格式
det_path = settings.ppocrv6_det_model or str(models_dir / "det" / "PP-OCRv6_det_small.onnx")
rec_path = settings.ppocrv6_rec_model or str(models_dir / "rec" / "PP-OCRv6_rec_small.onnx")
cls_path = settings.ppocrv6_cls_model or str(models_dir / "cls" / "ch_ppocr_mobile_v2.0_cls_mobile.onnx")
dict_path = settings.ppocrv6_dict_path or str(models_dir / "rec" / "ppocrv6_dict.txt")
params: dict = {
"Global.model_root_dir": str(models_dir),
"Global.log_level": "warn",
"Det.model_dir": det_path,
"Rec.model_dir": rec_path,
"Rec.dict_path": dict_path,
"Cls.model_dir": cls_path,
}
# Apple Silicon CoreML 加速(实验性)
# M4/M 系列芯片可用 Neural Engine,但 RapidOCR 官方标注实测性能不如 CPU
# 默认由 TIANMU_DEVICE 控制:auto 时尝试 CoreML,cpu 时强制 CPU
try:
import onnxruntime as ort
if settings.device in ("auto", "mps") and "CoreMLExecutionProvider" in ort.get_available_providers():
params["EngineConfig.onnxruntime.use_coreml"] = True
self._use_coreml = True
logger.info(" ✅ Apple CoreML 加速已启用(实验性)")
else:
logger.info(" CPU 推理(CoreML 未启用)")
except ImportError:
logger.info(" CPU 推理(onnxruntime 未安装)")
# 验证模型文件存在
for name, fp in [("det", det_path), ("rec", rec_path), ("cls", cls_path), ("dict", dict_path)]:
logger.info(" {}: {} | exists={}", name, fp, Path(fp).exists())
self._engine = RapidOCR(params=params)
self.loaded = True
self._model_name = "rapidocr"
logger.info("PP-OCRv6 引擎就绪")
async def close(self) -> None:
"""释放引擎资源。"""
self._engine = None
self.loaded = False
logger.info("PP-OCRv6 引擎已关闭")
async def health(self) -> dict:
"""返回引擎健康状态。"""
device = "coreml" if self._use_coreml else "cpu"
return {
"status": "ok" if self.loaded else "unavailable",
"model": self._model_name,
"device": device,
}
async def recognize(self, image_data: bytes | str) -> OCRResponse:
"""执行 OCR 识别。
Args:
image_data: 图片二进制数据或文件路径字符串。
Returns:
OCRResponse: 包含识别结果的结构化响应。
"""
if not self.loaded or self._engine is None:
return OCRResponse(engine="ppocrv6", error="引擎未加载")
start = time.perf_counter()
try:
result = self._engine(image_data)
except Exception as e:
logger.exception("PP-OCRv6 推理失败")
return OCRResponse(engine="ppocrv6", error=str(e))
elapsed = (time.perf_counter() - start) * 1000
# 解析返回结果
if result is None or len(result) == 0:
logger.info("PP-OCRv6 未识别到文字")
return OCRResponse(
engine="ppocrv6",
text="",
blocks=[],
duration_ms=round(elapsed, 1),
)
# RapidOCROutput → 结构化响应
return self._build_response(result, elapsed)
def _build_response(self, result: object, elapsed: float) -> OCRResponse:
"""将 RapidOCROutput 转换为统一响应模型。
Args:
result: RapidOCR 返回的 RapidOCROutput 实例。
elapsed: 推理耗时(毫秒)。
"""
# 尝试获取属性 — RapidOCROutput 的结构
boxes = getattr(result, "boxes", None)
txts = getattr(result, "txts", None)
scores = getattr(result, "scores", None)
if txts is None or boxes is None:
return OCRResponse(
engine="ppocrv6",
text="",
blocks=[],
duration_ms=round(elapsed, 1),
)
blocks: list[TextBlock] = []
for i, text in enumerate(txts):
score = float(scores[i]) if scores is not None else 1.0
box = boxes[i] if len(boxes) > i else []
# 标准化 bbox 为 4 顶点格式
points = self._normalize_bbox(box)
blocks.append(
TextBlock(text=text, confidence=round(score, 4), bbox=BBox(points=points))
)
# 布局后处理:阅读顺序排序 → 行归组 → 段落分组 → Markdown
markdown, lines, paragraphs = build_markdown_text(blocks)
return OCRResponse(
engine="ppocrv6",
text=markdown,
blocks=blocks,
duration_ms=round(elapsed, 1),
)
@staticmethod
def _normalize_bbox(box: object) -> list[list[float]]:
"""将 RapidOCR 输出的 bbox 统一为 4 顶点格式。"""
import numpy as np
if isinstance(box, (list, tuple)):
if not box:
return [[0, 0], [0, 0], [0, 0], [0, 0]]
if len(box) == 4 and all(isinstance(p, (list, tuple)) and len(p) == 2 for p in box):
return [[float(x), float(y)] for x, y in box]
if len(box) == 8:
return [[float(box[i]), float(box[i + 1])] for i in range(0, 8, 2)]
return [[float(box[0]), float(box[1])]] * 4
if isinstance(box, np.ndarray):
return box.reshape(-1, 2).tolist() if box.size >= 8 else [[0, 0]] * 4
return [[0, 0]] * 4
+123
View File
@@ -0,0 +1,123 @@
"""
天目 · 魂镜本地 OCR 引擎 — FastAPI 应用入口。
提供通用文字识别(PP-OCRv6,via RapidOCR)和复杂文档解析(PaddleOCR-VL)API。
所有模型由本地引擎直接加载推理,不依赖外部服务。
"""
from __future__ import annotations
import os
# 抑制进程退出时 multiprocessing 信号量泄漏警告(强制覆盖,子进程继承)
os.environ["PYTHONWARNINGS"] = "ignore::UserWarning"
os.environ["TOKENIZERS_PARALLELISM"] = "false"
import gc
import warnings
warnings.filterwarnings("ignore", message="resource_tracker:")
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src import __version__
from src.config import settings
from src.engine.rapidocr import RapidOCREngine
from src.engine.paddleocr_vl import PaddleOCRVLEngine
from src.endpoints import health, ocr
from src.utils.logging import logger, setup_logging
setup_logging(settings.log_level)
app = FastAPI(
title="天目 · 魂镜本地 OCR 引擎",
version=__version__,
description="本地 OCR 推理引擎 — PP-OCRv6(快速文字提取)+ PaddleOCR-VL(复杂文档解析)",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 全局引擎实例
rapidocr_engine = RapidOCREngine()
paddleocr_vl_engine = PaddleOCRVLEngine()
@app.on_event("startup")
async def startup() -> None:
logger.info(
"天目启动 | host={} port={} device={}(detected={}) models_dir={}",
settings.host,
settings.port,
settings.device,
settings.detected_device,
settings.models_dir_resolved,
)
# 注入引擎实例到端点模块
health.init(rapidocr_engine, paddleocr_vl_engine)
ocr.init(rapidocr_engine, paddleocr_vl_engine)
# 懒加载 RapidOCR 引擎(模型存在则加载,失败不阻塞启动)
if settings.ppocrv6_dir.exists():
try:
await rapidocr_engine.load()
except Exception as e:
logger.warning("PP-OCRv6 引擎加载失败(可在运行时重试): {}", e)
else:
logger.warning("PP-OCRv6 模型目录不存在: {}", settings.ppocrv6_dir)
@app.on_event("shutdown")
async def shutdown() -> None:
"""优雅关闭:释放引擎资源,超时兜底。"""
import asyncio
try:
await asyncio.wait_for(
asyncio.gather(
rapidocr_engine.close(),
paddleocr_vl_engine.close(),
),
timeout=5.0,
)
except asyncio.TimeoutError:
logger.warning("引擎关闭超时,强制退出")
except Exception as e:
logger.warning("引擎关闭异常: {}", e)
gc.collect()
logger.info("天目停止")
# 注册路由
app.include_router(health.router, tags=["health"])
app.include_router(ocr.router, tags=["ocr"])
def _run() -> None:
"""启动 uvicorn 服务。"""
import uvicorn
uvicorn.run(
"src.main:app",
host=settings.host,
port=settings.port,
log_level=settings.log_level.lower(),
)
def main_cli() -> None:
"""uv 命令行入口(uv run tianmu),确保 PYTHONWARNINGS 在 reloader 之前设置。"""
import sys
os.environ["PYTHONWARNINGS"] = "ignore::UserWarning"
sys.argv = [a for a in sys.argv if a != "tianmu"]
_run()
# 直接运行入口(python -m src.main)
if __name__ == "__main__":
_run()
+1
View File
@@ -0,0 +1 @@
"""天目数据模型。"""
+104
View File
@@ -0,0 +1,104 @@
"""
OCR 请求 / 响应 Pydantic 模型。
定义天目微服务内部的 API 契约,供 endpoints 和 engine 使用。
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class OCRRequest(BaseModel):
"""通用 OCR 请求。"""
image: str = Field(description="图片数据:base64 编码字符串或 HTTP(s) URL")
engine: str = Field(
default="auto",
description="引擎选择:auto(自动选择)/ ppocrv6 / paddleocr-vl",
)
lang: list[str] = Field(
default_factory=lambda: ["ch", "en"],
description="识别语言列表,默认中英文。仅对 ppocrv6 引擎生效",
)
resize: int | None = Field(
default=None,
description="可选:图片最大边长(px),超过则等比缩放。减少传输 / 推理耗时",
)
save_md: bool = Field(
default=False,
description="是否将识别结果保存为 Markdown 文件到 output/md/ 目录",
)
class BBox(BaseModel):
"""文本框坐标。"""
points: list[list[float]] = Field(description="四个顶点坐标 [[x1,y1],[x2,y1],[x3,y2],[x4,y2]]")
class TextBlock(BaseModel):
"""单行/单块识别结果。"""
text: str = Field(description="识别文字")
confidence: float = Field(description="置信度 0~1", ge=0, le=1)
bbox: BBox = Field(description="文本框坐标")
class OCRResponse(BaseModel):
"""通用 OCR 响应。"""
engine: str = Field(description="实际使用的引擎,如 ppocrv6 / paddleocr-vl")
text: str = Field(default="", description="拼接后的全文")
blocks: list[TextBlock] = Field(default_factory=list, description="逐块识别结果")
duration_ms: float = Field(default=0, description="推理耗时(毫秒)")
error: str | None = Field(default=None, description="错误信息,成功时为 null")
saved_md_path: str | None = Field(
default=None, description="保存的 Markdown 文件路径(仅 save_md=true 时有值)"
)
class OCRVLRequest(BaseModel):
"""PaddleOCR-VL 复杂文档解析请求。"""
image: str = Field(description="图片数据:base64 编码字符串或 HTTP(s) URL")
output_format: str = Field(
default="markdown",
description="输出格式:markdown / json / text",
)
save_md: bool = Field(
default=False,
description="是否将识别结果保存为 Markdown 文件到 output/md/ 目录",
)
class OCRVLResponse(BaseModel):
"""PaddleOCR-VL 响应。"""
engine: str = Field(default="paddleocr-vl")
text: str = Field(default="", description="结构化输出文本(Markdown / JSON / 纯文本)")
format: str = Field(default="markdown", description="实际输出格式")
duration_ms: float = Field(default=0, description="推理耗时(毫秒)")
error: str | None = Field(default=None, description="错误信息,成功时为 null")
saved_md_path: str | None = Field(
default=None, description="保存的 Markdown 文件路径(仅 save_md=true 时有值)"
)
# ── 健康检查 ──────────────────────────────────────────────
class EngineStatus(BaseModel):
"""引擎状态。"""
status: str = Field(description="ok / degraded / error / unavailable")
class HealthResponse(BaseModel):
"""健康检查响应。"""
status: str = Field(description="整体状态:ok / degraded / error")
engines: dict[str, Any] = Field(default_factory=dict, description="各引擎状态")
version: str = Field(description="天目版本")
+1
View File
@@ -0,0 +1 @@
"""天目工具模块。"""
+35
View File
@@ -0,0 +1,35 @@
"""
统一日志配置。
所有模块统一从这里导入 ``logger``,避免 loguru 配置散落在各处。
在应用启动时调用 ``setup_logging()`` 完成初始化。
"""
from __future__ import annotations
import sys
import loguru
logger = loguru.logger
def setup_logging(level: str = "INFO") -> None:
"""配置 loguru:清空默认 handler,写入 stderr(结构化 + 彩色输出)。
Args:
level: 日志级别(DEBUG / INFO / WARNING / ERROR)。
"""
logger.remove()
logger.add(
sink=sys.stderr,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> "
"| <level>{level: <7}</level> "
"| <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> "
"- <level>{message}</level>"
),
level=level.upper(),
colorize=True,
enqueue=True,
)