Сегодня потестируем ResNet-18 в задаче "матчинга" изображений, в задаче, к которой эту модель не готовили. А именно, попробуем искать динозавров на изображении по их признакам, выделенным с помощью ResNet. Посмотрим что это и как это можно сделать, сравним полученные результаты с результатами работы алгоритма cv2.matchTemplate(). Также, поближе познакомимся с работой ResNet.

Я выбрал ResNet, поскольку это классическая модель для решения задач компьютерного зрения. "Принципы её работы абсолютно понятны и запуск её на любом устройстве вызывает только покой и умиротворение" (с). Статья будет полезна новичкам, начинающим знакомство с обработкой изображений. Я постарался описать работу последних нейронных слоёв и трансформацию данных в процессе прямого прохода через модель. Также надеюсь на отклик опытных ML/python/torch/и_др. разработчиков. Буду рад ответить на вопросы и тем более жду конструктивной критики и замечаний.

Постановка задачи

Итак, перед нами два изображения (source и test) на которых изображены динозавры. Задача состоит в том, чтобы найти динозавра с одного изображения на другом изображении. Можно использовать любой алгоритм, в том числе и нейросети, но при этом нейросеть нельзя обучать на определение типа динозавра.

Рис.1. Два дино-алфавита. Слева - изображение "source", справа - "test".
Рис.1. Два дино-алфавита. Слева - изображение "source", справа - "test".

Если посмотреть внимательнее на каждого динозавра, то можно заметить, что часть из них просто поменяла окраску - появились пятна, полоски, изменился цвет. У некоторых кроме изменения цвета немного поменялась форма - хвост приподнялся или опустился, немного изменились костяные пластины на спине и тп. А многие поменяли и окраску, и форму. Ниже приведена визуализация динозавров в алфавитном порядке без посторонней информации.

динозавры на изображениях
dino_names = {'a': 'Allosaurus', 'b': 'Baryonyx', 'c': 'Caudipteryx', 
              'd': 'Diplodocus', 'e': 'Edmontonia', 'f': 'Fukuiraptor', 
              'g': 'Gallimimus', 'h': 'Hadrosaurus', 'i': 'Irritator',
              'j': 'Jobaria', 'k': 'Kentrosaurus', 'l': 'Leptoceratops',
              'm': 'Mussaurus', 'n': 'Noasaurus', 'o': 'Oviraptor',
              'p': 'Pterodactyl', 'q': 'Quaesitosaurus', 'r': 'Rebbachisaurus',
              's': 'Stegosaurus', 't': 'Tyrannosaurus', 'u': 'Urbacodon',
              'v': 'Velociraptor', 'w': 'Wuerhosaurus', 'x': 'Xenoceratops',
              'y': 'Yinlong', 'z': 'Zephyrosaurus'}

dinos_dict = {'a': (205, 301, 55, 247), 'b': (205, 301, 225, 385), 'c': (230, 294, 382, 446), 
              'd': (160, 293, 480, 650), 'e': (258, 298, 612, 722), 'f': (418, 479, 57, 183), 
              'g': (408, 472, 180, 286), 'h': (400, 486, 282, 442), 'i': (405, 474, 426, 586),
              'j': (385, 486, 540, 742), 'k': (575, 662, 55, 247), 'l': (610, 664, 240, 368),
              'm': (570, 666, 365, 515), 'n': (590, 659, 515, 621), 'o': (590, 664, 620, 721),
              'p': (765, 861, 70, 171), 'q': (735, 851, 188, 336), 'r': (760, 856, 310, 470),
              's': (770, 854, 480, 608), 't': (750, 851, 597, 757), 'u': (990, 1032, 63, 151),
              'v': (950, 1036, 150, 278), 'w': (956, 1040, 285, 413), 'x': (944, 1040, 420, 548), 
              'y': (980, 1034, 561, 631), 'z': (975, 1039, 634, 730)}

source = cv2.cvtColor(cv2.imread('images/abc.png') cv2.COLOR_BGR2RGB)

for n, dino in enumerate(dinos_dict):
    coords = dinos_dict[dino]
    template = source[coords[0]:coords[1], coords[2]:coords[3]]
    plt.subplot(5, 6, n+1)
    plt.imshow(template)
    plt.title(dino_names[dino])
