Как я научила свой компьютер играть в пары используя OpenCV и Глубокое обучение
Немного веселья с компьютерным зрением и CNN с маленькой базой данных.
Моё хобби это настольные игры и, поскольку я имею немного знаний о CNN, я решила сделать приложение, что может победить людей в карточной игре. Я хотела построить модель с нуля при помощи моей собственной базы данных, чтобы посмотреть, насколько хороша модель выйдет с нуля с маленькой базой данных. Было принято решение начать с не слишком сложной игры, Spot it! (она же, Пары).
В случае, если вы всё ещё не знаете об этой игре, вот короткое пояснение: Пары это простая игра на распознавание образов, в которой игроки пытаются найти изображения на двух карточках. В оригинальном Spot it! , на каждой карточке находятся по восемь картинок с разницей в размере между разными карточками. Любые две карточки имеют ровно по одной общей картинке. Если вы находите её первым, вы выигрываете карту. Как только колода из 55 карточек заканчивается, победа присуждается тому, у кого окажется больше карт.
Попробуйте сами: какой общий символ на карточках, показанных выше?С чего начать?
Первым шагом в любом data science исследовании является сбор данных. Я сделала несколько фото на свой телефон, по шесть фото каждой карты. Итого у меня 330 картинок. Четыре из них показаны ниже. Вы можете подумать:, а этого достаточно для создания полноценной Свёрточной Нейронной Сети (CNN)? Вернёмся к этому позже!
Обработка изображений
Хорошо, мы имеем данные, что дальше? Возможно, это самый важный пункт в пути к успеху: обработка изображений. Нам нужно обработать картинки, показанные на каждой карточке. Здесь возникает небольшие трудности. Вы можете видеть, что некоторые картинки достать сложнее: снеговик, привидение (третья картинка) и игла (четвёртая картинка) имеют яркий цвет, а пятна (вторая картинка) и восклицательный знак (четвёртая картинка) состоят из нескольких частей. После этого мы изменяем и сохраняем картинку.
Добавляем контраст
Мы используем цветовую систему Lab для изменения контраста. L означает яркость, a обозначает соотношение зелёного к фиолетовому, а b — голубого к жёлтому. Мы легко можем извлечь эти компоненты при помощи OpenCV:
import cv2
import imutils
imgname = 'picture1'
image = cv2.imread(f’{imgname}.jpg’)
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
Слева направо: оригинальное изображение, световая компонента, a компонента и b компонентаСейчас мы добавим контрастности к световой компоненте, сольём компоненты обратно и конвертируем картинку:
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8,8))
cl = clahe.apply(l)
limg = cv2.merge((cl,a,b))
final = cv2.cvtColor(limg, cv2.COLOR_LAB2BGR)
Слева направо: оригинальное изображение, световая компонента, с увеличенным контрастом, конвертированная обратно в RGBМасштабирование
Потом мы масштабируем и сохраняем картинку:
resized = cv2.resize(final, (800, 800))
# сохраним изображение
cv2.imwrite(f'{imgname}processed.jpg', blurred)
Готово!
Обнаружение карточек и картинок
Сейчас картинки обработаны и мы можем начать с нахождения образов на фото. Можно найти их внешние контуры при помощи OpenCV. Затем надо будет конвертировать изображение в чёрно-белое, выбрать порог (в нашем случае, 190), чтобы найти контуры. В коде:
image = cv2.imread(f’{imgname}processed.jpg’)
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
thresh = cv2.threshold(gray, 190, 255, cv2.THRESH_BINARY)[1]
# ищем контуры
cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
output = image.copy()
# рисуем контуры на картинке
for c in cnts:
cv2.drawContours(output, [c], -1, (255, 0, 0), 3)
Обрабатываемое изображение, конвертированное в чёрно-белое, разделённое по порогу и с контурамиПосле того, как мы отсортировали контуры по областям, мы можем найти контур с наибольшей площадью: это карточка. Мы можем создать белый фон, чтобы вытянуть картинки.
# сортируем по площади, берём наибольшую
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[0]
# создаём маску по наибольшему контуру
mask = np.zeros(gray.shape,np.uint8)
mask = cv2.drawContours(mask, [cnts], -1, 255, cv2.FILLED)
# карточку на передний план
fg_masked = cv2.bitwise_and(image, image, mask=mask)
# белый фон (используем инвертированную маску)
mask = cv2.bitwise_not(mask)
bk = np.full(image.shape, 255, dtype=np.uint8)
bk_masked = cv2.bitwise_and(bk, bk, mask=mask)
# сливаем фон и передний план
final = cv2.bitwise_or(fg_masked, bk_masked)
Маска, фон, передний план, объединённоеА сейчас пора выделять картинки! Мы можем использовать предидущее изображение, чтобы вновь определить внешние контуры — они и есть картинки. Если мы выделим площадь вокруг каждой картинки, мы сможем извлечь её. В этот раз код немножко длиннее:
# прямо как и в предидущем случае (с удалением карточки)
gray = cv2.cvtColor(final, cv2.COLOR_RGB2GRAY)
thresh = cv2.threshold(gray, 195, 255, cv2.THRESH_BINARY)[1]
thresh = cv2.bitwise_not(thresh)
cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[:10]
# обрабатываем каждый контур
i = 0
for c in cnts:
if cv2.contourArea(c) > 1000:
# рисуем маску, оставляем контур
mask = np.zeros(gray.shape, np.uint8)
mask = cv2.drawContours(mask, [c], -1, 255, cv2.FILLED)
# белый фон
fg_masked = cv2.bitwise_and(image, image, mask=mask)
mask = cv2.bitwise_not(mask)
bk = np.full(image.shape, 255, dtype=np.uint8)
bk_masked = cv2.bitwise_and(bk, bk, mask=mask)
finalcont = cv2.bitwise_or(fg_masked, bk_masked)
# ограничивающая область по контуру
output = finalcont.copy()
x,y,w,h = cv2.boundingRect(c)
# squares io rectangles
if w < h:
x += int((w-h)/2)
w = h
else:
y += int((h-w)/2)
h = w
# вырезаем область с картинкой
roi = finalcont[y:y+h, x:x+w]
roi = cv2.resize(roi, (400,400))
# сохраняем картинку
cv2.imwrite(f"{imgname}_icon{i}.jpg", roi)
i += 1
Разделённое по порогу, с определёнными контурами, картинки призрака и сердца (вырезанные по маске)Сортировка картинок
Сейчас начинается скучная часть! Время сортировки картинок. Нам нужны папки теста, трейна и валидации, содержащие по 57 подпапок каждая (у нас 57 различных картинок). Структура каталога выглядит так:
symbols
├── test
│ ├── anchor
│ ├── apple
│ │ ...
│ └── zebra
├── train
│ ├── anchor
│ ├── apple
│ │ ...
│ └── zebra
└── validation
├── anchor
├── apple
│ ...
└── zebra
Потребуется время, Чтобы поместить все извлечённые картинки в нужные каталоги (более 2500)! У меня есть код для создания подпапок, набор тестов и проверок на GitHub. Может, в следующий раз будет лучше провести сортировку алгоритмом кластеризации…
Обучение Свёрточной Нейронной Сети (CNN)
После скучной части наступает крутая часть. Давайте сделаем и обучим CNN. Вы можете найти информацию о CNN в этом посте.
Архитектура модели
Это задача многоклассовой классификации с одной меткой. Нам нужна одна метка для каждого символа. Вот почему необходимо выбрать softmax активации последнего уровня с 57 нейронами и категориальной функцией потерь перекрёстной энтропии.
Архитектура итоговой модели выглядит так:
# импорт
from keras import layers
from keras import models
from keras import optimizers
from keras.preprocessing.image import ImageDataGenerator
import matplotlib.pyplot as plt
# слои, активационный слой с 57 нейронами (по одному на каждый символ)
model = models.Sequential()
model.add(layers.Conv2D(32, (3, 3), activation='relu', input_shape=(400, 400, 3)))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(256, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(256, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.Flatten())
model.add(layers.Dropout(0.5))
model.add(layers.Dense(512, activation='relu'))
model.add(layers.Dense(57, activation='softmax'))
model.compile(loss='categorical_crossentropy', optimizer=optimizers.RMSprop(lr=1e-4), metrics=['acc'])
Аугментация данных
Для лучшей производительности я использовала аугментацию данных. Аугментация данных — это процесс увеличения количества и разнообразия входных данных. Это возможно путем поворота, сдвига, масштабирования, обрезки и отражения существующих изображений. Аугментацию данных легко выполнить с Keras:
# определим папки
train_dir = 'symbols/train'
validation_dir = 'symbols/validation'
test_dir = 'symbols/test'
# аугментация данных при помощи ImageDataGenerator из Keras (только для тренировки)
train_datagen = ImageDataGenerator(rescale=1./255, rotation_range=40, width_shift_range=0.1, height_shift_range=0.1, shear_range=0.1, zoom_range=0.1, horizontal_flip=True, vertical_flip=True)
test_datagen = ImageDataGenerator(rescale=1./255)
train_generator = train_datagen.flow_from_directory(train_dir, target_size=(400,400), batch_size=20, class_mode='categorical')
validation_generator = test_datagen.flow_from_directory(validation_dir, target_size=(400,400), batch_size=20, class_mode='categorical')
Если вам интересно, аугментирование привидения выглядит так:
Оригинальное привидение слева, на других изображениях примеры аугментирования данныхПодгон модели
Пора бы подогнать модель, сохранить ее для прогнозов и проверить результаты.
history = model.fit_generator(train_generator, steps_per_epoch=100, epochs=100, validation_data=validation_generator, validation_steps=50)
# не забывайте сохранить вашу модель
model.save('models/model.h5')
Полученные результаты
Базовая модель, которую я обучила, была без аугментации и исключения данных и имела меньше слоев. Эта модель дала следующие результаты:
Результаты базовой моделиВы можете ясно видеть, что эта модель обучается. Результаты итоговой модели (из кода в предыдущих абзацах) намного лучше. На изображении ниже вы можете увидеть точность и потери трейна и валидации.
Результаты итоговой моделиНа тестовом наборе эта модель допустила только одну ошибку: она назвала каплю вместо бомбы. Я решила остановиться на модели, точность которой составила 0,995 на тестовом наборе.
Найдите общую картинку двух карточек
Теперь можно угадать общую картинку двух карточек. Мы можем взять два изображения, идентифицировать каждую картинку отдельно и использовать пересечение, чтобы увидеть, какая картинка есть на обеих карточках. У этого есть три возможных исхода:
Что-то пошло не так: не найдено общих картинок.
На ровно одна общая картинка (может быть правильной или неправильной).
Есть больше одной общей картинки. В данном случае я выбрала картинку с наибольшей вероятностью (среднее значение обоих прогнозов).
Код находится на GitHub для прогнозирования всех комбинаций двух изображений в каталоге, файле main.py.
Некоторые результаты:
Заключение
Это идеальная модель? К сожалению, нет! Когда я сделала новые снимки карточек и дала модели найти общий символ, у неё были некоторые проблемы со снеговиком. Иногда она называла снеговиком глаз или зебру! Это дает несколько странные результаты:
Снеговик? Где?Эта модель лучше людей? Это зависит от обстоятельств: люди могут делать это идеально, но модель работает быстрее! Я рассчитала при помощи компьютера: я дала ему колоду из 55 карт и спросила общий символ для каждой комбинации из двух карт. Всего 1485 комбинаций. Это заняло у компьютера менее 140 секунд. Компьютер допустил несколько ошибок, но по скорости он точно превзойдет любого человека!
Я не думаю, что создать 100%-ную модель действительно сложно. Это может быть сделано, например, с использованием трансферного обучения. Чтобы понять, что делает модель, мы можем визуализировать слои для тестового изображения. Что попробовать в следующий раз!
Надеюсь, вам понравилось читать этот пост! ❤