Привет, дорогие читатели! Меня зовут Алина, я работаю операционным менеджером в компании Training Data, которая занимается сбором и разметкой данных. Я веду проекты по разметке, а еще благодаря знанию python пишу скрипты для автоматизации работы своей команды. У меня накопилось много интересного опыта, которым я хочу с вами поделиться.

Своей первой статьей я открываю рубрику разбора любопытных кейсов, с которыми столкнулись я и мои коллеги во время организации разметки данных в CVAT.

Computer Vision Annotation Tool (CVAT) – это инструмент с открытым исходным кодом для разметки цифровых изображений и видео. Основной его задачей является предоставление пользователю удобных и эффективных средств разметки наборов данных. “ - цитата из статьи создателей.

Все мы с вами прекрасно знаем детскую игру на развитие внимательности и наблюдательности - поиск отличий на картинках. Она встречалась нам в журналах, на календарях, а позже - на сайтах и мемах в VK. Но кто бы мог подумать, что подобная забава дойдет и до разметки данных для обучения нейронных сетей?

Скриншот взят с сайта: https://www.igraemsa.ru/igry-dlja-detej/igry-na-vnimanie-i-pamjat/najdi-otlichija/osminozhka
Скриншот взят с сайта: https://www.igraemsa.ru/igry-dlja-detej/igry-na-vnimanie-i-pamjat/najdi-otlichija/osminozhka

С каждым днем появляется всё больше запросов на разметку данных для самых разных нейронок, сфера деятельности которых порой удивляет, смешит или шокирует. Но д��я каких же направлений деятельности нейронке необходимо научиться находить отличия? Примеров можно найти кучу: пустоты на полках, забытые вещи, счетчик движения и т.д.

Передо мной встала задача организовать разметку данных покадровой съемки помещений, в которых люди случайно или специально оставляли предметы. Разметчикам необходимо было выделить область изменения на кадрах с помощью bbox (прямоугольника), также необходимо было, чтобы область изменения была зафиксирована на двух кадрах: кадр "до изменения", кадр "с изменением". Странные запросы от ребят из команды разработчиков нейронки я получаю не в первый раз, поэтому без лишних вопросов приступаем к задаче.

Инструмент для разметки данных CVAT не позволяет показывать в одной рабочей области сразу два и более отдельных изображений. Присутствует возможность лишь перелистывать изображения.

Интерфейс CVAT
Интерфейс CVAT

Поэтому на этапе загрузки изображений в CVAT я столкнулась со следующей проблемой: как показывать разметчику (человеку, который будет размечать данные) сразу несколько кадров?

Если выполнять эту задачу "в тупую", то мы просто заливаем в CVAT все кадры в их порядке, и разметчику нужно будет листать кадры вперед-назад, чтобы определить на них отличия. Далее нужно будет разметить bbox на одном кадре и сделать точно такой же bbox на другом. Времени такой метод займет очень много, качество будет сомнительным, разметчики будут быстро уставать. Поэтому начинаем придумывать оптимизацию.

Самое простое решение для проблемы с перелистыванием кадров - соединить соседние кадры в одно изображение.

Плюсы: легко и быстро программируется на Python через PIL; разметчику удобно сравнивать два кадра.

Минусы: увеличивается вес изображений, что ведет к повышению времени ожидания загрузки изображения в CVAT (однако и это можно пофиксить при желании).

Пример функции glue_together, которая выполняет "склеивание" двух изображений в одно:

from PIL import Image
import os.path as osp
 
def glue_together(image1, image2, out_path):
    img1 = Image.open(image1) 
    img2 = Image.open(image2)
    width = img1.size[0]*2 #определяем ширину будущего "склеенного" изображения
    height = img1.size[1] #берем высоту первого изображения
    img = Image.new('RGB', (width, height))
    img.paste(img1, (0,0))
    img.paste(img2, (img1.size[0], 0))
    img.save(osp.join(out_path, 
                      f'{osp.basename(image1)}__{osp.basename(image2)}'))

В функции использовались две библиотеки: PIL (pillow) и os. Первая позволяет обрабатывать изображения (я лично отдаю этой библиотеке большее предпочтение, чем OpenCV), вторая в данном случае нацелена на помощь в написании пути к файлу.

Обращаю внимание на то, что в моем кейсе все изображения имели одинаковую ширину и высоту, что позволило не запариваться с тем, чтобы подгонять файлы разной величины друг под друга.

Для примера я обрезала двух осьминожек:

Осьминожка правый
Осьминожка правый
Осьминожка левый
Осьминожка левый

И, использовав функцию glue_together, склеила их:

Осьминожки вместе! :)
Осьминожки вместе! :)