plt.tight_layout()
plt.show()
Рис. 2. Динозавры с исходного изображения, те которых мы будем искать.
Рис. 2. Динозавры с исходного изображения, те которых мы будем искать.
Рис. 3. Динозавры с тестового изображения
Рис. 3. Динозавры с тестового изображения

Таким образом, динозавров можно разделить на три типа. Первый — одинаковые по форме динозавры, второй — те, у которых есть незначительные различия формы и третий тип — динозавры сильно отличающиеся по форме. Всё это представлено в таблице ниже (буквы соответствуют названиям динозавров):

Одинаковые

Небольшое различие

Разные

C, G, M,

B, E, L,

A, D, F, H, I, J, K,

Q, R, U, W, X, Y,

N, O, P, S, T, V, Z.

Как видно из таблицы почти половина динозавров идентичны. Более того, у многих из них совпадает не только форма но и цвет (что не отражено в таблице). Исходное и тестовое изображения были специально подобраны таким образом, чтобы упростить нам поиск. А так же, чтобы было удобно считать количество правильно найденных динозавров. Мы сознательно идём на такое упрощение, так как задача преследует учебные/развлекательные цели.

Далее мы будем по очереди вырезать динозавров из исходного изображения и пытаться найти их на тестовом изображении.

Проверка cv2.matchTemplate()

Для начала давайте проверим как справится с этой задачей классический алгоритм -cv2.matchTemplate(). Вырежем из картинки прямоугольник, содержащий только одного динозавра. Попробуем найти его сначала на исходном изображении, чтобы убедиться что алгоритм работает, а потом попробуем найти его на тестовом изображении. Начнём с поиска Фукуираптора.

coords = dinos_dict['f'] # ccords = (415, 479, 55, 183)
template = source[coords[0]:coords[1], coords[2]:coords[3]] # Динозавр, которого будем искать.

match_source = cv2.matchTemplate(source, template, cv2.TM_CCORR_NORMED)
match_test = cv2.matchTemplate(test, template, cv2.TM_CCORR_NORMED)
plt.imshow(match_source)
plt.colorbar()
plt.show()
Рис. 4. Тепловая карта - результат работы алгоритма cv2.matchTemplate(). Сравнение образца с изображением происходит попиксельно. a) поиск фукуираптора на исходном изображении, b) поиск на тестовом.
Рис. 4. Тепловая карта - результат работы алгоритма cv2.matchTemplate(). Сравнение образца с изображением происходит попиксельно. a) поиск фукуираптора на исходном изображении, b) поиск на тестовом.

Обратите внимание, что при поиске динозавра на исходном изображении (Рис.4 "а") находится всего одна "горячая" точка. Её координаты совпадают с координатами верхнего левого угла "template": y - 415, x - 55. То есть алгоритм сработал правильно и с пиксельной точностью обнаружил динозавра. В то же время поиск динозавра на тестовом изображении не даёт определённого ответа на вопрос, где динозавр? Алгоритм говорит, что максимальное значение (самая горячая точка) имеет координаты y - 870, x - 590. Если принять точку максимума за верхний левый угол и вырезать из изображения кусок размером 64x128px (test[870:870+2*32, 595:595+4*32]), то можно визуализировать результат.

Рис. 5. a) Разыскиваемый динозавр - фукуираптор, b) обнаруженный динозавр - зефирозавр
Рис. 5. a) Разыскиваемый динозавр - фукуираптор, b) обнаруженный динозавр - зефирозавр

Видим, что простое попиксельное сравнение не справилось с задачей. Но нужно учитывать, что искали мы Фукуираптора (Fukuiraptor), а он находится в третьем столбце нашей таблицы, т.е. его форма довольно сильно отличается на "source" и "test" изображениях.

Если пройтись в цикле по всем динозаврами и посчитать среднее расстояние от реального местоположения до найденного, то получится — 296 px в то время как средний размер динозавра равен 144 px (по диагонали). Кажется, что результат не удовлетворительный. Но, как известно, измерять среднюю температуру по больнице не самый лучший способ узнать состояние пациентов. И на самом деле алгоритм работает хорошо. Давайте выпишем тех динозавров, координаты которых были близки к реальным (отличались менее чем половина размера самого динозавра).

