天天看點

PaddleOCR文字檢測模型的預處理簡介1 預處理流程初探2 算子處理過程3 預處理結果4 實際部署時的預處理5 總結

目錄

  • 簡介
  • 1 預處理流程初探
  • 2 算子處理過程
    • 2.1 DecodeImage
    • 2.2 DetResizeForTest
      • 2.2.1 構造方法
      • 2.2.2 調用方法
    • 2.3 NormalizeImage
    • 2.4 ToCHWImage
    • 2.5 KeepKeys
  • 3 預處理結果
  • 4 實際部署時的預處理
  • 5 總結

簡介

導出

ONNX

格式的模型後,在部署模型時,需要對模型的輸入進行預處理,轉換成符合模型輸入次元的張量;模型輸出張量結果後,也需要通過後處理,将張量轉換成需要的預測結果。

Java

中嘗試通過

OnnxRuntime

部署

PaddleOCR

文字檢測模型時,發現上述預處理和後處理過程還是有些東西值得學習的,于是花了些時間學習了項目源碼,經過一些實踐調試後,總結出了下面的預處理流程步驟。

整個内容比較新手向,适合對文字檢測模型的圖像預處理不了解的新手。

本文使用的文字檢測模型相關資訊如下:

模型名稱 對應配置檔案
ch_ppocr_mobile_v2.0_det_train det_mv3_db.yml

注意:在項目源碼中,模型推理過程的預處理實際上有兩個:

  1. 一個是在訓練過程中,為了快速檢驗模型訓練效果,在 “訓練模型” 上進行推理時所做的預處理。
  2. 另一個是實際部署模型時,将模型轉換成 “推理模型” 後,在 “推理模型” 上進行推理時所做的預處理。

上述的兩個預處理的步驟方法基本上是一緻的,隻是細節上有些差别。

本文的内容,是從第一種預處理的流程開始的。其實還是一開始沒太注意這個細節,是以在後面補充說明了 實際部署時的預處理方法 。

1 預處理流程初探

根據官方文檔的介紹,可以通過項目中的

tools/infer_det.py

直接在文字檢測模型的 訓練模型 上進行推理。

tools/infer_det.py

中可以看到如下代碼:

# create data ops
transforms = []
for op in config['Eval']['dataset']['transforms']:
    op_name = list(op)[0]
    if 'Label' in op_name:
        continue
    elif op_name == 'KeepKeys':
        op[op_name]['keep_keys'] = ['image', 'shape']
    transforms.append(op)

ops = create_operators(transforms, global_config)
           

這一步目的是讀取預處理算子的配置參數到

transforms

清單中,然後通過方法

create_operators()

,構造算子類的對象并添加到清單

ops

從上面的代碼中可以看到,算子的相關參數在

Eval.dataset.transforms

下,檢視模型的對應配置檔案,相關參數如下:

Eval:
  dataset:
    transforms:
      - DecodeImage: # load image
          img_mode: BGR
          channel_first: False
      - DetLabelEncode: # Class handling label
      - DetResizeForTest:
          image_shape: [736, 1280]
      - NormalizeImage:
          scale: 1./255.
          mean: [0.485, 0.456, 0.406]
          std: [0.229, 0.224, 0.225]
          order: 'hwc'
      - ToCHWImage:
      - KeepKeys:
          keep_keys: ['image', 'shape', 'polys', 'ignore_tags']
           

因為模型訓練和推理使用的是同一個配置檔案,而推理和訓練在具體細節上又有所差別。是以,上面的代碼中讀取算子配置參數時,做了兩個調整:

  1. 跳過了

    DetLabelEncode

    這個算子;
  2. KeepKeys

    算子的參數

    keep_keys

    的值修改成了

    ['image', 'shape']

是以從上述代碼和配置檔案中,可以得出結論,文字檢測模型在推理時,預處理共包含5個算子:

  1. DecodeImage
  2. DetResizeForTest
  3. NormalizeImage
  4. ToCHWImage
  5. KeepKeys

