阿里云OCR文档自学习(自定义KV模版)技术解析与代码复现
从一张图片中识别出所有的文本数据是最常见的OCR任务,现在市场上有很多开源的端到端模型可以实现,如百度开源的PaddleOcr等。但如果我们的任务是要提取出图片中关键的信息(不光识别出文字,还要知道文字的意义),这时通用的全文OCR就不能胜任了,往往需要对全文OCR的结果进行后处理。比如,现在我需要提取出一张身份证照片中的个人信息,最后得到{'姓名':'张三','性别':'男'}
这样的kv键值对的格式,如果只进行文本OCR,当然可以一股脑的提到照片中的所有文本,但后续怎么知道文本“张三”是姓名呢?怎么知道文本“男”是性别呢?身份证识别的场景还算简单,性别只有两种可能,不是“男”就是”女“,而且如果检测出了文本“姓名”,那它后面一个检测出来的文本一定就是姓名的value值了。但如果更复杂的场景,如发票、表格、铭牌等场景的信息提取可就不能这么简单的判定kv了。
这类问题可以大概称之为信息抽取问题。
前沿的信息抽取技术方案
- PP-Structure 基于 LayoutXLM 文档多模态系列方法
- Paddle-UIE-X(OCR+大模型技术)
- PP-ChatOCRv2-common(OCR+大模型技术)
通过自定义KV模版实现在固定版面图片的关键信息提取——以铭牌为例
让我们先忘掉标题上提到的阿里云OCR文档自学习(自定义KV模版),先看我怎么实现通过自定义KV模版进行关键信息提取的,最后
再比照阿里云的方案,看看我和阿里两者在实现上的相同之处。
我的方案大致步骤如下:
- 定义好模版
- 进行图片主体的分割以及透视变换(此步骤可选)
- 全局通用OCR,检测出所有文本,跟模版图片进行anchor match,计算单映矩阵
- 利用单映矩阵变换被检测图片到模版图片上,截取模版定义好的v值区域,对截取出的v值区域进行文本识别(不需要进行文本检测)
1. 定义好模版
所谓定义好模版就是选择一张图片作为模版,并进行特殊区域的标注。该方案中需进行两类标注,一类为关键区域标注(阿里云称之为参照区域),另一类为待检测区域标注。关键区域的标注主要是用来将被检测图片与模版图片进行相同区域的匹配,将被检测图片进行图形变换到模版图片,从而使得在模版图片上标注的待检测区域默认为待检测图片的相同区域。
我使用labelme工具进行标注,具体情况如下:
如上图所示,两类区域的标注我使用k和v作为前缀进行区分,以k开头的标注区域即关键区域标注,这类区域应该选择文本内容固定不变的区域,如上图中的“型号”、“出厂编号”、“电压”等文本是固定不变的,且最好这些区域分布在图片的边缘角落,为后续的计算单应矩阵做准备。以v开头的区域即我们需要实际检测的区域,“v_日期”区域的文本识别结果最后会形成{'日期':'2017年10月'}
这样的键值对。k_后面的文本是指该区域的文本内容,v_后面的文本是指该区域待检测的k名称。
对labelme导出的标注文件格式进行转化为下面格式,方便后续使用,距离每个矩形框的左上、右上、右下和左下4点坐标:
{
"k": {
"型号": [[120,228],[299,228],[299,296],[120,296]],
"出厂编号": [[107,498],[286,498],[286,569],[107,569]],
...
},
"v": {
"型号": [[317,232],[1010,232], [1010,295],[317,295]],
"工厂": [[102,601],[1078,601], [1078,677],[102,677]],
...
}
2. 进行图片主体的分割以及透视变换(此步骤可选)
此步骤非必须的,主要就是将图片中待识别的主体切割出来进行透视变换,从而提高下一步全局OCR识别的准确性,实际上不进行这一步也可以直接对图片进行全局OCR识别。
如上图所示,其中铭牌主体切割使用的是 yolov8-seg 算法,需要自行进行模型训练部署,这里不详细介绍。切割出来的图像应该近似4边形,需要找到该4边形的边缘以及4个顶点坐标(这里使用opencv的findContours),然后进行透视变换。
以下展示大致的代码思路:
# 加载训练好的YOLOv8-seg模型
model = YOLO(you_model_path)
def predict(image_path, draw_plt=True):
# 在图片列表上运行批量推理
results = model([image_path], save=False, imgsz=1280)
# 获取结果对象
result = results[0]
# 读取原始图像
original_image = cv2.imread(image_path)
original_image = cv2.cvtColor(original_image, cv2.COLOR_BGR2RGB)
# 获取原始图像的大小
height, width, _ = original_image.shape
# 提取分割掩码 一个图片可以有多个检测结果,选择置信度最高的一个
masks = result.masks.data.cpu().numpy() # 得到分割掩码列表
scores = result.boxes.data[:, 4].cpu().numpy()
# 找到置信度最高的掩码索引
max_score_index = np.argmax(scores)
# 提取置信度最高的掩码
best_mask = masks[max_score_index]
# 调整掩码大小以匹配原始图像大小
resized_mask = cv2.resize(best_mask, (width, height),
interpolation=cv2.INTER_NEAREST
).astype(bool)
# 将掩码转换为二值图像
mask_image = (resized_mask * 255).astype(np.uint8)
# 寻找轮廓
contours, _ = cv2.findContours(mask_image,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
# 使用多边形逼近找到近似四边形
epsilon = 0.02 * cv2.arcLength(contour, True)
approx = cv2.approxPolyDP(contour, epsilon, True)
进行透视变换,原4点坐标就是上面得到的approx,目标坐标为下面的dst。approx的坐标位置是不固定的,所有需要使用sort_vertices方法输出4点的上下左右位置。
def sort_vertices(approx):
# 确定4点的上下左右位置
a, b, c, d = approx[0][0], approx[1][0], approx[2][0], approx[3][0]
vertices = [[a[0], a[1]], [b[0], b[1]], [c[0], c[1]], [d[0], d[1]]]
sorted_vertices = np.array(vertices)
# print(sorted_vertices)
sorted_vertices = sorted_vertices[np.lexsort((sorted_vertices[:, 0],
sorted_vertices[:, 1]))]
top = sorted_vertices[:2]
bottom = sorted_vertices[2:]
# print(top, bottom)
top = top[np.argsort(top[:, 0])]
bottom = bottom[np.argsort(bottom[:, 0], )[::-1]]
sorted_vertices = np.concatenate((top, bottom))
# print(sorted_vertices)
return sorted_vertices
def four_point_transform(image, approx):
# approx的坐标位置是不固定的,所有需要使用sort_vertices方法输出4点的上下左右位置
tl, tr, br, bl = sort_vertices(approx)
# 计算新图像的宽度
widthA = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))
widthB = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))
maxWidth = max(int(widthA), int(widthB))
# 计算新图像的高度
heightA = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2))
heightB = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))
maxHeight = max(int(heightA), int(heightB))
# 变换后的四个顶点坐标
dst = np.array([[0, 0], [maxWidth - 1, 0],
[maxWidth - 1, maxHeight - 1], [0, maxHeight - 1]
], dtype="float32")
rect = [[tl[0], tl[1]], [tr[0], tr[1]], [br[0], br[1]], [bl[0], bl[1]]]
# 计算透视变换矩阵并应用
M = cv2.getPerspectiveTransform(np.array(rect, dtype="float32"), dst)
warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))
# 返回变换后的图像
return warped
具体效果如下图所示:
3. 全局通用OCR,检测出所有文本,跟模版图片进行anchor match,计算单映矩阵
如上图所示,左一为模版图片,绿色为手工标注的关键区域,黄色为手工标注的待检测区域,中间为经过透视变换后的待检测图片且进行全局的OCR检测,将全局的OCR检测出来的文本与模版图片的关键区域文本进行比对,共有6处区域的文本相同,我们认定这6块区域实现了anchor match,将模版图片和检测图片的这12块区域的坐标两两对应,计算出单应矩形,然后进行图形变换,将被检测图片映射到模版图片上,这时模版图片上的v区域(黄色矩形区域)也就成了检测图片的待检测区域。
4. 利用单映矩阵变换被检测图片到模版图片上,截取模版定义好的v值区域,对截取出的v值区域进行文本识别
H, _ = cv2.findHomography(srcPoints=np.array(srcPoints, dtype=np.int32),
dstPoints=np.array(dstPoints, dtype=np.int32),
method=cv2.RANSAC,
ransacReprojThreshold=5.0)
# 使用计算出的单应性矩阵进行透视变换
img_warped = cv2.warpPerspective(check_image, H, (width, height))
for key, value in value_dict.items():
# value 是模版图片中各个v_box的4点坐标,截取出来去进行文本识别
# print(f"正在进行 {key} 区域的文本识别")
# 指定矩形区域的左上角和右下角坐标
top_left, bottom_right = value[0], value[2] # (x, y)
cut_image = img_warped[int(top_left[1]):int(bottom_right[1]),
int(top_left[0]):int(bottom_right[0])]
result = paddle_ocr_image_rec_only(cut_image)
最终结果为:
{'工厂': '上海海仑电器有限公司制造', '型号': 'FLHBG3', '出厂日期': '2年月', '功率': 'L/、KW', '设备名称': '全自动恒压变频控制柜'}
比对阿里云上的自定义KV模版的操作步骤
OCR文档自学习_人工智能-阿里云 (aliyun.com)
-
上传模块图片(没什么好说的)
-
框选参照字段: 这一步相当于我上面步骤中标注k_*的区域
-
配置识别字段: 这一步相当于我上面步骤中标注v_*的区域
-
模版测试与发布: 对上面配置好的模版进行测试
总结
优点:成本低、效率高,只需预处理一张图片就能很好的进行信息的抽取
缺点:实际生产中需要识别的铭牌类型可能不止一种,而这种方式只能对某一固定模版的铭牌图片有效。对于出现的新的铭牌类型需要重新定制模版。且实际生产中不会提前告知你某一待检测图片是哪种类型,所以在最开始就需要判定该图片类型(或者说选定某个模版)
本方案的优缺点在阿里云的方案介绍中就已经可以看出了。
一张图片就能很好的进行信息的抽取
缺点:实际生产中需要识别的铭牌类型可能不止一种,而这种方式只能对某一固定模版的铭牌图片有效。对于出现的新的铭牌类型需要重新定制模版。且实际生产中不会提前告知你某一待检测图片是哪种类型,所以在最开始就需要判定该图片类型(或者说选定某个模版)
本方案的优缺点在阿里云的方案介绍中就已经可以看出了。