目錄
- 簡介
- 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 預處理流程初探
根據官方文檔的介紹,可以通過項目中的
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']
因為模型訓練和推理使用的是同一個配置檔案,而推理和訓練在具體細節上又有所差別。是以,上面的代碼中讀取算子配置參數時,做了兩個調整:
- 跳過了
這個算子;DetLabelEncode
- 将
算子的參數KeepKeys
的值修改成了keep_keys
['image', 'shape']
是以從上述代碼和配置檔案中,可以得出結論,文字檢測模型在推理時,預處理共包含5個算子:
- DecodeImage
- DetResizeForTest
- NormalizeImage
- ToCHWImage
- 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()
方法,該方法參數如下:
參數 | 類型 | 說明 |
---|---|---|
| | 是 ; 是從圖檔讀取的 類型資料 |
| | 包含算子對象的清單 |
跳轉到該方法,代碼如下:
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 總結
其實整體學習研究一遍後,發現整個預處理過程并不複雜,處理方法也比較常見,同時也對整個項目有了更深入的了解,接下來将嘗試繼續學習文字檢測模型的後處理流程。