Как я создал распознаватель рукописного текста и отправил его в App Store

1656602655 kak ya sozdal raspoznavatel rukopisnogo teksta i otpravil ego v

От построения сверточной нейронной сети до развертывания OCR на iOS

Мотивация проекта ✍️ ??

Пока я учился создавать модели глубокого обучения для набора данных MNIST несколько месяцев назад, я в результате создал приложение для iOS, которое распознавало рукописные символы.

Мой друг Каичи Момосе разрабатывал приложение для изучения японского языка Nukon. Он случайно хотел иметь в нем схожую черту. Затем мы сотрудничали, чтобы создать нечто более сложное, чем распознаватель цифр: OCR (оптическое распознавание/считывание символов) для японских иероглифов (хирагана и катакана).

1*Iy1B42dQjgAaYwweyrUA-g
Базовые хираганы и катакана

При разработке Nukon не было API для распознавания рукописного текста на японском языке. У нас не было другого выбора, кроме как создать свой OCR. Наибольшее преимущество, которое мы получили, создав ее с нуля, было то, что наша работа работает в автономном режиме. Пользователи могут находиться глубоко в горах без Интернета и открывать Nukon, чтобы поддерживать свой ежедневный распорядок изучения японского языка. Мы многому научились в течение этого процесса, но, что еще более важно, мы были в восторге от того, чтобы отправить лучший продукт для наших пользователей.

В этой статье будет рассмотрен процесс создания японского OCR для приложений iOS. Для тех, кто хочет создать его для других языков/символов, не стесняйтесь настроить, изменив набор данных.

Без лишних разговоров, давайте посмотрим, что будет рассмотрено:

Часть 1️: Получите набор данных и изображения предварительной обработки
Часть 2️⃣: Создание и обучение CNN (свертка нейронной сети)
Часть 3️⃣: Интегрируйте обученную модель в iOS

1*TZHSfqQ9CUAuBR_o2AhdZA
Как может выглядеть окончательное приложение (демо-версия поступает от Recogmize)

Получить набор данных и изображения предварительной обработки?

Набор данных поступает из базы данных символов ETL, содержащей девять наборов изображений рукописных символов и символов. Поскольку мы собираемся создать OCR для Hiragana, ETL8 – это набор данных, который мы будем использовать.

1*pZ12Pa-cCEs64Mf44ubmog
Изображения рукописного «а», созданные 160 писателями (из ETL8)

Чтобы получить изображение из базы данных, нам нужны некоторые вспомогательные функции, которые считывают и сохраняют изображение .npz формат.

import struct
import numpy as np
from PIL import Image

sz_record = 8199

def read_record_ETL8G(f):
    s = f.read(sz_record)
    r = struct.unpack('>2H8sI4B4H2B30x8128s11x', s)
    iF = Image.frombytes('F', (128, 127), r[14], 'bit', 4)
    iL = iF.convert('L')
    return r + (iL,)
  
def read_hiragana():
    # Type of characters = 70, person = 160, y = 127, x = 128
    ary = np.zeros([71, 160, 127, 128], dtype=np.uint8)

    for j in range(1, 33):
        filename="../../ETL8G/ETL8G_{:02d}".format(j)
        with open(filename, 'rb') as f:
            for id_dataset in range(5):
                moji = 0
                for i in range(956):
                    r = read_record_ETL8G(f)
                    if b'.HIRA' in r[2] or b'.WO.' in r[2]:
                        if not b'KAI' in r[2] and not b'HEI' in r[2]:
                            ary[moji, (j - 1) * 5 + id_dataset] = np.array(r[-1])
                            moji += 1
    np.savez_compressed("hiragana.npz", ary)

Раз у нас есть hiragana.npz сохранено, начнем обработку изображений, загрузив файл и изменяя размеры изображения до 32×32 пикселей. Мы также добавим расширение данных для создания дополнительных изображений, возвращаемых и масштабируемых. Когда наша модель учится на изображениях персонажей разных ракурсов, наша модель может лучше адаптироваться к почерку людей.

import scipy.misc
from keras.layers import Conv2D, MaxPooling2D
from keras.layers import Dense, Dropout, Activation, Flatten
from keras.models import Sequential
from keras.preprocessing.image import ImageDataGenerator
from keras.utils import np_utils
from sklearn.model_selection import train_test_split