繼續往下看代碼:

for file in get_image_file_list(config['Global']['infer_img']):     # 從配置檔案讀取圖檔路徑清單
    logger.info("infer_img: {}".format(file))
    with open(file, 'rb') as f:
        img = f.read()
        data = {'image': img}
    batch = transform(data, ops)     # 預處理方法
    
    images = np.expand_dims(batch[0], axis=0)
    shape_list = np.expand_dims(batch[1], axis=0)
    images = paddle.to_tensor(images)     # 飛槳架構的張量轉換
    preds = model(images)     # 模型調用
    post_result = post_process_class(preds, shape_list)     # 對模型輸出張量的後處理
    boxes = post_result[0]['points']
    
    # 寫入json和可視化結果
    dt_boxes_json = []
    for box in boxes:
        tmp_json = {"transcription": ""}
        tmp_json['points'] = box.tolist()
        dt_boxes_json.append(tmp_json)
    otstr = file + "\t" + json.dumps(dt_boxes_json) + "\n"
    fout.write(otstr.encode())
    src_img = cv2.imread(file)
    draw_det_res(boxes, config, src_img, file)
           

可以看到一開始的循環,讀取了配置檔案中的需要進行推理的圖檔路徑清單。

随後,在循環内對每張圖檔進行了預處理,然後輸入到模型進行了推理預測,最後将模型輸出寫入了json,并繪制到圖檔上給出了可視化結果。

預處理的部分在上述代碼的第6行,此處調用了

transform()

方法,該方法參數如下:

參數 類型 說明

data

dict

key

'image'

value

是從圖檔讀取的

bytes

類型資料

ops

list

包含算子對象的清單

跳轉到該方法,代碼如下:

def transform(data, ops=None):
    """ transform """
    if ops is None:
        ops = []
    for op in ops:
        data = op(data)
        if data is None:
            return None
    return data
           

方法很簡單,就是循環調用算子對象清單中的每一個算子,依次對資料進行處理。從這裡可以看出,在前面的配置檔案中,算子的先後順序也是有意義的,因為每個算子處理後的輸出都是下個算子處理的輸入。

此處不能直接跳轉到算子類的

__call__()

方法,經過調試,發現預處理算子類的定義在

ppocr/data/imaug/operators.py

中,接下來按照順序,具體了解每個算子是如何處理的。

2 算子處理過程

2.1 DecodeImage

因為是第一個算子,此時的輸入

data

是如前所述的

bytes

類型的位元組值,是以第一個算子的任務,就是在此基礎上進行解碼,得到圖像的像素矩陣。該算子類的代碼如下:

class DecodeImage(object):
    """ decode image """

    def __init__(self, img_mode='RGB', channel_first=False, **kwargs):
        self.img_mode = img_mode
        self.channel_first = channel_first

    def __call__(self, data):
        img = data['image']
        if six.PY2:
            assert type(img) is str and len(
                img) > 0, "invalid input 'img' in DecodeImage"
        else:
            assert type(img) is bytes and len(
                img) > 0, "invalid input 'img' in DecodeImage"
        img = np.frombuffer(img, dtype='uint8')
        img = cv2.imdecode(img, 1)
        if img is None:
            return None
        if self.img_mode == 'GRAY':
            img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
        elif self.img_mode == 'RGB':
            assert img.shape[2] == 3, 'invalid shape of image[%s]' % (img.shape)
            img = img[:, :, ::-1]

        if self.channel_first:
            img = img.transpose((2, 0, 1))

        data['image'] = img
        return data
           

解碼的方法很簡單,通過

numpy

frombuffer()

方法,将資料轉換成

uint8

類型的矩陣,然後直接使用

openCV

imdecode()

方法進行解碼,解碼後的圖像顔色格式為BGR,矩陣次元按 HWC 順序排列。

随後根據

img_mode

的參數值,将圖像轉換成灰階圖、RGB格式或保持BGR格式不變;根據

channel_first

參數決定是否将

channel

