代码示例 / 计算机视觉 / 手写识别

手写识别

作者: A_K_Nain, Sayak Paul
创建日期: 2021/08/16
最近修改日期: 2023/07/06

在 Colab 中查看 GitHub 源代码

描述: 使用变长序列训练手写识别模型。


介绍

本示例展示了如何将 Captcha OCR 示例扩展到 IAM 数据集, 该数据集具有变长的真实目标。数据集中的每个样本都是某些手写文本的图像,其对应的目标是图像中存在的字符串。 IAM 数据集在许多 OCR 基准中广泛使用,因此我们希望这个示例能作为构建 OCR 系统的良好起点。


数据收集

!wget -q https://github.com/sayakpaul/Handwriting-Recognizer-in-Keras/releases/download/v1.0.0/IAM_Words.zip
!unzip -qq IAM_Words.zip
!
!mkdir data
!mkdir data/words
!tar -xf IAM_Words/words.tgz -C data/words
!mv IAM_Words/words.txt data

预览数据集的组织方式。以 "#" 开头的行只是元数据。

!head -20 data/words.txt
#--- words.txt ---------------------------------------------------------------#
#
# iam 数据库单词信息
#
# 格式: a01-000u-00-00 ok 154 1 408 768 27 51 AT A
#
#     a01-000u-00-00  -> 行 00 的单词 ID,格式为 a01-000u
#     ok              -> 单词分割的结果
#                            ok: 单词正确
#                            er: 单词分割可能有问题
#
#     154             -> 将包含该单词的行灰度化的值
#     1               -> 该单词的组成部分数量
#     408 768 27 51   -> 该单词的边界框,格式为 x,y,w,h
#     AT              -> 该单词的语法标签,详见文件 tagset.txt
#     A               -> 该单词的转录
#
a01-000u-00-00 ok 154 408 768 27 51 AT A
a01-000u-00-01 ok 154 507 766 213 48 NN MOVE

导入

from tensorflow.keras.layers import StringLookup
from tensorflow import keras

import matplotlib.pyplot as plt
import tensorflow as tf
import numpy as np
import os

np.random.seed(42)
tf.random.set_seed(42)

数据集划分

base_path = "data"
words_list = []

words = open(f"{base_path}/words.txt", "r").readlines()
for line in words:
    if line[0] == "#":
        continue
    if line.split(" ")[1] != "err":  # 我们不需要处理有错误的条目。
        words_list.append(line)

len(words_list)

np.random.shuffle(words_list)

我们将把数据集分为三部分,比例为 90:5:5(训练:验证:测试)

split_idx = int(0.9 * len(words_list))
train_samples = words_list[:split_idx]
test_samples = words_list[split_idx:]

val_split_idx = int(0.5 * len(test_samples))
validation_samples = test_samples[:val_split_idx]
test_samples = test_samples[val_split_idx:]

assert len(words_list) == len(train_samples) + len(validation_samples) + len(
    test_samples
)

print(f"训练样本总数: {len(train_samples)}")
print(f"验证样本总数: {len(validation_samples)}")
print(f"测试样本总数: {len(test_samples)}")
训练样本总数: 86810
验证样本总数: 4823
测试样本总数: 4823

数据输入管道

我们通过首先准备图像路径来开始构建数据输入管道。

base_image_path = os.path.join(base_path, "words")


def get_image_paths_and_labels(samples):
    paths = []
    corrected_samples = []
    for (i, file_line) in enumerate(samples):
        line_split = file_line.strip()
        line_split = line_split.split(" ")

        # 每行拆分将具有此格式对应图像:
        # part1/part1-part2/part1-part2-part3.png
        image_name = line_split[0]
        partI = image_name.split("-")[0]
        partII = image_name.split("-")[1]
        img_path = os.path.join(
            base_image_path, partI, partI + "-" + partII, image_name + ".png"
        )
        if os.path.getsize(img_path):
            paths.append(img_path)
            corrected_samples.append(file_line.split("\n")[0])

    return paths, corrected_samples


train_img_paths, train_labels = get_image_paths_and_labels(train_samples)
validation_img_paths, validation_labels = get_image_paths_and_labels(validation_samples)
test_img_paths, test_labels = get_image_paths_and_labels(test_samples)

然后我们准备真实标签。