matches = []
for dino in dinos_dict:
    coords = dinos_dict[dino]
    template = image[coords[0]:coords[1], coords[2]:coords[3]].copy()
    coords_test = dinos_test[dino]
    dino_size = distance.euclidean((coords_test[2], coords_test[0]), (coords_test[3], coords_test[1]))
    a = (coords_test[2], coords_test[0]) # x_true, y_true
    template = image[coords[0]:coords[1], coords[2]:coords[3]].copy()
    match2 = cv2.matchTemplate(image2, template, cv2.TM_CCORR_NORMED)
    max_match = np.where(match2 == match2.max())
    b = (max_match[1][0], max_match[0][0]) # x, y
    dst = distance.euclidean(a, b)
    if dst < dino_size/2:
        print(dino, dst) # принтуем “название динозавра” и расстояние между найденными координатами и реальными
        delta_y = coords[1] - coords[0]
        delta_x = coords[3] - coords[2]
        matches[dino] = (b[1], b[1]+delta_y, b[0], b[0]+delta_x)

Получаем следующий результат

c 3.0
d 43.56604182158393
e 15.132745950421556
h 26.40075756488817
k 9.899494936611665
m 9.0
n 1.0
o 11.180339887498949
q 2.23606797749979
r 6.324555320336759
u 5.0
w 8.246211251235321
x 13.0
y 11.4017

То есть большая часть динозавров уверенно определяется, несмотря на различия в цвете. Давайте визуализируем

plt.show()
for n, dino in enumerate(matches):
    coords = dinos_dict[dino]
    template = image[coords[0]:coords[1], coords[2]:coords[3]]
    f_coord = matches[dino]
    found_dino = image2[f_coord[0]:f_coord[1], f_coord[2]:f_coord[3]]
    paste = np.concatenate((template, found_dino), axis=1)
    plt.subplot(6, 3, n+1)
    plt.imshow(paste)
    plt.title(dinos_name[dino])
plt.tight_layout()
plt.show()

Рис. 6. Результаты работы алгоритма cv2.matchTemplate(). Изображения объединялись в пары по принципу: слева - образец, справа - то что нашли
Рис. 6. Результаты работы алгоритма cv2.matchTemplate(). Изображения объединялись в пары по принципу: слева - образец, справа - то что нашли

Все найденные динозавры, за исключением диплодока, эдмонтонии, гадрозавра, овираптора и ноазавра находятся в первом столбце нашей таблицы, т.е. являются идентичными по форме. Только один динозавр из первого столбца не был обнаружен - это галлимим (Gallimimus). Возможно причина в жёлтом окрасе с яркими пятнами? Давайте проверим это предположение.

Если привести изображения к черно-белому формату (что нужно было сделать ещё в самом начале), то к уже имеющимся динозаврам добавятся ещё четыре - барионикс, галлимим(!), ирритатор и лептоцератопс. При этом гадрозавр (Hadrosaurus) теряется. Ниже приведена таблица найденных (зачёркнутые буквы) и не найденных (обычные буквы) динозавров.

Одинаковые

Небольшое различие

Разные

C, G, M,

B, E, L,

A, D, F, H, I, J, K,

Q, R, U, W, X, Y,

N, O, P, S, T, V, Z.

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

ResNet

Пришло время испытать нашу нейросеть. Прежде чем мы начнём несколько слов о ней. ResNet - победитель соревнования ILSVRC 2015 (Kaiming He, Xiangyu Zhang, Shaoqing Ren, Jian Sun) года в задаче классификации изображений. Первая сеть, которая по точности классификации обогнала человека. Состоит она из свёрточных слоев, объединенных в блоки, слоя "average pooling" и полносвязного слоя. На вход сети подаётся изображение, а на выходе мы имеем вектор, индекс максимального элемента которого является номером класса ("Australian terrier", например). Таким образом сеть предсказывает класс объекта на изображении. Как и любая другая нейросеть решающая подобную задачу ResNet делится на два принципиальных блока: блок извлекающий признаки (feature extractor) и блок классификатор — в данном случае полносвязный слой (FC).

Рис. 7. Принципиальная схема работы ResNet. Feature extractor извлекает признаки из изображения, полносвязный слой занимается классификацией. (GAP — global average pooling).
Рис. 7. Принципиальная схема работы ResNet. Feature extractor извлекает признаки из изображения, полносвязный слой занимается классификацией. (GAP — global average pooling).

