訓練PaddleOCR文字方向分類模型

Eslzzyl發表於2024-08-27

最近在做一個專案,涉及到掃描答題卡的方向判斷。其中一種方法是訓練一個文字方向分類模型來判斷方向。此處記錄一下訓練的過程。

環境準備

在一處空閒空間足夠大的地方克隆 PaddleOCR 倉庫:https://github.com/PaddlePaddle/PaddleOCR

PaddleOCR 倉庫體積較大,需下載約 700 MB 資料。

建立一個新的虛擬環境,根據 這個網頁 的指導安裝 PaddlePaddle 框架。

注意,不要安裝 Numpy 2.0 或更新版本,因為 PaddleOCR 可能不相容。Numpy 1.0 的最新版本是 1.26.4。

然後,進入 PaddleOCR 倉庫的根目錄,安裝必要的依賴:

pip install -r requirements.txt
pip install albumentations

訓練資料準備

我的資料集包含約 2 萬張答題卡掃描影像,資料集按照 9:1 的比例劃分為訓練集和測試(驗證)集。我假設所有影像均為正向,以 0.2 的機率隨機將影像旋轉 180 度來生成顛倒的影像。在隨機旋轉的同時生成方向標籤。

原始資料集包含多個目錄,每個目錄中存放一個科目的答題卡影像。我在同級目錄下建立了 train 目錄和 test 目錄,用於儲存實際的訓練資料。

使用以下 bash 指令碼將原始資料集中的影像複製到 train 目錄和 test 目錄:

#!/bin/bash

# 定義源目錄和目標目錄
source_dirs=("科目1", "科目2", "科目3", "科目4", "科目5")
target_train="train"
target_test="test"

# 遍歷所有源目錄
for dir in "${source_dirs[@]}"; do
    # 獲取目錄中的所有圖片檔名
    files=$(find "$dir" -maxdepth 1 -type f -name "*.png")
    
    # 計算要移動到 train 和 test 目錄中的檔案數量
    total_files=$(echo "$files" | wc -l)
    train_count=$((total_files * 90 / 100))
    test_count=$((total_files - train_count))

    # 從所有檔案中隨機選擇 train_count 個檔案移動到 train 目錄
    train_files=$(echo "$files" | shuf -n $train_count)
    for file in $train_files; do
        cp "$file" "$target_train"
    done

    # 從剩餘檔案中隨機選擇 test_count 個檔案移動到 test 目錄
    test_files=$(echo "$files" | shuf -n $test_count)
    for file in $test_files; do
        cp "$file" "$target_test"
    done
done

並使用以下 Python 程式碼完成影像的隨即旋轉和標籤生成:

import os
import random
from PIL import Image
from concurrent.futures import ThreadPoolExecutor
from tqdm import tqdm
import multiprocessing

def process_image(filename, directory, label_file):
    filepath = os.path.join(directory, filename)
    
    if filename.endswith('.png'):
        # 隨機決定是否旋轉影像
        if random.random() > 0.8:
            # 旋轉影像
            img = Image.open(filepath)
            img_rotated = img.rotate(180)
            img_rotated.save(filepath)
            
            # 寫入標籤(180度旋轉)
            with open(label_file, 'a') as f:
                f.write(f"{filepath}\t180\n")
        else:
            # 不旋轉,直接寫入標籤(0度)
            with open(label_file, 'a') as f:
                f.write(f"{filepath}\t0\n")

def rotate_and_label_images(directory, label_file):
    # 獲取所有檔案
    files = [filename for filename in os.listdir(directory) if filename.endswith('.png')]

    # 使用多執行緒處理檔案
    with ThreadPoolExecutor() as executor:
        list(tqdm(executor.map(lambda x: process_image(x, directory, label_file), files), total=len(files)))

# 指定目錄和標籤檔案路徑
train_dir = 'train'
test_dir = 'test'
train_label_file = 'train.txt'
test_label_file = 'test.txt'

# 處理 train 目錄
rotate_and_label_images(train_dir, train_label_file)

# 處理 test 目錄
rotate_and_label_images(test_dir, test_label_file)

上述的大部分程式碼是使用通義千問生成的。

依次執行上述兩個指令碼後,train 目錄和 test 目錄中 20% 的影像將是顛倒的,同時與它們同級的目錄中將會生成 train.txt 檔案和 test.txt 檔案,儲存檔名和標籤。兩個檔案大致遵循以下格式:

