GLM-OCR识别结果后处理:利用数据结构优化文本纠错与排版还原

张开发
2026/5/6 17:03:13 15 分钟阅读

分享文章

GLM-OCR识别结果后处理:利用数据结构优化文本纠错与排版还原
GLM-OCR识别结果后处理利用数据结构优化文本纠错与排版还原你有没有遇到过这种情况用OCR工具把一份PDF或者图片转成文字结果发现文本顺序是乱的段落被拆得七零八落还夹杂着不少错别字。原本一份好好的文档经过识别后变得面目全非想要直接使用还得自己花大量时间去整理和校对。这就是我们今天要聊的核心问题。GLM-OCR这类工具在识别文字内容上已经相当出色但识别出来的“原始文本序列”离我们真正能用的“结构化文档”还有一段距离。这段距离就需要靠“后处理”来填补。简单来说后处理就是给OCR的“毛坯房”做精装修。它不关心墙是怎么砌的识别算法只关心怎么把房间布局弄合理文本顺序、把墙面抹平纠正错字、把门窗装好还原格式。而数据结构就是我们手里最趁手的装修工具。这篇文章我就结合自己的实践经验跟你聊聊怎么用字符串、队列、树这些基础但强大的数据结构把GLM-OCR的输出变得整洁、准确、可用。1. 为什么OCR识别结果需要后处理在你开始动手写代码之前我们得先搞清楚要解决什么问题。直接拿GLM-OCR识别一份稍微复杂点的文档比如一份带标题、段落和列表的项目报告你可能会遇到下面这些典型的“车祸现场”文本顺序错乱OCR通常是按视觉块可能是从左到右、从上到下扫描输出文本的。如果文档有分栏、文本框或者图片环绕识别出来的文字顺序可能完全不符合人类的阅读逻辑。上一段话的结尾在输出序列里可能跑到了下一段话的开头后面。段落结构丢失文档里清晰的段落分隔在OCR眼里可能就是几行距离稍远的文字。它很可能把原本的一个段落根据行间距错误地拆分成好几个独立的文本块或者把本该分开的两个段落合并成了一整段。“噪声”与错误打印不清、纸张污渍、字体特效如加粗、斜体都可能导致识别错误产生错别字、多余字符如“.”被识别成“,”或字符缺失。比如“算法”被识别成“算法”“用户”被识别成“用户”。格式与层级信息缺失这是最影响可用性的一点。原文档的标题一级、二级、正文、列表项等丰富的层级结构在OCR的原始输出里几乎全部丢失了变成了一堆扁平的文字。你无法区分哪句话是章节标题哪句话是核心论点。如果不处理这些问题识别出来的文本基本没有直接利用的价值。后处理的目标就是要把这一堆混乱的文本序列还原成一份结构清晰、内容准确、便于后续处理如存档、分析、检索的电子文档。2. 核心思路将文本序列转化为数据结构面对一串杂乱的文本像人一样去“理解”和“整理”对程序来说太难了。我们的策略是降维打击不追求让程序理解语义而是通过计算文本的物理特征和统计特征将其转化为不同的数据结构进行处理。每一种数据结构都擅长解决一类特定问题。我们可以把整个后处理流程想象成一条流水线输入GLM-OCR输出的原始文本块列表每个块包含文本内容、坐标、置信度等。处理让数据在不同的“处理站”数据结构中流动、变形。输出结构化的文档对象包含标题、段落、纠正后的文本等。接下来我们看看几个关键“处理站”是如何工作的。3. 利用队列实现智能行排序与段落合并OCR输出的文本块通常带有一个边界框坐标。我们可以利用这个坐标信息尤其是y坐标纵轴来重建阅读顺序。一个朴素的想法是按y坐标排序。但现实很骨感如果文档有分栏右栏的顶部文字y坐标可能小于左栏的底部文字直接排序就乱套了。这里队列数据结构可以帮上大忙。我们采用一种“贪婪”的行分组算法def sort_and_group_text_blocks(blocks, y_threshold10): 对文本块进行排序和分组初步形成行和段落。 :param blocks: 列表每个元素是字典包含‘text’‘bbox’(x1,y1,x2,y2)等。 :param y_threshold: 判断是否为同一行的Y轴坐标阈值。 :return: 分组后的段落列表。 # 1. 按Y轴坐标取bbox的顶部y1或中心y进行主要排序 sorted_blocks sorted(blocks, keylambda b: (b[bbox][1], b[bbox][0])) lines [] current_line [] current_y_center None # 2. 将Y坐标接近的块合并为一行 for block in sorted_blocks: y_center (block[bbox][1] block[bbox][3]) / 2 if current_y_center is None or abs(y_center - current_y_center) y_threshold: current_line.append(block) # 更新当前行Y坐标基准可以用第一个块或平均值 if current_y_center is None: current_y_center y_center else: # 当前块属于新的一行将上一行按X坐标排序后存入lines if current_line: lines.append(sorted(current_line, keylambda b: b[bbox][0])) current_line [block] current_y_center y_center if current_line: lines.append(sorted(current_line, keylambda b: b[bbox][0])) # 3. 将行合并为段落 paragraphs [] current_paragraph [] # 估算一个平均行高作为段落间距判断依据 avg_line_height sum((line[-1][bbox][3] - line[0][bbox][1]) for line in lines) / len(lines) if lines else 0 for i, line in enumerate(lines): line_text .join([block[text] for block in line]) if not current_paragraph: current_paragraph.append(line_text) else: # 计算当前行与上一行的Y轴间距 prev_bottom lines[i-1][-1][bbox][3] curr_top line[0][bbox][1] line_gap curr_top - prev_bottom # 如果行间距显著大于平均行高则认为是新段落 if line_gap avg_line_height * 1.5: # 1.5是一个经验系数可调整 paragraphs.append( .join(current_paragraph)) current_paragraph [line_text] else: current_paragraph.append(line_text) if current_paragraph: paragraphs.append( .join(current_paragraph)) return paragraphs这段代码的核心思想是两次聚合第一次根据Y坐标将块聚合成行第二次根据行间距将行聚合成段落。队列的思想体现在current_line和current_paragraph这两个列表中它们临时保存着正在构建的当前行和当前段落符合“先进先出”地进行拼接。通过这种方式我们初步解决了顺序错乱和段落破碎的问题。4. 基于词典与字符串操作纠正常见错别字段落顺序理清了接下来要处理文本内容里的“噪音”。我们构建一个查找表本质上是一个哈希表或字典来纠正常见错误。class TextCorrector: def __init__(self, custom_dictNone): # 内置一个常见易错词词典 self.common_typos { 算法: 算法, 用户: 用户, 图像: 图像, 识别: 识别, 网络: 网络, 训练: 训练, 模型: 模型, # ... 可以不断扩充 } if custom_dict: self.common_typos.update(custom_dict) def correct_with_dict(self, text): 使用词典进行全词匹配替换 for wrong, right in self.common_typos.items(): text text.replace(wrong, right) return text def correct_common_errors(self, text): 纠正一些基于规则的常见错误 # 规则1连续重复字符如“使使用” - “使用” import re text re.sub(r([\u4e00-\u9fa5])\1, r\1, text) # 中文重复字 # 规则2纠正因字体导致的标点错误如“。”被识别为“.” punctuation_map {.: 。, ,: , ;: , :: , ?: , !: } for p_en, p_zh in punctuation_map.items(): # 简单策略在中文语境中前后都是中文时替换 text re.sub(f([\u4e00-\u9fa5]){re.escape(p_en)}([\u4e00-\u9fa5]), f\\1{p_zh}\\2, text) return text def process_paragraph(self, paragraph): 处理单个段落的纠错流程 corrected self.correct_with_dict(paragraph) corrected self.correct_common_errors(corrected) return corrected这个纠错器非常轻量且高效。common_typos字典是我们积累的“错题本”处理速度极快。规则纠正则处理一些模式化的错误比如因扫描导致的字符粘连或标点符号误识别。请注意这是一个浅层纠错对于复杂的语义错误如“北京”识别成“背景”无能为力但那需要引入语言模型不在本文讨论范围内。对于提升OCR文本的基础可读性这个简单方法已经能解决80%的常见表面错误。5. 构建树形结构还原文档标题层级这是后处理中最体现“智能”的一环。我们的目标是自动识别出哪些文本是标题并构建出它们之间的层级关系如第一章、1.1、1.1.1。这里树是最理想的数据结构。我们通过分析文本的格式特征来推断其是否为标题字体与大小标题通常字体更粗、更大。OCR结果可能包含字体信息。位置标题通常居中或缩进特殊。文本模式符合“第X章”、“X.Y”、“一、”、“一”等编号模式。长度标题通常较短。class DocumentNode: 表示文档树中的一个节点标题或段落 def __init__(self, text, level0, node_typeparagraph): self.text text self.level level # 层级0为正文1为一级标题2为二级标题... self.type node_type # heading 或 paragraph self.children [] # 子节点列表 def add_child(self, child_node): self.children.append(child_node) class DocumentStructureBuilder: def __init__(self): # 定义标题模式越靠前的模式优先级越高如“第X章”比“一、”级别高 self.heading_patterns [ (r^第[一二三四五六七八九十]章, 1), # 匹配“第一章”设为1级标题 (r^[一二三四五六七八九十]、, 2), # 匹配“一、”设为2级标题 (r^[0-9]\.[0-9], 3), # 匹配“1.1”设为3级标题 (r^\([一二三四五六七八九十]\), 4), # 匹配“一”设为4级标题 ] def _classify_text(self, text): 判断一段文本是标题还是正文并确定标题级别 for pattern, level in self.heading_patterns: import re if re.match(pattern, text.strip()): return heading, level # 额外的启发式规则如果文本长度短如小于20字且以冒号结尾可能是标题 if len(text) 20 and text.strip().endswith(): return heading, 99 # 设为未定义高级别后续可处理 return paragraph, 0 def build_tree(self, paragraphs): 将段落列表构建成文档树。 使用栈来维护当前标题路径。 root DocumentNode(ROOT, level0, node_typeroot) # 栈中保存当前路径上的标题节点栈顶是当前所属的最近标题 stack [root] for para in paragraphs: node_type, level self._classify_text(para) new_node DocumentNode(para, level, node_type) if node_type heading: # 找到栈中第一个层级小于当前标题的节点作为父节点 while stack and stack[-1].level level: stack.pop() # 此时栈顶节点就是新标题的父节点 if stack: stack[-1].add_child(new_node) else: root.add_child(new_node) # 新标题节点入栈成为新的当前上下文 stack.append(new_node) else: # 正文段落直接挂载到当前栈顶节点最近的标题下 stack[-1].add_child(new_node) return root def print_tree(self, node, indent0): 打印树结构用于调试 prefix * indent type_symbol H if node.type heading else P print(f{prefix}[{type_symbol}{node.level}] {node.text[:50]}...) for child in node.children: self.print_tree(child, indent 1)这个DocumentStructureBuilder做了几件关键事分类通过正则表达式和启发式规则给每个文本段打上“标题”或“正文”的标签并估算标题级别。建树使用一个栈来模拟解析过程。遍历文本段时遇到标题就从栈顶弹出所有级别高于或等于它的标题直到找到它的“父亲”然后挂载上去并把自己压入栈顶。遇到正文直接挂载到当前栈顶节点即最近的标题下。输出最终得到一棵树root。这棵树完美保留了文档的层级结构。你可以遍历这棵树轻松生成带缩进的Markdown、HTML或任何其他结构化格式。6. 实践组装完整的后处理流水线现在我们把各个“处理站”连接起来形成一个完整的流水线。class OCRPostProcessor: def __init__(self, correctorNone, builderNone): self.corrector corrector or TextCorrector() self.builder builder or DocumentStructureBuilder() def process(self, raw_ocr_blocks): 完整的后处理流程 :param raw_ocr_blocks: GLM-OCR输出的原始文本块列表 :return: 结构化的文档树 print(1. 原始文本块数量:, len(raw_ocr_blocks)) # 步骤1: 排序与分组 paragraphs sort_and_group_text_blocks(raw_ocr_blocks) print(2. 合并后段落数量:, len(paragraphs)) # 步骤2: 文本纠错 corrected_paragraphs [] for para in paragraphs: corrected_para self.corrector.process_paragraph(para) corrected_paragraphs.append(corrected_para) print(3. 文本纠错完成。) # 步骤3: 构建文档结构树 doc_tree self.builder.build_tree(corrected_paragraphs) print(4. 文档结构树构建完成。) # 步骤4: (可选) 从树生成格式化输出 formatted_output self._format_output(doc_tree) return { raw_paragraphs: paragraphs, corrected_paragraphs: corrected_paragraphs, document_tree: doc_tree, formatted_text: formatted_output } def _format_output(self, root_node, formatmarkdown): 将文档树转换为指定格式的文本 output_lines [] def dfs(node, depth): if node.type root: pass elif node.type heading: # Markdown格式根据级别添加#号 prefix # * (node.level 1) # 假设root level0, H11 output_lines.append(f{prefix}{node.text}) else: # 正文段落 output_lines.append(f{node.text}\n) # 段落间加空行 for child in node.children: dfs(child, depth 1) dfs(root_node, 0) return \n.join(output_lines) # 模拟使用 if __name__ __main__: # 假设这是GLM-OCR返回的数据 mock_ocr_blocks [ {text: 第, bbox: [50, 100, 70, 120]}, {text: 一, bbox: [70, 100, 90, 120]}, {text: 章, bbox: [90, 100, 110, 120]}, {text: 引, bbox: [110, 100, 130, 120]}, {text: 言, bbox: [130, 100, 150, 120]}, {text: 本, bbox: [50, 150, 70, 170]}, {text: 文, bbox: [70, 150, 90, 170]}, {text: 将, bbox: [90, 150, 110, 170]}, {text: 介, bbox: [110, 150, 130, 170]}, {text: 绍, bbox: [130, 150, 150, 170]}, {text: 算, bbox: [50, 200, 70, 220]}, # 模拟错误“算法” {text: 法, bbox: [70, 200, 90, 220]}, {text: 的, bbox: [90, 200, 110, 220]}, {text: 应, bbox: [110, 200, 130, 220]}, {text: 用, bbox: [130, 200, 150, 220]}, {text: 1.1, bbox: [50, 250, 80, 270]}, {text: 背, bbox: [80, 250, 100, 270]}, {text: 景, bbox: [100, 250, 120, 270]}, ] processor OCRPostProcessor() result processor.process(mock_ocr_blocks) print(\n 格式化输出 (Markdown) ) print(result[formatted_text]) print(\n 文档结构树 ) processor.builder.print_tree(result[document_tree])运行这段代码你会看到混乱的文本块如何一步步被整理、纠正最终变成一棵结构清晰的树并能输出为格式良好的Markdown文本。这个过程完全自动化无需人工干预。7. 总结与展望走完这一整套流程你会发现我们并没有用到什么高深莫测的AI算法仅仅是巧妙地运用了队列、字典、树这些基础数据结构就极大地提升了GLM-OCR输出文本的可用性。从乱序的文本块到有序的段落从满是错别字到基本通顺从扁平文字到层级文档每一步都是通过计算特征和规则匹配来实现的。这种方法的优势在于轻量、可控、高效。规则和词典可以随着使用不断积累和优化处理速度也很快。当然它也有局限比如对于排版极其复杂、字体样式信息缺失严重、或语义错误复杂的文档效果会打折扣。未来的优化方向可以有很多。例如可以引入统计语言模型如n-gram来纠正更复杂的错别字可以利用机器学习模型来更准确地判断标题级别将字体、位置、文本等多特征作为输入甚至可以将整个后处理流程 pipeline 化、配置化让用户可以根据不同的文档类型论文、报表、书籍选择不同的处理策略。核心思想是不变的将非结构化的文本序列通过基于规则和特征的计算转化为结构化的、易于处理的数据对象。当你掌握了这个思路不仅能处理OCR文本对于其他类似的“杂乱数据整理”问题也能找到清晰的解决路径。下次当你面对一堆杂乱的数据时不妨先问问自己我能用什么数据结构来规整它获取更多AI镜像想探索更多AI镜像和应用场景访问 CSDN星图镜像广场提供丰富的预置镜像覆盖大模型推理、图像生成、视频生成、模型微调等多个领域支持一键部署。

更多文章