docs: 添加涉密文件自检工具实施计划

This commit is contained in:
2026-06-08 13:53:24 +08:00
commit 31161d9a5f
1838 changed files with 455407 additions and 0 deletions

View File

@@ -0,0 +1,330 @@
# 【间隙·树·排序算法】 GapTree_Sort_Algorithm
# 对OCR结果或PDF提取的文本进行版面分析按人类阅读顺序进行排序。
# Author: hiroi-sora
# https://github.com/hiroi-sora/GapTree_Sort_Algorithm
from typing import Callable
class GapTree:
def __init__(self, get_bbox: Callable):
"""
:param get_bbox: 函数,传入单个文本块,
返回该文本块左上角、右下角的坐标元组 (x0, y0, x1, y1)
"""
self.get_bbox = get_bbox
# ======================= 调用接口 =====================
# 对文本块列表排序
def sort(self, text_blocks: list):
"""
对文本块列表,按人类阅读顺序进行排序。
:param text_blocks: 文本块对象列表
:return: 排序后的文本块列表
"""
# 封装块单元,并求页面左右边缘
units, page_l, page_r = self._get_units(text_blocks, self.get_bbox)
# 求行和竖切线
cuts, rows = self._get_cuts_rows(units, page_l, page_r)
# 求布局树
root = self._get_layout_tree(cuts, rows)
# 求树节点序列
nodes = self._preorder_traversal(root)
# 求排序后的 原始文本块序列
new_text_blocks = self._get_text_blocks(nodes)
# 测试:缓存中间变量,以便调试输出
self.current_rows = rows
self.current_cuts = cuts
self.current_nodes = nodes
return new_text_blocks
# 获取以区块为单位的文本块二层列表
def get_nodes_text_blocks(self):
"""
获取以区块为单位的文本块二层列表。需要在 sort 后调用。
:return: [ [区块1的text_blocks], [区块2的text_blocks]... ]
"""
result = []
for node in self.current_nodes:
tbs = []
if node["units"]:
for unit in node["units"]:
tbs.append(unit[1])
result.append(tbs)
return result
# ======================= 封装块单元列表 =====================
# 将原始文本块,封装为 ( (x0,y0,x2,y2), 原始 ) 。并检查页边界。
def _get_units(self, text_blocks, get_bbox):
# 封装单元列表 units [ ( (x0,y0,x2,y2), 原始文本块 ), ... ]
units = []
page_l, page_r = float("inf"), -1 # 记录文本块的左右最值,作为页边界
for tb in text_blocks:
x0, y0, x2, y2 = get_bbox(tb)
units.append(((x0, y0, x2, y2), tb))
if x0 < page_l:
page_l = x0
if x2 > page_r:
page_r = x2
units.sort(key=lambda a: a[0][1]) # 按顶部从上到下排序
return units, page_l, page_r
# ======================= 求行和竖切线 =====================
"""
扫描所有文本块,获取所有行和竖切线。
一个行,由一组垂直位置接近的文本块所组成。
一条竖切线,由多个连续行中,同一位置的间隙所组成。间隙划分同一行中不同列的文本块。
输入:一个页面上的文本块单元列表 units=[ ( (x0,y0,x2,y2), _ ) ] 。必须按上到下排序。
返回:
竖切线列表 cuts=[ ( 左边缘x, 右边缘x, 起始行号, 结束行号 ) ] 。从左到右排序
页面上的行 rows=[ [unit...] ] 。从上到下,从左到右排序
"""
def _get_cuts_rows(self, units, page_l, page_r):
# 使用间隙组 gaps2 更新 gaps1 。返回: 更新后的gaps1 , gaps1中被移除的间隙
def update_gaps(gaps1, gaps2):
flags1 = [True for _ in gaps1] # gaps1[i] 是否彻底移除
flags2 = [True for _ in gaps2] # gaps2[i] 是否新加入
new_gaps1 = []
for i1, g1 in enumerate(gaps1):
l1, r1, _ = g1
for i2, g2 in enumerate(gaps2): # 对每一个gap1考察所有gap2
l2, r2, _ = g2
# 计算交集的起点和终点
inter_l = max(l1, l2)
inter_r = min(r1, r2)
# 如果交集有效
if inter_l <= inter_r:
# 更新 gap1 左右边缘
new_gaps1.append((inter_l, inter_r, g1[2]))
flags1[i1] = False # 旧的 gap1 不应移除
flags2[i2] = False # 新的 gap2 不应添加
# gap2 新加入
for i2, f2 in enumerate(flags2):
if f2:
new_gaps1.append(gaps2[i2])
# 记录 gaps1 彻底移除的项
del_gaps1 = []
for i1, f1 in enumerate(flags1):
if f1:
del_gaps1.append(gaps1[i1])
return new_gaps1, del_gaps1
# ========================================
page_l -= 1 # 保证页面左右边缘不与文本块重叠
page_r += 1
# 存放所有行。“row”指同一水平线上的单元块可能属于多列。 [ [unit...] ]
rows = []
# 已生成完毕的竖切线。[ ( 左边缘x, 右边缘x , 起始行号, 结束行号 ) ]
completed_cuts = []
# 考察中的间隙。 [ (左边缘x, 右边缘x , 开始行号) ]
gaps = []
row_index = 0 # 当前行号
unit_index = 0 # 当前块号
# 从上到下遍历所有文本行
l_units = len(units)
while unit_index < l_units:
# ========== 查找当前行 row ==========
unit = units[unit_index] # 当前行最顶部的块
u_bottom = unit[0][3]
row = [unit] # 当前行
# 查找当前行的剩余块
for i in range(unit_index + 1, len(units)):
next_u = units[i]
next_top = next_u[0][1]
if next_top > u_bottom:
break # 下一块的顶部超过当前底部,结束本行
row.append(next_u) # 当前行添加块
unit_index = i # 步进 已遍历的块序号
# ========== 查找当前行的间隙 row_gaps ==========
row.sort(key=lambda x: (x[0][0], x[0][2])) # 当前行中的块 从左到右排序
row_gaps = [] # 当前行的间隙 [ ( ( 左边缘l, 右边缘r ), 开始行号) ]
search_start = page_l # 本轮搜索的线段起始点为页面左边缘
for u in row: # 遍历当前行的块
l = u[0][0] # 块左侧
r = u[0][2] # 块右侧
# 若块起始点大于搜索起始点,那么将这部分加入到结果
if l > search_start:
row_gaps.append((search_start, l, row_index))
# 若块结束点大于搜索起始点,更新搜索起始点
if r > search_start:
search_start = r
# 页面右边缘 加入最后一个间隙
row_gaps.append((search_start, page_r, row_index))
# ========== 更新考察中的间隙组 ==========
gaps, del_gaps = update_gaps(gaps, row_gaps)
# gaps 中被移除的项,加入生成完毕的竖切线 completed_cuts
row_max = row_index - 1 # 竖切线结束行号
for dg1 in del_gaps:
completed_cuts.append((*dg1, row_max))
# ========== End ==========
rows.append(row) # 总行列表添加当前行
unit_index += 1
row_index += 1
# 遍历结束,收集 gaps 中剩余的间隙,组成延伸到最后一行的竖切线
row_max = len(rows) - 1 # 竖切线结束行号
for g in gaps:
completed_cuts.append((*g, row_max))
completed_cuts.sort(key=lambda c: c[0])
return completed_cuts, rows
# ======================= 求布局树 =====================
"""
一个布局树节点表示一个区块。定义:
node = {
"x_left": 节点左边缘x,
"x_right": 右边缘x,
"r_top": 顶部的行号,
"r_bottom": 底部的行号,
"units": [], # 节点内部的文本块列表(除了根节点为空,其它节点非空)
"children": [], # 子节点,有序
}
"""
def _get_layout_tree(self, cuts, rows):
# 竖切线,将一个横行切开,断开的区域为“间隙”。
# 生成每一行对应的间隙 (左侧,右侧) 坐标列表
rows_gaps = [[] for _ in rows]
for g_i, cut in enumerate(cuts):
for r_i in range(cut[2], cut[3] + 1):
rows_gaps[r_i].append((cut[0], cut[1]))
root = { # 根节点
"x_left": cuts[0][0] - 1,
"x_right": cuts[-1][1] + 1,
"r_top": -1,
"r_bottom": -1,
"units": [],
"children": [],
}
completed_nodes = [root] # 已经完成结束的节点
now_nodes = [] # 当前正在考虑的节点。无顺序
# ========== 结束一个节点,加入节点树 ==========
def complete(node):
node_r = node["x_right"] - 2 # 当前节点右边界
max_nodes = [] # 符合父节点条件的,最低的完成节点列表
max_r = -2 # 符合父节点条件的最低行数
# 在完成列表中,寻找父节点
for com_node in completed_nodes:
# 父节点的垂直投影必须包含当前右界
if node_r < com_node["x_left"] or node_r > com_node["x_right"] + 0.0001:
continue
# 父节点底部必须在当前之上
if com_node["r_bottom"] >= node["r_top"]:
continue
# 遇到更低的符合条件节点
if com_node["r_bottom"] > max_r:
max_r = com_node["r_bottom"]
max_nodes = [com_node]
continue
# 遇到同样低的符合条件节点
if com_node["r_bottom"] == max_r:
max_nodes.append(com_node)
continue
# 在最低列表中,寻找最右的节点作为父节点
max_node = max(max_nodes, key=lambda n: n["x_right"])
max_node["children"].append(node) # 加入父节点
completed_nodes.append(node) # 加入完成列表
# ========== 遍历每行,更新节点树 ==========
for r_i, row in enumerate(rows):
row_gaps = rows_gaps[r_i] # 当前行的间隙组
u_i = g_i = 0 # 当前考察的 文本块、间隙下标
# ========== 检查是否有正在考虑的节点 可以结束 ==========
new_nodes = []
for node in now_nodes: # 遍历节点
l_flag = r_flag = False # 标记节点左右边缘是否延续
completed_flag = False # 标记节点是否可以结束
x_left = node["x_left"] # 左右边缘坐标
x_right = node["x_right"]
for gap in row_gaps: # 遍历该行所有间隙
if gap[1] == x_left: # 节点左边缘被间隙右侧延续
l_flag = True
if gap[0] == x_right: # 右边缘被间隙左侧延续
r_flag = True
# 任意间隙在本节点下方,打断本节点
if x_left < gap[0] < x_right or x_left < gap[1] < x_right:
completed_flag = True
break
if not l_flag or not r_flag: # 左右任意一个边缘无法延续
completed_flag = True
if completed_flag: # 节点结束,加入节点树
complete(node)
else: # 节点继续
node["r_bottom"] = r_i
new_nodes.append(node)
now_nodes = new_nodes
# ========== 从左到右遍历,将文本块加入对应列的节点 ==========
while u_i < len(row):
unit = row[u_i] # 当前块
# ========== 当前块 unit 位于间隙 g_i 与 g_i+1 之间的区间 ==========
x_l = row_gaps[g_i][1] # 左间隙 g_i 的右边界
x_r = row_gaps[g_i + 1][0] # 右间隙 g_i+1 的左边界
# 检查区间是否正确
if unit[0][0] + 0.0001 > x_r: # 块比右间隙更右,说明到了下一个区间
g_i += 1 # 间隙步进,块不步进
continue
# ========== 检查当前块可否加入已有的节点 ==========
flag = False
for node in now_nodes:
# 若某个节点的左右侧坐标,与当前块一致,则当前块加入节点
if node["x_left"] == x_l and node["x_right"] == x_r:
node["units"].append(unit)
flag = True
break
if flag:
u_i += 1 # 块步进
continue
# ========== 根据当前块创建新的节点,加入待考虑节点 ==========
now_nodes.append(
{
"x_left": x_l,
"x_right": x_r,
"r_top": r_i,
"r_bottom": r_i,
"units": [unit],
"children": [],
}
)
u_i += 1 # 块步进
# 将剩余节点也加入节点树
for node in now_nodes:
complete(node)
# 整理所有节点
for node in completed_nodes:
# 所有子节点 按从左到右排序
node["children"].sort(key=lambda n: n["x_left"])
# 所有块单元 按从上到下排序
node["units"].sort(key=lambda u: u[0][1])
return root
# ======================= 前序遍历布局树,求节点序列 =====================
def _preorder_traversal(self, root):
if not root:
return []
stack = [root]
result = []
while stack:
node = stack.pop()
result.append(node)
# 将当前节点的子节点逆序压入栈中,以保证左子节点先于右子节点处理
stack += reversed(node["children"])
return result
# ======================= 从节点序列中,提取原始文本块序列 =====================
def _get_text_blocks(self, nodes):
result = []
for node in nodes:
for unit in node["units"]:
result.append(unit[1])
return result