變換到第一個次元。

該算子總結如下:

作用:将圖檔從bytes類型解碼成所需格式的圖像矩陣

參數 參數值 參數說明
img_mode ‘BGR’ / ‘GRAY’ / ‘RGB’ 圖像顔色格式
channel_first True / False 是否将channel變換到第一個次元

2.2 DetResizeForTest

這個算子的任務是将圖檔進行縮放,想象中代碼應該很簡短,但實際檢視源碼後發現,其中内容還不少。

2.2.1 構造方法

先來看看這個算子類的構造方法:

def __init__(self, **kwargs):
    super(DetResizeForTest, self).__init__()
    self.resize_type = 0
    if 'image_shape' in kwargs:
        self.image_shape = kwargs['image_shape']
        self.resize_type = 1
    elif 'limit_side_len' in kwargs:
        self.limit_side_len = kwargs['limit_side_len']
        self.limit_type = kwargs.get('limit_type', 'min')
    elif 'resize_long' in kwargs:
        self.resize_type = 2
        self.resize_long = kwargs.get('resize_long', 960)
    else:
        self.limit_side_len = 736
        self.limit_type = 'min'
           

在PaddleOCR中推薦的文字檢測算法是

DB

算法,也就是本文選取模型采用的算法,但實際上在項目内還支援了

EAST

SAST

兩種檢測算法。使用不同的算法或者在不同的訓練資料下,圖檔的輸入大小也有一定差别,是以在

DetResizeForTest

這個算子内包含了不同類型的縮放方法。

而這裡源碼處理的方式也比較簡單:在構造算子對象時,直接從參數清單中判斷是否包含某個參數,來決定此時的縮放類别,并指派到資料成員

self.resize_type

中。

從前面的配置檔案可以看到,在使用 訓練模型 進行快速推理時,配置參數為

image_shape: [736, 1280]

,此時對象中的資料成員

self.resize_type

的值應該為

1

由不同參數決定的縮放類型

resize_type

具體代表了什麼含義呢?從調用方法上可以一探究竟。

2.2.2 調用方法

以下是調用方法的代碼:

def __call__(self, data):
    img = data['image']
    src_h, src_w, _ = img.shape

    if self.resize_type == 0:
        img, [ratio_h, ratio_w] = self.resize_image_type0(img)
    elif self.resize_type == 2:
        img, [ratio_h, ratio_w] = self.resize_image_type2(img)
    else:
        img, [ratio_h, ratio_w] = self.resize_image_type1(img)
    data['image'] = img
    data['shape'] = np.array([src_h, src_w, ratio_h, ratio_w])
    return data
           

和想象中一樣,根據不同的縮放類别,調用對應方法。這裡需要注意的是,縮放方法給出了兩個傳回值。

  • 第一個值

    img

    ,是縮放後的圖像;
  • 第二個值

    [ratio_h, ratio_w]

    ,是高、寬縮放比例的清單

同時,算子傳回的結果中則增加了

key

'shape'

的1X4的矩陣,存儲了圖像的原始高、寬和對應縮放比例。此處存儲縮放的

shape

資料是為了在後處理的過程中對圖像進行還原。

下面依次來看看不同縮放方法具體是怎麼操作的。

1. 當

self.resize_type

def resize_image_type0(self, img):
        limit_side_len = self.limit_side_len
        h, w, _ = img.shape

        if self.limit_type == 'max':
            if max(h, w) > limit_side_len:
                if h > w:
                    ratio = float(limit_side_len) / h
                else:
                    ratio = float(limit_side_len) / w
            else:
                ratio = 1.
        else:
            if min(h, w) < limit_side_len:
                if h < w:
                    ratio = float(limit_side_len) / h
                else:
                    ratio = float(limit_side_len) / w
            else:
                ratio = 1.
        resize_h = int(h * ratio)
        resize_w = int(w * ratio)
        resize_h = max(int(round(resize_h / 32) * 32), 32)
        resize_w = max(int(round(resize_w / 32) * 32), 32)

        try:
            if int(resize_w) <= 0 or int(resize_h) <= 0:
                return None, (None, None)
            img = cv2.resize(img, (int(resize_w), int(resize_h)))
        except:
            print(img.shape, resize_w, resize_h)
            sys.exit(0)
        ratio_h = resize_h / float(h)
        ratio_w = resize_w / float(w)
        return img, [ratio_h, ratio_w]
           