# 71 characters
nb_classes = 71
# input image dimensions
img_rows, img_cols = 32, 32

ary = np.load("hiragana.npz")['arr_0'].reshape([-1, 127, 128]).astype(np.float32) / 15
X_train = np.zeros([nb_classes * 160, img_rows, img_cols], dtype=np.float32)
for i in range(nb_classes * 160):
    X_train[i] = scipy.misc.imresize(ary[i], (img_rows, img_cols), mode="F")
 
y_train = np.repeat(np.arange(nb_classes), 160)

X_train, X_test, y_train, y_test = train_test_split(X_train, y_train, test_size=0.2)

# convert class vectors to categorical matrices
y_train = np_utils.to_categorical(y_train, nb_classes)
y_test = np_utils.to_categorical(y_test, nb_classes)

# data augmentation
datagen = ImageDataGenerator(rotation_range=15, zoom_range=0.20)
datagen.fit(X_train)

Создайте и учите CNN?️

Теперь наступает веселая часть! Мы будем использовать Keras для построения CNN (сверточной нейронной сети) для нашей модели. Когда я впервые создавал модель, экспериментировал с гиперпараметрами и настраивал их несколько раз. Комбинация ниже дала мне высокую точность — 98,77%. Не стесняйтесь сами поиграть с разными параметрами.

model = Sequential()

def model_6_layers():
    model.add(Conv2D(32, 3, 3, input_shape=input_shape))
    model.add(Activation('relu'))
    model.add(Conv2D(32, 3, 3))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Dropout(0.5))

    model.add(Conv2D(64, 3, 3))
    model.add(Activation('relu'))
    model.add(Conv2D(64, 3, 3))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Dropout(0.5))

    model.add(Flatten())
    model.add(Dense(256))
    model.add(Activation('relu'))
    model.add(Dropout(0.5))
    model.add(Dense(nb_classes))
    model.add(Activation('softmax'))

model_6_layers()

model.compile(loss="categorical_crossentropy", 
              optimizer="adam", metrics=['accuracy'])
model.fit_generator(datagen.flow(X_train, y_train, batch_size=16), 
                    samples_per_epoch=X_train.shape[0],
                    nb_epoch=30, validation_data=(X_test, y_test))

Вот несколько советов, если вы найдете производительность модели неудовлетворительна на этапе обучения:

Модель есть переоборудование

Это означает, что модель недостаточно обобщена. Просмотрите статью, чтобы получить интуитивные объяснения.

Как обнаружить переоборудование: acc (точность) продолжает расти, но val_acc (точность проверки) делает обратное в процессе обучения.

Некоторые решения по переоборудованию: регуляризация (например, отсев), увеличение данных, улучшение качества набора данных

Как узнать, учится ли модель?

Модель не учится если val_loss (потеря подтверждения) увеличивается или не уменьшается во время учебы.

Используйте TensorBoard – он предоставляет визуализацию для производительности модели со временем. Это избавляется от утомительной задачи пересматривать каждую отдельную эпоху и постоянно сравнивать значение.

Поскольку мы удовлетворены нашей точностью, мы удаляем выпадающие слои, прежде чем сохранить весы и конфигурацию модели как файл.

for k in model.layers:
    if type(k) is keras.layers.Dropout:
        model.layers.remove(k)
        
model.save('hiraganaModel.h5')

Единственная задача, которая осталась перед переходом к части iOS, – это конвертация hiraganaModel.h5 к модели CoreML.

import coremltools

output_labels = [
'あ', 'い', 'う', 'え', 'お',
'か', 'く', 'こ', 'し', 'せ',
'た', 'つ', 'と', 'に', 'ね',
'は', 'ふ', 'ほ', 'み', 'め',
'や', 'ゆ', 'よ', 'ら', 'り',
'る', 'わ', 'が', 'げ', 'じ',
'ぞ', 'だ', 'ぢ', 'づ', 'で',
'ど', 'ば', 'び',
'ぶ', 'べ', 'ぼ', 'ぱ', 'ぴ',
'ぷ', 'ぺ', 'ぽ',
'き', 'け', 'さ', 'す', 'そ',
'ち', 'て', 'な', 'ぬ', 'の',
'ひ', 'へ', 'ま', 'む', 'も',
'れ', 'を', 'ぎ', 'ご', 'ず',
'ぜ', 'ん', 'ぐ', 'ざ', 'ろ']

scale = 1/255.

