转自AI Studio,原文链接:【PaddlePaddle+OpenVINO】电表检测识别模型的部署 - 飞桨AI Studio

0 背景:PaddleOCR的电表识别任务(主线之五)

我国电力行业发展迅速,电表作为测电设备经历了普通电表、预付费电表和智能电表三个阶段的发展,虽然智能电表具有通信功能,但一方面环境和设备使得智能电表具有不稳定性,另一方面非智能电表仍然无法实现自动采集,人工抄表有时往往不可取代。采集到的大量电表图片如果能够借助人工智能技术批量检测和识别,将会大幅提升效率和精度。

在本系列项目中,我们使用Paddle工具库实现一个OCR垂类场景。在前置项目中,我们已经能基本跑出一个“看起来还行”的电表读数和编号检测模型。但是,之前这些项目、PPOCRLabel中测试电表读数和编号识别效果,都是面对单张、静态的图片文件,那么,如果传入的视频流,电表识别效果会怎样呢?

尽管视频流也是一帧帧的图片,归根到底模型还是针对图片去预测的,但是在视频流中,每帧传入图片的角度可能非常多变,观察在视频流下模型的表现情况,对于模型部署的实用性、指导训练过程的进一步优化,还是有着重要意义的。

同时,我们知道模型部署设备繁多,比如这个电表识别任务,我们可以是手机部署,也可以在边缘端硬件部署。

恰逢AI Studio再次联合OpenVINO搞起了活动(详情请见:Demo征集第二弹 | 飞桨联合OpenVINO,有奖征集预训练模型加速Demo!),那么本文就来探索下在OpenVINO运行时环境下,电表识别模型的部署效果。

0.1 环境说明

因为OpenVINO运行时环境目前在AI Studio上还不能直接跑起来(对,就是这么尴尬),照着教程pip安装openvino后import openvino.runtime的时候会出现下面的报错:

---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
/tmp/ipykernel_440/176423038.py in <module>
----> 1 from openvino.runtime import Core,Dimension

ModuleNotFoundError: No module named 'openvino.runtime'

所以,本文会给出所有代码和模型的压缩包openvino-deploy.zip,具体实现读者可以在本地自行完成。当然,还有一种在线完成方案,就是用openvino官方示例notebook给出的运行环境链接,在里面把解压后的文件逐一传上去,最后运行在线的notebook,但是网速非常感人……

0.2 相关资料

OpenVINO部署实践参考资料:

前置系列项目:

(主线篇)

0.3 模型训练

该过程在下列前置项目中进行了详细的说明,为节省篇幅,此处不再赘述。

  • PPOCR:多类别电表读数识别
  • PPOCR:使用TextRender进行电表编号识别的finetune

1 环境准备

1.1 本地OpenVINO运行时环境安装

其实OpenVINO运行时环境安装非常简单,只需要pip安装就行。

pip install openvino==2022.1.0 -i https://pypi.tuna.tsinghua.edu.cn/simple

如果运行下面这段代码不报错,那么我们就能确定安装成功了。

In [2]

!python -c "from openvino.runtime import Core"

1.1 引入模型库

本文的实现是基于openvino_notebooks而来,所以如果是在本地开发,首先需要git clone示例项目:

In [3]

# 有github和gitee两个源,选最快的那个
# 如果是用openvino_notebooks提供的Binder链接环境则无须如此,打开上级文件会发现整个openvino_notebooks项目已经在里面了
!git clone https://gitee.com/openvinotoolkit-prc/openvino_notebooks.git
fatal: destination path 'openvino_notebooks' already exists and is not an empty directory.

In [4]

# 注意要切换到notebooks目录里
# %cd openvino_notebooks/notebooks/

In [5]

import sys
import cv2
import numpy as np
import paddle
import math
import time
import collections
from PIL import Image

from openvino.runtime import Core,Dimension
from IPython import display
import copy

