初始化提交

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

View File
Whitespace-only changes.
+84
View File
@@ -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
+78
View File
@@ -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
+248
View File
@@ -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()
+531
View File
@@ -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
+316
View File
@@ -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()