coreml_model = coremltools.converters.keras.convert('./hiraganaModel.h5',
                                                    input_names="image",
                                                    image_input_names="image",
                                                    output_names="output",
                                                    class_labels= output_labels,
                                                    image_scale=scale)
coreml_model.author="Your Name"
coreml_model.license="MIT"
coreml_model.short_description = 'Detect hiragana character from handwriting'
coreml_model.input_description['image'] = 'Grayscale image containing a handwritten character'
coreml_model.output_description['output'] = 'Output a character in hiragana'
coreml_model.save('hiraganaModel.mlmodel')

The output_labels это все возможные выходы, которые мы увидим в iOS позже.

Интересный факт: если вы понимаете японский язык, вы можете знать, что порядок исходных символов не совпадает с «алфавитным порядком» хираганы. Нам понадобилось некоторое время, чтобы понять, что изображение в ETL8 не в «алфавитном порядке» (благодарю Каичи за то, что он это понял). Набор данных был составлен японским университетом, однако…?

Интегрировать обученную модель в iOS?

Наконец-то мы собираем все вместе! Перетащить и бросить hiraganaModel.mlmodel в проекте Xcode. Тогда вы увидите что-то вроде этого:

1*axV4LC7bbb-QfvWtdwYnjA
Подробности mlmodel в рабочей области Xcode

Примечание: Xcode создаст рабочую область после копирования модели. Нам нужно переключить нашу среду кодирования на рабочее пространство иначе модель ML не будет работать!

Конечная цель состоит в том, чтобы наша модель Хираганы предусмотрела персонажа, передавая изображение. Чтобы достичь этого, мы создадим простой пользовательский интерфейс, чтобы пользователь мог писать, и будем сохранять записи пользователя в формате изображения. Наконец мы получаем значение пикселей изображения и передаем их нашей модели.

Сделаем это шаг за шагом:

  1. «Рисуйте» символы UIView с UIBezierPath
import UIKit

class viewController: UIViewController {

    @IBOutlet weak var canvas: UIView!
    var path = UIBezierPath()
    var startPoint = CGPoint()
    var touchPoint = CGPoint()
  
    override func viewDidLoad() {
        super.viewDidLoad()
        canvas.clipsToBounds = true
        canvas.isMultipleTouchEnabled = true
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first
        if let point = touch?.location(in: canvas) {
            startPoint = point
        }
    }
  
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first
        if let point = touch?.location(in: canvas) {
            touchPoint = point
        }
        
        path.move(to: startPoint)
        path.addLine(to: touchPoint)
        startPoint = touchPoint
        draw()
    }
    
    func draw() {
        let strokeLayer = CAShapeLayer()
        strokeLayer.fillColor = nil
        strokeLayer.lineWidth = 8
        strokeLayer.strokeColor = UIColor.orange.cgColor
        strokeLayer.path = path.cgPath
        canvas.layer.addSublayer(strokeLayer)
    }
    
    // clear the drawing in view
    @IBAction func clearPressed(_ sender: UIButton) {
        path.removeAllPoints()
        canvas.layer.sublayers = nil
        canvas.setNeedsDisplay()
    }
}

The strokeLayer.strokeColor может быть любого цвета. Однако цвет фона canvas должно быть черный. Хотя наши обучающие изображения имеют белый фон и черные штрихи, модель ML плохо реагирует на входное изображение с этим стилем.

2. Поворот UIView в UIImage и получить значение пикселей с помощью CVPixelBuffer

В расширении есть две вспомогательные функции. Вместе они превращают изображение в буфер пикселей, что эквивалентно значению пикселей. Входные данные width и height должны быть оба 32 поскольку входные размеры нашей модели 32 на 32 пикселя.

Как только мы имеем pixelBufferможем позвонить model.prediction() и перейти pixelBuffer. И вот мы идем! Мы можем иметь выход classLabel!

@IBAction func recognizePressed(_ sender: UIButton) {
        // Turn view into an image
        let resultImage = UIImage.init(view: canvas)
        let pixelBuffer = resultImage.pixelBufferGray(width: 32, height: 32)
        let model = hiraganaModel3()
        // output a Hiragana character
        let output = try? model.prediction(image: pixelBuffer!)
        print(output?.classLabel)
}