test/8D64250B-5B95-11EF-8A7E-3024A9806847.png	0
test/1F83BFEB-5B83-11EF-8A7E-3024A9806847.png	0
test/CC126431-5B92-11EF-8A7E-3024A9806847.png	0
test/87249B09-5B8B-11EF-8A7E-3024A9806847.png	180
test/G669F21C-5B8B-11EF-8A7E-3024A9806847.png	0
test/7AA0DB14-5B8C-11EF-8A7E-3024A9806847.png	180
test/EC082795-5B84-11EF-8A7E-3024A9806847.png	0
test/80B03DC5-3296-11EF-9E58-5CBAEF6F52AE.png	0
test/FEA16C22-24BC-11EF-BDD7-E86A6470B412.png	180
test/B1722A7E-5B8B-11EF-8A7E-3024A9806847.png	180
test/065A748A-5B99-11EF-8A7E-3024A9806847.png	0

需要注意,檔名和標籤之間應該用 \t 分隔,而不是空格。否則訓練指令碼將無法識別。

切換到 PaddleOCR 倉庫根目錄,新建一個 train_data 目錄,然後在其中建立一個名為 cls 的、連結到上述資料集所在目錄的軟連結。

ln -s /path/to/the/dataset cls

開始訓練

我在訓練時始終無法成功使 PaddlePaddle 呼叫 GPU 進行計算。我使用多種方法重灌了若干次,並且使用 PaddlePaddle 訓練了一個簡單的卷積網路,可以正常呼叫 GPU。但一到 PaddleOCR 的環境中就不行了,表現為佔用了一定的視訊記憶體,但 GPU 完全沒有計算,CPU滿載。GitHub issue 中沒有發現類似的問題。考慮到文字方向分類模型比較輕量,且在我的訓練資料上可以快速收斂,因此我使用 CPU 完成了簡單的訓練。

回到 PaddleOCR 倉庫根目錄,開啟 configs/cls/cls_mv3.yml,根據需要進行修改。我進行了以下修改:

@@ -1,6 +1,6 @@
 Global:
-  use_gpu: true
-  epoch_num: 100
+  use_gpu: false
+  epoch_num: 10
   log_smooth_window: 20
   print_batch_step: 10
   save_model_dir: ./output/cls/mv3/
@@ -61,7 +61,7 @@ Train:
           channel_first: False
       - ClsLabelEncode: # Class handling label
       - BaseDataAugmentation:
-      - RandAugment:
+      # - RandAugment:
       - ClsResizeImg:
           image_shape: [3, 48, 192]
       - KeepKeys:

上述改動基於 commit 1752c56

正如上面那段說明所提到的,我在訓練時無論如何也呼叫不了 GPU,於是我將 use_gpu 改為 false(注意這個選項的值的首字母應該小寫),並根據實際的收斂速度減少了 epoch 數量。此外,我沒有使用此處描述的資料增強。

執行

python tools/train.py -c configs/cls/cls_mv3.yml

來啟動訓練。

在經過大約 4 個 epoch 後,模型收斂,精度約為 99.4%。10 個 epoch 跑完之後,模型將被儲存到 ``

模型轉換

為了執行推理,需要先將訓練階段的模型轉換成推理模型。執行

python3 tools/export_model.py -c configs/cls/cls_mv3.yml -o Global.pretrained_model=./output/cls/mv3/latest Global.save_inference_dir=./inference/cls/

即可將訓練階段的模型轉換成推理模型。轉換後,我們可以使用推理模型所在目錄的名字來引用這個模型。

推理

直接使用 paddleocr 命令列工具似乎無法完成純粹的文字方向分類任務,但訓練時提供的文字方向分類模型推理指令碼在 PaddleOCR 的倉庫中,前面已經提到,這倉庫很大。為了避免在推理端克隆龐大的 Git 倉庫,我們可以簡單修改推理指令碼的程式碼,使之僅依賴編譯好的 PaddleOCR pypi 包。

# Copyright (c) 2020 PaddlePaddle Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import sys
import cv2
import copy
import numpy as np
import math
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from tqdm import tqdm

import paddleocr.tools.infer.utility as utility
from paddleocr.ppocr.postprocess import build_post_process
from paddleocr.ppocr.utils.logging import get_logger
from paddleocr.ppocr.utils.utility import get_image_file_list, check_and_read

__dir__ = os.path.dirname(os.path.abspath(__file__))
sys.path.append(__dir__)
sys.path.insert(0, os.path.abspath(os.path.join(__dir__, "../..")))

os.environ["FLAGS_allocator_strategy"] = "auto_growth"

logger = get_logger()


