1. 为什么需要格式转换:从检测到识别的数据流转
接触过OCR项目的朋友应该都有体会,**文本检测(Detection)和文本识别(Recognition)**在PaddleOCR里虽然是两个紧密配合的环节,但它们的训练数据格式却完全是两套逻辑。检测模型要学的是“文字在哪里”,标注文件里记录的是文本框的四个角点坐标;识别模型要学的是“这个区域里是什么字”,训练时喂给它的是裁剪好的文字行图片和对应的字符串标签。
我刚做OCR项目时踩过一个很典型的坑——手里攒了一批检测格式的标注数据,是从网上开源数据集和旧项目里整理出来的,格式五花八门,有的是XML,有的是txt,有的是JSON。想拿来训练识别模型,发现根本没法直接喂进去。那会儿我才意识到,检测数据和识别数据之间的格式转换不是“顺便处理一下”的小事,而是决定整个OCR项目数据链路是否顺畅的关键环节。
这篇博文就把我当时做的一套转换方案完整拆开来讲,包含格式解析、坐标处理、图像裁剪、编码坑点以及批量处理的流程设计。无论你是刚接触PaddleOCR的新手,还是已经在做数据清洗的老手,这套思路都能直接套用到自己的项目里。
2. 两类数据格式的核心差异
先看清楚两头的数据长什么样,后面写脚本才有依据。
2.1 检测数据格式(det)
PaddleOCR的检测模型训练数据长这样,每行是一张图片的标注信息:
img_001.jpg [{"transcription": "某某银行", "points": [[45, 123], [210, 123], [210, 156], [45, 156]]}, {"transcription": "开户行", "points": [[45, 180], [120, 180], [120, 210], [45, 210]]}]这里有几个关键点:
- 图片路径和标注内容之间用tab分隔
- 标注内容是JSON数组,每个元素是一个文本框对象
transcription是框内文字,points是四个顶点坐标,顺序是左上、右上、右下、左下- 注意,
transcription也有可能是###,表示“这个框里的内容不需要识别”,通常是模糊、艺术字或者明显非文字区域
2.2 识别数据格式(rec)
识别模型的训练数据格式就简洁多了,每行一对路径和标签:
train/word_001.jpg 某某银行 train/word_002.jpg 开户行图片是已经从原图中裁剪好的文字行图片,通常已经做过预处理,比如透视矫正、灰度化、统一尺寸等。标签就是该图片对应的文本字符串。
2.3 转换的本质是什么
从上面的对比能看出,检测转识别的本质就是三件事:
- 解析:读取检测格式中的坐标和文本内容
- 裁剪:根据坐标从原图中抠出文字行区域
- 产出:把裁剪图片保存成文件,并生成对应的标签文件
算上排序问题、编码问题、无效数据过滤问题,实际做起来比听起来要复杂不少。
3. 转换方案的总体设计
在做方案设计时,我明确了两件事。第一,转换工具必须能批量处理,因为实际项目中数据量动辄上万张图,逐张手工处理是不可能的。第二,转换的过程必须保留原始标注信息,避免数据变更后又要回头重新标注的麻烦。
3.1 工具选型:Python 脚本
我选择了Python来实现这个转换脚本,原因很直接:
- PaddleOCR本身就是Python生态,训练数据处理的周边工具基本都是Python写的
- 检测数据里的JSON格式用Python的
json标准库解析非常方便 - 图像裁剪用
PIL(Pillow)或者OpenCV都行,几行代码的事
处理图片的库,我选了OpenCV。原因是我后续还要对裁剪出来的图片做灰度化和统一尺寸处理,OpenCV的函数更齐全,而且它在处理大图和批量循环时性能比Pillow稳定一些。
3.2 整体流程拆解
转换的核心流程拆成几个模块:
- 数据解析模块:读取检测格式的标注文件,逐行解析
- 图片裁剪模块:根据坐标从原图裁出文字区域,保存为图片
- 标签生成模块:生成识别格式的标签文件
- 数据验证模块:检查裁剪结果是否存在无效图片、坐标异常等问题
每个模块单独封装成函数,由主函数串起来循环处理。这样的好处是,遇到格式特殊的标注文件时,只需要改数据解析模块,其他部分不用动。
4. 核心代码实现与解析
直接上代码。下面这套脚本我在实际项目中跑通了,处理了大概2万张图的数据集,整体稳定。
4.1 依赖库准备
pip install opencv-python numpy处理标注文件只需要Python标准库,不需要额外安装。
4.2 解析检测格式标注
import json import os def parse_det_annotation(txt_path): """解析PaddleOCR检测格式标注文件 Args: txt_path: 检测标注文件路径 Returns: list: 每个元素是 (图片路径, 文本框信息列表) """ results = [] with open(txt_path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue # 按tab分割图片路径和标注内容 parts = line.split('\t') if len(parts) < 2: print(f"警告: 跳过格式异常的行: {line[:50]}...") continue img_path = parts[0] annotation = parts[1] try: boxes = json.loads(annotation) except json.JSONDecodeError as e: print(f"警告: JSON解析失败 {img_path}: {e}") continue results.append((img_path, boxes)) return results这个函数看起来简单,但有几个细节是实际处理时踩坑踩出来的:
- 标注文件可能存在空行,所以用
if not line: continue跳过 - 图片路径和标注内容理论上是用tab分隔的,但实际中有些数据用了空格或者多个tab,稳妥起见应该用
split('\t', 1)只分割一次,防止图片路径或内容中本身包含tab - JSON解析失败时不能直接崩溃,要记录警告并跳过该行,等整体转换完后再回头处理问题数据
4.3 坐标处理与图像裁剪
把检测框坐标转成裁剪区域,这一步有几个数学细节要处理好。
import cv2 import numpy as np def crop_by_points(img, points): """根据四个顶点坐标裁剪文字区域 处理原则: 1. 取四个点的最小外接矩形作为裁剪区域 2. 如果矩形面积明显小于四点围成的多边形面积,说明文字有倾斜,考虑扩展边界 """ # 提取所有点的x和y坐标 xs = [p[0] for p in points] ys = [p[1] for p in points] x_min, x_max = max(0, min(xs)), min(img.shape[1], max(xs)) y_min, y_max = max(0, min(ys)), min(img.shape[0], max(ys)) # 检查裁剪区域是否有效 if x_max <= x_min or y_max <= y_min: return None # 根据文字方向适当扩展边界,防止文字贴边被切掉 # 这里上下各扩展2像素作为安全边界 y_min = max(0, y_min - 2) y_max = min(img.shape[0], y_max + 2) crop = img[y_min:y_max, x_min:x_max] return crop这里有一个值得细说的点:是否要做透视矫正。
如果检测框标得比较准,且文字行基本水平,直接取外接矩形就够了。但如果标注框是倾斜的(比如拍到了歪着的门牌号),直接取外接矩形会带入大量背景,识别效果会变差。这时就需要用四点透视变换把倾斜的文字行拉正。
透视变换的处理方式是通过OpenCV的getPerspectiveTransform和warpPerspective函数实现的。核心是构造变换前后的四组对应点,然后做透视映射。在处理自然场景图片时,加入透视变换能明显提升识别模型的训练效果。
不过透视变换也有代价:计算量更大,而且如果标注框本身有误差,变换后会进一步放大误差。所以在实际项目中我加了一个开关,处理精度不高的标注数据时先不做透视变换,等确认标注质量后再决定是否开启。
4.4 完整转换流程
def convert_det_to_rec(det_txt_path, img_root_dir, output_dir, min_width=4, has_perspective=False): """检测标注转识别数据 Args: det_txt_path: 检测标注文件路径 img_root_dir: 原图所在根目录 output_dir: 输出目录,包含图片子目录和标签文件 min_width: 最小有效宽度,小于此宽度的图片丢弃 has_perspective: 是否开启透视变换矫正 """ img_out_dir = os.path.join(output_dir, 'train_images') os.makedirs(img_out_dir, exist_ok=True) label_lines = [] annotations = parse_det_annotation(det_txt_path) global_idx = 0 for img_path, boxes in annotations: # 拼接原图完整路径,注意路径分割符适配 full_img_path = os.path.join(img_root_dir, img_path.replace('/', os.sep)) if not os.path.exists(full_img_path): print(f"警告: 图片不存在,跳过 {full_img_path}") continue img = cv2.imread(full_img_path) if img is None: print(f"警告: 无法读取图片 {full_img_path}") continue for box in boxes: text = box.get('transcription', '') points = box.get('points', []) # 过滤无效框和无效文本 if len(points) != 4: continue if text == '###' or text == '': continue crop = crop_by_points(img, points) if crop is None: continue # 检查图片大小是否满足识别模型要求 h, w = crop.shape[:2] if w < min_width or h < 4: continue # 可选:透视矫正 if has_perspective and is_skewed(points): crop = perspective_correct(img, points) # 保存图片,命名规则用全局递增编号 out_img_name = f'img_{global_idx:06d}.jpg' out_img_path = os.path.join(img_out_dir, out_img_name) cv2.imwrite(out_img_path, crop) # 写入标签行 label_lines.append(f'{os.path.join("train_images", out_img_name)}\t{text}') global_idx += 1 # 写标签文件 label_path = os.path.join(output_dir, 'rec_gt.txt') with open(label_path, 'w', encoding='utf-8') as f: f.write('\n'.join(label_lines)) print(f"转换完成,共生成 {global_idx} 条识别数据")这段代码是核心,有几个设计上的细节说明一下。
文件名用全局递增编号的最主要原因是避免重名冲突。如果直接用原图文件名加框序号,很可能出现不同目录下同名图片的情况,导致文件互相覆盖。虽然编号文件名不直观,但配合标签文件就能反查来源,不影响后续使用。
关于无效文本的过滤:检测标注中的###通常表示“忽略该区域”。转换时如果不做这个过滤,会在识别训练集中混入大量无意义的标签,影响模型收敛。
4.5 分类验证与训练集/验证集划分
在实际操作中,直接一次转换全部数据很容易出问题。我习惯先把数据转换到一个验证目录,抽几张图看一眼裁剪效果,确认没问题后再全量转换。
另外,PaddleOCR训练时一般还需要把数据划分为训练集和验证集,划分比例常用的有9:1和8:2。我在转换脚本里加了参数支持:
import random def split_train_val(label_lines, val_ratio=0.1, seed=42): """按比例划分训练集和验证集""" random.seed(seed) shuffled = label_lines.copy() random.shuffle(shuffled) val_size = int(len(shuffled) * val_ratio) val_lines = shuffled[:val_size] train_lines = shuffled[val_size:] with open('train_gt.txt', 'w', encoding='utf-8') as f: f.write('\n'.join(train_lines)) with open('val_gt.txt', 'w', encoding='utf-8') as f: f.write('\n'.join(val_lines))注意,划分时必须先整体打乱再切分,不能简单把前80%当训练集后20%当验证集。因为原始标注文件里同一个场景的图片往往连在一起,直接顺序切分会导致验证集和训练集分布偏差太大。
5. 转换过程中的坑与排查实录
这部分专门写我在实操中实际遇到的问题,每一个都是真实遇到后花时间排查过的。
5.1 坐标精度的坑:浮点坐标与像素坐标
有些检测模型输出的坐标是浮点数,比如[[45.23, 123.87], ...]。OpenCV的切片操作要求坐标是整数,直接传入浮点数会触发TypeError。我最早写脚本时没注意这个,跑了一半程序崩了,重新检查才发现问题是坐标类型没做转换。
解决方案很简单,在进入裁剪函数之前统一做取整:
points = [[int(round(p[0])), int(round(p[1]))] for p in points]这里用round而不是直接int强转,是为了让坐标更接近真实位置,减少因为截断引入的偏移。虽然差一两个像素对最终识别影响不大,但既然能做到更准,没必要省这一步。
5.2 边界越界问题:裁剪区域超出图片范围
自然场景的检测框有时会超出图片边缘(比如文字被图片边框切了一半,标注时框的坐标仍然标了完整矩形)。直接用这种坐标去裁剪,OpenCV会报错,或者裁出来的区域包含大量黑边。
解决方式就是我前面代码里写的max(0, min(...))处理。这样确保裁剪区域始终在图片有效范围内。不过也要注意,如果框有一大半都在图片外,裁剪出来的有效区域可能很小,这种数据在后面的宽度过滤环节会被过滤掉。
5.3 空标注文件导致的崩溃
检测标注文件可能是空文件,也可能是某些行有内容但整体无效。解析函数里如果没有加空行保护,parts[1]会直接IndexError。这种错误在批量处理时特别恶心,不是每次都重现,但一旦某次加载了一个空标注文件,整个转换流程就断了。
我在脚本里先判断len(parts) < 2再继续,这个小改动避免了很多次半夜跑批处理时中途挂掉的情况。
5.4 中文标签的编码问题
这是一个特别隐蔽的坑。Windows环境下生成的标注文件可能是GBK编码,而PaddleOCR的训练框架统一要求UTF-8。如果直接用UTF-8去读GBK文件,所有中文标签都会变成乱码。更重要的是,乱码不会导致报错,只会导致训练出的模型输出一堆乱码文字。
处理方式是读取文件时先尝试UTF-8,失败后回退到GBK:
def read_annotation_file(path): for encoding in ['utf-8', 'gbk', 'gb2312']: try: with open(path, 'r', encoding=encoding) as f: return f.read() except UnicodeDecodeError: continue raise ValueError(f"无法识别文件编码: {path}")我当时处理一个从旧项目迁移过来的数据集时,就因为这个编码问题浪费了整整一天时间,训练出来的模型识别结果全是乱码。建议大家在数据转换脚本里直接加上编码自动检测,省得后面排查。
5.5 路径分隔符的兼容
检测标注里的图片路径,有些用的是正斜杠/,有些是反斜杠\,还有Windows风格的反斜杠。直接拼接img_root_dir + '/' + img_path在Linux上没问题,但Windows上可能出错。
稳妥的处理方式是用os.path.join,在拼接前把路径中的/统一转成os.sep:
import platform def normalize_path(path): if platform.system() == 'Windows': return path.replace('\\', '/') return path.replace('\\', '/')实际上我的做法是只做一步:全部统一成/。因为在当前所有主流操作系统和Python里,正斜杠都能正常用于文件访问。
5.6 裁剪后图片太小的过滤
识别模型对输入图片有一个最小尺寸要求。如果裁剪出来的图片宽高都小于一定值,直接送进模型训练,特征都被压缩没了,根本学不到东西。特别是有些标注框非常小,比如票据里的小字,裁出来可能就只有10x8像素。
根据PaddleOCR的文档和我的实测经验,裁剪图片的宽度建议至少20像素以上,高度至少8像素以上。当然这取决于具体的识别模型和字典,但设置一个min_width参数可以灵活调整。
6. 转换过程中的一个关键决策:标签顺序与图像顺序的对齐
在实际转换过程中,我发现一个容易被忽略但影响很大的问题:转换后生成的图片文件顺序必须与标签文件中的顺序严格一致。
为什么这么说?因为后续训练框架在读取数据时,有些数据加载器会按照标签文件中的路径逐个读取图片。如果图片文件名是递增编号,且标签文件的行顺序也对应递增编号,那么数据加载会很顺畅。但如果中间某张图片转换失败被跳过了,编号断档,就会导致标签和图片对不上。
我在转换脚本中采用了一个策略来保证一致性:只对成功转换并保存的图片分配编号并写入标签。凡是过滤掉的、坐标异常的、读取失败的,都不占用编号。这样生成的标签文件和图片文件始终是严格一一对应的关系。
具体实现上就是我代码中global_idx只有在图片成功保存后才递增。这个细节看起来不起眼,但如果不加注意,后面花在数据对齐上的时间可能比写转换脚本本身还多。
7. 转换质量验证的三个层面
转换完成后不能直接开训,要充分验证转换结果的质量。我每次转换数据,都按三个层面做检查。
7.1 抽样目检
从生成的图片里随机抽20到50张,人工看一眼裁剪效果:
- 文字是否完整在图片中
- 是否带了过多背景干扰
- 有没有文字被截断一半
- 有没有图片是纯空白或纯噪声
这一步花费的时间不多,但能快速筛掉大部分明显问题。尤其要注意的是,检测框标注不准确时,裁剪出来的图片往往文字居中但周围有大片空白背景,这时需要裁得更紧凑一些。
7.2 标签内容检查
- 统计标签中是否有空字符串
- 检查是否有异常字符(比如全角空格、制表符误入)
- 检查空白标签占比,如果高于1%,说明检测标注里有大量
###未被过滤掉 - 检查是否有重复的标签行(
sort | uniq -d可以快速查重)
7.3 数据分布检查
统计图片尺寸的分布情况。如果大量图片高度集中在某一两个像素值上,说明裁剪逻辑可能有问题,比如高度统一变成了固定值。正常情况下,裁剪后的文字行图片高度应该大致在一个合理范围内波动,但不应出现严重的极端情况。
检查尺寸分布用Python几行就能搞定:
import cv2 import os def check_size_distribution(img_dir): widths = [] heights = [] for fname in os.listdir(img_dir): img = cv2.imread(os.path.join(img_dir, fname)) if img is not None: h, w = img.shape[:2] widths.append(w) heights.append(h) print(f"数量: {len(widths)}") print(f"宽度范围: {min(widths)} - {max(widths)}, 平均: {sum(widths)/len(widths):.1f}") print(f"高度范围: {min(heights)} - {max(heights)}, 平均: {sum(heights)/len(heights):.1f}")如果发现大量图片高度小于10像素,就要检查过滤阈值是否设置偏低。
8. 进阶话题:从检测数据转换时的图像预处理
转换完成后,数据是否可以直接用于训练?答案是可以,但效果未必最优。这里补充一些进阶的数据预处理思路。
8.1 灰度化和对比度增强
PaddleOCR的识别模型对灰度图支持良好。如果场景中存在光照不均、反光严重等问题,在裁剪后做灰度化和自适应对比度增强,可以显著提升识别准确率。我是用OpenCV的createCLAHE做限制对比度自适应直方图均衡化,效果比全局直方图均衡化好很多,不会把本来清晰的文字弄成过曝。
8.2 角度矫正
有些检测框标注的是倾斜文字,比如门牌号、车辆牌照、外卖小票等。直接按外接矩形裁剪会得到斜着的文字图。如果识别数据集里大量是这样的图片,训练出的模型在推理时对倾斜文字的鲁棒性会下降。
解决办法是对倾斜明显的框做透视矫正,原理是把四边形拉伸成长方形:
def perspective_correct(img, points): """四点透视矫正""" src = np.array(points, dtype=np.float32) # 计算目标矩形的边长 width_top = np.linalg.norm(src[1] - src[0]) width_bottom = np.linalg.norm(src[2] - src[3]) height_left = np.linalg.norm(src[3] - src[0]) height_right = np.linalg.norm(src[2] - src[1]) max_width = max(int(width_top), int(width_bottom)) max_height = max(int(height_left), int(height_right)) dst = np.array([ [0, 0], [max_width - 1, 0], [max_width - 1, max_height - 1], [0, max_height - 1] ], dtype=np.float32) matrix = cv2.getPerspectiveTransform(src, dst) result = cv2.warpPerspective(img, matrix, (max_width, max_height)) return result8.3 统一尺寸策略
PaddleOCR的识别模型有自己的输入尺寸要求,通常长边按比例缩放,短边做填充或者裁剪。在数据集准备阶段,可以先把所有图片统一到一个固定尺寸范围内。但要注意,统一尺寸不等于简单拉伸成正方形,那样会造成文字畸变。正确做法是等比缩放后做边缘填充:
def resize_with_padding(img, target_w=320, target_h=48, pad_val=0): h, w = img.shape[:2] scale = min(target_w / w, target_h / h) new_w = max(1, int(w * scale)) new_h = max(1, int(h * scale)) resized = cv2.resize(img, (new_w, new_h)) canvas = np.full((target_h, target_w, 3), pad_val, dtype=np.uint8) x_off = (target_w - new_w) // 2 y_off = (target_h - new_h) // 2 canvas[y_off:y_off+new_h, x_off:x_off+new_w] = resized return canvas实际项目中,我在转换完成后的批量预处理里加入了灰度化、CLAHE增强和统一尺寸填充三步。这些预处理操作不仅在训练时有效,在推理部署时的预处理环节也适用,保证训练和推理使用一致的预处理方式。
9. 反向需求:识别数据转检测格式
虽然这篇博文主标题是检测转识别,但实际开发中经常会有反过来的需求。我在做数据增强的时候遇到过:需要把已有的识别数据扩充回检测格式,用于检测模型的迭代训练。
反向转换的思路正好相反:
- 将识别图片嵌入到生成的背景图中,形成新的检测图
- 记录嵌入位置的坐标,组装成检测标注格式
这个方案常用于检测数据的合成增强。简单说,核心逻辑是:随机选背景图,随机位置放置文字行,记录新坐标点。这个过程比检测转识别更容易出错,主要难点在于:
- 嵌入位置不能遮挡背景中已有的重要文字或图案
- 多个文字行之间不能重叠
- 坐标计算必须与嵌入的像素位置严格对应
我建议有类似需求的朋友,先用少量数据或测试代码验证坐标对应关系是否正确,再扩展到全量数据。
10. 总结一点实操体会
写到这里,整个检测转识别的转换方案算是完整梳理了一遍。我在实际项目中用这套脚本处理过多个数据集,包括自然场景拍摄的门头照、票据扫描件、网上下载的公开数据集等。整体感觉是:转换本身不复杂,难的在于处理好各种“意外情况”。
最后再分享一个我养成的小习惯:转换脚本里加上日志输出,每处理一张图记录一次进度,同时打开日志重定向到文件:
python convert_det_to_rec.py --input det_gt.txt --img_dir . --output rec_data 2>&1 | tee convert_log.txt这样一旦处理过程中途失败,可以从日志里快速定位到哪张图出了问题,不用从头排查。批量处理上,配合multiprocessing做并行可以对单张图片的处理进行加速,实测中在4核机器上并行处理能提升2到3倍的速度。但在并行时要特别注意输出文件的编号同步,避免多个进程写入相同文件名。我用的是每个进程处理不同图片目录、最后合并标签文件的方式,规避了文件锁冲突。
这套方案经过多个项目验证,已经算是比较稳定可靠了。如果你在转换过程中遇到其他问题,欢迎交流,我在踩坑的路上积累了不少经验。