За классификацию в ResNet отвечает последний полносвязный слой (FC). Делает он это на основе вектора признаков размерность которого (1, 2048) для Resnet-50 и (1, 512) для ResNet-18. Другими словами из изображения извлекаются (какие-то) признаки в размере 2048 штук и подаются на вход полносвязного слоя. Полносвязный слой в свою очередь выдает вероятность объекта (с данными признаками) принадлежать к тому или иному классу.

За извлечение признаков в ResNet-50 отвечает не один слой, а несколько блоков, состоящих из свёрточных и пулинг слоёв. Изображение 224х224х3 (h, w, c) проходя через эти блоки уменьшается по ширине и высоте, но увеличивается в глубину. И на предпоследний "average pooling" слой доходит тензор размерности 7х7х2048. Можно сказать, что изображение из 3-х канального (RGB) становится 2048-ми канальным. Каждый канал — это матрица 7 на 7, отвечающая за определенный признак на изображении. Её ещё называют картой признаков или "feature map". И, как уже было сказано, таких карт у нас 2048 (для ResNet-50 и 512 для ResNet-18). Чем больше значение у элементов матрицы тем сильнее выражен тот или иной признак на изображении. Трудно сказать за какой именно признак отвечает, например, i-ый канал. По большей части признаки — это что-то абстрактное.

Рис. 8. Архитектура ResNet-50, также показаны "метаморфозы" происходящие с изображением.
Рис. 8. Архитектура ResNet-50, также показаны "метаморфозы" происходящие с изображением.

Далее эти карты признаков проходят через слой пулинга ("global average pooling") и превращаются в вектор 1х2048. Этот вектор поступает на вход полносвязному слою (классификатору). Как проходя через слой "average pooling", тензор 7х7х2048 становится вектором 1х2048? Все просто - сорок девять значений карты признака усредняются, описывая насколько в среднем данный признак встречается на изображении.

Рис. 9. Работа слоя "global average pooling", он же GAP
Рис. 9. Работа слоя "global average pooling", он же GAP

Но откуда следует размер 7х7? Архитектура ResNet такова, что любое изображение дойдя до слоя GAP "сжимается" в 32 раза по ширине и высоте. Попробуйте подать на вход сети изображение 256x256 — получите тензор 8х8. Возьмите изображение 160х192 - получите 5х6. Можно сказать, что изображение разбивается на ячейки размером 32х32 пикселя, каждая из которых, пройдя через сеть, превратится в вектор 1x2048.

Абсолютно по той же логике работает ResNet-18, единственное отличие в том, что "feature extractor" извлекает из изображения не 2048, а 512 признаков.

Forward hook

Наконец, мы подошли к той идее, которую будем использовать для решения задачи поиска динозавров. Давайте возьмем динозавра с первого изображения, с помощью ResNet получим для него вектор признаков и сравним эти признаки с признаками тестового изображения, также полученными с помощью нейросети. Для того чтобы это сработало сеть должна быть обучена (pretrained=True), иначе те признаки которые мы получим будут сравнимы по качеству с рандомом. При этом нам самим не требуется обучать нейросеть, всё это сделали разработчики ResNet за нас.

Рис. 10. Упрощенная схема получения тепловой карты с помощью фичей.
Рис. 10. Упрощенная схема получения тепловой карты с помощью фичей.

На рисунке 10 изображён алгоритм получения тепловой карты с помощью ResNet. Знак "*" (звёздочка) добавлен условно, ниже мы поговорим о том, как можно сравнивать вектора между собой. Каждый вектор размера 512 это выход GlobalAveragePooling слоя. И если получение вектора-признаков одного динозавра почти не вызывает вопросов, то с получением признаков объектов на изображении придется разбираться (см. ниже). Также стоит отметить, что определить координаты динозавра (в px) по полученной тепловой карте мы сможем не точнее чем 32 пикселя. Поскольку точка максимума на тепловой карте при переносе её обратно на изображение превращается в участок размером 32х32 пикселя. Помните мы говорили, что любое изображение проходя через сеть "сжимается" в 32 раза по ширине и высоте? В обратную сторону это тоже работает.

Итак, если подать на вход нейросети изображение, то в результате мы получим один из тысячи классов, так как сеть обучена для задачи классификации. Но нам не нужно знать к какому классу по мнению нейросети относится тот или иной динозавр, нам нужно достать его признаки. Для того чтобы это сделать устанавливаем хук на тот слой, выход с которого является признаками. В ResNet-18, как и в ResNet-50 этот слой называется avgpool. Ниже показан код, который перехватывает выход с "avgpool":

