初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user