View File

@@ -0,0 +1,98 @@
# =========================================
# =============== 按行预处理 ===============
# =========================================
from statistics import median # 中位数
from math import atan2, cos, sin, sqrt, pi, radians, degrees
from umi_log import logger
angle_threshold = 3 # 进行一些操作的最小角度阈值
angle_threshold_rad = radians(angle_threshold)
# 计算两点之间的距离
def _distance(point1, point2):
return sqrt((point2[0] - point1[0]) ** 2 + (point2[1] - point1[1]) ** 2)
# 计算一个box的旋转角度
def _calculateAngle(box):
# 获取宽高
width = _distance(box[0], box[1])
height = _distance(box[1], box[2])
# 选择距离较大的两个顶点对,计算角度弧度值
if width < height:
angle_rad = atan2(box[2][1] - box[1][1], box[2][0] - box[1][0])
else:
angle_rad = atan2(box[1][1] - box[0][1], box[1][0] - box[0][0])
# 标准化角度到[-pi/2, pi/2)范围(加上阈值)
if angle_rad < -pi / 2 + angle_threshold_rad:
angle_rad += pi
elif angle_rad >= pi / 2 + angle_threshold_rad:
angle_rad -= pi
return angle_rad
# 估计一组文本块的旋转角度
def _estimateRotation(textBlocks):
# blocks["box"] = [左上角,右上角,右下角,左下角]
angle_rads = (_calculateAngle(block["box"]) for block in textBlocks)
median_angle = median(angle_rads) # 中位数
return median_angle
# 获取旋转后的标准bbox。angle_threshold为执行旋转的阈值最小角度值
def _getBboxes(textBlocks, rotation_rad):
# 角度低于阈值接近0°则不进行旋转以提高性能。
if abs(rotation_rad) <= angle_threshold_rad:
bboxes = [
( # 直接构造bbox
min(x for x, y in tb["box"]),
min(y for x, y in tb["box"]),
max(x for x, y in tb["box"]),
max(y for x, y in tb["box"]),
)
for tb in textBlocks
]
# 否则,进行旋转操作。
else:
logger.debug(f"文本块预处理旋转 {degrees(rotation_rad):.2f} °")
bboxes = []
min_x, min_y = float("inf"), float("inf") # 初始化最小的x和y坐标
cos_angle = cos(-rotation_rad) # 计算角度正弦值
sin_angle = sin(-rotation_rad)
for tb in textBlocks:
box = tb["box"]
rotated_box = [ # 旋转box的每个顶点
(cos_angle * x - sin_angle * y, sin_angle * x + cos_angle * y)
for x, y in box
]
# 解包旋转后的顶点坐标分别得到所有x和y的值
xs, ys = zip(*rotated_box)
# 构建标准bbox (左上角x, 左上角y, 右下角x, 右下角y)
bbox = (min(xs), min(ys), max(xs), max(ys))
bboxes.append(bbox)
min_x, min_y = min(min_x, bbox[0]), min(min_y, bbox[1])
# 如果旋转后存在负坐标将所有包围盒平移使得最小的x和y坐标为0确保所有坐标非负
if min_x < 0 or min_y < 0:
bboxes = [
(x - min_x, y - min_y, x2 - min_x, y2 - min_y)
for (x, y, x2, y2) in bboxes
]
return bboxes
# 预处理 textBlocks ,将包围盒 ["box"] 转为标准化 bbox ,同时去除 ["text"] 不完整的项
def linePreprocessing(textBlocks):
textBlocks = [i for i in textBlocks if i.get("text", False)]
# 判断角度
rotation_rad = _estimateRotation(textBlocks)
# 获取标准化bbox
bboxes = _getBboxes(textBlocks, rotation_rad)
# 写入tb
for i, tb in enumerate(textBlocks):
tb["normalized_bbox"] = bboxes[i]
# 按y排序
textBlocks.sort(key=lambda tb: tb["normalized_bbox"][1])
return textBlocks