import torchvision
import torch

model = torchvision.models.resnet18(pretrained=True)
model.train(False)
avgpool_features = None

def get_features (module, inputs, output):
    global  avgpool_features
    avgpool_features = output

# для того чтобы установить хук нужно знать название слоя
# в нашем случае — это «avgpool»
model = torchvision.models.resnet18(pretrained=True)
# устанавливаем хук
model.avgpool.register_forward_hook(get_features)

dummy_x = torch.randn(3, 224, 224)
# выход модели (предсказанный класс) нас не интересует
model(dummy_x[None,:,:,:])

assert avgpool_features.shape == (1, 512), 'expected (1, 512), but real is {}'.format(avgpool_features.shape)

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

Использование avgpool-features

На самом деле пользуясь этим приёмом можно проделывать много разных интересных штук. Например, можно получить вот такую ROC-AUC кривую (рис. 11) для данного соревнования. Первое место мы, конечно, не займём, но здесь важна простота и скорость решения задачи. Всё что мы сделали — это прогнали все изображения из тренировочного датасета через ResNet и сохранили их "avgpool_features". Далее использовали эти признаки для обучения RandomForestClassifier из sklearn. Код решения на github-е.

Рис. 11. ROC-AUC кривая для задачи классификации кошек и собак (Kaggle).
Рис. 11. ROC-AUC кривая для задачи классификации кошек и собак (Kaggle).

Вернёмся к динозаврам...

Достаем признаки фукуираптора с исходного изображения:

# Берём динозавра
dinos_dict = {'a': (205, 301, 55, 247), 'b': (205, 301, 225, 385), 'c': (230, 294, 382, 446), 
              'd': (160, 288, 490, 650), 'e': (240, 304, 610, 738), 'f': (415, 479, 55, 183), 
              'g': (408, 472, 180, 286), 'h': (390, 486, 282, 442), 'i': (405, 474, 426, 586),
              'j': (385, 486, 540, 742), 'k': (575, 671, 55, 247), 'l': (600, 664, 240, 368),
              'm': (570, 666, 365, 515), 'n': (590, 659, 515, 621), 'o': (590, 664, 620, 721),
              'p': (765, 861, 70, 171), 'q': (735, 851, 188, 336), 'r': (760, 856, 310, 470),
              's': (770, 854, 480, 608), 't': (755, 851, 597, 757), 'u': (990, 1032, 60, 156),
              'v': (940, 1036, 150, 278), 'w': (944, 1040, 285, 413), 'x': (944, 1040, 420, 548), 
              'y': (970, 1034, 561, 631), 'z': (975, 1039, 634, 730)}

coords = dinos_dict['f'] # f - Fukuiraptor
template = image[coords[0]:coords[1], coords[2]:coords[3]]

# Загружаем модель
model = torchvision.models.resnet18(pretrained=True)

avgpool_features = None
def get_features(module, inputs, output):
    global avgpool_features
    avgpool_features = output

# Добавляем форвард хук
model.avgpool.register_forward_hook(get_features)
model.eval()

# Не меняем форму входного изображения (обычно используют transforms.Resize((224,224)))
preprocess = torchvision.transforms.Compose([
    torchvision.transforms.ToPILImage(),
    torchvision.transforms.ToTensor(),
    torchvision.transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])

# Извлекаем признаки динозавра
input_tensor = preprocess(template) # template — динозавр под буквой F
model(input_tensor[None,:,:,:])
template_features = avgpool_features
template_features.shape # для ResNet18 shape равен [1, 512, 1, 1]

С тестовым изображением мы поступим аналогичным образом. С той разницей, что будем сохранять не вектор признаков, а карты признаков. Помните, мы говорили о том, что на вход AVG-слоя поступает тензор размером [7, 7, 2048]? Этот тензор нам и нужен. Для того чтобы его получить, добавляем хук на последний свёрточный блок нашей модели. На рисунке 8 он называется "Stage 5", в pytorch-модели он называется "layer4":

layer4_features = None
def get_features_map(module, inputs, output):
    global layer4_features
    layer4_features = output

model.layer4.register_forward_hook(get_features_map)

