Советы и рекомендации по предварительной обработке наборов данных изображений

В этом руководстве рассматриваются следующие пошаговые руководства:

  • конвертировать аннотации XML в аннотации YOLO
  • визуализировать ограничивающие рамки на изображении, используя недавно созданные аннотации YOLO
  • разделить наборы данных на обучающие, проверочные и тестовые наборы

Обзор

ПАСКАЛЬ ЛОС XML

Проект PASCAL Visual Object Classes (VOC) — один из первых проектов компьютерного зрения, целью которого является стандартизация наборов данных и формата аннотаций. Аннотации можно использовать для классификации изображений и задач обнаружения объектов.

Следующий фрагмент кода является примером XML-аннотации PASCAL VOC:

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

  • folder — родительский каталог образа.
  • filename — имя изображения (включая расширение).
  • path — абсолютный путь к изображению
  • source:database — исходное расположение файла в базе данных. Применимо только в том случае, если используется база данных. В противном случае по умолчанию будет Unknown.
  • size:width — ширина изображения в пикселях.
  • size:height — высота изображения в пикселях.
  • size:depth — глубина изображения. Для задач обнаружения объектов он представляет количество каналов.
  • segmented — определяет, являются ли аннотации линейными (0) или нелинейными (1). Нелинейные относятся к многоугольным формам.
  • object:name — метка для объекта.
  • object:pose — определяет, имеет ли объект разную ориентацию. Обычные изображения по умолчанию имеют значение Unspecified.
  • object:truncated — определяет, виден ли объект полностью (0) или частично (1). Частично видимый относится к объекту, который скрыт за другим объектом.
  • object:difficult — определяет, легко ли распознается объект (0) или трудно распознается (1).
  • object:bndbox:xmin — координата x верхней левой позиции.
  • object:bndbox:ymin — координата y верхней левой позиции.
  • object:bndbox:xmax — координата x нижней правой позиции.
  • object:bndbox:ymax — координата y нижнего правого положения.

Одна из основных проблем с XML-аннотациями PASCAL VOC заключается в том, что мы не можем использовать их напрямую для обучения, особенно задачам обнаружения объектов. Большинство современных моделей используют различные форматы аннотаций. Наиболее популярны:

  • COCO — один файл JSON состоит из пяти разделов информации для всех наборов данных.
  • YOLO — отдельный текстовый файл для каждого изображения с тем же именем, которое соответствует предполагаемому изображению.

ЙОЛО

Технические характеристики YOLO следующие:

  • каждый объект должен иметь свою строку в текстовом файле
  • каждая строка должна иметь следующий шаблон: class x_center y_center width height
  • номер класса должен быть целым числом и начинаться с 0
  • x_center, y_center, width, height должны быть в нормализованной форме (диапазон от 0 до 1)

Например, приведенные выше аннотации можно представить в формате YOLO следующим образом:

0 0.65814696485623 0.6966426858513189 0.07987220447284345 0.14148681055155876
0 0.7124600638977636 0.6882494004796164 0.09584664536741214 0.11990407673860912

Перейдите к следующему разделу, чтобы узнать, как преобразовать аннотации XML в текстовый файл YOLO.

Конвертировать PASCAL VOC XML в YOLO

Создайте новый скрипт с именем xml2yolo.py в рабочем каталоге. Убедитесь, что наборы данных и скрипт Python структурированы следующим образом:

root
├──annotations (folder)
├  ├── 1.xml
├  ├── 2.xml
├  └── n.xml
├──images (folder)
├  ├── 1.jpg
├  ├── 2.jpg
├  └── n.jpg
└──xml2yolo.py

Импортировать

Начните с добавления следующих операторов импорта вверху файла:

import xml.etree.ElementTree as ET
import glob
import os
import json

Вспомогательные функции

Затем определите следующие служебные функции:

  • xml_to_yolo_bbox — конвертировать ограничивающую рамку XML (xmin, ymin, xmax, ymax) в ограничивающую рамку YOLO (x_center, y_center, width, height)
  • yolo_to_xml_bbox — преобразовать ограничивающие рамки YOLO в ограничивающую рамку XML
def xml_to_yolo_bbox(bbox, w, h):
    # xmin, ymin, xmax, ymax
    x_center = ((bbox[2] + bbox[0]) / 2) / w
    y_center = ((bbox[3] + bbox[1]) / 2) / h
    width = (bbox[2] - bbox[0]) / w
    height = (bbox[3] - bbox[1]) / h
    return [x_center, y_center, width, height]
def yolo_to_xml_bbox(bbox, w, h):
    # x_center, y_center width heigth
    w_half_len = (bbox[2] * w) / 2
    h_half_len = (bbox[3] * h) / 2
    xmin = int((bbox[0] * w) - w_half_len)
    ymin = int((bbox[1] * h) - h_half_len)
    xmax = int((bbox[0] * w) + w_half_len)
    ymax = int((bbox[1] * h) + h_half_len)
    return [xmin, ymin, xmax, ymax]

Инициализация

