Привет, Хабр! С вами Анастасия Белозерова, руководитель исследовательских проектов в области транспорта в VisionLabs. В прошлом посте я рассказала, какие задачи можно решить с помощью видеоаналитики. А сегодня объясню, как устроен наш пайплайн распознавания автомобилей.
Чтобы решить поставленную задачу, иногда достаточно задетектировать и распознать только номер — например, в кейсе шлагбаума придомовой территории. Но я расскажу про пайплайн (многошаговый алгоритм), который анализирует транспортное средство целиком. Чтобы фиксировать и валидировать нарушения правил дорожного движения, назначать плату за проезд, разыскивать угнанные автомобили и в целом для большинства кейсов из предыдущего поста, нам мало одного номера и кадра, с которого он взят. Важна вся история перемещения транспортного средства, поэтому без пайплайна не обойтись.
Описывать я буду те методы, которые мы уже внедрили в продукт. Все визуализации в этом посте — результаты работы моей команды. Приступим!
Начнем с того, как мы внедряем наше решение. Если у заказчика уже есть сеть камер, мы работаем с ней. Считаем сайзинг для нашей системы (необходимые вычислительные ресурсы для решения целевых задач) и даем рекомендации по закупке серверов. Но бюджета на «железо» всегда не хватает. Завести себе центр обработки данных, или ЦОД, с десятками GPU NVIDIA А100 могут всего несколько компаний в стране, и у них есть свои решения для любой задачи. Поэтому обычно наши ресурсы ограничены, а работать все должно в реальном времени. Значит, приходится оптимизировать каждый шаг пайплайна.
Выше — пример визуализации нашего трекинга ТС. Трекер здесь работает идеально, ведь условия съемки простые: машины едут медленно и не очень плотно, сложных кейсов перекрытия не возникает. Фон однородный, разрешение кадра достаточно высокое. Мечта, а не условия.
Часто в кадре бывают посторонние объекты: здания, мусорные баки, заборы. Детектор может спутать их с прицепами и фолзить — выдавать False Positive, то есть ложно срабатывать или детектировать. А еще наш детектор любит канализационные люки: видимо, они похожи на колесные диски. Так что мы упорно отучаем его их детектировать. Разрешение камеры тоже обычно далеко от FullHD и качество картинки оставляет желать лучшего. Приходится работать с тем, что есть.
Чтобы разобрать видеопоток на отдельные ТС, нужны такие шаги:
Задетектировать все автомобили и построить их треки. Каждой машине, которая появляется на видео, нужно присвоить ID и определить ее перемещения на всех кадрах.
Выбрать для каждого ТС его лучшие кадры, или бестшоты. Даже если мы видели транспортное средство всего две секунды, у нас будет целых 50 изображений этого ТС при fps=25. Мы не будем пытаться распознать что-то на всех изображениях: каждый запуск сети стоит нам драгоценных миллисекунд (ms), а ресурсы ограничены. Поэтому важная задача нашего алгоритма — выбрать хорошие кадры. Потом мы отправим их на распознавание атрибутов и получим информацию о транспортном средстве.
Распознать атрибуты. Выбрав бестшоты, мы отдаем их сетям для распознавания признаков ТС. Среди них — модель, тип, цвет. Могут быть и намного более экзотичные запросы: число осей (от него зависят тарифы на проезд по «платке»), принадлежность к экстренным службам или общественному транспорту. Например, шлагбаумы обязаны пропускать пожарные машины, скорую помощь и МЧС на придомовые территории, и желательно делать это автоматически.
Распознать номера. Конечно, государственный регистрационный знак (ГРЗ) — тоже атрибут. Но чтобы его распознать, нужно построить целый пайплайн, поэтому вынесем это в отдельный пункт.
Агрегировать результаты. За трек у нас накопилось K распознаваний по нескольким кадрам. Мы выбираем лучшие кадры, но результаты работы сетей на них все же могут отличаться. Например, если мы определяем наличие мигалки у ТС, ее может быть не видно в начале трека, на краю кадра. Классификатор будет выдавать ответ «Нет мигалки». А при полном въезде в кадр и видимой крыше выдаст уже верный результат: «Есть мигалка». Получается, нам нужно разумно агрегировать результаты распознаваний и выдать один ответ на целый трек.
Весь пайплайн представлен на этой иллюстрации:
Теперь давайте пройдемся по каждому из основных шагов.
Первый шаг в нашем пайплайне — построение треков ТС. Чтобы одновременно отслеживать передвижения нескольких объектов в одном кадре, нужно решить задачу MOT, или Multi-Object Tracking. Если мы посмотрим на самый известный бенчмарк по трекингу на paperswithcode — МОТ17, то увидим, что в топе решений алгоритмы с именами ХХХSORT:
Это не случайно. В статье Simple Online and Realtime Tracker (SORT) авторы показали, как прикрутить к детектору фильтр Калмана и Венгерский алгоритм для матчинга по величине IoU. И при этом получили чуть ли не лучший по метрикам результат на бенчмарке МОТ — работая в 20 раз быстрее, чем тогдашние SOTA методы. С тех пор почти ни одно решение для МОТ не обходится без этих двух компонентов, даже если в названии очередного метода нет слова SORT. Больше информации о трекинге можно найти в посте на Хабре от 2020 года, для начального погружения отлично подойдет.
В нашем алгоритме тоже используются идеи из SORT-подобных трекеров, вышедших в последние годы. Кроме самой идеи SORT-а, мы, например, применяем:
двухстадийный матчинг из ByteTrack (2021);
склейку порвавшихся треков с историей с помощью реидентификации (ReID), подробно о ней расскажу ниже;
расширенное представление фильтра Калмана, как в BoT-SORT (2022).
Но нам нужно ужиматься (в VisionLabs мы говорим «щемиться»), поэтому использовать как есть идеи из статей обычно не получается. Приходится оптимизировать все, что можно.
Теперь давайте пошагово разберем, как устроен наш трекинг. Итак, мы получили из видеопотока N-й кадр. Любому МОТ-трекеру нужна какая-то инициализация — первые боксы треков, с которых начинается весь процесс отслеживания объектов. Поэтому нам нужно использовать сеть-детектор для локализации всех транспортных средств на полном кадре.
Какой тут может быть детектор? Самый популярный ответ — YOLO v5 или YOLO v8 от Ultralytics. Их легко завести из репозитория и обучить под свою задачу. У этих реализаций по сути нет аналогов по простоте, красоте и дружелюбности к новому пользователю, поэтому их любят CV-инженеры. Так что если вы ищете не сверхкрутой, но хороший и точный детектор без страданий, выбор очевиден.
Нам нужен детектор уровня YOLOv5-nano по скорости (на CPU мы готовы выделить до 50 ms для кадра 640x480), но превосходящий его по точности. Поэтому у нас собственная архитектура детектора, мы собираем ее в mmdetection. Это SSD-подобный детектор с FPN и легким бэкбоном.
Работать в mmdetection и даже устанавливать его больно: фреймворк сложный, модели собирать трудно, все еще много багов. Зато большой простор для экспериментов. В результате наш детектор быстрый и точный. Собрали мы его у себя несколько лет назад. На удивление с тех пор так и не смогли побить его по точности ничем другим — на той же скорости, конечно.
Этап, когда мы запускаем детектор на полном кадре и ищем все ТС, называется у нас full detect. На картинке ниже как раз такой кадр. ТС там было одно, его и нашли:
На N-м кадре мы нашли M транспортных средств и инициализировали M их треков.
Теперь нам прилетает N+1 кадр. Обычно в трекерах из статей ждут, что на N+1-м кадре тоже будет запускаться детектор, и мы будем обрабатывать его результаты. Но мы не в той ситуации, чтобы звать детектор для каждого кадра: их прилетает 25 штук за одну секунду. Точнее, мы могли бы это делать, но тогда не было бы и речи о запуске остальной аналитики и распознавания внутри пайплайна в real-time. Full-detect запускается на большом разрешении, занимает много ресурсов и времени — 45 ms. Поэтому нам такой вариант не подходит.
Есть и хорошая новость: запускать точный детектор каждый кадр часто бессмысленно. Между двумя последовательными кадрами ситуация обычно почти не меняется: ТС не умеют телепортироваться из одного конца кадра в другой, а двигаются плавно — хоть, порой, и очень быстро. Значит, можно схитрить и использовать redetect — второй маленький, но шустрый детектор, у нас он занимает всего 2,5 ms. Это наш термин, не удивляйтесь, что вы его раньше не слышали.
По кадру N мы уже поняли, где находится машина, и ожидаем, что на N+1 она расположена примерно в этой же зоне. Теперь расширим bbox, который мы нашли на N-м кадре и перенесем его на следующий кадр. Выглядеть это будет так:
Чтобы обнаружить авто на кадре N+1, мы будем использовать не все изображение, а только эту желтую зону. Запустим на ней redetect. В желтой зоне нам нужно найти только одну машину, поэтому если redetect найдет их несколько, придется сделать выбор. Выбирать бокс можно по разным признакам: по положению, размеру, центральности. Дальше уже полет фантазии и тестирование гипотез.
Конечно, машина может быть в кадре не одна, и у нас будет несколько таких зон для запуска redetect. Тогда мы можем просто объединить все зоны в один батч и прогнать его через сеть redetect за раз. Какие мы хитрюшки!
Итак, с помощью redetect мы довольно дешево получили боксы машин на N+1 кадре. Теперь матчим их с боксами с прошлого кадра по IoU, ищем лучшие варианты для продолжения треков, ассоциируем боксы с треками, корректируем боксы с помощью фильтра Калмана и обновляем его внутреннее состояние. В фильтре как бы копятся знания о движении боксов: он строит линейную модель движения и может предсказывать, куда машина поедет дальше. Эти знания нужно каждый раз дополнять.
На кадре N+2, который придет к нам следующим, нужно проделать то же самое:
взять расширенные bbox с прошлого кадра;
запустить на них redetect;
выбрать один бокс из каждой зоны;
продлить треки: тут уже можно матчить новые детекции с предиктами от фильтра Калмана, но не обязательно;
обновить состояние трекера.
А когда же full-detect? Неужели мы будем жить только на редектах? Обычно за одну секунду нужно обработать 25 кадров. Full-detect срабатывает у нас на каждом восьмом кадре, то есть примерно три раза в секунду. А семь кадров между запусками full-detect обрабатывает redetect.
Можно щемиться в ресурсах еще сильнее — например, звать full-detect только один или два раза в секунду. Но готовьтесь пострадать в качестве. Еще, например, можно прогонять через redetect не все семь кадров подряд, а через один или даже два. При этом использовать между запусками предсказания фильтра Калмана для каждого трека. Но, по нашим экспериментам, такое работает только в самых простых кейсах без падения метрик. Если у нас простой кейс, схема для восьми кадров может быть, например, такой: full-detect → redetect → tracker → tracker → redetect → tracker → tracker → redetect → full-detect.
Теперь мы знаем, что нам делать и на N+3 и на N+5 000-м кадрах — все то же самое. Сейчас мы разобрали трекер очень верхнеуровнево. В нем заложено еще много логики, которая конфигурируется под конкретный домен использования трекера. Машины движутся по-разному при съемке КПП, на трассе или с дрона — поэтому нет и не может быть трекера, который везде будет работать достаточно хорошо. Мы создаем одну общую канву, а дальше для каждого типа внедрения у нас уже есть конфиг с оптимальными параметрами.
Если у нас есть больше ресурсов и мы можем использовать больше моделей в пайплайне, получится значительно понизить число IDs (Identitty Switches) — переключений треков с одного объекта на другой. Например, при матчинге боксов для продолжения треков лучше использовать семантические или визуальные признаки кропов авто — то есть дескриптор, а не только IoU для боксов. Впервые так предложили делать в DeepSORT. Про него, кстати, есть пост на Хабре.
А еще можно накапливать и усреднять дескриптор по треку для потенциальной склейки порвавшихся треков:
выполнять ReID того же объекта, который был потерян;
присваивать ему тот же ID, что был до потери трека.
Результаты в публикациях (например, в BoT-SORT) показывают, что каждый трекер при добавлении в его логику ReID повышает метрики и позволяет уменьшить количество IDs. Подобный матчинг с историей по схожести дескрипторов делаем и мы, когда есть такая возможность.
Схемы трекинга могут разрастаться до пугающих размеров. Пример — BoT-SORT-ReID:
Наша тоже пугающая и в целом близкая к этой. Только в ней нет пост-процессинга по истории, мы же все делаем real-time.
Итак, у нас есть трек автомобиля, ура. Если мы отслеживали его хотя бы две секунды, у нас уже набралось 50 кадров, по которым мы можем провести необходимое распознавание. Но, как и раньше, времени и ресурсов у нас на 50 кадров нет. Ресурсы есть только на K кадров, где K — задаваемый параметр, и он должен быть не меньше 3. Значит, эти кадры должны быть самыми лучшими, то есть бестшотами.
Как и в случае с трекингом, логику выбора бестшотов можно конфигурировать под задачу и домен — в зависимости от скорости движения, загруженности потока, перекрытий машины и других особенностей. Самая простая логика — выбирать кропы ТС в качестве бестшотов, если они находятся не слишком близко к краю кадра и не пересекаются с боксами других ТС, если такие есть. В лучшем случае оценивать качество кропа можно обучить отдельную сетку: она будет определять, подходит ли он для использования в распознавании.
На иллюстрации нашего пайплайна ниже — примеры плохих (красных) и хороших (зеленых) кадров. Как видите, последние и правда больше подходят для распознавания. На них видны все значимые части машин видны, в том числе номер:
Итак, у нас есть K отличных кадров из трека. Теперь мы хотим по ним распознать атрибуты.
На скрине выше приведен весь набор признаков, который мы умеем распознавать. Числа под атрибутами — это степень уверенности, с которой сеть относит объект к конкретному классу, фактически вероятность именно этого класса среди всех. Все это результаты нашего продукта LUNA CARS, и сам скрин сделан в его интерфейсе.
Почти все атрибуты распознаются с помощью сетей-классификаторов. Их задача — из фиксированного набора классов (например, из 14 типов) выбрать самый вероятный вариант для конкретного изображения, или кропа ТС.
Задача классификации — самая понятная и решенная в CV, но все равно с подготовкой каждой сети возникают свои сложности. Если при любых условиях нужно распознавать сложный атрибут (допустим, модель ТС), придется собирать данные: веб-выгрузки, данные с камер и внедрений. Потом проводить эксперименты с архитектурами и пайплайном обучения, закрывать Domain Gap — это разница в точности распознавания между разными доменами. Например, на картинках из интернета сеть может работать классно, а на реальных городских камерах уже нет. И не всегда есть возможность решить эту проблему сбором целевых данных. А хотелось бы.
Проблемы могут возникнуть уже на этапе формализации задачи и правил — например, ТС могут быть одной модели, но отличаться по типу кузова. Надо их различать или нет? Или одну и ту же модель производители могут выпускать под разными названиями — точно ли мы хотим их различать? А как выбрать набор цветов для классификации? С этими вопросами приходится помучиться, но в любом случае мы решаем их на продуктовом уровне.
Хочу отдельно рассказать про подсчет числа осей. От того, сколько осей у ТС, зависит цена проезда по платным дорогам, поэтому этот атрибут важен. Как его распознать?
Конечно, можно просто обучить классификатор на классы «одна ось», «две оси», «три оси» — и так набрать M классов. Но тут возникли бы вопросы с разметкой: если ТС видно не целиком, например, у него только одна ось в кадре, тогда какой класс нам надо предсказывать? А если оси других машин влезли в кадр, будем ли мы размечать и их тоже?
С подходом через классификацию, наверное, можно получить хороший результат. Но мы решили реализовать подсчет через детекцию при помощи быстрой и легкой сетки-детектора, которую запускаем на кропе.
Но проблема возникает все та же — бывает сложно понять, относится колесо к этому транспортному средству или нет. Дело в том, что внутри нашего детектора мы не выполняем сегментацию и не находим маску конкретного ТС. А значит, не можем понять, все ли найденные колеса относятся к интересующей нас машине. Если мы просто их подсчитаем, результат будет некорректным.
Поэтому мы решили воспользоваться тем, что все оси машины должны лежать на одной прямой. Тогда мы можем посчитать координаты центров всех боксов осей, найденных на кропе, и с помощью RANSAC-регрессии провести оптимальную прямую через сабсет (sub-set, предвыборка) этих центров. Все точки, которые в него не вошли, мы будем считать чужими осями — их можно выбросить.
Как работает RANSAC. Есть множество точек A, из него собирается некоторое количество сабсетов. Когда точек мало, мы можем перебрать абсолютно все сабсеты из четырех или пяти точек, которые у нас есть. Дальше по каждому сабсету строим прямую, оцениваем расстояние от каждой точки до этой прямой и выбираем, какие из них лучше ложатся на прямую (это будут инлаеры — inlaiers) и какие не ложатся (выбросы — outliers). Все прямые оцениваются с помощью расстояний всех точек до нее. Выбирается лучшая прямая: близко к ней расположено наибольшее количество точек-центров.
Например, у нас есть прямая, которая в конкретном сабсете идеально прошла через три точки. А четвертая точка находится от прямой довольно далеко. Вероятно, это выброс — чужое колесо, так что мы не будем использовать его при подсчете. В качестве ответа по подсчету осей мы выбираем количество точек, которые хорошо ложатся на нашу оптимальную прямую.
Ниже можно посмотреть как RANSAC-метод работает для большого числа точек:
Результаты работы такого подхода для дорожной камеры:
Этот метод не идеальный. Может оказаться, что на снимке транспортные средства расположены плотно, и чужая ось залезла на нашу прямую, как на последней фотографии выше. Но и с этим можно справиться, ведь мы обрабатываем несколько бестшотов за трек. Если на других кадрах машина обнаружится корректно и чужие оси в кадр не влезут, ошибки отдельных подсчетов будут нивелированы.
Вот так будет выглядеть результат для трека:
Как вы теперь понимаете, ситуация, когда все оси хорошо видны и задетектировались, не такая уж частая. Поэтому тут важно адекватно сагрегировать подсчет осей.
Когда результаты распознавания атрибутов для всех бестшотов готовы, их нужно агрегировать. И это снова творчество. Можно среди всех предсказаний за K кадров выбрать одно, с наибольшей уверенностью сети. Можно усреднять выходы сети за несколько кадров или использовать самое частое предсказание. Или предусмотреть разные стратегии при разном ограничении на K — число бестшотов. Вариантов много.
У нас атрибуты распознаются независимо, так что их можно использовать для кросс-проверки. Например, если мы с большой уверенностью определили, что на треке такси, то это точно не полицейская машина (спасибо, кэп). И таких эвристик на взаимосвязи можно накрутить массу.
Последняя специальная функция, о которой я бы хотела рассказать и которую уже упоминала выше, — реидентификация (ReID). Это повторное обнаружение объекта, который когда-то уже был в поле нашей видимости.
Я писала, что ReID у нас используется для склейки треков. Для каждого трека мы можем накапливать усредненный дескриптор внешности этого ТС (вектор длины D) и сравнивать по этому вектору, насколько треки друг с другом похожи. Если мы потеряли трек, а через пять кадров возник новый, который внешне похож на потерянный, логично их склеить и присвоить тот же ID.
ReID можно использовать и более глобально. Например, для поиска того же ТС с других камер, когда номера нет, его не видно или он поддельный.
На скриншоте выше слева мы видим query-запрос, справа — отсортированные по похожести объекты, полученные с других камер.
Как происходит выбор похожих автомобилей? Мы смотрим расстояние между дескриптором машины-запроса и дескрипторами всех ТС в базе событий: чем оно меньше, тем больше объекты похожи. На основании расстояния ранжируем выборку.
Зеленым на визуализации подсвечены правильно выбранные объекты — те же ТС, что в запросе, но снятые с другого положения. Как видите, неправильные ответы тоже есть — это те кадры, которые сейчас не выделены. На них изображены другие ТС. В случае с белыми машинами найти различия действительно сложно: мы не видим их номеров, и нам они кажутся совершенно одинаковыми. Но при разметке ассесоры видели номера, поэтому сама разметка корректна. Ошибку допустили мы, когда сказали, что машина в запросе и выдаче одна и та же.
В целом, для такого поиска можно использовать и явно задаваемые атрибуты: модель, тип, цвет.
В этом посте я еще не сказала о LPR — License Plate Recognition. Мы продолжаем развивать это направление и сейчас умеем читать номера уже более 60 стран на разных языках. Там тоже много интересного: само построение пайплайна, использование синтетических данных для обучения, борьба с Domain Gap, подбор оптимальной архитектуры сети, распознавание индивидуальных номеров. Как раз об этом будет мой следующий пост.
А пока задавайте вопросы в комментариях — постараюсь на все ответить.