Отлично! Теперь разметчику будет легче ориентироваться среди кадров. Однако уже перед началом разметки появляются новые вопросы:

  • Как мне точно узнать, где заканчивается один кадр и начинается другой?

  • Выделение одинаковых областей "на глаз" на двух кадрах - достаточно сложно и займёт много времени, можем как-то ускорить? Можем как-то упростить?

Вопросы действительно толковые и решение их приведет к более быстрой и качественной разметке. Если не решить их, то разметчику пр��дется:

а) запоминать параметры первого bbox'а, чтобы сделать точно такой же второй;

б) постоянно ориентироваться на "прицел" в CVAT, который поможет в определении верхней и нижней границы bbox;

в) стараться попасть в такую же точку левого верхнего угла первого bbox'а при создании второго bbox'а, ведь "прицел" не помогает определить правую и левую границы, поэтому придется работать "на глаз".

Пример такой неоптимизированной разметки
Пример такой неоптимизированной разметки

Первое, что приходит на ум - сделать сетку поверх изображения. Идея достаточно годная, и она даже решит все вышеупомянутые проблемы. Но мне подсказали более крутой вариант: не просто нарисовать сетку на исходном изображении, а создать сетку в виде разметки в самом CVAT, чтобы разметчикам оставалось лишь менять классы bbox'ов, не тратя время на их создание.

Супер, круто, офигенно! Но... как это реализовать?

Делюсь пошаговой инструкцией:

Так как файлы мы уже залили в CVAT, у нас создался таск и, значит, сгенерировался xml файл с разметкой. Для того, чтобы скачать его, необходимо зайти на страницу таска, в правом верхнем углу кликнуть на Actions, выбрать Export task dataset, в списке Export format выбрать CVAT for images 1.1 и нажать OK.У вас скачается архив, в котором лежит xml файл с разметкой. Откроем его.

Исходный файл с "пустой" разметкой
Исходный файл с "пустой" разметкой

Тег <meta>, описывающий всю инфу о таске, нам не нужен, с ним мы работать не будем. Наше внимание обращаем на тег <image>. Как раз в нём должна быть информация о разметке на конкретном изображении. Создадим рандомный bbox на фотографии и посмотрим как изменится файл.

Пример инфы об одном bbox'е на изображении
Пример инфы об одном bbox'е на изображении

Под тегом <meta> появился тег <box>. В нем мы видим название класса (label) и координаты bbox'а: xtl (x top left) - левая верхняя точка по x, ytl (y top left) - левая верхняя точка по y, xbr (x bottom right) - правая низшая точка по x, ybr (y bottom right) - правая низшая точка по y.

Для работы с xml воспользуемся библиотекой ElementTree:

import xml.etree.ElementTree as ET
from tqdm import tqdm
import os.path as osp

def make_patches(w, h, numb_lines=10): #создание списка координат bbox'a
    step = int(h / numb_lines)
    coords = []
    
    for x in range(0, w, step):
        for y in range(0, h, step):
            x1, y1, x2, y2 = x, y, x + step, y + step
            coords.append((x1, y1, x2, y2))
    return coords

  
def pre_annotation(empty_annotation, pre_annotation):
		#создание нового файла разметки
    tree = ET.parse(empty_annotation)
    root = tree.getroot()
    for child in tqdm(root):
        if child.tag not in {'version', 'meta'}:
            image_coords = make_patches(int(child.attrib['width']), 
                                        int(child.attrib['height']), 10)
            
            for coord in image_coords:
                bbox = ET.Element('box', label="без изменений", occluded="0", 
                                  source="pre_annotated", 
                                  xtl=str(coord[0]),  ytl=str(coord[1]), 
                                  xbr=str(coord[2]), 
                                  ybr=str(coord[3]),z_order="0")
                child.append(bbox)

    xml_str = ET.tostring(root, encoding='utf-8', method='xml')

    with open(osp.join(pre_annotation,'pre_annotation.xml'), 'wb') as xmlfile:
        xmlfile.write(xml_str)
        xmlfile.close() 

Для того, чтобы загрузить наш файл с предразметкой - pre_annotation.xml, необходимо снова зайти на страницу таска, в правом верхнем углу кликнуть на Actions, выбрать Upload annotations, в выпадающем списке выбрать CVAT 1.1 и указать путь к файлу pre_annotation.xml.

Предразметка загрузится на таск и CVAT об этом сообщит:

Откроем изображение и посмотрим что получилось:

Как мы видим, сетка наложилась удачно. Однако она не ориентирована на то, что наше изображение состоит из двух кадров, и нам необходимо, чтобы каждый bbox для каждого кадра имел одинаковые координаты, когда мы будем обрабатывать результат разметки. В данном же примере координаты левого верхнего bbox'а левого кадра НЕ равны координатам левого верхнего bbox'a правого кадра. Для этого даже не нужно залезать в xml и просчитывать разметку отдельно для каждого кадра, это видно сразу по тому, как bbox'ы съехали за правую границу склеенного изображения.