sys.path.append("../utils")
import notebook_utils as utils
import pre_post_processing as processing
/srv/conda/envs/notebook/lib/python3.7/site-packages/paddle/vision/transforms/functional_pil.py:36: DeprecationWarning: NEAREST is deprecated and will be removed in Pillow 10 (2023-07-01). Use Resampling.NEAREST or Dither.NONE instead.
  'nearest': Image.NEAREST,
/srv/conda/envs/notebook/lib/python3.7/site-packages/paddle/vision/transforms/functional_pil.py:37: DeprecationWarning: BILINEAR is deprecated and will be removed in Pillow 10 (2023-07-01). Use Resampling.BILINEAR instead.
  'bilinear': Image.BILINEAR,
/srv/conda/envs/notebook/lib/python3.7/site-packages/paddle/vision/transforms/functional_pil.py:38: DeprecationWarning: BICUBIC is deprecated and will be removed in Pillow 10 (2023-07-01). Use Resampling.BICUBIC instead.
  'bicubic': Image.BICUBIC,
/srv/conda/envs/notebook/lib/python3.7/site-packages/paddle/vision/transforms/functional_pil.py:39: DeprecationWarning: BOX is deprecated and will be removed in Pillow 10 (2023-07-01). Use Resampling.BOX instead.
  'box': Image.BOX,
/srv/conda/envs/notebook/lib/python3.7/site-packages/paddle/vision/transforms/functional_pil.py:40: DeprecationWarning: LANCZOS is deprecated and will be removed in Pillow 10 (2023-07-01). Use Resampling.LANCZOS instead.
  'lanczos': Image.LANCZOS,
/srv/conda/envs/notebook/lib/python3.7/site-packages/paddle/vision/transforms/functional_pil.py:41: DeprecationWarning: HAMMING is deprecated and will be removed in Pillow 10 (2023-07-01). Use Resampling.HAMMING instead.
  'hamming': Image.HAMMING

1.2 加载PaddleOCR电表检测识别模型

PaddleOCR的模型包括了检测模型和识别模型,这两个模型都要加载进来。

使用OpenVINO运行时进行推理部署的时候,它首先会生成一个推理引擎(Inference Engine),然后需要将网络结构文件.pdmoel和权重文件.pdiparams加载到intel CPU中。

本文用的电表检测和识别模型在前置项目数据标注懒人包:PPOCRLabel极速增强版——以电表识别为例(二)中已经放出。

1.2.1 加载电表读数、编号检测模型

In [6]

# 指定检测模型所在路径
det_model_dir = "./model/det_finetune"
det_model_file_path = det_model_dir + "/inference.pdmodel"
det_params_file_path = det_model_dir + "/inference.pdiparams"

# 初始化检测模型的Inference Engine
det_ie = Core()
det_net = det_ie.read_model(model=det_model_file_path, weights=det_params_file_path)
det_compiled_model = det_ie.compile_model(model=det_net, device_name="CPU")

# 获取检测模型的输入输出节点
det_input_layer = next(iter(det_compiled_model.inputs))
det_output_layer = next(iter(det_compiled_model.outputs))

1.2.2 加载电表识别模型

对于识别模型,由于遇到的文本输入可能是不定长的,因此要对输入的shape进行一个变长操作,具体内容在基于Paddle和OpenVINO的实践的后半部分课程有详细说明。

In [7]

# 指定识别模型所在路径
rec_model_dir = "./model/rec_finetune"
rec_model_file_path = rec_model_dir + "/inference.pdmodel"
rec_params_file_path = rec_model_dir + "/inference.pdiparams"

In [8]

# 在CPU上初始化识别模型的Inference Engine
rec_ie = Core()
# 读取网络结构和权重文件
rec_net = rec_ie.read_model(model=rec_model_file_path, weights=rec_params_file_path)

# 在最后一层对输入文本的shape做一个变长操作
for input_layer in rec_net.inputs:
    input_shape = input_layer.partial_shape
    input_shape[3] = Dimension(-1)
    rec_net.reshape({input_layer: input_shape})

rec_compiled_model = rec_ie.compile_model(model=rec_net, device_name="CPU")