此時的配置參數為

limit_side_len

limit_type

,從代碼上來看,

limit_side_len

實際上是定義一個圖檔邊長的最值,根據

limit_type

來确定定義的是最大值還是最小值。

這種方法最後将把圖檔寬高縮放成均在參數限制内的、32的整數倍(這裡取32是模型結構決定的)。

2. 當

self.resize_type

1

def resize_image_type1(self, img):
    resize_h, resize_w = self.image_shape
    ori_h, ori_w = img.shape[:2]
    ratio_h = float(resize_h) / ori_h
    ratio_w = float(resize_w) / ori_w
    img = cv2.resize(img, (int(resize_w), int(resize_h)))
    return img, [ratio_h, ratio_w]
           

此時的配置參數為

image_shape

,為縮放後圖檔的寬高。

這種方法直接調用了

openCV

resize()

方法進行縮放,然後計算了縮放比例。

3. 當

self.resize_type

2

def resize_image_type2(self, img):
        h, w, _ = img.shape
        resize_w = w
        resize_h = h

        if resize_h > resize_w:
            ratio = float(self.resize_long) / resize_h
        else:
            ratio = float(self.resize_long) / resize_w

        resize_h = int(resize_h * ratio)
        resize_w = int(resize_w * ratio)

        max_stride = 128
        resize_h = (resize_h + max_stride - 1) // max_stride * max_stride
        resize_w = (resize_w + max_stride - 1) // max_stride * max_stride
        img = cv2.resize(img, (int(resize_w), int(resize_h)))
        ratio_h = resize_h / float(h)
        ratio_w = resize_w / float(w)

        return img, [ratio_h, ratio_w]
           

此時的配置參數為

resize_long

,為縮放後圖檔的最長邊的值。

這種方法首先是從圖檔寬高中找出最長邊,計算縮放比例,然後保持圖檔的寬高比不變進行縮放。

但到這裡并沒有結束,接下來代碼中定義了一個名為

max_stride

的變量,值為

128

,然後在此基礎上做了一個計算:

resize_h = (resize_h + max_stride - 1) // max_stride * max_stride
resize_w = (resize_w + max_stride - 1) // max_stride * max_stride
           

其實就是将邊長做了一個向上取

max_stride

整數倍的處理。

随後根據最終取整的寬高值計算了縮放比例,傳回結果。

從項目配置中來看,這種縮放方法主要是用于

SAST

這個檢測算法的預進行中,是以猜測

max_stride

變量的值以及取整的操作和具體算法有關聯,有興趣的讀者可以閱讀 SAST paper 進一步研究。

最後,該算子總結如下:

作用:對圖像進行縮放,在傳回結果中添加縮放比例

resize_type(縮放類型) 縮放類型說明 參數 參數值 參數說明
在限定的邊長範圍内,縮放邊長到32的整數倍 limit_side_len 736 邊長最值
limit_type min / max 邊長限值為最大值還是最小值
1 根據參數縮放圖像到指定寬高 image_shape [736, 1280] 縮放後的圖像高、寬
2 根據最長邊确定比例,縮放邊長到128的整數倍 resize_long 960 最長邊縮放長度

注:該算子參數可以為

None

,從代碼上可以看到,此時

resize_type

limit_side_len

736

limit_type

min

2.3 NormalizeImage

從配置參數上看:

NormalizeImage:
    scale: 1./255.
    mean: [0.485, 0.456, 0.406]
    std: [0.229, 0.224, 0.225]
    order: 'hwc'
           

這裡使用的圖像歸一化是常見的方法,先乘上

