Files
hunjing/tianmu/src/engine/layout.py
T
2026-08-21 16:00:13 +08:00

490 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
OCR 结果布局后处理(纯逻辑,无 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