# Извлекаем признаки тестового изображения
input_tensor = preprocess(test_image)
model(input_tensor[None,:,:,:])
image_features = layer4_features
image_features.shape # [1, 512, 35, 23]

Изображение ([1094, 733, 3]) делится на квадраты — 32х32 пикселя, каждый из которых пройдя через сеть становится вектором [1, 512]. В результате получается матрица, состоящая из 35*23 векторов. Именно с ними мы и будем сравнивать наш "template_features". В результате мы получим тепловую карту, элементами которой будет "степень похожести" признаков динозавра на признаки "image_features". Очевидным недостатком такого подхода является низкая точность локализации найденного динозавра. Ведь мы ищем динозавра на сетке "с частотой дискретизации" 32 пикселя.

Понятно, что признаки тестового изображения не прошли через слой "avgpool" и должны отличаться от признаков фукуираптоа (template_features). Для того чтобы это исправить можно было бы подать получившийся тензор в "AvgPool2d" модуля "torch.nn". Но в этом случае размер ядра свёртки (kernel_size) пришлось бы подбирать для каждого динозавра. Или использовать средний размер динозавра для оценки kernel_size. Также можно было бы использовать не "avgpool_features" фукуираптора, а фичи с layer4. Тогда нам пришлось бы сравнивать тензор размера [1, 512, 2, 4], с тензором [1, 512, 35, 23]. Это можно сделать при помощи F.conv2d() того же torch.nn. Но результаты будут не сильно отличаться от приведенных в этой статье. (Хотя мы, конечно, понимаем, что сумма средних и средняя сумма - не одно и то же.)

Сравниваем признаки

Чтобы сравнить признаки между собой рассчитаем косинусное расстояние между вектором динозавра и каждым вектором тестового изображения. Для начала давайте просто "перемножим" вектор с трехмерной матрицей:

F.conv2d()

Можно сделать это вручную, а можно воспользоваться функционалом torch. Создадим свёрточный слой conv2d, в качестве параметров которого будет вектор template_features, а в качестве изображения тензор image_features.

import torch.nn.functional as F
heat_map = F.conv2d(image_features, template_features)

# Избавляемся от лишней размерности
image_features = image_features.squeeze()
template_features = template_features.squeeze()
# Сохраняем размеры исходного тензора
h, w = image_features.shape[1:]
# Получаем heat_map
image_features = image_features.view((template_features.shape[0], -1))
heat_map = template_features_sq @ image_features_sq
heat_map = heat_map.view((h, w))
# Накладываем heat_map на тестовое изображение
heat_map = heat_map - heat_map.min()
heat_map = ((heat_map / heat_map.max()) * 255).byte()
heat_map = heat_map.squeeze().cpu().detach().numpy()
image_height, image_width = test_image.shape[:2]
heat_map_resize = cv2.resize(heat_map.copy(), (image_width, image_height))
heat_map_rgb = cv2.applyColorMap(heat_map_resize, cv2.COLORMAP_JET)
heat_map_rgb = 255 - heat_map_rgb
heat_map_rgb = heat_map_rgb.astype(np.uint8)
inverted_heat_map = cv2.applyColorMap(heat_map_rgb, cv2.COLORMAP_JET)
res = cv2.addWeighted(test_image, 0.5, inverted_heat_map, 0.5, 0)
res = cv2.addWeighted(test_image, 0.5, heat_map_rgb, 0.5, 0)

plt.subplot(1, 2, 1)
plt.imshow(res)
plt.subplot(1, 2, 2)
plt.imshow(heat_map)
plt.show()
max = np.where(heat_map_resize==heat_map_resize.max())
print(f'max = {heat_map_resize.max()}, y = {max[0][0]}, x = {max[1][0]}')
Рис. 12. Тепловая карта, полученная без нормирования векторов.
Рис. 12. Тепловая карта, полученная без нормирования векторов.

Фукуираптор не найден, максимум находится где-то в районе велоцираптора. Забавно, что "подсвечиваются" в основном динозавры стоящие на двух ногах. Кажется, мы движемся в правильном направлении. Нормируем признаки тестового изображения (F.normalize(image_features, dim=1, p=1)), перемножаем с признаками Фукуираптора и получаем следующий результат.

Рис. 13. Результат поиска Фукуираптора
Рис. 13. Результат поиска Фукуираптора

Координаты максимума равны x = 48px, y = 296px.

