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

При разработке Nukon не было API для распознавания рукописного текста на японском языке. У нас не было другого выбора, кроме как создать свой OCR. Наибольшее преимущество, которое мы получили, создав ее с нуля, было то, что наша работа работает в автономном режиме. Пользователи могут находиться глубоко в горах без Интернета и открывать Nukon, чтобы поддерживать свой ежедневный распорядок изучения японского языка. Мы многому научились в течение этого процесса, но, что еще более важно, мы были в восторге от того, чтобы отправить лучший продукт для наших пользователей.
В этой статье будет рассмотрен процесс создания японского OCR для приложений iOS. Для тех, кто хочет создать его для других языков/символов, не стесняйтесь настроить, изменив набор данных.
Без лишних разговоров, давайте посмотрим, что будет рассмотрено:
Часть 1️: Получите набор данных и изображения предварительной обработки
Часть 2️⃣: Создание и обучение CNN (свертка нейронной сети)
Часть 3️⃣: Интегрируйте обученную модель в iOS

Получить набор данных и изображения предварительной обработки?
Набор данных поступает из базы данных символов ETL, содержащей девять наборов изображений рукописных символов и символов. Поскольку мы собираемся создать OCR для Hiragana, 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. Тогда вы увидите что-то вроде этого:

Примечание: Xcode создаст рабочую область после копирования модели. Нам нужно переключить нашу среду кодирования на рабочее пространство иначе модель ML не будет работать!
Конечная цель состоит в том, чтобы наша модель Хираганы предусмотрела персонажа, передавая изображение. Чтобы достичь этого, мы создадим простой пользовательский интерфейс, чтобы пользователь мог писать, и будем сохранять записи пользователя в формате изображения. Наконец мы получаем значение пикселей изображения и передаем их нашей модели.
Сделаем это шаг за шагом:
- «Рисуйте» символы
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

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

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