1. 为什么我们需要从“行”回到“页”?
大家好,我是老张,在OCR和文档分析这个领域摸爬滚打了十来年。今天想和大家聊聊一个非常具体,但在做手写文本识别时几乎绕不开的问题:如何处理像CASIA-HWDB2.x这样的行级分割数据集,并把它还原成我们更熟悉的“整页”形式,同时生成准确的行级边界框(bbox)标注。
你可能已经下载了CASIA-HWDB2.x离线数据集,打开一看,发现它和我们常见的整页扫描图片不一样。它已经把每一页手写文档,按行切割成了一个个独立的小图片文件,比如 001-P16_0.jpg, 001-P16_1.jpg... 每个文件对应一行文字,旁边还配有一个文本文件,记录了这一行里所有的字符。
这带来了一个很现实的矛盾:我们最终要训练或测试的模型,无论是文本检测还是端到端识别,很多时候期望的输入是一张完整的“页”,而不是零散的行。想象一下,你要教一个AI理解一篇手写信件,你肯定不会把每一行剪下来再分别给它看,对吧?你需要让它看到完整的版面布局、行间距、段落结构,这些上下文信息对于提升识别准确率至关重要。
所以,这个“从行到页”的合并操作,本质上是一个数据预处理的重构过程。我们不是要创造新数据,而是把数据集提供的“原材料”(行图片和行标签),按照它原本的逻辑,“拼装”回完整的文档页面。这个过程的核心产出有两个:1. 一张拼接好的整页图片;2. 一个标注文件,里面记录了拼接后图片上每一行文字的精确坐标(bbox)和内容。
我刚开始接触这个数据集时也走了些弯路,比如直接简单地把行图片堆叠起来,结果发现bbox对不上,或者拼接的图片看起来非常不自然。后来经过几次实践,才摸索出一套比较稳定可靠的方法。接下来,我就把自己踩过的坑和总结的经验,毫无保留地分享给你。
2. 动手之前:彻底理解你的“原材料”
在开始写代码拼接之前,我们必须花点时间搞清楚CASIA-HWDB2.x数据集到底给了我们什么。这一步理解透了,后面的逻辑就顺理成章了。
2.1 数据集结构探秘
首先,解压下载的数据包后,你通常会看到两个核心文件夹:一个放图片(比如 HWDB2.xTrain_images),一个放标签(比如 HWDB2.xTrain_label)。图片文件夹里,是成千上万个类似 001-P16_0.jpg 这样的文件。这个命名规则就是我们的“钥匙”。
001-P16_0.jpg 这个名字可以拆解为三部分:
001-P16:这是页标识符。它唯一地代表原始文档中的某一页。所有共享这个前缀的图片(如_0,_1,_2...)都来自同一页。_0:这是行索引。表示这是该页的第0行(通常从0开始计数)。数字顺序对应了原文从上到下的阅读顺序。.jpg:图片格式。
标签文件夹里的文件与之严格对应,比如 001-P16_0.txt。用文本编辑器打开它,你会发现里面就一行字符串,例如“2002年以来,国内企业家包括许多著名企业家在内涉嫌违法犯罪被捕入”。这就是该行图片中的所有字符,没有空格,标点符号与汉字一同记录。
2.2 图片与标签的对应关系
这里有一个关键点:标签文件里只有字符序列,没有提供每个字符的位置,也没有提供整行图片的坐标。那么,我们后续要生成的行级bbox,其坐标信息从哪里来?答案就藏在图片的像素尺寸和我们的拼接逻辑里。
每一张行图片的宽度和高度(width和height)是已知的。当我们决定如何将这些行图片拼接成一整页时,每一行在最终大图中的起始Y坐标(顶部位置)和结束Y坐标(底部位置)也就随之确定了。X坐标(左右边界)则需要通过我们定义的拼接规则(比如对齐方式、填充策略)来计算。因此,bbox的生成不是一个独立步骤,它完全依赖于拼接图片的过程。
理解了这个,我们就明白,接下来的核心任务有两个:一是设计一个合理的图片拼接算法,让生成的“页”看起来自然;二是基于这个拼接过程,同步计算出每一行对应的bbox坐标。
3. 核心实战:图片拼接的算法与细节
好了,理论铺垫完成,我们进入最关键的实操环节:怎么写代码把这些行图片拼回去。我把自己优化过的完整思路和代码拆开揉碎了讲给你听。
3.1 确定拼接骨架:排序、对齐与填充
拼接的第一步,是把属于同一页的所有行图片找出来,并按照行索引排序。这个通过解析文件名就能轻松实现。
排好序后,我们面临第一个技术挑战:这些行图片的宽度不一致。由于书写习惯和每行字数不同,有的行很长,有的行很短。如果我们直接竖向堆叠,得到的页面会像锯齿一样参差不齐,非常难看,也不符合真实文档的版面特征(通常有固定的左右页边距或对齐方式)。
我的解决方案是:以最宽的那一行为基准,将所有较窄的行填充(pad)到同样的宽度。这里就引入了两个设计选择:
- 填充什么颜色? 对于手写文档背景,填充白色(RGB: 255,255,255)是最安全的选择,模拟纸张底色。
- 填充在左边还是右边? 这里不能一刀切。我参考了中文书写排版和原始数据集的一些特点,采用了一个简单的启发式规则:
- 首行填充在左侧:因为中文段落开头通常缩进两格,但数据集标签中并无空格,从视觉上看,行首往往留有较多空白。将填充加在左侧,有助于维持这种“段首缩进”的视觉假象,使拼接后的页面更规整。
- 非首行填充在右侧:对于段落中间的行,将空白填充在右侧,可以使文本主体部分在视觉上左对齐,这符合大多数印刷和手写文档的阅读习惯。
这个“首行左填充,其他行右填充”的策略,是我经过多次试验后觉得视觉效果比较好的一个平衡点。当然,你也可以根据你的具体任务调整这个策略。
3.2 代码实现:一步步构建页面
让我们结合代码片段来看具体实现。我会用注释的方式详细解释每一块在做什么。
import numpy as np
import cv2
import os
from glob import glob
import re
from tqdm import tqdm # 用于显示进度条,处理大量文件时很实用
def pad_segment(img, is_first_line, padding_width):
"""
对单个行图片进行填充。
:param img: 输入的行图片,numpy数组格式 (H, W, C)
:param is_first_line: 布尔值,指示是否是当前页的第一行
:param padding_width: 需要填充的像素宽度(目标宽度-当前宽度)
:return: 填充后的图片
"""
if padding_width <= 0:
return img # 如果不需要填充,直接返回原图
# 创建填充块,颜色为白色
# 填充块的高度与原图相同,宽度为padding_width,通道数相同(3通道彩色)
pad_block = np.ones((img.shape[0], padding_width, 3), dtype=np.uint8) * 255
# 根据是否是首行决定填充方向
if is_first_line:
# 首行:在左侧填充 (pad_block + img)
padded_img = np.hstack((pad_block, img))
else:
# 非首行:在右侧填充 (img + pad_block)
padded_img = np.hstack((img, pad_block))
return padded_img
上面这个 pad_segment 函数是核心之一。它负责处理单张图片的宽度对齐。接下来是主流程,它负责组织所有行,计算填充,并完成纵向拼接。
def create_page_from_segments(segment_paths, label_root):
"""
将属于同一页的多张行图片拼接为一张整页图片,并计算行级bbox。
:param segment_paths: 列表,同一页所有行图片的完整文件路径,已按行号排序。
:param label_root: 行级标签文件所在的根目录。
:return: page_image (整页图片), bboxes (行级bbox列表), texts (行文本列表)
"""
# 1. 读取所有行图片,并收集尺寸信息
segment_imgs = []
heights = []
widths = []
for seg_path in segment_paths:
img = cv2.imread(seg_path)
if img is None:
print(f"警告:无法读取图片 {seg_path}")
continue
segment_imgs.append(img)
h, w = img.shape[:2]
heights.append(h)
widths.append(w)
# 2. 确定目标宽度(最宽行的宽度)
target_width = max(widths)
# 3. 读取对应行的文本标签
page_id = os.path.basename(segment_paths[0]).split('_')[0] # 例如 '001-P16'
line_texts = []
for seg_path in segment_paths:
base_name = os.path.splitext(os.path.basename(seg_path))[0] # '001-P16_0'
label_file = os.path.join(label_root, base_name + '.txt')
try:
with open(label_file, 'r', encoding='utf-8') as f:
# 标签文件只有一行文本
line_texts.append(f.read().strip())
except FileNotFoundError:
print(f"警告:未找到标签文件 {label_file}")
line_texts.append("") # 用空字符串占位
# 4. 逐行处理:填充图片,并计算该行在最终页面中的bbox
padded_imgs = []
bboxes = [] # 每个bbox格式为 [x1, y1, x2, y2] (左上角,右下角)
current_y = 0 # 当前行顶部在最终页面中的Y坐标
for i, (img, w, h, text) in enumerate(zip(segment_imgs, widths, heights, line_texts)):
# 判断是否是当前页的第一行(注意:这里指逻辑第一行,i==0)
is_first_line = (i == 0)
# 计算需要填充的宽度
pad_width = target_width - w
# 对图片进行填充
padded_img = pad_segment(img, is_first_line, pad_width)
# 计算该行在填充后的图片中的bbox坐标
# 注意:填充操作改变了有效内容在行图片内的X起始位置
if is_first_line:
# 首行左填充,所以有效内容从 pad_width 开始
x1 = pad_width
x2 = target_width # 或者 pad_width + w
else:
# 非首行右填充,有效内容从0开始
x1 = 0
x2 = w # 注意,这里x2是原图宽度,因为填充加在了右边
# 计算Y坐标:当前行的顶部是current_y,底部是current_y + h
y1 = current_y
y2 = current_y + h
bboxes.append([x1, y1, x2, y2, text]) # 把文本信息和坐标绑定
# 更新当前Y坐标,并保存填充后的行图片
current_y += h
padded_imgs.append(padded_img)
# 5. 纵向堆叠所有处理后的行图片,形成整页
if padded_imgs:
# 确保所有行图片宽度一致(应该都是target_width)
page_image = np.vstack(padded_imgs)
else:
page_image = None
return page_image, bboxes
这段代码已经完成了90%的核心工作。它输出了拼接好的整页图片 page_image,以及一个列表 bboxes,里面包含了每一行在最终图片中的坐标 (x1, y1, x2, y2) 和对应的文本内容 text。
3.3 锦上添花:为整页添加“页边距”
直接拼接出来的图片,文字几乎是紧贴着图片边缘的,这不太像真实的扫描文档。通常,文档周围会有一些留白(页边距)。我们可以很容易地加上这个效果。
def add_page_margin(image, margin_top=50, margin_bottom=50, margin_left=100, margin_right=100):
"""
为整页图片添加白色页边距。
:param image: 输入的整页图片
:param margin_top/bottom/left/right: 四个方向的边距大小(像素)
:return: 添加了边距的图片
"""
if image is None:
return None
# 使用OpenCV的copyMakeBorder函数,用常量值(白色)填充边界
bordered_img = cv2.copyMakeBorder(image,
top=margin_top,
bottom=margin_bottom,
left=margin_left,
right=margin_right,
borderType=cv2.BORDER_CONSTANT,
value=[255, 255, 255])
return bordered_img
这里有一个至关重要的细节! 当你添加了页边距后,之前计算的所有行bbox坐标就都失效了!因为整张图片的坐标系原点 (0,0) 从原来的左上角,移动到了加了边距后的新图片的左上角。所以,必须在添加边距后,同步更新所有bbox的坐标。
更新规则很简单:将所有bbox的 x1 和 x2 加上 margin_left,将所有bbox的 y1 和 y2 加上 margin_top。
# 假设我们已经有了 page_image 和 bboxes
margin_left, margin_top = 100, 50
page_with_margin = add_page_margin(page_image, margin_top=margin_top, margin_left=margin_left)
# 更新bbox坐标
for box in bboxes:
box[0] += margin_left # x1
box[1] += margin_top # y1
box[2] += margin_left # x2
box[3] += margin_top # y2
# box[4] 是文本,保持不变
4. 生成标准化的标注文件
有了更新后的bbox坐标和对应的文本,我们需要将其保存为一种通用的标注格式,方便后续训练模型使用。常见的格式有几种,比如:
- 每行一个标注:
x1,y1,x2,y1,x2,y2,x1,y2,transcript(四点坐标+文本) - 矩形框标注:
x1,y1,x2,y2,transcript(左上右下两点坐标+文本)
原始文章示例采用的是第一种(四点坐标)。我们这里也生成这种格式,它与一些OCR工具(如PPOCR、MMOCR)的标注格式兼容。
def save_annotation(bboxes, output_label_path):
"""
将bbox和文本信息保存到标注文件。
:param bboxes: 列表,每个元素为 [x1, y1, x2, y2, text]
:param output_label_path: 输出标签文件的路径
"""
with open(output_label_path, 'w', encoding='utf-8') as f:
for box in bboxes:
x1, y1, x2, y2, text = box
# 格式化为四点坐标:左上、右上、右下、左下
# 注意:我们存储的是矩形,所以四点坐标很容易从 (x1,y1,x2,y2) 推导
line = f"{int(x1)},{int(y1)},{int(x2)},{int(y1)},{int(x2)},{int(y2)},{int(x1)},{int(y2)},{text}\n"
f.write(line)
保存后的标签文件内容就像这样:
110,60,2670,60,2670,180,110,180,2002年以来,国内企业家包括许多著名企业家在内涉嫌违法犯罪被捕入
100,180,2591,180,2591,306,100,306,狱的人数不断增多,此方面的报道也屡屡见诸极端。不是哪个被抓了,就
...
每一行代表页面中的一个文本行区域,前8个数字是四个顶点的坐标(顺时针或逆时针),最后一个字段是该区域的文本内容。
5. 避坑指南与高级技巧
理论很美好,实践起来总会遇到各种小问题。我把自己在多次实践中总结的几个关键注意事项和优化点分享给你,能帮你节省大量调试时间。
5.1 文件名排序的“坑”
我们依赖文件名中的数字索引(如 _0, _1, _10)进行排序。如果你直接用Python的 list.sort() 或 sorted() 对字符串列表 ['001-P16_0.jpg', '001-P16_1.jpg', '001-P16_10.jpg', '001-P16_2.jpg'] 排序,你会得到 _10 排在 _2 前面的错误结果,因为这是字符串的字典序比较。
解决方案:使用自然排序(natural sort)或者自定义排序键。
import re
def natural_sort_key(filename):
"""
提取文件名中的数字部分用于排序。
例如 '001-P16_10.jpg' -> ('001-P16', 10)
"""
# 匹配页标识符和行号
match = re.match(r'(.+?)_(\d+)\.jpg', filename)
if match:
page_id, line_num = match.groups()
return page_id, int(line_num) # 将行号转为整数比较
return filename
# 使用方式
segment_files = ['001-P16_10.jpg', '001-P16_0.jpg', '001-P16_2.jpg']
sorted_files = sorted(segment_files, key=natural_sort_key)
# 结果:['001-P16_0.jpg', '001-P16_2.jpg', '001-P16_10.jpg']
5.2 处理“空白行”或“损坏文件”
数据集中偶尔可能会遇到图片损坏无法读取,或者标签文件缺失的情况。你的脚本必须有足够的鲁棒性来处理这些异常,而不是整个流程崩溃。
- 图片读取失败:使用
cv2.imread后检查返回值是否为None,并记录日志或跳过该文件。 - 标签文件缺失:在打开标签文件时使用
try-except,缺失时可以用空字符串或特殊标记(如[MISSING])替代,但务必记录,以便后续检查数据完整性。
5.3 内存管理与批量处理
CASIA-HWDB2.x训练集可能有数万页,每页由多行组成。如果一次性将所有图片读入内存,很容易导致内存不足(OOM)。
建议采用流式或分页处理:
- 按页标识符分组处理,一次只处理一页的数据。
- 处理完一页,立即保存生成的页图片和标签文件,然后释放相关变量(特别是大的numpy数组)的内存。
- 使用
tqdm显示进度,对于长时间运行的任务非常友好。
5.4 可视化校验:眼见为实
生成第一批数据后,强烈建议你写一个简单的可视化脚本进行校验。把生成的整页图片加载出来,然后用OpenCV的 cv2.polylines 或 cv2.rectangle 函数,根据你生成的bbox坐标,在图片上画出矩形框。看看框的位置是否准确无误地框住了每一行文字。
这是验证你整个拼接和坐标计算逻辑是否正确的终极方法。我早期好几个bug都是通过可视化才发现的,比如坐标偏移了几个像素,或者填充逻辑错误导致bbox框到了空白区域。
def visualize_bboxes(page_image, bboxes, output_path='debug.jpg'):
"""
在整页图片上绘制bbox,用于校验。
:param page_image: 整页图片
:param bboxes: 格式为 [[x1,y1,x2,y2,text], ...]
:param output_path: 输出图片路径
"""
img_viz = page_image.copy()
for box in bboxes:
x1, y1, x2, y2, _ = map(int, box[:4]) # 取前四个坐标并转为整数
# 画矩形框,颜色为红色(0,0,255),线宽为2
cv2.rectangle(img_viz, (x1, y1), (x2, y2), (0, 0, 255), 2)
cv2.imwrite(output_path, img_viz)
print(f"校验图片已保存至:{output_path}")
6. 扩展思考:不止于CASIA数据集
我们上面讨论的方法虽然针对CASIA-HWDB2.x,但其核心思想——通过行图片尺寸和排序信息重构页面,并同步推导空间标注——可以迁移到很多类似的数据集上。
比如,有些古籍扫描数据集也是按行切割的,或者你从某些PDF解析工具中得到了一系列文本块(block)的图片和位置。只要你能确定这些片段之间的原始空间关系(顺序、对齐方式),就可以用类似的思路将它们重新组合成更大的语义单元(如段落、栏目、整页),并生成相应的标注。
这个过程的本质是利用低级、局部的标注(行图片+行文本),通过规则和算法,生成高级、全局的标注(页面+行位置)。这是一种非常经济高效的数据标注利用方式,尤其适用于那些只有内容标注而没有精确位置标注的历史数据集。
最后,我想说的是,数据处理是AI项目里最“脏”最累的活,但也是决定模型上限的基础。把CASIA-HWDB2.x从行级转换成页级,虽然步骤繁琐,但当你拥有了一份格式规整、标注准确的整页手写数据集时,你会发现后续无论是训练一个文本检测模型,还是一个端到端的识别模型,都变得顺手很多。希望这篇详细的实践指南能帮你扫清障碍,顺利搞定数据预处理这一关。如果在实际操作中遇到新的问题,不妨多从可视化校验入手,一步步调试,总能找到原因。
191



被折叠的 条评论
为什么被折叠?