Данный недочет необходимо исправить. А также появилась новая идея: сделать так, чтобы при выборе одного бокса, второй бокс-дубликат с другого кадра автоматически изменялся. Я видела два варианта решения этого вопроса:

1. создать такую сетку, где bbox'ы будут связаны;

2. решить этот вопрос при обработке файла разметки

Первый вариант выглядел наиболее интересным, потому что я не представляла как это можно сделать. Но решение проблемы рано или поздно всплывает в голове, и вот что было решено сделать:

создать полигон из 8-ми точек

пошаговая демонстрация полигона
пошаговая демонстрация полигона

Для этого был написан следующий код:

import xml.etree.ElementTree as ET 
from tqdm import tqdm 
import os.path as osp 
from PIL import Image

def new_glue_together(image1, image2, out_path):
  img1 = Image.open(image1)
  img2 = Image.open(image2)
  width = img1.size[0]*2+int(img1.size[0]/80)
  height = img1.size[1]

  img = Image.new('RGB', (width, img1.size[1]))
  img.paste(img1, (0,0))
  img.paste(img2, (img1.size[0]+int(img1.size[0]/80), 0))
  img.save(osp.join(out_path, f'{osp.basename(image1)}__{osp.basename(image2)}'))

def new_make_patches(w_start, h_start, ROWS_PER_IMAGE = 10):
    coords = []
    frame_w = w_start//2-int((w_start//2)*0.005964)
    
    side = h_start//ROWS_PER_IMAGE
    x, y, w, h = (0, 0, side, side)
    while y < h_start:
        x = 0
        while x < frame_w:
            w_ = min(w, frame_w - x)
            h_ = min(h, h_start - y)
            coords.append([
                (x+w_,y), 
                (x+w_,y+h_), 
                (x,y+h_), 
                (x,y),
                (x+w_ + frame_w + int(frame_w/80), y), 
                (x+w_ + frame_w + int(frame_w/80), y+h_), 
                (x + frame_w + int(frame_w/80), y+h_), 
                (x + frame_w + int(frame_w/80), y)
            ])
            x += w
        y += h
    return coords 

def pre_annotation(empty_annotation, pre_annotation):

    tree = ET.parse(empty_annotation)
    root = tree.getroot()
    for child in tqdm(root):
        if child.tag not in {'version', 'meta'}:
            image_coords = new_make_patches(int(child.attrib['width']), 
                                            int(child.attrib['height']), 10)
            
            for coord in image_coords:
                str_coords = ""
                for c in coord:
                    if c != coord[-1]:
                        str_coords += str(c[0])+', '+str(c[1])+'; '
                    else:
                        str_coords += str(c[0])+', '+str(c[1])
                bbox = ET.Element('polygon', label="без изменений", occluded="0", 
                                  source="pre_annotated", points=str_coords, 
                                  z_order="0")
                child.append(bbox)

    xml_str = ET.tostring(root, encoding='utf-8', method='xml')

    with open(osp.join(pre_annotation,'pre_annotation.xml'), 'wb') as xmlfile:
        xmlfile.write(xml_str)
        xmlfile.close()

Что изменилось:

  1. переписана функция glue_together:
    а) добавлено создание "перегородки" между изображениями, имхо так легче понимать, где кончается один кадр и начинается другой

  2. переписана функция make_patches:
    а) список координат собирается под полигон, а не бокс
    б) учитывается добавленная "перегородка" между кадрами

  3. переписана функция pre_annotation:
    а) теперь она заточена на добавление полигонов, а не bbox'ов

Результат:

Вуаля! Скучная разметка данных превратилась в игру-головоломку.

Теперь можно подвести итоги
Посредством того, что мы визуализировали 2 кадра на одном изображении и создали сеточную предразметку, нам удалось добиться:

  • создания комфортного процесса разметки для разметчиков;

  • повышения качества разметки;

  • повышения скорости разметки;

  • предотвращения быстрой переутомляемости разметчиков.

Таким образом, мы выполнили запрос разработчиков, которые хотели чтобы область изменения была зафиксирована на двух кадрах. Конечно, чтобы создать удовлетворяющий файл-результат разметки нужно написать еще один скрипт, который преобразует xml-файл из CVAT'а в понятный для разработчиков вид. Стоит отметить, что это влечет за собой еще больше нагрузки на человека, который занимается технической поддержкой проектов разметки (в данном случае нагрузка падает на меня). Но в подобных задачах упор всегда делается на то, чтобы оптимизировать по-максимуму время работы разметчиков, потому что процесс ручной разметки почти всегда идет дольше, чем процесс написания дополнительного скрипта.

P.S. Исходные скрипты и фотографии можно найти на моем профиле гитхаба.