scale

進行線性變換,再減去對應通道的平均值,最後除以對應通道的标準差。算子的調用方法如下:

def __call__(self, data):
    img = data['image']
    from PIL import Image
    if isinstance(img, Image.Image):
        img = np.array(img)

    assert isinstance(img,
                      np.ndarray), "invalid input 'img' in NormalizeImage"
    data['image'] = (
        img.astype('float32') * self.scale - self.mean) / self.std
    return data
           

該算子總結如下:

作用:對圖像進行歸一化

參數 參數值 參數說明
scale 1./255. 線性變換參數
mean [0.485, 0.456, 0.406] BGR三通道對應平均值
std [0.229, 0.224, 0.225] BGR三通道對應标準差
order ‘hwc’ 圖像矩陣次元順序

2.4 ToCHWImage

這個算子非常簡單,代碼如下:

class ToCHWImage(object):
    """ convert hwc image to chw image
    """
    def __init__(self, **kwargs):
        pass

    def __call__(self, data):
        img = data['image']
        from PIL import Image
        if isinstance(img, Image.Image):
            img = np.array(img)
        data['image'] = img.transpose((2, 0, 1))
        return data
           

即将圖像矩陣次元變換為 CHW ,沒有配置參數。

2.5 KeepKeys

最後一個算子也很簡單,直接看源碼:

class KeepKeys(object):
    def __init__(self, keep_keys, **kwargs):
        self.keep_keys = keep_keys

    def __call__(self, data):
        data_list = []
        for key in self.keep_keys:
            data_list.append(data[key])
        return data_list
           

其實就是将

data

dict

類型,變成了

list

。個人認為叫做

RemoveKeys

更合适,這個算子也沒有參數。

3 預處理結果

為了便于描述,将代碼再貼一遍(更為完整的部分在最前):

batch = transform(data, ops)

images = np.expand_dims(batch[0], axis=0)
shape_list = np.expand_dims(batch[1], axis=0)
images = paddle.to_tensor(images)
           

經過上面的一系列處理,

transform()

方法傳回了一個

list

結果

batch

batch

中的第一個元素為圖像矩陣,顔色格式為BGR,次元順序依次為CHW ;

batch

中的第二個元素為圖像縮放資料的1X4矩陣,分别為:原始高、原始寬、高度縮放比例和寬度縮放比例。

可以看到在轉換成張量前,兩個元素均擴充了一個次元,按照上文給出的

訓練模型

推理時的配置參數,最終輸入到模型的張量次元為

[1, 3, 736, 1280]

4 實際部署時的預處理

根據項目導出的可用于部署的模型來看,實際部署推理時,文字檢測模型運作的是項目下

tools/infer/predict_det.py

這個腳本,腳本内定義了一個

TextDetector

的類,其構造方法中包含了預處理的配置清單,如下:

pre_process_list = [{
            'DetResizeForTest': None
        }, {
            'NormalizeImage': {
                'std': [0.229, 0.224, 0.225],
                'mean': [0.485, 0.456, 0.406],
                'scale': '1./255.',
                'order': 'hwc'
            }
        }, {
            'ToCHWImage': None
        }, {
            'KeepKeys': {
                'keep_keys': ['image', 'shape']
            }
        }]
           

很明顯和上文的預處理基本一緻,首先是少了

DecodeImage

這個圖像解碼的過程,因為在實際部署時,這個步驟将在把圖像輸入到文字檢測模型前完成。

另一個變化是

DetResizeForTest

的參數為

None

,采用了和上文預進行中不同的縮放方法(具體參考上文2.2節)。

最後,

KeepKeys

的參數相比上文預處理的參數雖然少了兩個,但算子内實際隻用到了這兩個參數,可以了解為沒有變化。

5 總結

其實整體學習研究一遍後,發現整個預處理過程并不複雜,處理方法也比較常見,同時也對整個項目有了更深入的了解,接下來将嘗試繼續學習文字檢測模型的後處理流程。

繼續閱讀