Продолжите, добавив в скрипт следующие переменные:

classes = []
input_dir = "annotations/"
output_dir = "labels/"
image_dir = "images/"
os.mkdir(output_dir)

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

if not os.path.isdir(output_dir):
    os.mkdir(output_dir)

Получить XML-файлы

После этого используйте функцию glob.glob, чтобы получить список всех файлов xml в папке annotation. Прокрутите его и проверьте, есть ли соответствующие изображения для файла этикетки:

files = glob.glob(os.path.join(input_dir, '*.xml'))
for fil in files:
    basename = os.path.basename(fil)
    filename = os.path.splitext(basename)[0]
    if not os.path.exists(os.path.join(image_dir, f"{filename}.jpg")):
        print(f"{filename} image does not exist!")
        continue

По логике, файл этикетки должен иметь соответствующий файл изображения. Однако для файла изображения не обязательно требуется файл этикетки. Файл изображения может не содержать никаких объектов, и мы называем его фоновым изображением.

Приведенный выше код распечатает имя аннотации без пары изображений. Либо удалите аннотации, либо заполните недостающие изображения.

Анализировать содержимое XML-файлов

Затем используйте модуль ET для анализа содержимого файлов xml. Вызовите функции find или findall, чтобы извлечь определенные элементы из файла. Каждый объект элемента содержит встроенную функцию text для получения базового значения. Добавьте результат и сохраните его как файл с тем же базовым именем.

files = glob.glob(os.path.join(input_dir, '*.xml'))
for fil in files:
    
    ...
    result = []
    tree = ET.parse(fil)
    root = tree.getroot()
    width = int(root.find("size").find("width").text)
    height = int(root.find("size").find("height").text)
    for obj in root.findall('object'):
        label = obj.find("name").text
        if label not in classes:
            classes.append(label)
        index = classes.index(label)
        pil_bbox = [int(x.text) for x in obj.find("bndbox")]
        yolo_bbox = xml_to_yolo_bbox(pil_bbox, width, height)
        bbox_string = " ".join([str(x) for x in yolo_bbox])
        result.append(f"{index} {bbox_string}")
    if result:
        with open(os.path.join(output_dir, f"{filename}.txt"), "w", encoding="utf-8") as f:
            f.write("\n".join(result))

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

with open('classes.txt', 'w', encoding='utf8') as f:
    f.write(json.dumps(classes))

Он создаст текстовый файл с именем classes.txt. Текстовый файл будет содержать список строк, представляющих все уникальные классы в наборах данных. Например:

["tablets"]

Запустить скрипт

Полный скрипт для конвертации выглядит следующим образом:

Выполните следующую команду, чтобы преобразовать аннотации XML в текстовые файлы в формате YOLO:

python xml2yolo.py

Визуализируйте ограничивающие рамки

Перед обучением рекомендуется проверять качество наборов данных. Иногда аннотации или пары изображений могут быть неправильными. Следовательно, существует необходимость проверки качества наборов данных. Хороший способ — нарисовать ограничивающие рамки поверх соответствующих изображений.

Мы можем использовать для этого пакеты Pillow или OpenCV. В этом руководстве используется пакет Pillow для рисования ограничивающих рамок.

Давайте создадим еще один скрипт с именем draw.py для визуализации аннотаций и изображений.

Импортировать

Добавьте в файл следующие операторы импорта:

from PIL import Image, ImageDraw

Вспомогательные функции

Нам понадобятся две функции полезности:

  • yolo_to_xml_bbox — преобразовать ограничивающие рамки YOLO обратно в формат XML (на основе пикселей). В основном это связано с тем, что Pillow использует пиксели для всех своих ImageDraw.Draw функций.
  • draw_image — рисовать ограничивающие рамки поверх входных изображений. Затем отобразите его через пользовательский интерфейс.
def yolo_to_xml_bbox(bbox, w, h):
    # x_center, y_center width heigth
    w_half_len = (bbox[2] * w) / 2
    h_half_len = (bbox[3] * h) / 2
    xmin = int((bbox[0] * w) - w_half_len)
    ymin = int((bbox[1] * h) - h_half_len)
    xmax = int((bbox[0] * w) + w_half_len)
    ymax = int((bbox[1] * h) + h_half_len)
    return [xmin, ymin, xmax, ymax]
def draw_image(img, bboxes):
    draw = ImageDraw.Draw(img)
    for bbox in bboxes:
        draw.rectangle(bbox, outline="red", width=2)
    img.show()

Измените переменную outline, указав другой цвет ограничивающих рамок.

Инициализация

Продолжите инициализацию следующих переменных:

image_filename = "images/medical_pills.jpg"
label_filename = "labels/medical_pills.txt"
bboxes = []

Замените имена файлов соответственно.

Обработка изображений

Загрузите изображение и сохраните предварительно обработанные данные в переменной bboxes:

img = Image.open(image_filename)
with open(label_filename, 'r', encoding='utf8') as f:
    for line in f:
        data = line.strip().split(' ')
        bbox = [float(x) for x in data[1:]]
        bboxes.append(yolo_to_xml_bbox(bbox, img.width, img.height))

