初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
天目 pytest 配置。
|
||||
|
||||
提供服务启停夹具,方便运行集成测试。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
API_BASE = os.environ.get("TIANMU_API_BASE", "http://localhost:8101")
|
||||
TIANMU_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _wait_for_service(url: str, timeout: float = 15.0, interval: float = 0.5) -> bool:
|
||||
"""轮询等待服务就绪。
|
||||
|
||||
Args:
|
||||
url: 健康检查 URL。
|
||||
timeout: 最长等待秒数。
|
||||
interval: 轮询间隔秒数。
|
||||
|
||||
Returns:
|
||||
True 表示服务就绪,False 表示超时。
|
||||
"""
|
||||
start = time.monotonic()
|
||||
while time.monotonic() - start < timeout:
|
||||
try:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except (urllib.error.URLError, ConnectionError, OSError):
|
||||
pass
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tianmu_server():
|
||||
"""启动天目服务(仅一次,整个 session 共用)。
|
||||
|
||||
如果环境变量 TIANMU_API_BASE 已设置,跳过启动(连接外部服务)。
|
||||
启动后轮询 /healthz,就绪后才继续运行测试。
|
||||
"""
|
||||
if "TIANMU_API_BASE" in os.environ:
|
||||
yield
|
||||
return
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["python3", "-m", "src.main"],
|
||||
cwd=str(TIANMU_DIR),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
ready = _wait_for_service(f"{API_BASE}/healthz")
|
||||
if not ready:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
raise RuntimeError(
|
||||
f"天目服务启动超时({API_BASE}/healthz 15s 内未就绪)"
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_base() -> str:
|
||||
return API_BASE
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
天目健康检查测试(pytest)。
|
||||
|
||||
需先启动天目服务:
|
||||
cd tianmu && source .venv/bin/activate && python3 -m src.main
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
API_BASE = os.environ.get("TIANMU_API_BASE", "http://localhost:8101")
|
||||
|
||||
|
||||
def test_healthz():
|
||||
"""健康检查应返回 200 并包含引擎状态。"""
|
||||
req = urllib.request.Request(f"{API_BASE}/healthz")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
assert resp.status == 200
|
||||
data = json.loads(resp.read())
|
||||
assert "status" in data
|
||||
assert "engines" in data
|
||||
assert "version" in data
|
||||
assert data["version"] == "0.2.0"
|
||||
assert "ppocrv6" in data["engines"]
|
||||
|
||||
|
||||
def test_ocr_empty_image():
|
||||
"""发送空数据应得到有效的错误响应(而非崩溃)。"""
|
||||
body = json.dumps({"image": ""}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_BASE}/v1/ocr",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
assert resp.status == 200
|
||||
data = json.loads(resp.read())
|
||||
# 空图片不会触发 422,而是返回 engine 处理结果或错误
|
||||
assert "engine" in data
|
||||
assert "error" in data # 可能是 None 或错误消息,但字段必须存在
|
||||
|
||||
|
||||
def test_ocr_png_image():
|
||||
"""PP-OCRv6 应正确识别简单图片中的文字。"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# 生成白底黑字图片
|
||||
img = Image.new("RGB", (400, 60), color="white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.text((10, 20), "Hello World 123", fill="black")
|
||||
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
body = json.dumps({"image": b64, "engine": "ppocrv6"}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_BASE}/v1/ocr",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read())
|
||||
assert data["engine"] == "ppocrv6"
|
||||
assert data["error"] is None
|
||||
assert len(data["text"]) > 0 # 识别到文字
|
||||
assert len(data["blocks"]) > 0
|
||||
assert data["blocks"][0]["confidence"] > 0.5
|
||||
assert data["duration_ms"] > 0
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
天目 OCR 布局质量集成测试。
|
||||
|
||||
生成模拟真实文档的图片(含标题、正文、列表、编号项、分隔线等),
|
||||
验证:
|
||||
1. PP-OCRv6 正确识别文字内容
|
||||
2. 布局后处理生成结构化 Markdown(标题级别、段落分组)
|
||||
3. 双栏排版识别
|
||||
|
||||
使用方法:
|
||||
1. 先启动天目服务:uv run tianmu
|
||||
2. 运行本脚本:python tests/test_integration_layout.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
API_BASE = os.environ.get("TIANMU_API_BASE", "http://localhost:8101")
|
||||
|
||||
|
||||
# ── 字体加载 ──────────────────────────────────────────────
|
||||
|
||||
def _load_font(size: int) -> ImageFont.FreeTypeFont | None:
|
||||
paths = [
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
]
|
||||
for fp in paths:
|
||||
if os.path.exists(fp):
|
||||
try:
|
||||
return ImageFont.truetype(fp, size)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ── 测试场景 1:标准文档布局 ────────────────────────────
|
||||
|
||||
def create_standard_doc() -> bytes:
|
||||
"""生成一个含标题、正文、列表的标准文档图片。"""
|
||||
font_title = _load_font(28)
|
||||
font_body = _load_font(16)
|
||||
|
||||
img = Image.new("RGB", (600, 400), color="white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
y = 20
|
||||
lines = []
|
||||
title = "项目实施方案"
|
||||
lines.append((title, font_title, (20, y)))
|
||||
y += 45
|
||||
|
||||
lines.append(("", None, None)) # 空行
|
||||
|
||||
h2 = "一、项目背景"
|
||||
lines.append((h2, font_body, (20, y)))
|
||||
y += 30
|
||||
|
||||
body_texts = [
|
||||
"本项目旨在构建一套基于视觉感知的智能文档处理系统。",
|
||||
"系统通过本地OCR引擎实现文字提取,支持多种文档类型。",
|
||||
]
|
||||
for t in body_texts:
|
||||
lines.append((t, font_body, (20, y)))
|
||||
y += 25
|
||||
|
||||
lines.append(("", None, None))
|
||||
y += 5
|
||||
|
||||
items = [
|
||||
"• 核心技术栈:Python + FastAPI + ONNX Runtime",
|
||||
"• 模型支持:PP-OCRv6 + PaddleOCR-VL",
|
||||
"• 部署方式:Docker 容器化微服务",
|
||||
]
|
||||
for item in items:
|
||||
lines.append((item, font_body, (20, y)))
|
||||
y += 25
|
||||
|
||||
for text, font, pos in lines:
|
||||
if text and font and pos:
|
||||
draw.text(pos, text, fill="black", font=font)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── 测试场景 2:双栏布局 ────────────────────────────────
|
||||
|
||||
def create_two_column_doc() -> bytes:
|
||||
"""生成一个模拟双栏排版的图片。"""
|
||||
font_body = _load_font(14)
|
||||
|
||||
img = Image.new("RGB", (700, 300), color="white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 左栏 (x=20~300)
|
||||
left_items = [
|
||||
"左栏项目一:需求分析",
|
||||
"左栏项目二:方案设计",
|
||||
"左栏项目三:开发实施",
|
||||
]
|
||||
y = 20
|
||||
for item in left_items:
|
||||
draw.text((20, y), item, fill="black", font=font_body)
|
||||
y += 30
|
||||
|
||||
# 右栏 (x=380~680)
|
||||
right_items = [
|
||||
"右栏概述:本栏说明验收标准",
|
||||
"右栏条目1:功能完整性",
|
||||
"右栏条目2:性能达标",
|
||||
]
|
||||
y = 20
|
||||
for item in right_items:
|
||||
draw.text((380, y), item, fill="black", font=font_body)
|
||||
y += 30
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── 测试场景 3:编号 + TOC 目录 ─────────────────────────
|
||||
|
||||
def create_toc_doc() -> bytes:
|
||||
"""生成含编号和 TOC 目录的图片。"""
|
||||
font = _load_font(16)
|
||||
|
||||
img = Image.new("RGB", (500, 250), color="white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
items = [
|
||||
("1. 引言", 20),
|
||||
("2. 相关工作", 50),
|
||||
(" 2.1 传统方法", 75),
|
||||
(" 2.2 深度学习方法", 100),
|
||||
("3. 实验与分析", 130),
|
||||
(" 3.1 数据集", 155),
|
||||
(" 3.2 实验结果", 180),
|
||||
]
|
||||
for text, y in items:
|
||||
draw.text((20, y), text, fill="black", font=font)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── 运行测试 ──────────────────────────────────────────────
|
||||
|
||||
def run_ocr(image_bytes: bytes, engine: str = "ppocrv6") -> dict | None:
|
||||
b64 = base64.b64encode(image_bytes).decode("ascii")
|
||||
body = json.dumps({"image": b64, "engine": engine}).encode("utf-8")
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{API_BASE}/v1/ocr",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
print(f" ✗ API 请求失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" 天目 OCR 布局质量集成测试")
|
||||
print(f" API: {API_BASE}")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 标准文档布局
|
||||
print("\n▶ 1/3 标准文档(标题 + 正文 + 列表)")
|
||||
img1 = create_standard_doc()
|
||||
result = run_ocr(img1)
|
||||
if result and result.get("text"):
|
||||
text = result["text"]
|
||||
print(f" 识别文本 ({len(text)} 字符):")
|
||||
for line in text.split("\n")[:10]:
|
||||
if line.strip():
|
||||
print(f" {line.strip()[:80]}")
|
||||
if "项目实施方案" in text:
|
||||
print(" ✓ 识别到标题")
|
||||
if "•" in text or "- 核心" in text:
|
||||
print(" ✓ 识别到列表项")
|
||||
print(f" 耗时: {result.get('duration_ms', 0):.0f} ms")
|
||||
else:
|
||||
err = result.get("error") if result else "无响应"
|
||||
print(f" ✗ 识别失败: {err}")
|
||||
|
||||
# 2. 双栏布局
|
||||
print("\n▶ 2/3 双栏排版")
|
||||
img2 = create_two_column_doc()
|
||||
result = run_ocr(img2)
|
||||
if result and result.get("text"):
|
||||
text = result["text"]
|
||||
print(f" 识别文本 ({len(text)} 字符):")
|
||||
for line in text.split("\n")[:10]:
|
||||
if line.strip():
|
||||
print(f" {line.strip()[:80]}")
|
||||
if "左栏项目" in text and "右栏概述" in text:
|
||||
print(" ✓ 双栏内容均被识别")
|
||||
print(f" 耗时: {result.get('duration_ms', 0):.0f} ms")
|
||||
else:
|
||||
err = result.get("error") if result else "无响应"
|
||||
print(f" ✗ 识别失败: {err}")
|
||||
|
||||
# 3. 编号 + 目录
|
||||
print("\n▶ 3/3 编号项 + TOC 目录")
|
||||
img3 = create_toc_doc()
|
||||
result = run_ocr(img3)
|
||||
if result and result.get("text"):
|
||||
text = result["text"]
|
||||
print(f" 识别文本 ({len(text)} 字符):")
|
||||
for line in text.split("\n")[:10]:
|
||||
if line.strip():
|
||||
print(f" {line.strip()[:80]}")
|
||||
checks = [
|
||||
("1. 引言" in text, "识别编号 1."),
|
||||
("2. 相关" in text, "识别编号 2."),
|
||||
("3. 实验" in text, "识别编号 3."),
|
||||
]
|
||||
for ok, desc in checks:
|
||||
print(f" {'✓' if ok else '✗'} {desc}")
|
||||
print(f" 耗时: {result.get('duration_ms', 0):.0f} ms")
|
||||
else:
|
||||
err = result.get("error") if result else "无响应"
|
||||
print(f" ✗ 识别失败: {err}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" 测试完成")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,531 @@
|
||||
"""
|
||||
布局后处理单元测试(纯逻辑,不依赖引擎和服务)。
|
||||
|
||||
测试 `src.engine.layout` 中的纯函数:
|
||||
- sort_and_group:阅读顺序排序 + 行归组
|
||||
- group_paragraphs:段落分组
|
||||
- build_markdown_text:Markdown 格式化
|
||||
- 辅助函数
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.engine.layout import (
|
||||
_block_center_x,
|
||||
_block_center_y,
|
||||
_block_height,
|
||||
_detect_columns,
|
||||
_detect_heading_level,
|
||||
_detect_line_type,
|
||||
_sort_and_group_single,
|
||||
build_markdown_text,
|
||||
group_paragraphs,
|
||||
sort_and_group,
|
||||
)
|
||||
from src.schemas.ocr import BBox, TextBlock
|
||||
|
||||
|
||||
# ── 工厂函数 ──────────────────────────────────────────────
|
||||
|
||||
def _make_block(
|
||||
text: str,
|
||||
x1: float = 0,
|
||||
y1: float = 0,
|
||||
x2: float = 100,
|
||||
y2: float = 30,
|
||||
confidence: float = 0.95,
|
||||
) -> TextBlock:
|
||||
"""创建一个测试用的 TextBlock。"""
|
||||
return TextBlock(
|
||||
text=text,
|
||||
confidence=confidence,
|
||||
bbox=BBox(points=[[x1, y1], [x2, y1], [x2, y2], [x1, y2]]),
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 辅助函数
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
"""辅助函数测试。"""
|
||||
|
||||
def test_block_height(self) -> None:
|
||||
block = _make_block("测试", y1=10, y2=50)
|
||||
assert _block_height(block) == 40.0
|
||||
|
||||
def test_block_height_empty(self) -> None:
|
||||
block = _make_block("测试", x1=0, y1=0, x2=0, y2=0)
|
||||
assert _block_height(block) == 0.0
|
||||
|
||||
def test_block_center_y(self) -> None:
|
||||
block = _make_block("测试", y1=10, y2=50)
|
||||
assert _block_center_y(block) == 30.0
|
||||
|
||||
def test_block_center_x(self) -> None:
|
||||
block = _make_block("测试", x1=20, x2=100)
|
||||
assert _block_center_x(block) == 60.0
|
||||
|
||||
def test_detect_heading_chinese(self) -> None:
|
||||
"""中文短行 → heading。"""
|
||||
assert _detect_line_type("第一章 绪论") == "heading"
|
||||
|
||||
def test_detect_heading_short_no_punct(self) -> None:
|
||||
"""短行不以句号结尾 → heading。"""
|
||||
assert _detect_line_type("摘要") == "heading"
|
||||
|
||||
def test_detect_not_heading_with_punct(self) -> None:
|
||||
"""以句号结尾的短行 → text。"""
|
||||
assert _detect_line_type("这是一个句子。") == "text"
|
||||
|
||||
def test_detect_long_text(self) -> None:
|
||||
"""长行(>= 40 字符)→ text。"""
|
||||
long = "一二三四五六七八九十" * 4
|
||||
assert _detect_line_type(long) == "text"
|
||||
|
||||
def test_detect_list_item_dash(self) -> None:
|
||||
assert _detect_line_type("- 列表项") == "list_item"
|
||||
|
||||
def test_detect_list_item_bullet(self) -> None:
|
||||
assert _detect_line_type("• 项目") == "list_item"
|
||||
|
||||
def test_detect_numbered(self) -> None:
|
||||
assert _detect_line_type("1. 首先") == "numbered"
|
||||
assert _detect_line_type("2) 其次") == "numbered"
|
||||
|
||||
def test_detect_separator(self) -> None:
|
||||
assert _detect_line_type("---") == "separator"
|
||||
assert _detect_line_type("===") == "separator"
|
||||
assert _detect_line_type("***") == "separator"
|
||||
|
||||
def test_detect_code(self) -> None:
|
||||
assert _detect_line_type(" def foo():") == "code"
|
||||
assert _detect_line_type("\t\tindented") == "code"
|
||||
|
||||
def test_detect_blockquote_as_text(self) -> None:
|
||||
"""> 前缀在 OCR 语境中通常是排版伪影,不作为代码块。"""
|
||||
assert _detect_line_type("> 引用") == "text"
|
||||
|
||||
def test_detect_empty_text(self) -> None:
|
||||
assert _detect_line_type("") == "text"
|
||||
assert _detect_line_type(" ") == "text"
|
||||
|
||||
def test_detect_centered_heading(self) -> None:
|
||||
"""居中短行 → 即使非中文也应视为 heading。"""
|
||||
assert _detect_line_type("Hello World", center_x_ratio=0.5) == "heading"
|
||||
|
||||
def test_detect_not_centered(self) -> None:
|
||||
"""不居中短行 + 非中文锁 → 不回退。"""
|
||||
# "hello world" 非中文、非大写、不居中 → text
|
||||
assert _detect_line_type("hello world", center_x_ratio=0.1) == "text"
|
||||
|
||||
def test_detect_uppercase_heading(self) -> None:
|
||||
"""全大写短行 → heading。"""
|
||||
assert _detect_line_type("INTRODUCTION") == "heading"
|
||||
|
||||
# ── 新增:编号变体 ────────────────────────────────
|
||||
|
||||
def test_detect_numbered_without_space(self) -> None:
|
||||
"""编号后无空格("1.内容")→ numbered。"""
|
||||
assert _detect_line_type("1.内容") == "numbered"
|
||||
assert _detect_line_type("1)内容") == "numbered"
|
||||
|
||||
def test_detect_numbered_parenthesized(self) -> None:
|
||||
"""括号编号 → numbered。"""
|
||||
assert _detect_line_type("(1) 内容") == "numbered"
|
||||
assert _detect_line_type("(2)内容") == "numbered"
|
||||
|
||||
def test_detect_numbered_chinese_numeral(self) -> None:
|
||||
"""中文数字序号 → numbered。"""
|
||||
assert _detect_line_type("① 第一项") == "numbered"
|
||||
assert _detect_line_type("②第二项") == "numbered"
|
||||
|
||||
# ── 新增:TOC 目录行 ──────────────────────────────
|
||||
|
||||
def test_detect_toc_line(self) -> None:
|
||||
"""TOC 目录行(含连续点号)→ numbered。"""
|
||||
assert _detect_line_type("第一章 .............. 1") == "numbered"
|
||||
assert _detect_line_type("1.1 概述 ............ 10") == "numbered"
|
||||
|
||||
# ── 新增:更多 bullet 变体 ────────────────────────
|
||||
|
||||
def test_detect_bullet_variants(self) -> None:
|
||||
"""更多 bullet 变体 → list_item。"""
|
||||
assert _detect_line_type("▪ 项目") == "list_item"
|
||||
assert _detect_line_type("▸ 子项") == "list_item"
|
||||
assert _detect_line_type("→ 链接") == "list_item"
|
||||
assert _detect_line_type("· 条目") == "list_item"
|
||||
|
||||
def test_detect_standalone_bullet(self) -> None:
|
||||
"""独立 bullet 字符 → list_item。"""
|
||||
assert _detect_line_type("•") == "list_item"
|
||||
assert _detect_line_type("▪") == "list_item"
|
||||
assert _detect_line_type("→") == "list_item"
|
||||
|
||||
# ── 新增:字体大小辅助标题检测 ────────────────────
|
||||
|
||||
def test_detect_larger_font_heading(self) -> None:
|
||||
"""大字(rel_height > 1.3)→ heading。"""
|
||||
assert _detect_line_type("这是一段长文本但字号大", rel_height=1.5) == "heading"
|
||||
# 中号字(rel_height < 1.3)且不居中 → text
|
||||
assert _detect_line_type("这是一段长文本字号普通", rel_height=1.1) == "text"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 标题级别检测
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestHeadingLevel:
|
||||
"""标题级别检测。"""
|
||||
|
||||
def test_h1_centered_large(self) -> None:
|
||||
assert _detect_heading_level(rel_height=1.5, is_centered=True) == 1
|
||||
|
||||
def test_h1_large_alone(self) -> None:
|
||||
assert _detect_heading_level(rel_height=1.8) == 1
|
||||
|
||||
def test_h2_large(self) -> None:
|
||||
assert _detect_heading_level(rel_height=1.4) == 2
|
||||
|
||||
def test_h2_centered(self) -> None:
|
||||
assert _detect_heading_level(is_centered=True) == 2
|
||||
|
||||
def test_h3_default(self) -> None:
|
||||
assert _detect_heading_level() == 3
|
||||
assert _detect_heading_level(rel_height=1.1) == 3
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# sort_and_group
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSortAndGroup:
|
||||
"""阅读顺序排序 + 行归组。"""
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert sort_and_group([]) == []
|
||||
|
||||
def test_single_block(self) -> None:
|
||||
blocks = [_make_block("你好")]
|
||||
lines = sort_and_group(blocks)
|
||||
assert len(lines) == 1
|
||||
assert len(lines[0]) == 1
|
||||
assert lines[0][0].text == "你好"
|
||||
|
||||
def test_top_to_bottom_sort(self) -> None:
|
||||
"""按 Y 坐标从上到下排序。"""
|
||||
blocks = [
|
||||
_make_block("第二行", y1=100, y2=130),
|
||||
_make_block("第一行", y1=10, y2=40),
|
||||
]
|
||||
lines = sort_and_group(blocks)
|
||||
assert len(lines) == 2
|
||||
assert lines[0][0].text == "第一行"
|
||||
assert lines[1][0].text == "第二行"
|
||||
|
||||
def test_same_line_grouping(self) -> None:
|
||||
"""Y 坐标接近的 blocks 归为同一行,按 X 排序。"""
|
||||
blocks = [
|
||||
_make_block("右", x1=100, x2=130, y1=10, y2=30),
|
||||
_make_block("左", x1=0, x2=30, y1=10, y2=30),
|
||||
]
|
||||
lines = sort_and_group(blocks)
|
||||
assert len(lines) == 1
|
||||
assert len(lines[0]) == 2
|
||||
assert lines[0][0].text == "左"
|
||||
assert lines[0][1].text == "右"
|
||||
|
||||
def test_multi_line_grouping(self) -> None:
|
||||
"""多行正确归组。"""
|
||||
blocks = [
|
||||
_make_block("B1", x1=0, x2=30, y1=100, y2=130),
|
||||
_make_block("A1", x1=0, x2=30, y1=10, y2=40),
|
||||
_make_block("A2", x1=40, x2=70, y1=10, y2=40),
|
||||
]
|
||||
lines = sort_and_group(blocks)
|
||||
assert len(lines) == 2
|
||||
# 第一行:A1、A2
|
||||
assert len(lines[0]) == 2
|
||||
assert lines[0][0].text == "A1"
|
||||
assert lines[0][1].text == "A2"
|
||||
# 第二行:B1
|
||||
assert len(lines[1]) == 1
|
||||
assert lines[1][0].text == "B1"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 多栏检测
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMultiColumn:
|
||||
"""多栏检测。"""
|
||||
|
||||
def assert_one_column(self, blocks: list, page_width: float = 800) -> None:
|
||||
cols = _detect_columns(blocks, page_width)
|
||||
assert len(cols) == 1, f"应检测为单栏,实际 {len(cols)} 栏"
|
||||
|
||||
def assert_two_columns(self, blocks: list, page_width: float = 800) -> None:
|
||||
cols = _detect_columns(blocks, page_width)
|
||||
assert len(cols) == 2, f"应检测为双栏,实际 {len(cols)} 栏"
|
||||
assert len(cols[0]) > 0
|
||||
assert len(cols[1]) > 0
|
||||
|
||||
def test_single_column_few_blocks(self) -> None:
|
||||
"""少于 4 个 block 不触发多栏检测。"""
|
||||
self.assert_one_column([_make_block("A")])
|
||||
|
||||
def test_single_column_no_gap(self) -> None:
|
||||
"""无间隙 → 单栏。"""
|
||||
blocks = [
|
||||
_make_block("左1", x1=0, x2=100, y1=10, y2=30),
|
||||
_make_block("右1", x1=100, x2=200, y1=10, y2=30),
|
||||
_make_block("左2", x1=0, x2=100, y1=40, y2=60),
|
||||
_make_block("右2", x1=100, x2=200, y1=40, y2=60),
|
||||
]
|
||||
self.assert_one_column(blocks)
|
||||
|
||||
def test_two_columns_clear_gap(self) -> None:
|
||||
"""有明显水平间隙 → 双栏。"""
|
||||
blocks = [
|
||||
_make_block("左栏 A", x1=0, x2=150, y1=10, y2=30),
|
||||
_make_block("左栏 B", x1=0, x2=150, y1=40, y2=60),
|
||||
_make_block("右栏 A", x1=500, x2=650, y1=15, y2=35),
|
||||
_make_block("右栏 B", x1=500, x2=650, y1=50, y2=70),
|
||||
]
|
||||
self.assert_two_columns(blocks, page_width=800)
|
||||
|
||||
def test_sort_and_group_two_columns(self) -> None:
|
||||
"""双栏排版:左栏两行,右栏两行。"""
|
||||
blocks = [
|
||||
_make_block("右A", x1=500, x2=650, y1=50, y2=70),
|
||||
_make_block("左B", x1=0, x2=150, y1=40, y2=60),
|
||||
_make_block("左A", x1=0, x2=150, y1=10, y2=30),
|
||||
_make_block("右B", x1=500, x2=650, y1=80, y2=100),
|
||||
]
|
||||
lines = sort_and_group(blocks, page_width=800)
|
||||
# 左栏两行 → 右栏两行
|
||||
assert len(lines) == 4
|
||||
# 左栏第一行
|
||||
assert lines[0][0].text == "左A"
|
||||
# 左栏第二行
|
||||
assert lines[1][0].text == "左B"
|
||||
# 右栏第一行
|
||||
assert lines[2][0].text == "右A"
|
||||
# 右栏第二行
|
||||
assert lines[3][0].text == "右B"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# group_paragraphs
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGroupParagraphs:
|
||||
"""段落分组。"""
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert group_paragraphs([]) == []
|
||||
|
||||
def test_single_para(self) -> None:
|
||||
"""多行同段落(用长文本避免触发标题检测)。"""
|
||||
lines = [
|
||||
[_make_block("第一行正文内容这里是长文本")],
|
||||
[_make_block("第二行正文内容再接一些字")],
|
||||
]
|
||||
paras = group_paragraphs(lines)
|
||||
assert len(paras) == 1
|
||||
assert paras[0]["type"] == "text"
|
||||
assert paras[0]["type"] == "text"
|
||||
|
||||
def test_paragraph_break_by_gap(self) -> None:
|
||||
"""行间距大 → 分段(需要 3+ 行,单行间距无法超过自身中位值的 1.8 倍)。"""
|
||||
lines = [
|
||||
[_make_block("第一段正文在这里第一行啊", y1=10, y2=30)],
|
||||
[_make_block("第一段正文在这里第二行", y1=40, y2=60)],
|
||||
[_make_block("第二段正文这里间距很大", y1=200, y2=230)],
|
||||
]
|
||||
paras = group_paragraphs(lines, page_width=800)
|
||||
assert len(paras) == 2
|
||||
# 第一段:两行
|
||||
assert paras[0]["type"] == "text"
|
||||
assert "第一段正文在这里" in paras[0]["text"]
|
||||
# 第二段:一行
|
||||
assert "第二段正文" in paras[1]["text"]
|
||||
|
||||
def test_heading_paragraph(self) -> None:
|
||||
"""短行 → 标题段。"""
|
||||
lines = [[_make_block("第一章")], [_make_block("正文内容")]]
|
||||
paras = group_paragraphs(lines)
|
||||
assert len(paras) >= 1
|
||||
# 标题段
|
||||
assert paras[0]["type"] == "heading"
|
||||
|
||||
def test_list_paragraph(self) -> None:
|
||||
"""列表项段落。"""
|
||||
lines = [
|
||||
[_make_block("- 项目一", y1=10, y2=30)],
|
||||
[_make_block("- 项目二", y1=40, y2=60)],
|
||||
]
|
||||
paras = group_paragraphs(lines)
|
||||
assert len(paras) == 1
|
||||
assert paras[0]["type"] == "list_item"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# build_markdown_text
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildMarkdown:
|
||||
"""完整的 Markdown 转换。"""
|
||||
|
||||
def test_empty(self) -> None:
|
||||
md, lines, paras = build_markdown_text([])
|
||||
assert md == ""
|
||||
assert lines == []
|
||||
assert paras == []
|
||||
|
||||
def test_simple_text(self) -> None:
|
||||
"""简单段落转换。"""
|
||||
blocks = [
|
||||
_make_block("你好世界"),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "你好世界" in md
|
||||
|
||||
def test_heading_conversion(self) -> None:
|
||||
"""标题应加 # 前缀(级别基于字体大小)。"""
|
||||
# 标题 block 更大 → 推断为 h2
|
||||
blocks = [
|
||||
_make_block("第一章", x1=0, x2=100, y1=10, y2=50), # 40px 高
|
||||
_make_block("正文内容长一些避免被检测为标题", x1=0, x2=200, y1=60, y2=80), # 20px 高
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "### 第一章" in md or "## 第一章" in md
|
||||
assert "正文内容长一些避免被检测为标题" in md
|
||||
|
||||
def test_list_conversion(self) -> None:
|
||||
"""列表项应加 - 前缀。"""
|
||||
blocks = [
|
||||
_make_block("- 苹果"),
|
||||
_make_block("- 香蕉"),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "- 苹果" in md
|
||||
assert "- 香蕉" in md
|
||||
|
||||
def test_centered_heading(self) -> None:
|
||||
"""居中的短行 → heading。"""
|
||||
# 中心 X 在页面宽度中间
|
||||
blocks = [
|
||||
_make_block("欢迎光临", x1=300, x2=500, y1=10, y2=40),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks, page_width=800)
|
||||
assert "欢迎光临" in md
|
||||
|
||||
def test_heading_level_h1(self) -> None:
|
||||
"""居中大字 → h1(# 前缀)。"""
|
||||
blocks = [
|
||||
_make_block("年度报告", x1=300, x2=500, y1=10, y2=50),
|
||||
_make_block("正文内容较小", x1=0, x2=100, y1=60, y2=75),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks, page_width=800)
|
||||
assert "# 年度报告" in md or "## 年度报告" in md
|
||||
|
||||
def test_heading_level_h3(self) -> None:
|
||||
"""等大字体短行 → h3(### 前缀)。"""
|
||||
blocks = [
|
||||
_make_block("摘要", x1=0, x2=100, y1=10, y2=30),
|
||||
_make_block("第一章内容文字", x1=0, x2=100, y1=40, y2=70),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks, page_width=800)
|
||||
# "摘要" ≤ 8 字无标点 → heading, 但字体与正文同大 → h3
|
||||
assert "### 摘要" in md
|
||||
|
||||
def test_separator_conversion(self) -> None:
|
||||
"""分隔线 → ---。"""
|
||||
blocks = [
|
||||
_make_block("---"),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "---" in md
|
||||
|
||||
def test_multi_line_same_para(self) -> None:
|
||||
"""多行近距离内容合并在一个段落(长文本避免标题检测)。"""
|
||||
blocks = [
|
||||
_make_block("第一行长文本避免被检测为标题行", y1=10, y2=30),
|
||||
_make_block("第二行避免被检测为标题", y1=35, y2=55),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "第一行长文本避免被检测为标题行" in md
|
||||
assert "第二行避免被检测为标题" in md
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 边缘情况
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""边缘情况。"""
|
||||
|
||||
def test_single_block_only(self) -> None:
|
||||
blocks = [_make_block("Hello")]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert md == "Hello"
|
||||
|
||||
def test_blocks_with_same_y(self) -> None:
|
||||
"""完全同一 Y 位置的多个 blocks(罕见但需处理)。"""
|
||||
blocks = [
|
||||
_make_block("A", x1=0, x2=30, y1=10, y2=30),
|
||||
_make_block("B", x1=40, x2=70, y1=10, y2=30),
|
||||
]
|
||||
lines = sort_and_group(blocks)
|
||||
assert len(lines) == 1
|
||||
assert len(lines[0]) == 2
|
||||
|
||||
def test_blocks_with_negligible_height(self) -> None:
|
||||
"""高度为 0 的 block 不应崩。"""
|
||||
block = TextBlock(
|
||||
text="边缘",
|
||||
confidence=0.5,
|
||||
bbox=BBox(points=[[0, 0], [0, 0], [0, 0], [0, 0]]),
|
||||
)
|
||||
assert _block_height(block) == 0.0
|
||||
lines = sort_and_group([block])
|
||||
assert len(lines) == 1
|
||||
|
||||
def test_confidence_range(self) -> None:
|
||||
"""置信度在 0~1 范围内。"""
|
||||
block = _make_block("test", confidence=1.0)
|
||||
assert 0 <= block.confidence <= 1
|
||||
|
||||
def test_long_line_content(self) -> None:
|
||||
"""长行(> 40 字符)→ text 而非 heading。"""
|
||||
long_text = "A" * 50
|
||||
blocks = [_make_block(long_text)]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert long_text in md
|
||||
assert "##" not in md
|
||||
|
||||
def test_empty_text_blocks(self) -> None:
|
||||
"""空文本 block 不应造成影响。"""
|
||||
blocks = [
|
||||
_make_block(""),
|
||||
_make_block("实际内容"),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "实际内容" in md
|
||||
|
||||
def test_numeric_only(self) -> None:
|
||||
"""纯数字行 → numbered。"""
|
||||
blocks = [_make_block("42")]
|
||||
# 短行数字 → numbered(先于 heading 触发)
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "42" in md
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
天目 OCR 集成测试。
|
||||
|
||||
覆盖场景:
|
||||
- PP-OCRv6 文字识别(英文、中文、中英混合)
|
||||
- PaddleOCR-VL 文档解析(含引擎未加载时的优雅降级)
|
||||
- URL 图片下载识别
|
||||
- resize 缩放功能
|
||||
- save_md 保存 Markdown
|
||||
- 引擎选择(auto / 显式指定)
|
||||
- 无效输入(空数据、非法 base64)
|
||||
|
||||
使用方法:
|
||||
|
||||
1. 先启动天目服务:
|
||||
cd tianmu && source .venv/bin/activate && python3 -m src.main
|
||||
|
||||
2. 运行此脚本(另一终端):
|
||||
python3 tests/run_ocr_test.py
|
||||
|
||||
3. 或指定图片:
|
||||
python3 tests/run_ocr_test.py /path/to/image.jpg
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
API_BASE = os.environ.get("TIANMU_API_BASE", "http://localhost:8101")
|
||||
|
||||
# ── 测试用例 ──────────────────────────────────────────────
|
||||
|
||||
TEST_CASES = [
|
||||
{
|
||||
"name": "英文数字",
|
||||
"text": "Hello OCR 12345",
|
||||
"size": (400, 60),
|
||||
"font_size": 20,
|
||||
},
|
||||
{
|
||||
"name": "中文短句",
|
||||
"text": "你好世界 OCR 测试",
|
||||
"size": (400, 60),
|
||||
"font_size": 20,
|
||||
},
|
||||
{
|
||||
"name": "中英混合",
|
||||
"text": "RapidOCR 天目 PP-OCRv6 识别",
|
||||
"size": (500, 60),
|
||||
"font_size": 20,
|
||||
},
|
||||
{
|
||||
"name": "多行文本",
|
||||
"text": "第一行文字\n第二行文字\n第三行文字",
|
||||
"size": (400, 120),
|
||||
"font_size": 18,
|
||||
},
|
||||
]
|
||||
|
||||
# ── PaddleOCR-VL 解析场景(仅当引擎就绪时测试) ──
|
||||
|
||||
VL_TEST_PROMPTS = [
|
||||
"markdown",
|
||||
"text",
|
||||
]
|
||||
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_test_image(text: str, size: tuple[int, int] = (400, 60), font_size: int = 20) -> bytes:
|
||||
"""生成用于测试的图片(白底黑字)。"""
|
||||
img = Image.new("RGB", size, color="white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 尝试加载中文字体,回退默认
|
||||
font = _load_font(font_size)
|
||||
|
||||
# 多行支持
|
||||
y_offset = 10
|
||||
for line in text.split("\n"):
|
||||
draw.text((10, y_offset), line, fill="black", font=font)
|
||||
y_offset += font_size + 5
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _load_font(font_size: int) -> ImageFont.FreeTypeFont | None:
|
||||
"""加载系统字体(多平台回退)。"""
|
||||
font_paths = [
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
]
|
||||
for fp in font_paths:
|
||||
if os.path.exists(fp):
|
||||
try:
|
||||
return ImageFont.truetype(fp, font_size)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ── 测试函数 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def check_health() -> dict | None:
|
||||
"""测试健康检查端点。"""
|
||||
try:
|
||||
req = urllib.request.Request(f"{API_BASE}/healthz")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read())
|
||||
engine_status = data.get("engines", {})
|
||||
ppocr = engine_status.get("ppocrv6", {}).get("status", "?")
|
||||
pvl = engine_status.get("paddleocr-vl", {}).get("status", "?")
|
||||
print(f" ✓ 健康检查 | status={data.get('status')} "
|
||||
f"| ppocrv6={ppocr} | paddleocr-vl={pvl}")
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f" ✗ 健康检查失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def run_ocr_test(image_bytes: bytes, engine: str = "ppocrv6", **kwargs) -> dict | None:
|
||||
"""测试 OCR 端点。"""
|
||||
b64 = base64.b64encode(image_bytes).decode("ascii")
|
||||
body = {"image": b64, "engine": engine}
|
||||
body.update(kwargs)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{API_BASE}/v1/ocr",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" ✗ HTTP {e.code}: {e.read().decode()[:200]}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" ✗ 请求失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def run_ocr_vl_test(image_bytes: bytes, output_format: str = "markdown", **kwargs) -> dict | None:
|
||||
"""测试 PaddleOCR-VL 端点(无需引擎就绪)。"""
|
||||
b64 = base64.b64encode(image_bytes).decode("ascii")
|
||||
body = {"image": b64, "output_format": output_format}
|
||||
body.update(kwargs)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{API_BASE}/v1/ocr/vl",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" ✗ HTTP {e.code}: {e.read().decode()[:200]}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" ✗ 请求失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def run_ocr_by_url(url: str) -> dict | None:
|
||||
"""测试通过 URL 识别图片。"""
|
||||
body = json.dumps({"image": url, "engine": "ppocrv6"})
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{API_BASE}/v1/ocr",
|
||||
data=body.encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
print(f" ✗ URL 请求失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── 结果格式化 ────────────────────────────────────────────
|
||||
|
||||
|
||||
def print_result(case_name: str, result: dict | None) -> None:
|
||||
"""格式化打印测试结果。"""
|
||||
if result is None:
|
||||
print(f" ✗ {case_name}: 无响应")
|
||||
return
|
||||
|
||||
error = result.get("error")
|
||||
if error:
|
||||
print(f" ✗ {case_name}: {error}")
|
||||
return
|
||||
|
||||
text = result.get("text", "")
|
||||
blocks = result.get("blocks", [])
|
||||
duration = result.get("duration_ms", 0)
|
||||
|
||||
# 截断显示,防止控制台溢出
|
||||
display_text = repr(text[:80]) + ("..." if len(text) > 80 else "")
|
||||
print(f" ✓ {case_name}")
|
||||
print(f" text: {display_text}")
|
||||
print(f" blocks: {len(blocks)} 块" if blocks else "")
|
||||
print(f" duration: {duration:.0f} ms")
|
||||
if blocks:
|
||||
print(f" confidence: {blocks[0]['confidence']:.3f}")
|
||||
|
||||
|
||||
# ── 主入口 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" 天目 OCR 集成测试套件")
|
||||
print(f" API: {API_BASE}")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# 1. 健康检查
|
||||
print("▶ 1/6 健康检查")
|
||||
health = check_health()
|
||||
if health is None:
|
||||
print("\n⚠ 天目服务未启动,请先运行: cd tianmu && python3 -m src.main")
|
||||
sys.exit(1)
|
||||
print()
|
||||
|
||||
# 2. PP-OCRv6 基础识别测试
|
||||
print("▶ 2/6 PP-OCRv6 文字识别")
|
||||
for case in TEST_CASES:
|
||||
img_bytes = create_test_image(case["text"], case["size"], case["font_size"])
|
||||
result = run_ocr_test(img_bytes, engine="ppocrv6")
|
||||
print_result(case["name"], result)
|
||||
print()
|
||||
|
||||
# 3. PaddleOCR-VL 端点测试(引擎可能未加载,验证降级)
|
||||
print("▶ 3/6 PaddleOCR-VL 端点(含降级验证)")
|
||||
img_bytes = create_test_image("文档测试", (400, 60))
|
||||
for fmt in VL_TEST_PROMPTS:
|
||||
result = run_ocr_test_vl(img_bytes, output_format=fmt)
|
||||
if result:
|
||||
err = result.get("error")
|
||||
if err:
|
||||
print(f" ~ {fmt}: 引擎未就绪 | {err[:80]}")
|
||||
else:
|
||||
print(f" ✓ {fmt}: {repr(result.get('text', '')[:60])}")
|
||||
else:
|
||||
print(f" ✗ {fmt}: 无响应")
|
||||
print()
|
||||
|
||||
# 4. resize 缩放测试
|
||||
print("▶ 4/6 resize 缩放测试")
|
||||
img_bytes = create_test_image("Resize Test", (800, 60), font_size=30)
|
||||
result_resized = run_ocr_test(img_bytes, engine="ppocrv6", resize=400)
|
||||
result_original = run_ocr_test(img_bytes, engine="ppocrv6")
|
||||
if result_resized:
|
||||
print(f" ✓ resize=400 | duration={result_resized.get('duration_ms', 0):.0f} ms")
|
||||
if result_original:
|
||||
print(f" ✓ 原始大小 | duration={result_original.get('duration_ms', 0):.0f} ms (对比)")
|
||||
print()
|
||||
|
||||
# 5. save_md 保存功能测试
|
||||
print("▶ 5/6 save_md 保存测试")
|
||||
img_bytes = create_test_image("保存测试 Markdown 文件", (500, 60))
|
||||
result = run_ocr_test(img_bytes, engine="ppocrv6", save_md=True)
|
||||
if result:
|
||||
md_path = result.get("saved_md_path")
|
||||
if md_path:
|
||||
print(f" ✓ 已保存到: {md_path}")
|
||||
else:
|
||||
print(f" ~ 未返回路径 | text={repr(result.get('text', '')[:40])}")
|
||||
print()
|
||||
|
||||
# 6. 自定义图片
|
||||
if len(sys.argv) > 1:
|
||||
print("▶ 6/6 自定义图片")
|
||||
for img_path in sys.argv[1:]:
|
||||
if os.path.isfile(img_path):
|
||||
print(f" 📷 {img_path}")
|
||||
with open(img_path, "rb") as f:
|
||||
image_bytes = f.read()
|
||||
result = run_ocr_test(image_bytes, engine="auto")
|
||||
print_result(os.path.basename(img_path), result)
|
||||
else:
|
||||
print(f" ✗ 文件不存在: {img_path}")
|
||||
print()
|
||||
else:
|
||||
# 或者测试 auto 引擎选择
|
||||
print("▶ 6/6 auto 引擎选择测试")
|
||||
img_bytes = create_test_image("引擎自动选择", (400, 60))
|
||||
result = run_ocr_test(img_bytes, engine="auto")
|
||||
print_result("auto → ppocrv6", result)
|
||||
print()
|
||||
|
||||
# 汇总
|
||||
print("=" * 60)
|
||||
print(" 测试完成")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user