# 获取检测模型的输入输出节点
rec_input_layer = next(iter(rec_compiled_model.inputs))
rec_output_layer = next(iter(rec_compiled_model.outputs))

1.3 定义前处理函数

In [9]

# 图片前处理
def image_preprocess(input_image, size):
    img = cv2.resize(input_image, (size,size))
    img = np.transpose(img, [2,0,1]) / 255
    img = np.expand_dims(img, 0)
    # NormalizeImage: {mean: [0.485, 0.456, 0.406], std: [0.229, 0.224, 0.225], is_scale: True}
    img_mean = np.array([0.485, 0.456,0.406]).reshape((3,1,1))
    img_std = np.array([0.229, 0.224, 0.225]).reshape((3,1,1))
    img -= img_mean
    img /= img_std
    return img.astype(np.float32)

In [10]

# 数字识别前处理
def resize_norm_img(img, max_wh_ratio):
    rec_image_shape = [3, 32, 320]
    imgC, imgH, imgW = rec_image_shape
    assert imgC == img.shape[2]
    character_type = "ch"
    if character_type == "ch":
        imgW = int((32 * max_wh_ratio))
    h, w = img.shape[:2]
    ratio = w / float(h)
    if math.ceil(imgH * ratio) > imgW:
        resized_w = imgW
    else:
        resized_w = int(math.ceil(imgH * ratio))
    resized_image = cv2.resize(img, (resized_w, imgH))
    resized_image = resized_image.astype('float32')
    resized_image = resized_image.transpose((2, 0, 1)) / 255
    resized_image -= 0.5
    resized_image /= 0.5
    padding_im = np.zeros((imgC, imgH, imgW), dtype=np.float32)
    padding_im[:, :, 0:resized_w] = resized_image
    return padding_im

2 视频流的传入与实时预测代码

这里主要实现了两种视频传入方式:

  1. 调用设备自带或外接的摄像头
  2. 直接调用一个视频文件

具体实现有下面几个步骤:

  1. 创建一个视频流播放器,按指定的fps指标播放视频
  2. 对视频流进行抽帧,送入检测模型和识别模型
  3. 调用推理引擎进行电表读数和编号的检测与识别
  4. 可视化识别结果。

In [19]