View File

@@ -0,0 +1,173 @@
# 段落分析器
# 对已经是一个列区块之内的文本块,判断其段落关系。
from typing import Callable
import unicodedata
# 传入前句尾字符和后句首字符,返回分隔符
def word_separator(letter1, letter2):
# 判断Unicode字符是否属于中文、日文或韩文字符集
def is_cjk(character):
cjk_unicode_ranges = [
(0x4E00, 0x9FFF), # 中文
(0x3040, 0x30FF), # 日文
(0x1100, 0x11FF), # 韩文
(0x3130, 0x318F), # 韩文兼容字母
(0xAC00, 0xD7AF), # 韩文音节
# 全角符号
(0x3000, 0x303F), # 中文符号和标点
(0xFE30, 0xFE4F), # 中文兼容形式标点
(0xFF00, 0xFFEF), # 半角和全角形式字符
]
return any(start <= ord(character) <= end for start, end in cjk_unicode_ranges)
if is_cjk(letter1) and is_cjk(letter2):
return ""
# 特殊情况:前文为连字符。
if letter1 == "-":
return ""
# 特殊情况:后文为任意标点符号。
if unicodedata.category(letter2).startswith("P"):
return ""
# 其它正常情况加空格
return " "
TH = 1.2 # 行高用作对比的阈值
class ParagraphParse:
def __init__(self, get_info: Callable, set_end: Callable) -> None:
"""
:param get_info: 函数,传入单个文本块,
返回该文本块的信息元组: ( (x0, y0, x1, y1), "文本" )
:param set_end: 函数,传入单个文本块 和文本尾部的分隔符,该函数要将分隔符保存。
"""
self.get_info = get_info
self.set_end = set_end
# ======================= 调用接口:对文本块列表进行结尾分隔符预测 =====================
def run(self, text_blocks: list):
"""
对属于一个区块内的文本块列表,进行段落分析,预测每个文本块结尾的分隔符。
:param text_blocks: 文本块对象列表
:return: 排序后的文本块列表
"""
# 封装块单元
units = self._get_units(text_blocks, self.get_info)
# 执行分析
self._parse(units)
return text_blocks
# ======================= 封装块单元列表 =====================
# 将原始文本块,封装为 ( (x0,y0,x2,y2), ("开头","结尾"), 原始 ) 。
def _get_units(self, text_blocks, get_info):
units = []
for tb in text_blocks:
bbox, text = get_info(tb)
units.append((bbox, (text[0], text[-1]), tb))
return units
# ======================= 分析 =====================
# 执行分析
def _parse(self, units):
units.sort(key=lambda a: a[0][1]) # 确保从上到下有序
para_l, para_top, para_r, para_bottom = units[0][0] # 当前段的左右
para_line_h = para_bottom - para_top # 当前段行高
para_line_s = None # 当前段行间距
now_para = [units[0]] # 当前段的块
paras = [] # 总的段
paras_line_space = [] # 总的段的行间距
# 取 左右相等为一个自然段的主体
for i in range(1, len(units)):
l, top, r, bottom = units[i][0] # 当前块上下左右边缘
h = bottom - top
ls = top - para_bottom # 行间距
# 检测是否同一段
if ( # 左右边缘都相等
abs(para_l - l) <= para_line_h * TH
and abs(para_r - r) <= para_line_h * TH
# 行间距不大
and (para_line_s == None or ls < para_line_s + para_line_h * 0.5)
):
# 更新数据
para_l = (para_l + l) / 2
para_r = (para_r + r) / 2
para_line_h = (para_line_h + h) / 2
para_line_s = ls if para_line_s == None else (para_line_s + ls) / 2
# 添加到当前段
now_para.append(units[i])
else: # 非同一段,归档上一段,创建新一段
paras.append(now_para)
paras_line_space.append(para_line_s)
now_para = [units[i]]
para_l, para_r, para_line_h = l, r, bottom - top
para_line_s = None
para_bottom = bottom
# 归档最后一段
paras.append(now_para)
paras_line_space.append(para_line_s)
# 合并只有1行的段添加到上/下段作为首/尾句
for i1 in reversed(range(len(paras))):
para = paras[i1]
if len(para) == 1:
l, top, r, bottom = para[0][0]
up_flag = down_flag = False
# 上段末尾条件:左对齐,右不超,行间距够小
if i1 > 0:
# 检查左右
up_l, up_top, up_r, up_bottom = paras[i1 - 1][-1][0]
up_dist, up_h = abs(up_l - l), up_bottom - up_top
up_flag = up_dist <= up_h * TH and r <= up_r + up_h * TH
# 检查行间距
if (
paras_line_space[i1 - 1] != None
and top - up_bottom > paras_line_space[i1 - 1] + up_h * 0.5
):
up_flag = False
# 下段开头条件:右对齐/单行超出,左缩进
if i1 < len(paras) - 1:
down_l, down_top, down_r, down_bottom = paras[i1 + 1][0][0]
down_h = down_bottom - down_top
# 左对齐或缩进
if down_l - down_h * TH <= l <= down_l + down_h * (1 + TH):
if len(paras[i1 + 1]) > 1: # 多行,右对齐
down_flag = abs(down_r - r) <= down_h * TH
else: # 单行,右可超出
down_flag = down_r - down_h * TH < r
# 检查行间距
if (
paras_line_space[i1 + 1] != None
and down_top - bottom > paras_line_space[i1 + 1] + down_h * 0.5
):
down_flag = False
# 选择添加到上还是下段
if up_flag and down_flag: # 两段都符合,则选择垂直距离更近的
if top - up_bottom < down_top - bottom:
paras[i1 - 1].append(para[0])
else:
paras[i1 + 1].insert(0, para[0])
elif up_flag: # 只有一段符合,直接选择
paras[i1 - 1].append(para[0])
elif down_flag:
paras[i1 + 1].insert(0, para[0])
if up_flag or down_flag:
del paras[i1]
del paras_line_space[i1]
# 刷新所有段添加end
for para in paras:
for i1 in range(len(para) - 1):
letter1 = para[i1][1][1] # 行1结尾字母
letter2 = para[i1 + 1][1][0] # 行2开头字母
sep = word_separator(letter1, letter2)
self.set_end(para[i1][2], sep)
self.set_end(para[-1][2], "\n")
return units