# 找到训练数据中标签的最大长度和词汇表的大小。
train_labels_cleaned = []
characters = set()
max_len = 0

for label in train_labels:
    label = label.split(" ")[-1].strip()
    for char in label:
        characters.add(char)

    max_len = max(max_len, len(label))
    train_labels_cleaned.append(label)

characters = sorted(list(characters))

print("最大长度: ", max_len)
print("词汇表大小: ", len(characters))

# 检查一些标签样本。
train_labels_cleaned[:10]
最大长度:  21
词汇表大小:  78

['sure',
 'he',
 'during',
 'of',
 'booty',
 'gastronomy',
 'boy',
 'The',
 'and',
 'in']

现在我们也清理验证和测试标签。

def clean_labels(labels):
    cleaned_labels = []
    for label in labels:
        label = label.split(" ")[-1].strip()
        cleaned_labels.append(label)
    return cleaned_labels


validation_labels_cleaned = clean_labels(validation_labels)
test_labels_cleaned = clean_labels(test_labels)

构建字符词汇表

Keras 提供不同的预处理层来处理不同类型的数据。 本指南提供了全面介绍。 我们的示例涉及在字符级别上预处理标签。这意味着如果有两个标签,例如“cat”和“dog”,那么我们的字符 词汇表应该是 {a, c, d, g, o, t}(不包含任何特殊标记)。我们使用 StringLookup 层来实现这一目的。

AUTOTUNE = tf.data.AUTOTUNE

# 将字符映射到整数。
char_to_num = StringLookup(vocabulary=list(characters), mask_token=None)

# 将整数映射回原始字符。
num_to_char = StringLookup(
    vocabulary=char_to_num.get_vocabulary(), mask_token=None, invert=True
)

不失真的调整图像大小

许多 OCR 模型处理矩形图像,而不是正方形图像。当我们稍后可视化数据集中的一些样本时,这一点会更加清晰。 尽管对正方形图像进行不考虑纵横比的调整大小并不会引入大量失真,但对矩形图像则不然。 但是,将图像调整为统一的大小是小批量处理的要求。因此,我们需要执行调整大小操作,以满足以下标准:

  • 保持纵横比。
  • 不影响图像内容。
def distortion_free_resize(image, img_size):
    w, h = img_size
    image = tf.image.resize(image, size=(h, w), preserve_aspect_ratio=True)

    # 检查需要进行的填充量。
    pad_height = h - tf.shape(image)[0]
    pad_width = w - tf.shape(image)[1]

    # 仅在您希望两侧进行相同数量的填充时需要。
    if pad_height % 2 != 0:
        height = pad_height // 2
        pad_height_top = height + 1
        pad_height_bottom = height
    else:
        pad_height_top = pad_height_bottom = pad_height // 2

    if pad_width % 2 != 0:
        width = pad_width // 2
        pad_width_left = width + 1
        pad_width_right = width
    else:
        pad_width_left = pad_width_right = pad_width // 2

    image = tf.pad(
        image,
        paddings=[
            [pad_height_top, pad_height_bottom],
            [pad_width_left, pad_width_right],
            [0, 0],
        ],
    )

    image = tf.transpose(image, perm=[1, 0, 2])
    image = tf.image.flip_left_right(image)
    return image

如果我们只是进行普通的调整大小,那么图像看起来会是这样的:

请注意,这种调整大小会引入不必要的拉伸。

整合工具

batch_size = 64
padding_token = 99
image_width = 128
image_height = 32


def preprocess_image(image_path, img_size=(image_width, image_height)):
    image = tf.io.read_file(image_path)
    image = tf.image.decode_png(image, 1)
    image = distortion_free_resize(image, img_size)
    image = tf.cast(image, tf.float32) / 255.0
    return image


def vectorize_label(label):
    label = char_to_num(tf.strings.unicode_split(label, input_encoding="UTF-8"))
    length = tf.shape(label)[0]
    pad_amount = max_len - length
    label = tf.pad(label, paddings=[[0, pad_amount]], constant_values=padding_token)
    return label


def process_images_labels(image_path, label):
    image = preprocess_image(image_path)
    label = vectorize_label(label)
    return {"image": image, "label": label}