# 这里source指的就是输入的视频流接口,比如通常笔记本自带的摄像头,可以指定为0
def run_paddle_ocr(source=0, flip=False, use_popup=False, skip_first_frames=0):
    # 创建一个 video player
    player = None
    try:
        player = utils.VideoPlayer(source=source, flip=flip, fps=30, skip_first_frames=skip_first_frames)
        # 开始捕获视频
        player.start()
        if use_popup:
            # 设置退出按键
            title = "Press ESC to Exit"
            cv2.namedWindow(winname=title, flags=cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_AUTOSIZE)

        processing_times = collections.deque()
        det_request = det_compiled_model.create_infer_request()
        while True:
            # 如果有视频输入,开始抽帧
            frame = player.next()
            if frame is None:
                print("Source ended")
                break
            # 对抽帧的图片进行缩放,防止出现传入图片分辨率过大的情况
            scale = 2560 / max(frame.shape)
            if scale < 1:
                frame = cv2.resize(src=frame, dsize=None, fx=scale, fy=scale,
                                   interpolation=cv2.INTER_AREA)
            # 图片送入检测模型前,要做个前处理
            # 注意:这里有第一个关键要点!resize的时候可以不要用默认的640。
            # 因为电表图片训练的时候,为了获得更好的效果,特地将图片都resize到了1600*1600,部署时也不要差太多,否则效果不好。
            test_image = image_preprocess(frame,1280)
                
            # 统计前处理时间
            start_time = time.time()
            # 执行预测
            det_request.infer(inputs={det_input_layer.any_name: test_image})
            det_results = det_request.get_tensor(det_output_layer).data
            stop_time = time.time()

            # 检测模型后处理
            ori_im = frame.copy()
            data = {'image': frame}
            data_resize = processing.DetResizeForTest(data)
            data_list = []
            keep_keys = ['image', 'shape']
            for key in keep_keys:
                data_list.append(data_resize[key])
            img, shape_list = data_list

            shape_list = np.expand_dims(shape_list, axis=0) 
            pred = det_results[0]    
            if isinstance(pred, paddle.Tensor):
                pred = pred.numpy()
            # DB模块实际上是对像素点进行预测,因此这里有个像素点的阈值,判定该像素点是否属于我们需要的电表读数/编号
            # 注意:这里是第二个关键要点!
            # 如果我们不希望把电表图片的无关数字纳进来,这个阈值可能需要设置得高点。
            # 但是如果设置太高,又会导致DB框出现“断片”!需要根据实际情况进行调整。
            segmentation = pred > 0.4

            boxes_batch = []
            # 通过调用OpenCV的外接矩形,把识别出的像素点转化成DB框
            for batch_index in range(pred.shape[0]):
                src_h, src_w, ratio_h, ratio_w = shape_list[batch_index]
                mask = segmentation[batch_index]
                boxes, scores = processing.boxes_from_bitmap(pred[batch_index], mask,src_w, src_h)
                boxes_batch.append({'points': boxes})
            post_result = boxes_batch
            dt_boxes = post_result[0]['points']
            dt_boxes = processing.filter_tag_det_res(dt_boxes, ori_im.shape)

            processing_times.append(stop_time - start_time)
            # 取前200帧的前处理时间
            if len(processing_times) > 200:
                processing_times.popleft()
            processing_time_det = np.mean(processing_times) * 1000

            # 识别模型前处理
            dt_boxes = processing.sorted_boxes(dt_boxes)
            img_crop_list = []   
            if dt_boxes != []:
                for bno in range(len(dt_boxes)):
                    tmp_box = copy.deepcopy(dt_boxes[bno])
                    img_crop = processing.get_rotate_crop_image(ori_im, tmp_box)
                    img_crop_list.append(img_crop)

                # 开始识别,传入文本长度
                img_num = len(img_crop_list)
                # 计算文本的高和宽
                width_list = []
                for img in img_crop_list:
                    width_list.append(img.shape[1] / float(img.shape[0]))
                # 注意:这里是第三个关键要点!
                # 抽帧的时候,可以是多个batch拼接的,将像素点预测结果做一个排序,取置信度最大的框
                indices = np.argsort(np.array(width_list))
                rec_res = [['', 0.0]] * img_num
                batch_num = 4
                
                # 对于每一个检测到的DB框,都进运行识别任务
                for beg_img_no in range(0, img_num, batch_num):
                    end_img_no = min(img_num, beg_img_no + batch_num)

                    norm_img_batch = []
                    max_wh_ratio = 0
                    for ino in range(beg_img_no, end_img_no):
                        h, w = img_crop_list[indices[ino]].shape[0:2]
                        wh_ratio = w * 1.0 / h
                        max_wh_ratio = max(max_wh_ratio, wh_ratio)
                    for ino in range(beg_img_no, end_img_no):
                        norm_img = resize_norm_img(img_crop_list[indices[ino]],max_wh_ratio)
                        norm_img = norm_img[np.newaxis, :]
                        norm_img_batch.append(norm_img)

                    norm_img_batch = np.concatenate(norm_img_batch)
                    norm_img_batch = norm_img_batch.copy()

                    # 启动识别模型 
                    rec_request = rec_compiled_model.create_infer_request()
                    rec_request.infer(inputs={rec_input_layer.any_name: norm_img_batch})
                    rec_results = rec_request.get_tensor(rec_output_layer).data

                    # 识别结果后处理
                    postprocess_op = processing.build_post_process(processing.postprocess_params)
                    rec_result = postprocess_op(rec_results)
                    for rno in range(len(rec_result)):
                        rec_res[indices[beg_img_no + rno]] = rec_result[rno]

                # 识别结果包括两个部分:
                # 识别结果,置信度得分,这些都要取出来                  
                if rec_res != []:
                    image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
                    boxes = dt_boxes
                    txts = [rec_res[i][0] for i in range(len(rec_res))] 
                    scores = [rec_res[i][1] for i in range(len(rec_res))] 

                    # 在输出的预测图片上,画出识别结果
                    # 注意:这里是第四个关键要点!
                    # 如果检测到的数字置信度有点低,是否丢弃
                    draw_img = processing.draw_ocr_box_txt(
                        image,
                        boxes,
                        txts,
                        scores,
                        drop_score=0.5)

                    # 画出识别结果
                    _, f_width = draw_img.shape[:2]
                    fps = 1000 / processing_time_det
                    cv2.putText(img=draw_img, text=f"OpenVINO Inference time: {processing_time_det:.1f}ms ({fps:.1f} FPS)", 
                                org=(20, 40),fontFace=cv2.FONT_HERSHEY_COMPLEX, fontScale=f_width / 1000,
                                color=(0, 0, 255), thickness=1, lineType=cv2.LINE_AA)

                    # 绘制窗体时防止闪烁
                    if use_popup: 
                        draw_img = cv2.cvtColor(draw_img, cv2.COLOR_RGB2BGR)
                        cv2.imshow(winname=title, mat=draw_img)
                        key = cv2.waitKey(1)
                        # escape = 27
                        if key == 27:
                            break
                    else:
                        # encode numpy array to jpg
                        draw_img = cv2.cvtColor(draw_img, cv2.COLOR_RGB2BGR)
                        _, encoded_img = cv2.imencode(ext=".jpg", img=draw_img,
                                                      params=[cv2.IMWRITE_JPEG_QUALITY, 100])
                        # create IPython image
                        i = display.Image(data=encoded_img)
                        # display the image in this notebook
                        display.clear_output(wait=True)
                        display.display(i)
            
    # ctrl-c
    except KeyboardInterrupt:
        print("Interrupted")
    # any different error
    except RuntimeError as e:
        print(e)
    finally:
        if player is not None:
            # stop capturing
            player.stop()
        if use_popup:
            cv2.destroyAllWindows()

