0 项目背景

在本项目中,我们尝试基于Paddle工具库实现一个OCR垂类场景。原始数据集是一系列电度表的照片,类型较多,需要完成电表的读数识别,对于有编号的电表,还要完成其编号的识别。

1 数据集分析

注:因保密授权原因,数据集尚未公开,待更新

首先,我们来简单看一下数据集的情况。总的来说,这个场景面临几个比较大的问题:

  • 电表类型较多。相比之下,现有数据量(500张)可能不够。
  • 照片角度倾斜较厉害。这个比较好理解,有些电表可能不具备正面拍照条件,有不少图片是从下往上、甚至从左下往右上拍的。
  • 反光严重。反光问题对定位目标框以及识别数字可能都会产生影响。
  • 表号是点阵数字,不易识别。这个问题是标注的时候发现的,有的标注,PPOCRLabel自动识别的四点检测定位其实已经非常准了,但里面的数字识别效果却很离谱。
  • 对检测框精准度要求非常高。电表显示读数的地方附近一般不是空白,往往有单位、字符或是小数点上的读数等,如果检测框没框准,就会把其它可识别项纳进来,如果也是数字,就算加了后处理也处理不掉。

下面,读者可以通过这几张典型图片,初步感受下数据集的基本情况。

!unzip -O GB2312  data/data117381/M2021.zip
import math
import numpy as np
import os
from collections import defaultdict
import cv2
import matplotlib.pyplot as plt
import json
from PIL import Image
%matplotlib inline
im = Image.open('M2021/10中继站混合.jpg')
im = im.resize((600, 400),Image.ANTIALIAS)
im



2 开发思路

鉴于上面提到的这些问题,该场景的开发几乎是从数据标注就开始陷入纠结。比如是标注一次(PPOCRLabel)还是标注两次(Labelimg标检测框+PPOCRLabel识别finetune)?比如是全程用PPOCR还是PPDET+PPOCR

最后发现,标注似乎可以一次到位,但是开发路线,可以两条都试试看,尤其是根据数据集的实际情况,也可以引入PaddleDetection的旋转目标检测模型。

整体思路如下:

在本文中,首先介绍第一条路线前半部分的实现,即:

PPOCRLabel半自动标注数据集——>四点标注转为标准的目标检测VOC格式——>使用PaddleDetection的目标检测模型训练文字框检测模型——>直接使用PaddleOCR自带的服务端模型检测框内电表读数/编号——>串接部署模型。

注:因为先研究的主要是检测部分的调优,这里还没有训练PPOCR文字识别模块的finetune模型,直接先使用了PaddleOCR服务端部署模型进行数字识别。从图上也可以看出,这个模型是三条路线都会通用的,后续会继续更新。

3 环境准备

需要注意的是,本文处理使用PaddleOCR和PaddleDetecion,其实在划分VOC数据集的时候还用到了PaddleX,所以要装的环境还比较多。

  • PaddleOCR 2.3.0
    • pip安装
  • PaddleX 1.3.0
    • pip安装
  • PaddleDetection 2.3
    • git拉取
# 安装PaddleOCR
!pip install paddleocr --no-deps -r requirements.txt
# 安装PaddleX
!pip install paddlex==1.3

4 准备VOC数据集

将PPOCRLabel标注的OCR数据集格式转化为目标检测的VOC数据格式,关于四点标注如何转换为目标检测数据集格式,请参见关联项目:从OCR到(旋转)目标检测:四点标注转VOC和roLabelImg

注:原标注文件中有个别地方标注错误,比如有电表多余的文字框未处理完,需要手动删除。另外原数据集中,有的文件名是有空格的,这个在与VOC对照txt文件的分隔符会有冲突,导致读取数据的时候报错,也需要手动删除文件名和标注文件的空格。

os.makedirs('./Annotations', exist_ok=True)
print('建立Annotations目录', 3)
  
mem = defaultdict(list)