class TextClassifier(object):
    def __init__(self, args):
        self.cls_image_shape = [int(v) for v in args.cls_image_shape.split(",")]
        self.cls_batch_num = args.cls_batch_num
        self.cls_thresh = args.cls_thresh
        postprocess_params = {
            "name": "ClsPostProcess",
            "label_list": args.label_list,
        }
        self.postprocess_op = build_post_process(postprocess_params)
        (
            self.predictor,
            self.input_tensor,
            self.output_tensors,
            _,
        ) = utility.create_predictor(args, "cls", logger)
        self.use_onnx = args.use_onnx

    def resize_norm_img(self, img):
        imgC, imgH, imgW = self.cls_image_shape
        h = img.shape[0]
        w = img.shape[1]
        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")
        if self.cls_image_shape[0] == 1:
            resized_image = resized_image / 255
            resized_image = resized_image[np.newaxis, :]
        else:
            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

    def __call__(self, img_list):
        img_list = copy.deepcopy(img_list)
        img_num = len(img_list)
        # Calculate the aspect ratio of all text bars
        width_list = []
        for img in img_list:
            width_list.append(img.shape[1] / float(img.shape[0]))
        # Sorting can speed up the cls process
        indices = np.argsort(np.array(width_list))

        cls_res = [["", 0.0]] * img_num
        batch_num = self.cls_batch_num
        elapse = 0
        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
            starttime = time.time()
            for ino in range(beg_img_no, end_img_no):
                h, w = img_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 = self.resize_norm_img(img_list[indices[ino]])
                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()

            if self.use_onnx:
                input_dict = {self.input_tensor.name: norm_img_batch}
                outputs = self.predictor.run(self.output_tensors, input_dict)
                prob_out = outputs[0]
            else:
                self.input_tensor.copy_from_cpu(norm_img_batch)
                self.predictor.run()
                prob_out = self.output_tensors[0].copy_to_cpu()
                self.predictor.try_shrink_memory()
            cls_result = self.postprocess_op(prob_out)
            elapse += time.time() - starttime
            for rno in range(len(cls_result)):
                label, score = cls_result[rno]
                cls_res[indices[beg_img_no + rno]] = [label, score]
                if "180" in label and score > self.cls_thresh:
                    img_list[indices[beg_img_no + rno]] = cv2.rotate(
                        img_list[indices[beg_img_no + rno]], 1
                    )
        return img_list, cls_res, elapse


def cls(image_dir: str, cls_model_dir: str, use_gpu=False):
    args = utility.parse_args()
    args.image_dir = image_dir
    args.cls_model_dir = cls_model_dir
    args.use_gpu = use_gpu

    image_file_list = get_image_file_list(args.image_dir)
    text_classifier = TextClassifier(args)
    valid_image_file_list = []
    upside_img_list = []

    # Process images in batches of 10
    batch_size = 10
    for i in range(0, len(image_file_list), batch_size):
        batch_files = image_file_list[i:i + batch_size]
        batch_imgs = []
        for image_file in batch_files:
            img, flag, _ = check_and_read(image_file)
            if not flag:
                img = cv2.imread(image_file)
            if img is None:
                logger.info("error in loading image:{}".format(image_file))
                continue
            valid_image_file_list.append(image_file)
            batch_imgs.append(img)
        try:
            batch_imgs, cls_res, predict_time = text_classifier(batch_imgs)
        except Exception as E:
            logger.info(traceback.format_exc())
            logger.info(E)
            exit()
        for ino in range(len(batch_imgs)):
            # 如果識別結果為180度,需要記錄並在日誌中輸出
            if "180" in cls_res[ino][0] and cls_res[ino][1] > args.cls_thresh:
                upside_img_list.append(batch_files[ino])
                logger.info(
                    "The image is upside down: {}, score: {}".format(
                        valid_image_file_list[i + ino], cls_res[ino][1]
                    )
                )
    return upside_img_list


def process_image(path):
    img = cv2.imread(path)
    img = cv2.rotate(img, cv2.ROTATE_180)
    cv2.imwrite(path, img)


def check_and_rotate(path):
    cls_model_dir = "./cls"
    upside_img_list = cls(path, cls_model_dir, False)
    print(f"len of upside_img_list: {len(upside_img_list)}")
    with ThreadPoolExecutor() as executor:
        list(tqdm(executor.map(process_image, upside_img_list), total=len(upside_img_list)))

結果

  • 誤判實驗:2263 張正向圖片,出現了 1 次誤判。
  • 漏判實驗:2263 張顛倒圖片,出現了 2263-2056=207 次漏判,漏判率 9.1%

相關文章