Что может пойти не так при распознавании товаров в ритейле, часть 2
Не пригодилось бизнесу или не попало в пайплайн
Planogram compliance
Planogram (планограмма) - это 2D выкладка товаров (учитывает только “фейсы” и не учитывает то, что и сколько находится в глубине), которая в теории должна соответствовать ситуации на полке и которой должны руководствоваться сотрудники магазина. Ключевое слово здесь “в теории”, потому что реальность почьи всегда довольно сильно отличается от планограммы.
Типичная планограмма полки с энергетиками
В ритейле есть метрика planogram compliance rate (PCR), которая говорит нам, насколько товары на полке соответствуют плану, так что задача была в том, чтобы эту метрику реализовать. За основу был взят код для статьи “Computer vision based planogram compliance evaluation”. Алгоритм примерно такой:
Берем предсказания для всего кадра - детекции и эмбеддинги с классами - и с помощью вьюпортов разделяем их на полки;
На каждой полке объединяем товары в группы товаров (по классам + координатам);
Берем планограмму - боксы и классы - и точно так же на каждой полке объединяем товары в группы;
Предсказания и планограмму превращаем в граф одним и тем же способом - каждый бокс кластера превращаем в вершину, а ребра добавляем только в том случае, если соседний кластер находится на одном из 8 кардинальных направлений (вверх, вниз, влево, вправо и по диагоналям) на расстоянии, не большим порога. Получаем 2 ориентированных графа;
Далее мы ищем все уникальные общие подграфы размера минимум 2 между двумя графами (в оригинальной статье выбирался только наибольший общий подграф, но на наших данных этого оказалось недостаточно) таким образом, чтобы метки классов совпадали и чтобы максимально совпадали ребра к соседям;
Далее нам надо разгруппировать кластера и понять, сколько и каких товаров не хватает. Помним, что вершина каждого графа - это кластер; размер кластера предсказаний - N, размер кластера на планограмме - M. Создаем булевый список, проходимся по каждой паре заматченных вершин и если N ≥ M - то добавляем M значений true, если N < M - добавляем N значений true и (M - N) значений false (то есть M-N товаров из этого кластера не хватает). Для всех незаматченных вершин графа планограммы добавляем в список значения false;
Далее опционально есть вторая стадия. Как она работает: проходимся по всем несматченным вершинам из графа предсказаний и несматченным вершинам из графа планограммы и если там есть пересечения классов (например, есть 2 предсказанных вершины какого-то класса и 3 вершины этого класса на планограмме - то считаем их все заматченными), то модифицируем булевый список из предыдущего шага так, чтобы учесть, что некоторые товары нашлись, просто в других местах. Таким образом вторая стадия нам дает PCR ≥ PCR первой стадии.
Слева направо: 1 - фото с камеры и объединенные в кластера детекции; 2 - граф предсказаний; 3 - граф планограммы; 4 - сматченные кластера зеленые, несматченные красные; 5 - планограмма. На визуализации графа звездочки - сматченные первой стадией, треугольники - сматченные второй стадией, кружки - несматченные.
Вроде выглядит неплохо, но есть 2 больших проблемы. Во-первых, даже на хороших кейсах (как на картинках выше, обычно все гораздо хуже) графы строятся криво, а идеально их построить невозможно, поэтому и матчатся они криво. Во-вторых, совместно с бизнесом мы поняли, что конкретно у нас планограммы почти никогда не соответствуют реальности, поэтому в целом нет особого смысла считать PCR, важнее сконцентрироваться на on-shelf availability (OSA). Можно ли сказать, что весь этот эксперимент был зря? Вряд ли, поскольку мы лучше начали понимать соответствие между планом и реальностью, да и графы вспомнили с университетских времен (а еще всегда можно сказать, что негативный опыт - это тоже опыт).
Stitching фото с соседних камер
Выше мы поговорили про соответствие планограмм и реальных фото с камер, но там не упомянули о том, что далеко не всегда планограмма по “ширине” соответствовала одному фото с камеры. Грубо говоря, планограмма может быть шириной 10 товаров, а фото с камеры покрывает 20, или наоборот, поскольку планограммы меняются часто, а камеры перевешивать не очень хочется. Поэтому идея была следующая: объединить снимки со всех камер на одном физическом стеллаже (3 стеллажа, из которых состоит целый ряд) в один, и планограммы все объединить, тогда получится соответствие 1 к 1. Это примерно та же задача, что и собрать из нескольких снимков панораму, или image stitching.
Существует много решений этой задачи, в основном на снимках ищутся ключевые точки и затем они матчатся (например, подробно про использование такого алгоритма в OpenCV - статья). Но разница между нашей задачей и задачей склейки панорамы в том, что в панораме все снимки сделаны из (почти) одной точки, а у нас все снимки сделаны с разных камер, соответственно, правая часть левого снимка и левая часть правого снимка (одни и те же товары) будут сняты с совсем разных ракурсов. Кроме того, в нашем случае очень сложно искать и матчить ключевые точки (мы пробовали стандартные алгоритмы image stitching - все было довольно плохо), потому что на фотках могут быть по 20 одинаковых бутылок, и какие из них на левой фотке соответствуют каким на правой фотке - вопрос очень хороший. Так что задача поделилась на 2 задачи: найти уникальные дескрипторы и сматчить их.
Найти уникальные дескрипторы оказалось не очень сложно - сразу решили использовать ценники, поскольку у нас уже был хороший детектор ценников + это достаточно уникально распределенные данные (да, в теории они могли бы быть равномерно распределены по полкам, но на деле оказалось, что на каждой полке свой паттерн). Ниже пример фото с трех соседних камер, с ценниками (фото обрезаны по ценникам для удобства):
Далее нам нужно сматчить соответствующие ценники. Здесь, наверное, глобально 2 подхода были: матчинг центров ценников по координатам и матчинг боксов ценников по IoU. Первый способ зашел не очень хорошо, поскольку он работал в основном в пределах одной полки, а не всего сразу, и чтобы одновременно хорошо сматчилось на всех полках - такое почти никогда не получалось. Второй способ с IoU звучит достаточно костыльно, но работал он сильно лучше, так что опишем алгоритм ниже (работает для 2 соседних фото, но можно вызывать итеративно для любого количества):
Фото с камер нужно обработать так, чтобы все полки были максимально горизонтальные, а стеллажи вертикальные (в этом была самая большая проблема, но об этом будет ниже);
Обрезаем фотки по детекциям товаров или ценников, чтобы лишние поля фото не затрагивать, а также удаляем лишние ценники, которые по размеру слишком сильно отличаются от среднего размера;
Чтобы преобразовать одно фото в координаты другого, нужно подобрать 3 параметра - scale, vertical shift, horizontal shift. Сначала выбираем baseline scale, который считаем по соотношению высот ценников (если на правой фотке ценники в среднем в 1.5 раза выше, чем на левой фотке, значит вероятно нужно уменьшить правое фото в 1.5 раза);
Далее делаем первое грубое приближение. В тройном цикле перебираем scale, vshift и hshift с довольно большим шагом, и для каждого набора параметров сдвигаем/скейлим координаты ценников с правой фотки, чтобы привести их к координатам левой фотки. На каждой итерации оставляем только те ценники, которые находятся внутри центра пересечения двух фоток (не всего пересечения, а именно центра, так как там матчинг будет наиболее уверенный). Для оставшихся ценников слева и справа считаем усложненное IoU: для тех ценников, у которых IoU>0.6, мы суммируем их IoU, но также штрафуем за те ценники, которые не сматчились слева и справа. То есть формула выглядит примерно так: final_iou = good_iou_sum - bad_iou_sum / 3;
По final_iou выбираем набор параметров в первом приближении, а затем в окрестности этих параметров уже с большей гранулярностью повторяем поиск (если на первом этапе шаг был условно 0.2, то тут уже шаг 0.02) и находим лучший набор параметров;
Скейлим правое фото и сдвигаем его, отрезаем лишнее от обоих фото и склеиваем их.
Визуализации ниже:
Матчинг ценников на 1 и 2 фото. Все, что за синими линиями, мы не учитываем, а также берем только центр пересечения, то есть внутри красных линий
Аналогичный матчинг для ценников на 2 и 3 фото. Как видим, нижняя полка хуже всего матчится, поскольку она сильнее всего перспективно искажена (и это проблема не только ценников, но и товаров, они в итоге на нижней полке выглядели так себе)
Склеенная фотка из трех. Не сказать что идеально, но с этим можно работать
Сразу же обнаружился очевидный минус - делать два тройных цикла довольно долго, учитывая что у каждого параметра перебираются по 10-20 значений. Запустили профайлинг, глянули что работает медленнее всего, и в итоге сделали несколько итераций оптимизации. Во-первых, обрезали фотки по ценникам и оптимизировали перебор вертикальных шифтов; во-вторых, банально добавили мультипроцессинг; в-третьих, вынесли все numpy функции и обернули их в numba декоратор @jit(fastmath=True) . На удивление, даже не подсчет IoU был самым медленным, а постоянные сдвиги боксов и фильтрация лишних в пределах пересечения, но за счет numba эти функции ускорились в несколько раз, а с учетом всех оптимизаций алгоритм ускорился примерно в 3 раза.
Так почему же этот костыльный алгоритм оказался в статье “что может пойти не так”? В основном по двум причинам. Первое - для того, чтобы этот алгоритм работал хорошо, нужно варпить фотографии (шаг 1 алгоритма), а с этим сразу возникли проблемы из-за плохого освещения и качества камер, и если варпить плохо - то стичинг будет тоже работать плохо. Второе - мы поняли, что даже если все хорошо варпить и стичить, то все равно на границах товары будут теряться или дублироваться, плюс с планограммой такой огромный набор продуктов будет матчить еще сложнее (см. выше про planogram compliance), так что в прод эта глобальная идея с объединением фото с нескольких камер не пошла.
Test-time augmentation
В первой статье про классификацию мы писали о том, что эмбеддер выдавал нам метрику на уровне 85%, поэтому дальше мы начали его оборачивать во всякие эвристики типа realgram и трекинг. Конечно, это произошло не сразу, и самые первые эксперименты у нас были направлены на улучшение качества эмбеддера. Про всякие переборы архитектур и аугментаций писать нет смысла, так как это довольно очевидно в этой задаче, но среди более нестандартных подходов выделяется test-time augmentation, который мы тоже пробовали.
Test-time augmentation (или TTA) - это способ улучшить предсказание модели посредством искажения входных данных и усреднения результатов модели на нескольких искаженных вариациях одного и того же примера. В CV это чаще всего делается так: на входе есть изображение, мы аугментируем его несколькими разными способами, получаем несколько изображений, прогоняем их через модель и получаем несколько предсказаний (классификация/регрессия/детекция, не важно), а дальше мы эти предсказания агрегируем через, например, среднее, моду, медиану и так далее. В нашем случае чуть интереснее, поскольку для каждого изображения мы получаем не просто класс, а целый набор классов со своими скорами из топ-N ближайших соседей из search space. Мы у себя неоднократно замечали, что минимальные изменения в изображении могут приводить к совсем другим результатам (можно сказать, что это просто плохой эмбеддер, но также можно сказать, что с любым эмбеддером и достаточно большим search space будут возникать такие проблемы), так что захотелось попробовать TTA для увеличения стабильности предсказаний.
Как выбрать эти аугментации? Примерно как и любые гиперпараметры, можно их перебрать через GridSearch или RandomSearch и посмотреть на изменение метрик на валидационной выборке. Пробовали много разных ауг (примерно 30 штук, в основном из torchvision и albumentations), из них зашло около 12, все они на фотке ниже:
Аугментации, которые независимо друг от друга зашли лучше всего. Выглядят не слишком сильными, но для модели этого очень часто было достаточно, чтобы топ-1 ближайший кроп изменился
Допустим, мы видим, что каждая из этих ауг не ухудшает предсказание, а совсем чуть-чуть улучшает. Дальше - для каждого (включая оригинальный кроп) считаем топ-1 класс, а затем эти 13 предсказаний агрегируем. В нашем случае самым лучшим способом агрегации было добавить средний эмбеддинг среди всех 13, потом посчитать 14 предсказаний, а потом брать самый популярный класс из них. Но, опять же, было бы все так хорошо - не писали бы об этом в этой статье. Во-первых, прирост составил на разных датасетах от 0.2% до 0.5%, что не очень существенно ради таких затрат. Во-вторых, что более важно, эмбеддер нужно прогонять в 13 раз чаще, чем при обычном использовании топ-1 из search space, а этого мы уже позволить себе не могли. Даже если это все батчевать, все равно время обработки одного изображения с камеры увеличивалось в несколько раз, а когда камер несколько тысяч и на многих из них еще и попадаются люди/препятствия, так что приходится делать фото снова - это непозволительно дорого…
Change detector
Как мы писали во второй статье из цикла про классификацию, одна из самых больших проблем - это “мигания”, когда на полке ничего не меняется, но предсказания на соседних кадрах при этом одинаковые. Трекинг в этом случае - не единственное возможное решение, и сначала он нам не очень нравился, потому что выдавал довольно большое количество FP (если поставить порог по эмбеддингам даже 0.9, то все равно разные товары будут матчиться между собой, хотя не должны; кроме того с таким порогом в целом будет матчиться мало), так что мы смотрели в сторону альтернативных вариантов. Одна из идей состояла в том, чтобы сделать простой детектор на классическом CV, который ловил бы изменения между двумя снимками, но не просто используя пиксельную разницу, а чуть сложнее.
Назвали это чудо change detector, на вход он принимал 2 соседних фото с камеры и маски обстаклов, алгоритм работы у него был такой:
Маскируем все обстаклы (людей, тележки итд), поскольку нам важна семантическая разница на полках, а не динамические вещи в кадре;
Ресайзим картинки вниз до 1024 по большей стороне (для скорости);
С помощью SIFT + FLANN матчера ищем ключевые точки и матчим их, чтобы компенсировать вибрации и шифры камеры (внимательный читатель скажет, что мы для стичинга их не использовали, а тут вдруг использовали - все верно, но здесь у нас 2 фотографии с одной и той же камеры, так что и сифты должны быть одинаковые);
Варпим одно изображение в пространство другого и блюрим, чтобы убрать шум и артефакты от JPEG сжатия;
Считаем маску измененности с помощью SSIM, которая даст нам не попиксельную разницу, а семантическую.
Дальше уже дело за малым - в каждом боксе смотрим процент измененности и в зависимости от порога считаем его сматченным или нет. Если бокс считается сматченным - берем предсказание из ближайшего по IoU бокса с прошлого фото, примерно как и в трекинге.
Визуализация: сначала первое фото, потом фото с разницей в полчаса и с наложенной маской и процентами измененности каждого бокса, справа - чисто маска измененности
Вроде бы качественный анализ показывал, что работает хорошо: матчит меньше, чем трекер, зато вообще без FP. Но в около-боевых условиях оказалось, что он матчит уж слишком мало, потому что сложно было подобрать такие гиперпараметры, чтобы матчинг был довольно устойчивый к не-семантическим изменениям (JPEG сжатие, небольшое изменение освещения, шум камеры). То есть tradeoff такой же, как и в трекере - или меньше матчить, или больше FP.