Рис. 14. Изображение a) разыскиваемый динозавр, b) найденный динозавр.
Рис. 14. Изображение a) разыскиваемый динозавр, b) найденный динозавр.

Попався, кажется наш алгоритм работает! Но, к сожалению, Фукуираптор - это скорее исключение, чем правило. Если пройтись в цикле по всем динозаврам получим следующий результат.

Одинаковые

С небольшим отличием

Разные

C, G, M,

B, E, L,

A, D, F, H, I, J, K,

Q, R, U, W, X, Y,

N, O, P, S, T, V, Z.

Наш алгоритм, на основе сравнения признаков справился гораздо хуже чем предыдущий, увы. Нашлось всего семь динозавров. Скорей всего причина кроется в разбиении изображения сеткой 32х32 px. Так как мы не управляем этим разбиением часть динозавров могли объединиться с буквами на изображении и потерять свои характерные признаки. Хотя я бы не стал говорить, что нейросеть проиграла в сухую. Вы только посмотрите на Алозавра с первой и со второй картинки, что в них общего? Тем не мене для Алозавра мы имеем "матч".

Рис. 15. Эти динозавры похожи, но почему?
Рис. 15. Эти динозавры похожи, но почему?
Ещё один способ искать Фукуираптора

Ещё один способ сравнить признаки - это рассчитать расстояние между векторами оно же среднеквадратичное (sum || T_i - S_i||^2). Но прежде чем это сделать будем нормировать вектора по каждому признаку отдельно.

def cat_template_features(features, shape):
    '''Конкатинируем признаки фукуираптора, чтобы можно было рассчитать mse_loss'''
    for i in range(shape[3]):
        for j in range(shape[2]):
            if j == 0:
                line = features
                continue
            line = torch.cat([line, features], dim=2)
        if i == 0:
            cat_features = line
            continue
        cat_features = torch.cat([cat_features, line], dim=3)
    return cat_features

#normalize each feature
image_norm = image_features.norm(2, dim=[2,3], keepdim=True)
image_features_n = F.normalize(image_features, dim=[2,3], p=2)
template_features_n = template_features / image_norm 

# конкатинируем признаки
cat_features = cat_template_features(template_features, image_features.shape):

# Рассчитаем расстояние 
loss = F.mse_loss(image_features_n, cat_features, reduce=False)
heat_map = loss.sum(dim=1)
Рис. 16. Поиск фукуираптора. Тепловая карта построена с помошью "mse_loss"
Рис. 16. Поиск фукуираптора. Тепловая карта построена с помошью "mse_loss"

В целом, результат получается хуже. Проход в цикле по всем динозаврам дает только велоцераптора и вуерозавра ((.

Окончательный результат

Если упростить нейронке жизнь - нарезать изображения на отдельные фрагменты, содержащие только динозавров и сравнивать их между собой, то находятся уже 16 динозавров из 26.

Одинаковые

С небольшим отличием

Разные

C, G, M,

B, E, L,

A, D, F, H, I, J, K,

Q, R, U, W, X, Y,

N, O, P, S, T, V, Z.

На этом всё. Не могу сказать, что ResNet-18 потерпела сокрушительное поражение. Но я всё-таки ожидал большего. С одной стороны кажется нечестным вырезать динозавров и сравнивать их признаки по отдельности. Но на самом деле мы выиграли только в скорости обработки. Поскольку если захотеть, можно сделать копии изображения со сдвигом несколько пикселей и искать динозавра на всём массиве изображений. Также стоит учитывать, что хоть мы и нашли такое же количество динозавров как в первом случае (16 штук), мы использовали почти в два раза меньше данных. Сравните количество пикселей в ячейке 32*32 с длиной вектора 1*512.

Возможно, кто-нибудь подскажет как найти на картинке хотя бы двадцать динозавров. Использование ResNet-50 не улучшило результаты. Также замена слоя avgpool на maxpool практически ничего не меняет. Всем спасибо, за то что дочитали до конца! Ссылка на гитхаб для проведения тестов.

Комментарии (2)


  1. S_A
    24.07.2024 09:10

    Попробуйте вложения от dinov2, будете приятно удивлены. Без шуток


  1. uhf
    24.07.2024 09:10

    А если про поиске пройтись скользящим окном с шагом в пиксел? Будет дольше, но точнее.