extension UIImage {
    // Resizes the image to width x height and converts it to a grayscale CVPixelBuffer
    func pixelBufferGray(width: Int, height: Int) -> CVPixelBuffer? {
        return _pixelBuffer(width: width, height: height,
                           pixelFormatType: kCVPixelFormatType_OneComponent8,
                           colorSpace: CGColorSpaceCreateDeviceGray(),
                           alphaInfo: .none)
    }
    
    func _pixelBuffer(width: Int, height: Int, pixelFormatType: OSType,
                     colorSpace: CGColorSpace, alphaInfo: CGImageAlphaInfo) -> CVPixelBuffer? {
        var maybePixelBuffer: CVPixelBuffer?
        let attrs = [kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue,
                     kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue]
        let status = CVPixelBufferCreate(kCFAllocatorDefault,
                                         width,
                                         height,
                                         pixelFormatType,
                                         attrs as CFDictionary,
                                         &maybePixelBuffer)
        
        guard status == kCVReturnSuccess, let pixelBuffer = maybePixelBuffer else {
            return nil
        }
        
        CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
        let pixelData = CVPixelBufferGetBaseAddress(pixelBuffer)
        
        guard let context = CGContext(data: pixelData,
                                      width: width,
                                      height: height,
                                      bitsPerComponent: 8,
                                      bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
                                      space: colorSpace,
                                      bitmapInfo: alphaInfo.rawValue)
            else {
                return nil
        }
        
        UIGraphicsPushContext(context)
        context.translateBy(x: 0, y: CGFloat(height))
        context.scaleBy(x: 1, y: -1)
        self.draw(in: CGRect(x: 0, y: 0, width: width, height: height))
        UIGraphicsPopContext()
        
        CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
        return pixelBuffer
    }
}

3. Показать выход с помощью UIAlertController

Этот шаг является абсолютно необязательным. Как показано в GIF в начале, я добавил контроллер уведомлений, чтобы сообщать результат.

func informResultPopUp(message: String) {
        let alertController = UIAlertController(title: message, 
                                                message: nil, 
                                                preferredStyle: .alert)
        let ok = UIAlertAction(title: "Ok", style: .default, handler: { action in
            self.dismiss(animated: true, completion: nil)
        })
        alertController.addAction(ok)
        self.present(alertController, animated: true) { () in
        }
}

Вуаль! Мы только что создали OCR, готовый к демонстрации (и App-Store)! ??

Вывод?

Создание OCR не так уж сложно. Как вы видели, эта статья состоит из шагов и проблем, с которыми я столкнулся при создании этого проекта. Мне понравился процесс демонстрации кучи кода Python, подключив его к iOS, и я намерен это делать и дальше.

Я надеюсь, что эта статья предоставит полезную информацию для тех, кто хочет создать OCR, но не знает с чего начать.

Вы можете найти исходный код здесь.

Бонус: если вам интересно экспериментировать с неглубокими алгоритмами, продолжайте читать!

[Optional] Поезд с мелкими алгоритмами?

Прежде чем ввести CNN, мы из Каичи проверили другие алгоритмы машинного обучения, чтобы выяснить, смогут ли они выполнить работу (и сэкономить нам затраты на вычисление!). Мы выбрали KNN и Random Forest.

Чтобы оценить результаты, мы определили нашу точность базового уровня как 1/71 = 0,014.

Мы предположили, что человек, не знающий японского языка, может иметь 1,4% шанс правильно угадать символ.

Таким образом, модель будет хорошо работать, если бы ее точность превысила 1,4%. Давайте посмотрим, было ли это так. ?

KNN

1*FVM7oO8WGGDrCY-fBsU8Qw
Учился с KNN

Окончательная точность, которую мы получили, составила 54,84%. Уже намного выше 1,4%!

Случайный лес

1*B8NjOqzuBj9dOqob5hI3Rg
Учился с помощью Random Forest

Точность 79,23%, поэтому Random Forest превзошел наши ожидания. При настройке гиперпараметров мы получили лучшие результаты за счет увеличения количества отметок и глубины деревьев. Мы подумали, что наличие большего количества деревьев (оценщиков) в лесу означает, что будут изучены больше черт на изображении. Кроме того, чем глубже дерево, тем больше деталей оно узнало из характеристик.

Если вам интересно узнать больше, я нашел статью, в которой обсуждается классификация изображений с помощью Random Forest.

Спасибо, что читаете. Любые мнения и отзывы приветствуются!

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *