初始化提交

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

+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