with open('./M2021/Label.txt','r',encoding='utf8')as fp:
    s = [i[:-1].split('\t') for i in fp.readlines()]
    for i in enumerate(s):
        path = i[1][0]
        print(path)
        anno = json.loads(i[1][1])
        filename = i[1][0][6:-4]
        img = cv2.imread(path)
        height, width = img.shape[:-1]  
        for j in range(len(anno)): 
            if len(anno[j-1]['transcription']) > 8:
                label = 'No.'
            else:
                label = 'indicator'
            x1 = min(int(anno[j-1]['points'][0][0]),int(anno[j-1]['points'][1][0]),int(anno[j-1]['points'][2][0]),int(anno[j-1]['points'][3][0]))
            x2 = max(int(anno[j-1]['points'][0][0]),int(anno[j-1]['points'][1][0]),int(anno[j-1]['points'][2][0]),int(anno[j-1]['points'][3][0]))
            y1 = min(int(anno[j-1]['points'][0][1]),int(anno[j-1]['points'][1][1]),int(anno[j-1]['points'][2][1]),int(anno[j-1]['points'][3][1]))
            y2 = max(int(anno[j-1]['points'][0][1]),int(anno[j-1]['points'][1][1]),int(anno[j-1]['points'][2][1]),int(anno[j-1]['points'][3][1]))
            mem[filename].append([label, x1, y1, x2, y2])
 

 
            with open(os.path.join('./Annotations', filename.rstrip('.jpg')) + '.xml', 'w') as f:
                f.write(f"""<annotation>
            <folder>JPEGImages</folder>
            <filename>{filename}.jpg</filename>
            <size>
                <width>{width}</width>
                <height>{height}</height>
                <depth>3</depth>
            </size>
            <segmented>0</segmented>\n""")
                for label, x1, y1, x2, y2 in mem[filename]:
                    f.write(f"""    <object>
                <name>{label}</name>
                <pose>Unspecified</pose>
                <truncated>0</truncated>
                <difficult>0</difficult>
                <bndbox>
                    <xmin>{x1}</xmin>
                    <ymin>{y1}</ymin>
                    <xmax>{x2}</xmax>
                    <ymax>{y2}</ymax>
                </bndbox>
            </object>\n""")
                f.write("</annotation>")