Нарисуйте ограничивающие рамки

Наконец, вызовите служебные функции draw_image, чтобы нарисовать ограничивающие рамки на изображении:

draw_image(img, bboxes)

Запустить скрипт

Полный скрипт в следующем Github gist:

Сохраните файл и выполните следующую команду в терминале:

python draw.py

Сценарий отобразит изображение с соответствующей ограничивающей рамкой, нарисованной поверх него. Например:

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

Разделить на разные наборы

Следующим шагом является разделение наборов данных на разные части. Для простоты в этом руководстве рассматривается разделение на три набора, а именно: обучение, проверка и тестирование. Создайте новый скрипт Python с именем split_datasets.py.

Импортировать

Как обычно, добавьте следующие операторы импорта вверху скрипта:

import random
import glob
import os
import shutil

Вспомогательные функции

После этого определите вспомогательную функцию, которая будет копировать существующие изображения и аннотации в новые папки.

def copyfiles(fil, root_dir):
    basename = os.path.basename(fil)
    filename = os.path.splitext(basename)[0]
    # image
    src = fil
    dest = os.path.join(root_dir, image_dir, f"{filename}.jpg")
    shutil.copyfile(src, dest)
    # label
    src = os.path.join(label_dir, f"{filename}.txt")
    dest = os.path.join(root_dir, label_dir, f"{filename}.txt")
    if os.path.exists(src):
        shutil.copyfile(src, dest)

Копирование используется вместо перемещения, чтобы предотвратить случайную потерю данных при возникновении ошибки или ошибки в сценарии.

Инициализация

Продолжите, инициализировав следующие переменные:

label_dir = "labels/"
image_dir = "images/"
lower_limit = 0
files = glob.glob(os.path.join(image_dir, '*.jpg'))

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

Мы вызовем функцию random.shuffle, чтобы случайным образом изменить порядок наборов данных:

random.shuffle(files)

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

Следующим шагом является определение пропорции разделения. В этом уроке используется следующее соотношение:

  • train — 80% наборов данных
  • val — 10% наборов данных
  • test — 10% наборов данных
folders = {"train": 0.8, "val": 0.1, "test": 0.1}
check_sum = sum([folders[x] for x in folders])
assert check_sum == 1.0, "Split proportion is not equal to 1.0"

Хорошей практикой является добавление утверждения для проверки того, что окончательная сумма отношения равна 1. Это гарантирует отсутствие перекрытия наборов данных между каждой частью.

Просто измените базовое значение словаря. Например, следующий фрагмент кода:

folders = {"train": 0.6, "val": 0.2, "test": 0.2}

установит отношение следующим образом:

  • train — 60% наборов данных
  • val — 20% наборов данных
  • test — 20% наборов данных

Терминал выдаст следующее сообщение об ошибке, если пропорция не равна 1.

AssertionError: Split proportion is not equal to 1.0

Копировать файлы

И последнее, но не менее важное: скопируйте соответствующие изображения и аннотации в нужную часть:

for folder in folders:
    os.mkdir(folder)
    temp_label_dir = os.path.join(folder, label_dir)
    os.mkdir(temp_label_dir)
    temp_image_dir = os.path.join(folder, image_dir)
    os.mkdir(temp_image_dir)
    limit = round(len(files) * folders[folder])
    for fil in files[lower_limit:lower_limit + limit]:
        copyfiles(fil, folder)
    lower_limit = lower_limit + limit

Приведенный выше фрагмент кода создаст соответствующие папки для train, val и test. Это вызовет ошибку, если папки присутствуют во время создания (при последующем запуске скрипта). Удаляйте сгенерированную папку перед каждым запуском, чтобы исключить перекрытие наборов данных.

Запустить скрипт

Полный код для разделения наборов данных выглядит следующим образом:

Выполните следующую команду в терминале:

python split_datasets.py

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

root
├──annotations (folder)
├  ├── 1.xml
├  ├── 2.xml
├  └── n.xml
├──images (folder)
├  ├── 1.jpg
├  ├── 2.jpg
├  └── n.jpg
├──test(folder)
├  ├── images (folder)
├  └── labels (folder)
├──train(folder)
├  ├── images (folder)
├  └── labels (folder)
├──val (folder)
├  ├── images (folder)
├  └── labels (folder)
├──draw.py
├──split_datasets.py
└──xml2yolo.py

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

Заключение

Давайте подытожим наши знания по сегодняшней теме.

Эта статья началась с краткого введения в формат PASCAL VOC XML и YOLO.

Затем он предоставил подробное руководство по преобразованию XML-файлов PASCAL VOC в аннотации YOLO.

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

Наконец, он рассмотрел разделение наборов данных на три разные части, а именно обучение, проверку и тестирование.

Спасибо за чтение этого произведения. Желаю отличного дня!

Рекомендации

  1. YOLOv5 — обучать пользовательские данные
  2. Kaggle — набор данных для обнаружения таблеток