- 作者:
- 分类:知识&开发->AI->edge_AI
- 阅读:311
- 点赞:0
- 版权:CC BY-SA 4.0
- 创建:2024-09-18
- 更新:2024-09-25
原文链接(持续更新):https://neucrack.com/p/550
源码: https://github.com/PaddlePaddle/PaddleOCR/
文档: https://paddlepaddle.github.io/PaddleOCR/ppocr/overview.html
PP-OCR是一个两阶段的OCR系统,先检测文本的坐标,类似 Yolov8-obb 一样输出文本的四个角,这样方便仿射变换成标准的文本排版,再识别文本,其中文本检测算法选用DB,文本识别算法选用CRNN,并在检测和识别模块之间添加文本方向分类器,以应对不同方向的文本识别(就是识别文本是正着(0度)还是倒着(180度), 即二分类模型,目前不支持垂直(90度)识别(v4)),文本分类器是可选的。
测试运行
# CPU:
# pip install paddlepaddle
# GPU:
# pip install paddlepaddle-gpu
# pip install paddleocr
# Cli:
# paddleocr --image_dir ./test_images --use_angle_cls false --use_gpu false --ocr_version PP-OCRv4 --lang=ch
# Python script:
from PaddleOCR.paddleocr import PaddleOCR, draw_ocr
import sys
img_path = sys.argv[1]
# Paddleocr目前支持的多语言语种可以通过修改lang参数进行切换
# 例如`ch`, `en`, `fr`, `german`, `korean`, `japan`
ocr = PaddleOCR(use_angle_cls=False, lang="ch", ocr_version="PP-OCRv3") # need to run only once to download and load model into memory
result = ocr.ocr(img_path, cls=False)
for idx in range(len(result)):
res = result[idx]
for line in res:
print(line)
# 显示结果
from PIL import Image
result = result[0]
image = Image.open(img_path).convert('RGB')
boxes = [line[0] for line in result]
txts = [line[1][0] for line in result]
scores = [line[1][1] for line in result]
im_show = draw_ocr(image, boxes, txts, scores, font_path='./fonts/simfang.ttf')
im_show = Image.fromarray(im_show)
im_show.save('result.jpg')
检测
使用 DB 算法,可以选用不同 backbone,官方默认是 mobilenetv3,可以选 resnet50-vd resnet18-vd 等。
在下载预训练模型时可以看到对应的模型文件:
在配置文件中还可以看到预处理是bgr
图形输入, ((x/255) - mean) / std 中的值,所以在量化时要十分注意预处理值。
如果要转换成(x- mean)*scale
的形式:
# mean: mean * 255
# scale: 1/(std*255)
# 举例:
# scale 1/255.0
# "mean": [0.485, 0.456, 0.406],
# "std": [0.229, 0.224, 0.225],
# 最终:
# mean: 123.675, 116.28, 103.53
# scale: 0.01712475, 0.017507, 0.01742919
后处理:
可以看到输出的tensor形状为(1,1,input_h, input_w)
,是一个 mask 图,每个float 值代表了对应像素的有数字的概率,通过设置一个 threshold, 就能得到一张二值化图。
(上面这张图来自v4 onnx infer 640x640)
(上面这张图来自v4 maixcam 量化后320x224画到原图的样子,与onnx有略微不同)
然后根据这个图来得到文字框,需要用到几个参数,在配置文件里面有,比如
PostProcess:
name: DistillationDBPostProcess
model_name:
- Student
key: head_out
thresh: 0.3
box_thresh: 0.6
max_candidates: 1000
unclip_ratio: 1.5
然后根据db_postprocess.py
(具体根据训练模型使用到的算法选择后处理的文件中的后处理类的__call__
方法看到做了什么操作,这里主要看boxes_from_bitmap
这个函数解析成文字的最小包围矩形:
def boxes_from_bitmap(self, pred, _bitmap, dest_width, dest_height):
"""
_bitmap: single map with shape (1, H, W),
whose values are binarized as {0, 1}
"""
bitmap = _bitmap
height, width = bitmap.shape
outs = cv2.findContours(
(bitmap * 255).astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE
)
if len(outs) == 3:
img, contours, _ = outs[0], outs[1], outs[2]
elif len(outs) == 2:
contours, _ = outs[0], outs[1]
num_contours = min(len(contours), self.max_candidates)
boxes = []
scores = []
for index in range(num_contours):
contour = contours[index]
points, sside = self.get_mini_boxes(contour)
if sside < self.min_size:
continue
points = np.array(points)
if self.score_mode == "fast":
score = self.box_score_fast(pred, points.reshape(-1, 2))
else:
score = self.box_score_slow(pred, contour)
if self.box_thresh > score:
continue
box = self.unclip(points, self.unclip_ratio)
if len(box) > 1:
continue
box = np.array(box).reshape(-1, 1, 2)
box, sside = self.get_mini_boxes(box)
if sside < self.min_size + 2:
continue
box = np.array(box)
box[:, 0] = np.clip(np.round(box[:, 0] / width * dest_width), 0, dest_width)
box[:, 1] = np.clip(
np.round(box[:, 1] / height * dest_height), 0, dest_height
)
boxes.append(box.astype("int32"))
scores.append(score)
return np.array(boxes, dtype="int32"), scores
可以看到boxes_from_bitmap
函数中,先用cv2.findContours((bitmap * 255).astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
从二值化后的图中找轮廓,然后取不大于max_candidates的轮廓进行处理。
然后获取每个轮廓的最小包围矩形,如果矩形最短边小于min_size
就忽略(这里min_size
写死了为 3 像素)。
然后计算框的置信度,有快速和慢速两种方法,默认用 fast 即可,即用前面找出来的最小包围矩形区域内所有 score 的均值作为最终置信度,慢速就是用轮廓所有像素点概率的均值,快速相比慢速可能多了少数小于阈值的像素,如下图黄色和红色中间的置信度小于阈值的区域会被快速方式计算进去,所以最终快速方式计算的会比慢速计算的置信度小,不过用c写的话直接实现慢速的方式就行,效率和快速一样的,这里用了快速慢速应该只是python写处理比较要调用 numpy 有限制才不得不这样:
然后计算出box的置信度后,需要和 box_thresh
比较,小于这个阈值就忽略。
然后需要扩展box,从前面的mask也可以看到 box比文字区域小,训练时用到了一个 unclip_ratio
来扩展,这里也需要使用同样的方法进行还原:
def unclip(self, box, unclip_ratio):
poly = Polygon(box)
distance = poly.area * unclip_ratio / poly.length
offset = pyclipper.PyclipperOffset()
offset.AddPath(box, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
expanded = offset.Execute(distance)
return expanded
这里distance = poly.area * unclip_ratio / poly.length
通过面积除以周长再乘以unclip_ratio
比例得到一个扩展距离值,这里调用了pyclipper
库进行扩展,使用了JT_ROUND
所以会返回多个点是一个圆角矩形轮廓。然后再对这个轮廓求最小包围矩形,同样如果太小也忽略,就得到了最终的目标框。
官方源码里面有各种语言的后处理实现,需要注意需要C++20,看起来官方的源码还是有可以优化速度的地方的,直接移植过来用就行,有极致的速度需求可以自己实现优化一下。
识别
同样在模型库 下载预训练模型
检测出来的文字图像大小不一,首先进行预处理:
- 把box从上到下,从左到右排序
- 从图中裁切出文字图,然后变换成标准的矩形图(这里还判断了如果高度大于宽度的1.5倍,则旋转了90度)。这里裁出文字标准图:
- 得到原图中的四个点坐标
- 计算出不规则矩形的上边长和下边长,去最长的最为标准图的宽度;同理得到标准图的高度。官方的c代码中则直接取了上边和左边长作为标准图宽高,后者效率更高,实际取两边最长还是取其中一边都没法完全保证图像不会被拉伸,所以取一边就行了。
- 将原图的四个点变换到标准图上,对图像进行拉神变换,得到标准图。这里官方的代码是先裁切出图再变换,实际上opencv cv::warpPerspective 可以直接一步到位
- 如果高度大于宽度1.5被,旋转90度
- 如果要用文字方向判断模型,就在这里执行,然后根据情况旋转图像。
- 可以在 config/rec 下看到默认用的 SVTR_LCNet 识别算法, 后处理用的 CTCLabelDecode , rec_char_dict_path 为 ocr/PaddleOCR/ppocr/utils/ppocr_keys_v1.txt
- 讲图像预处理为 320x48(默认,也可以修改) 的横图,先让图像保持高度为 48, 宽高比例不变情况下缩放,得到的宽度大于等于 320, 如果小于320则把图像放320x48的左边,右边留空(黑色),然后对值预处理,归一后标准化, mean 0.5, std0.5, 即
((x/255) - 0.5) / 0.5
,换成 mean 和 scale 的计算方法为(x - mean*255) * 1/(std*255)
即(x - 127.5) * 0.00784313725490196
。这里如果宽度>=320,则输入分辨率直接为图像的分辨率即w*48,对于量化模型需要固定宽高,尝试将图像保持比例缩放到宽度为320,这样320x48的图中下部分就填充黑色,实测效果也还可以,不过太长的图也会识别不准确,因为算法在大于320时是根据图片的宽度直接作为输入宽度的所以想量化一个固定长度比如480x48也不好用,比如现在图片是400x48的图,给320x48输入的模型还能识别出来,给480x48的模型则因为宽度不足(就算宽度刚刚好实测效果也不如320的模型),所以可以考虑都用320的模型,图片长于320则切片识别。
后处理:
- 对于 320x48 的输入,输出是一个 1x40x6625 的输出,看到前面训练用的
PaddleOCR/ppocr/utils/ppocr_keys_v1.txt
标签文件刚好是 6623 行,所以实际是有 6623 个符号,另外多的 2 个为空白区域以及代码中的use_space_char
即空格字符选项(默认为true), 注意6625个中下标为0表示空白区域,最后一个为空格字符,中间6623个为labels的字符。前一层为softmax,所以这里是每个符号的概率,40则为横向每个位置,宽度为320,即每个值代表了8个像素。 - 先找到每个位置(共40个位置)的最大概率的符号(下标和得分),接下来只需要对这个 40 个值进行处理。
- 首先需要对 40 个值进行去重,先标记相邻的两个预测的分类是否变化,记录一个list,即为true的位置就有新的字符出现。
- 因为分类下标为0代表空字符,所以去更新记录预测的分类下标为0的都记为没有新字符出现。
- 通过记录到新字符出现的位置,将所有字符从标签表中映射出来就是最终识别到的字了。去重找新出现的下标这几步在 c 中一个循环就可以做完了,不必像官方的代码用python的逻辑做(因为python写循环慢所以才用Numpy的api操作,直接写c就写循环就好了)。