def prepare_dataset(image_paths, labels):
    dataset = tf.data.Dataset.from_tensor_slices((image_paths, labels)).map(
        process_images_labels, num_parallel_calls=AUTOTUNE
    )
    return dataset.batch(batch_size).cache().prefetch(AUTOTUNE)

准备 tf.data.Dataset 对象

train_ds = prepare_dataset(train_img_paths, train_labels_cleaned)  # 准备训练数据集
validation_ds = prepare_dataset(validation_img_paths, validation_labels_cleaned)  # 准备验证数据集
test_ds = prepare_dataset(test_img_paths, test_labels_cleaned)  # 准备测试数据集

可视化几个样本

for data in train_ds.take(1):
    images, labels = data["image"], data["label"]

    _, ax = plt.subplots(4, 4, figsize=(15, 8))

    for i in range(16):
        img = images[i]
        img = tf.image.flip_left_right(img)
        img = tf.transpose(img, perm=[1, 0, 2])
        img = (img * 255.0).numpy().clip(0, 255).astype(np.uint8)
        img = img[:, :, 0]

        # 收集标签不等于 padding_token 的索引。
        label = labels[i]
        indices = tf.gather(label, tf.where(tf.math.not_equal(label, padding_token)))
        # 转换为字符串。
        label = tf.strings.reduce_join(num_to_char(indices))
        label = label.numpy().decode("utf-8")

        ax[i // 4, i % 4].imshow(img, cmap="gray")
        ax[i // 4, i % 4].set_title(label)
        ax[i // 4, i % 4].axis("off")


plt.show()

png

你会注意到原始图像的内容保持尽可能真实,并进行了相应的填充。


模型

我们的模型将使用 CTC 损失作为端点层。要详细了解 CTC 损失,请参阅 这篇文章

class CTCLayer(keras.layers.Layer):
    def __init__(self, name=None):
        super().__init__(name=name)
        self.loss_fn = keras.backend.ctc_batch_cost

    def call(self, y_true, y_pred):
        batch_len = tf.cast(tf.shape(y_true)[0], dtype="int64")
        input_length = tf.cast(tf.shape(y_pred)[1], dtype="int64")
        label_length = tf.cast(tf.shape(y_true)[1], dtype="int64")

        input_length = input_length * tf.ones(shape=(batch_len, 1), dtype="int64")
        label_length = label_length * tf.ones(shape=(batch_len, 1), dtype="int64")
        loss = self.loss_fn(y_true, y_pred, input_length, label_length)
        self.add_loss(loss)

        # 在测试时,只需返回计算的预测值。
        return y_pred


def build_model():
    # 模型的输入
    input_img = keras.Input(shape=(image_width, image_height, 1), name="image")
    labels = keras.layers.Input(name="label", shape=(None,))

    # 第一个卷积块。
    x = keras.layers.Conv2D(
        32,
        (3, 3),
        activation="relu",
        kernel_initializer="he_normal",
        padding="same",
        name="Conv1",
    )(input_img)
    x = keras.layers.MaxPooling2D((2, 2), name="pool1")(x)

    # 第二个卷积块。
    x = keras.layers.Conv2D(
        64,
        (3, 3),
        activation="relu",
        kernel_initializer="he_normal",
        padding="same",
        name="Conv2",
    )(x)
    x = keras.layers.MaxPooling2D((2, 2), name="pool2")(x)

    # 我们使用了两个最大池化,池化大小和步幅为2。
    # 因此,下采样特征图缩小为4倍。最后一层的过滤器数量为64。
    # 在将输出传递给模型的 RNN 部分之前,请相应地重塑形状。
    new_shape = ((image_width // 4), (image_height // 4) * 64)
    x = keras.layers.Reshape(target_shape=new_shape, name="reshape")(x)
    x = keras.layers.Dense(64, activation="relu", name="dense1")(x)
    x = keras.layers.Dropout(0.2)(x)

    # RNN。
    x = keras.layers.Bidirectional(
        keras.layers.LSTM(128, return_sequences=True, dropout=0.25)
    )(x)
    x = keras.layers.Bidirectional(
        keras.layers.LSTM(64, return_sequences=True, dropout=0.25)
    )(x)

    # +2 是为了考虑 CTC 损失引入的两个特殊标记。
    # 此推荐来自这里: https://git.io/J0eXP。
    x = keras.layers.Dense(
        len(char_to_num.get_vocabulary()) + 2, activation="softmax", name="dense2"
    )(x)

    # 添加 CTC 层以计算每一步的 CTC 损失。
    output = CTCLayer(name="ctc_loss")(labels, x)

    # 定义模型。
    model = keras.models.Model(
        inputs=[input_img, labels], outputs=output, name="handwriting_recognizer"
    )
    # 优化器。
    opt = keras.optimizers.Adam()
    # 编译模型并返回。
    model.compile(optimizer=opt)
    return model


# 获取模型。
model = build_model()
model.summary()
模型: "handwriting_recognizer"
__________________________________________________________________________________________________
层 (类型)                         输出形状          参数 #     连接到                        
==================================================================================================
image (输入层)                   [(None, 128, 32, 1)] 0                                            
__________________________________________________________________________________________________
Conv1 (卷积层)                    (None, 128, 32, 32)  320         image[0][0]                      
__________________________________________________________________________________________________
pool1 (最大池化层)                (None, 64, 16, 32)   0           Conv1[0][0]                      
__________________________________________________________________________________________________
Conv2 (卷积层)                    (None, 64, 16, 64)   18496       pool1[0][0]                      
__________________________________________________________________________________________________
pool2 (最大池化层)                (None, 32, 8, 64)    0           Conv2[0][0]                      
__________________________________________________________________________________________________
reshape (重塑)                   (None, 32, 512)      0           pool2[0][0]                      
__________________________________________________________________________________________________
dense1 (密集层)                  (None, 32, 64)       32832       reshape[0][0]                    
__________________________________________________________________________________________________
dropout (丢弃)                   (None, 32, 64)       0           dense1[0][0]                     
__________________________________________________________________________________________________
bidirectional (双向)             (None, 32, 256)      197632      dropout[0][0]                    
__________________________________________________________________________________________________
bidirectional_1 (双向)           (None, 32, 128)      164352      bidirectional[0][0]              
__________________________________________________________________________________________________
label (输入层)                    [(None, None)]       0                                            
__________________________________________________________________________________________________
dense2 (密集层)                  (None, 32, 81)       10449       bidirectional_1[0][0]            
__________________________________________________________________________________________________
ctc_loss (CTCLayer)              (None, 32, 81)       0           label[0][0]                      
                                                                 dense2[0][0]                     
==================================================================================================
总参数: 424,081
可训练参数: 424,081
不可训练参数: 0
__________________________________________________________________________________________________

评估指标

编辑距离 是评估OCR模型最广泛使用的指标。在这一部分,我们将实现它并将其用作回调函数来监控我们的模型。

我们首先为了方便起见将验证图像及其标签分开。

validation_images = []
validation_labels = []

for batch in validation_ds:
    validation_images.append(batch["image"])
    validation_labels.append(batch["label"])

现在,我们创建一个回调函数来监控编辑距离。

def calculate_edit_distance(labels, predictions):
    # 获取一个批次并将其标签转换为稀疏张量。
    saprse_labels = tf.cast(tf.sparse.from_dense(labels), dtype=tf.int64)

    # 进行预测并将其转换为稀疏张量。
    input_len = np.ones(predictions.shape[0]) * predictions.shape[1]
    predictions_decoded = keras.backend.ctc_decode(
        predictions, input_length=input_len, greedy=True
    )[0][0][:, :max_len]
    sparse_predictions = tf.cast(
        tf.sparse.from_dense(predictions_decoded), dtype=tf.int64
    )

    # 计算单个编辑距离并对其取平均值。
    edit_distances = tf.edit_distance(
        sparse_predictions, saprse_labels, normalize=False
    )
    return tf.reduce_mean(edit_distances)


class EditDistanceCallback(keras.callbacks.Callback):
    def __init__(self, pred_model):
        super().__init__()
        self.prediction_model = pred_model

    def on_epoch_end(self, epoch, logs=None):
        edit_distances = []

        for i in range(len(validation_images)):
            labels = validation_labels[i]
            predictions = self.prediction_model.predict(validation_images[i])
            edit_distances.append(calculate_edit_distance(labels, predictions).numpy())

        print(
            f"第 {epoch + 1} 轮的平均编辑距离: {np.mean(edit_distances):.4f}"
        )

训练

现在我们准备开始模型训练。

epochs = 10  # 为了得到好的结果,这个值应该至少为 50。

model = build_model()
prediction_model = keras.models.Model(
    model.get_layer(name="image").input, model.get_layer(name="dense2").output
)
edit_distance_callback = EditDistanceCallback(prediction_model)

# 训练模型。
history = model.fit(
    train_ds,
    validation_data=validation_ds,
    epochs=epochs,
    callbacks=[edit_distance_callback],
)
Epoch 1/10
1357/1357 [==============================] - 89s 51ms/step - loss: 13.6670 - val_loss: 11.8041
第 1 轮的平均编辑距离: 20.5117
Epoch 2/10
1357/1357 [==============================] - 48s 36ms/step - loss: 10.6864 - val_loss: 9.6994
第 2 轮的平均编辑距离: 20.1167
Epoch 3/10
1357/1357 [==============================] - 48s 35ms/step - loss: 9.0437 - val_loss: 8.0355
第 3 轮的平均编辑距离: 19.7270
Epoch 4/10
1357/1357 [==============================] - 48s 35ms/step - loss: 7.6098 - val_loss: 6.4239
第 4 轮的平均编辑距离: 19.1106
Epoch 5/10
1357/1357 [==============================] - 48s 35ms/step - loss: 6.3194 - val_loss: 4.9814
第 5 轮的平均编辑距离: 18.4894
Epoch 6/10
1357/1357 [==============================] - 48s 35ms/step - loss: 5.3417 - val_loss: 4.1307
第 6 轮的平均编辑距离: 18.1909
Epoch 7/10
1357/1357 [==============================] - 48s 35ms/step - loss: 4.6396 - val_loss: 3.7706
第 7 轮的平均编辑距离: 18.1224
Epoch 8/10
1357/1357 [==============================] - 48s 35ms/step - loss: 4.1926 - val_loss: 3.3682
第 8 轮的平均编辑距离: 17.9387
Epoch 9/10
1357/1357 [==============================] - 48s 36ms/step - loss: 3.8532 - val_loss: 3.1829
第 9 轮的平均编辑距离: 17.9074
Epoch 10/10
1357/1357 [==============================] - 49s 36ms/step - loss: 3.5769 - val_loss: 2.9221
第 10 轮的平均编辑距离: 17.7960

推理

# 用于解码网络输出的工具函数。
def decode_batch_predictions(pred):
    input_len = np.ones(pred.shape[0]) * pred.shape[1]
    # 使用贪婪搜索。对于复杂任务,可以使用束搜索。
    results = keras.backend.ctc_decode(pred, input_length=input_len, greedy=True)[0][0][
        :, :max_len
    ]
    # 遍历结果并获取文本。
    output_text = []
    for res in results:
        res = tf.gather(res, tf.where(tf.math.not_equal(res, -1)))
        res = tf.strings.reduce_join(num_to_char(res)).numpy().decode("utf-8")
        output_text.append(res)
    return output_text


# 让我们在一些测试样本上检查结果。
for batch in test_ds.take(1):
    batch_images = batch["image"]
    _, ax = plt.subplots(4, 4, figsize=(15, 8))

    preds = prediction_model.predict(batch_images)
    pred_texts = decode_batch_predictions(preds)

    for i in range(16):
        img = batch_images[i]
        img = tf.image.flip_left_right(img)
        img = tf.transpose(img, perm=[1, 0, 2])
        img = (img * 255.0).numpy().clip(0, 255).astype(np.uint8)
        img = img[:, :, 0]

        title = f"预测: {pred_texts[i]}"
        ax[i // 4, i % 4].imshow(img, cmap="gray")
        ax[i // 4, i % 4].set_title(title)
        ax[i // 4, i % 4].axis("off")

plt.show()

png

为了获得更好的结果,模型应该至少训练50个epochs。


最后的备注

  • prediction_model 完全兼容 TensorFlow Lite。如果您有兴趣,可以在移动应用程序中使用它。您可以在这方面找到 这个笔记本 有用。
  • 并不是所有的训练示例都完美对齐,如这个例子所示。这可能会影响复杂序列的模型性能。为此,我们可以利用空间变换网络(Jaderberg et al.),帮助模型学习最大化其性能的仿射变换。