# 将处理好的数据集和标注文件统一移动到DataSet目录下
!mkdir Dataset
!mkdir Dataset/JPEGImages
!cp ./M2021/*.jpg Dataset/JPEGImages/
!mv ./Annotations Dataset/
# 使用PaddleX切分数据集
!paddlex --split_dataset --format VOC --dataset_dir Dataset --val_value 0.2 --test_value 0.1
Dataset Split Done.[0m
[0mTrain samples: 303[0m
[0mEval samples: 86[0m
[0mTest samples: 43[0m
[0mSplit files saved in Dataset[0m
[0m[0m

5 训练目标文字框检测模型

本文先使用最基本的yolov3_darknet53模型,对应的配置文件在configs/yolov3/yolov3_darknet53_270e_voc.yml,这里面除了调整优化参数外,最需要注意的是配置VOC数据集路径,对configs/datasets/voc.yml覆盖如下修改即可:

metric: VOC
map_type: 11point
num_classes: 2

TrainDataset:
  !VOCDataSet
    dataset_dir: /home/aistudio/Dataset
    anno_path: train_list.txt
    label_list: labels.txt
    data_fields: ['image', 'gt_bbox', 'gt_class', 'difficult']

EvalDataset:
  !VOCDataSet
    dataset_dir: /home/aistudio/Dataset
    anno_path: val_list.txt
    label_list: labels.txt
    data_fields: ['image', 'gt_bbox', 'gt_class', 'difficult']

TestDataset:
  !ImageFolder
    anno_path: /home/aistudio/Dataset/labels.txt

configs/yolov3/_base_/optimizer_270e.yml的设置,供参考

epoch: 90

LearningRate:
  base_lr: 0.001
  schedulers:
  - !PiecewiseDecay
    gamma: 0.1
    milestones:
    - 70
    - 80
  - !LinearWarmup
    start_factor: 0.
    steps: 400

OptimizerBuilder:
  optimizer:
    momentum: 0.9
    type: Momentum
  regularizer:
    factor: 0.0005
    type: L2

# 拉取模型库
!git clone https://gitee.com/paddlepaddle/PaddleDetection.git
%cd PaddleDetection/
/home/aistudio/PaddleDetection
!pip install -r requirements.txt
!export CUDA_VISIBLE_DEVICES=0
!python tools/train.py -c configs/yolov3/yolov3_darknet53_270e_voc.yml --use_vdl=True --vdl_log_dir=./output --eval

在60个epoch后,验证集的准确率达到90%以上,这时,可以停下来看下初步的效果。




由于前面用PaddleX划分数据集的时候一并划分了测试集,这时将configs/datasets/voc.yml中的val_list.txt替换成test_list.txt可以马上看到在测试集上的模型性能,mAP也达到了88%左右,因此可以确定模型过拟合程度还算可控,这时可以准备串接数字识别模型了。

!python tools/eval.py -c configs/yolov3/yolov3_darknet53_270e_voc.yml -o weights=output/yolov3_darknet53_270e_voc/59.pdparams
/opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages/paddle/tensor/creation.py:125: DeprecationWarning: `np.object` is a deprecated alias for the builtin `object`. To silence this warning, use `object` by itself. Doing this will not modify any behavior and is safe. 
Deprecated in NumPy 1.20; for more details and guidance: https://numpy.org/devdocs/release/1.20.0-notes.html#deprecations
  if data.dtype == np.object:
Warning: import ppdet from source directory without installing, run 'python setup.py install' to install ppdet firstly
W0110 00:12:25.506500  4809 device_context.cc:404] Please NOTE: device: 0, GPU Compute Capability: 7.0, Driver API Version: 10.1, Runtime API Version: 10.1
W0110 00:12:25.511188  4809 device_context.cc:422] device: 0, cuDNN Version: 7.6.
[01/10 00:12:30] ppdet.utils.checkpoint INFO: Finish loading model weights: output/yolov3_darknet53_270e_voc/59.pdparams
[01/10 00:12:30] ppdet.engine INFO: Eval iter: 0
[01/10 00:12:41] ppdet.metrics.metrics INFO: Accumulating evaluatation results...
[01/10 00:12:41] ppdet.metrics.metrics INFO: mAP(0.50, 11point) = 87.99%
[01/10 00:12:41] ppdet.engine INFO: Total sample number: 56, averge FPS: 4.952572054833387

如果还不放心,可以先看看对测试集上目标文字框的预测结果,注意,这里有个事项就是一定要把draw_threshold调得比较低,如果是默认的0.5,会漏掉很多的框。

!python tools/infer.py -c configs/yolov3/yolov3_darknet53_270e_voc.yml --infer_img=../Dataset/JPEGImages/牵引变通信2.jpg -o weights=output/yolov3_darknet53_270e_voc/59.pdparams  --draw_threshold=0.1

如果我们觉得模型达到了预期,那么就可以先导出模型,准备Python部署并串接PPOCR的文字识别模型。

!python tools/export_model.py -c configs/yolov3/yolov3_darknet53_270e_voc.yml --output_dir=./inference_model -o weights=output/yolov3_darknet53_270e_voc/59 --export_serving_model=True

6 串接文字识别模型

这里先采用一种相对简单的办法,直接对PaddleDetection的Python部署代码进行改造,因为还没有到正式部署阶段,这里直接通过将最终输出结果画在待预测图片上,表示整个流程的跑通,因此,改造的重点文件是deploy/python/visualize.py

用到的关键代码如下

# 对目标检测区域进行ocr识别,并返回识别结果
def ocr_box(im, bbox, output_dir='output/'):
    xmin, ymin, xmax, ymax = bbox
    # 把检测框裁剪下来,并保存——这点很重要,因为ocr的whl只能去预测图片文件
    ocr_crop = im.crop((xmin, ymin, xmax, ymax))
    crop_path = os.path.join(output_dir, 'crop.jpg')
    ocr_crop.save(crop_path, quality=95)
    # 中文检测模型
    # ocr = PaddleOCR(det_model_dir='https://paddleocr.bj.bcebos.com/dygraph_v2.0/ch/ch_ppocr_server_v2.0_det_infer.tar', rec_model_dir='https://paddleocr.bj.bcebos.com/dygraph_v2.0/ch/ch_ppocr_server_v2.0_rec_infer.tar', cls_model_dir='https://paddleocr.bj.bcebos.com/dygraph_v2.0/ch/ch_ppocr_mobile_v2.0_cls_infer.tar')
    # 英文检测模型
    ocr = PaddleOCR(lang='en')
    ocr_res = ocr.ocr(crop_path, cls=True)
    for line in ocr_res:
        print(line)
    txts = [line[1][0] for line in ocr_res]
    print(txts)
    return txts
def draw_box(im, np_boxes, labels, threshold=0.1):
    draw_thickness = min(im.size) // 320
    draw = ImageDraw.Draw(im)
    clsid2color = {}
    color_list = get_color_map_list(len(labels))
    expect_boxes = (np_boxes[:, 1] > threshold) & (np_boxes[:, 0] > -1)
    np_boxes = np_boxes[expect_boxes, :]

    for dt in np_boxes:
        clsid, bbox, score = int(dt[0]), dt[2:], dt[1]
        if clsid not in clsid2color:
            clsid2color[clsid] = color_list[clsid]
        color = tuple(clsid2color[clsid])

        if len(bbox) == 4:
            xmin, ymin, xmax, ymax = bbox
            print('class_id:{:d}, confidence:{:.4f}, left_top:[{:.2f},{:.2f}],'
                  'right_bottom:[{:.2f},{:.2f}]'.format(
                      int(clsid), score, xmin, ymin, xmax, ymax))
            # draw bbox
            draw.line(
                [(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin),
                 (xmin, ymin)],
                width=draw_thickness,
                fill=color)
            # 对检测框进行ocr识别
            ocr_box(im, bbox)
        # 因为暂时没有四点标注的情况,下面这个分支不会被用到
        elif len(bbox) == 8:
            x1, y1, x2, y2, x3, y3, x4, y4 = bbox
            draw.line(
                [(x1, y1), (x2, y2), (x3, y3), (x4, y4), (x1, y1)],
                width=2,
                fill=color)
            xmin = min(x1, x2, x3, x4)
            ymin = min(y1, y2, y3, y4)

        # draw label
        # 画标签的时候,将ocr识别结果嵌进去
        ocr_res = ocr_box(im, bbox)
        text = "{}:{} {:.4f}".format(labels[clsid], ocr_res, score)
        tw, th = draw.textsize(text)
        draw.rectangle(
            [(xmin + 1, ymin - th), (xmin + tw + 1, ymin)], fill=color)
        draw.text((xmin + 1, ymin - th), text, fill=(255, 255, 255))
    return im

实际测试结果初步表明,目前基于英文数据集上训练的识别模型效果要好于基于中文数据集训练的模型,尽管中文的模型用的网络结构是PPOCR-V2。不过想想这也很正常,因为电表读数和编号,都是阿拉伯数字和个别英文字母,与中文OCR模型训练时使用的数据集差异还是非常大的。

!python deploy/python/infer.py --threshold=0.1 --model_dir=./inference_model/yolov3_darknet53_270e_voc --image_file=../Dataset/JPEGImages/台安站给水所3.jpg --device=GPU



抽取几张测试图片,我们会发现,尽管目标框框得还算比较准,但是具体在数字识别的时候,有的电表读得出读数编号却有些错乱、有的电表编号读得精准但是因为图片歪斜,读数却读不出来。

因此,后续我们将围绕这些问题进行改进。

7 小结

本文初步实现了一个基于PaddleOCR+PaddleDetection的电表读数和编号识别模型,但是该模型在识别准确率上还需进一步提高,接下来,将围绕以下几个方面进行改进:

  • 继续跑通设计思路的第二、三条路径,即
    • 直接微调PaddleOCR的文本检测模型
    • 训练旋转目标检测模型
  • 微调PaddleOCR的文字识别模型,目前,基本确定要用英文识别模型训练
  • 思考文本框检测和文字识别检测的前后处理实现
Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