除了代码中注释的内容,在电表识别模型用OpenVINO运行时,还有一个非常关键的要点:必须字典文件必须要与识别模型finetune训练时一致!

因为从上面的代码也可以看出,识别结果计算出来后,是要找字典文件去映射出结果的!本文给出的模型,在训练时用到的字典文件内容如下:

0
1
2
3
4
5
6
7
8
9
-

也放在ppocr_keys_v1.txt文件中了。

3 在OpenVINO运行时环境实现电表识别任务

这里提供两种方式,webcam方式需要读者在本地完成;传入视频文件的方式则不一定需要。需要说明的是,这里提供的两个电表检测视频是直接手机录屏保存的,和真实场景的录屏虽然接近,但可能还是有一定差距。

In [9]

run_paddle_ocr(source=0, flip=False, use_popup=True)
Cannot open camera 0

在配置好输入的size和DB像素判定阈值后,我们发现在测试视频上电表读数识别效果还不错。在抽的绝大多是帧里,都很准确地框出了电表读数整数位的位置、识别的读数正确率也非常高。

In [14]

# 电表读数识别效果

video_file = "./data/test.mp4"
run_paddle_ocr(source=video_file, flip=False, use_popup=False)

<IPython.core.display.Image object>
Source ended

第二个测试视频效果就差了些,电表编号完全给漏了。

In [1]

video_file = "./data/SVID_20220411_003747_1.mp4"
run_paddle_ocr(source=video_file, flip=False, use_popup=False)

4 小结

本文使用OpenVINO™实现了电表检测识别模型的跨平台运行(本地Windows,在线环境Linux)。

从预测速度看,视频流处理的抽帧和前后处理过程占用了大部分时间,OpenVINO的预测效果还是很快的。精度上,基本还原了电表检测和识别模型的实际效果,没有明显损失。

在后续项目中,还将继续探索如何把模型部署到Intel Myriad Stick 2计算加速棒中。

Logo

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

更多推荐