Как стать автором
Обновить

Поиск пропавших людей на снимках лесного массива, полученных с помощью БПЛА или ещё один разбор задачи Цифрового Прорыва

Время на прочтение13 мин
Количество просмотров6.7K

Привет, Хабр!
Это статья является продолжением цикла материалов по разбору задач Всероссийского чемпионата "Цифровой Прорыв", связанных с Computer Vision. Решение, предлагаемое в статье, позволяет получить место в топ-10 лидерборда, при этом реализация самого подхода у автора статьи заняла ~ 3-4 часа. В конце даются советы по улучшению решения, а также идеи, которые могут привести к победе.

Оглавление

Введение

По данным поисково-спасательного отряда «ЛизаАлерт» каждый год в нашей стране фиксируется примерно 180 тысяч обращений о пропаже людей. Наибольшее количество заявок связано с поиском потерявшихся в лесных массивах в период появления грибов. Пытаясь выйти из чащи, человек способен уйти далеко в любом направлении. Зона поиска может занимать несколько десятков километров. Поэтому сейчас при поисково-спасательных работах стали применять беспилотные летательные аппараты (БПЛА) для обнаружения пропавших людей в труднодоступных местах – в лесу, на болотах.

За один день БПЛА способен сделать более 10000 снимков. Эти изображения просматривают волонтеры, чтобы найти потенциальные признаки присутствия потерявшегося. Как правило для проработки фотоматериалов одного полета требуется до 30 волонтеров, которые тратят на анализ снимков около 8 часов драгоценного времени. Примерно через час монотонной работы человеческий глаз устает и может пропустить важные элементы.

Для облегчения работы волонтеров и повышения шансов на успешный исход поисков перед участниками чемпионата стоит задача по разработке алгоритма детекции человеческих силуэтов на снимках, полученных с помощью БПЛА

Условие задачи

На основе данных, полученных с БПЛА, разработайте модель, которая будет находить изображения, на которых присутствуют люди, и будет детектировать их положение на изображении.

Описание входных данных

  • train.csv — файл, содержащий данные о количестве людей на изображении и координаты их положения;

  • train/ — папка, содержащая фотографии лесного массива для обучения;

  • sample_solution.csv — файл, содержащий данные о фотографиях тестового набора;

  • test/ — папка, содержащая фотографии лесного массива для предсказания.

В файле train.csv присутствуют два столбца:

  • count_region — количество людей на изображении (столбец добавлен для удобства);

  • region_shape — массив с положением людей на изображении, где:

    • 'r' — область, внутри которой находится человек;

    • 'cy' — центр окружности по координате y;

    • 'cx' — центр окружности по координате x.

На что стоит обратить внимание

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

Стоит отметить, что в файле для отправки должен присутствовать только столбец region shape, как показано в файле sample solution. Столбец count region генерируется автоматически при вычислении точности вашего решения.

Метрика

Для корректной оценки вашего решения в файле для отправки необходимо отсортировать найденные области с человеком сначала в порядке возрастания координаты x, затем в порядке возрастания координаты y.

В качестве метрики выступает самописная метрика, которая состоит из двух частей:

Result = 0.6*Recall + 0.4*V_{norm}

Vnorm— нормализованное значение разности площади и координат областей, рассчитанное по формуле

V_{norm}=1-\sum((x-x_{pred})^{2}+(y-y_{pred})^{2}+(r-r_{pred})^{2})/const

Recall вычисляется как:

Recall=\frac{TP}{TP+FN}

Решение задачи

Метрика, предложенная организаторами, не слишком универсальная. Изначально не было известно о том, что требуется отправлять не просто координаты центра и радиус для каждого человека, а ещё необходимо, чтобы эти значения были отсортированы сначала по cx, а затем по cy. Эту информацию организаторы добавили уже после вопросов участников о том, почему у решений разный score, если переставить местами порядок следования людей. По-моему, это выглядит очень неразумно, когда существует mAP, который инвариантен к порядку следования объектов.

Готовое решение

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

проект Lacmus

Неудивительно, что решение для этой задачи уже существовало, причём от Российских разработчиков. Команда проекта Lacmus занимается внедрением современных Deep Learning-решений для поиска людей, потерявшихся вне населённой местности: в лесу, поле и т.д. Для того, чтобы понять, как именно работает их решение, следует прочитать статью "Проект Lacmus: как компьютерное зрение помогает спасать потерявшихся людей".

В качестве детектора объектов используется RetinaNet(ResNet50). Почему же авторы выбрали именно это архитектуру ? Для решения задачи, связанной с детекцией людей на фотографиях высокого разрешения, нужно каким-то образом преодолеть дисбаланс классов. Размер человека относительно размера всего изображения, на котором необходимо осуществлять поиск, ничтожно мал. Как же RetinaNet борется с этими сложностями ?
Главная особенность RetinaNet, позволяющая бороться с негативным влиянием дисбаланса классов при обучении — это оригинальная функция потерь Focal Loss:

где p — это оценённая моделью вероятность содержания в области искомого объекта (попросту говоря, выход нейросети, если он приводится к промежутку [0, 1]).
К сожалению, этот подход не позволяет попасть даже в топ-15. Видимо, контекст задачи в нашем случае слишком важен. Думаю, что примеры ниже дадут объяснение тому, почему эта нейронная сеть не подходит нам в данный момент.

Пример изображения, где не были найдены люди
Пример изображения, где не были найдены люди
Пример изображения, на котором были найдены люди
Пример изображения, на котором были найдены люди

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

А куда же мы без YOLO?

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

Искусственный интеллект ищет пропавших людей
Искусственный интеллект ищет пропавших людей

Откуда брать данные?

В обучающем наборе всего 58 изображений, на которых присутствует хотя бы один человек, при этом весь обучающий набор состоит из 5163 изображений. По-моему, это прекрасный датасет для решения такой сложной и комплексной задачи. Но тут к нам на помощь приходит проект Lacmus и их набор данных Lacmus Drone Dataset (LaDD).

Lacmus Drone Dataset

LADD — это набор данных изображений, созданных с помощь беспилотников, для обнаружения пешеходов. Набор данных собран волонтерами из организаций Lisa Alert, Sova и других. Съемка производилась горизонтально с высоты 40-50 метров, на снимках запечатлены люди в различных позах. Набор данных состоит из 1036 изображений. Аннотации LADD представлены в формате VOC.

Взглянем, как выглядит разметка для конкретного изображения, например, 411.jpg.

<?xml version="1.0"?>
<annotation xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <folder>VocGalsTfl</folder>
  <filename>411</filename>
  <source>
    <database>Unknown</database>
  </source>
  <size>
    <height>2250</height>
    <width>4000</width>
    <depth>3</depth>
  </size>
  <segmented>0</segmented>
  <object>
    <name>Pedestrian</name>
    <pose>Unspecified</pose>
    <truncated>0</truncated>
    <difficult>0</difficult>
    <bndbox>
      <ymin>957</ymin>
      <xmin>922</xmin>
      <ymax>1007</ymax>
      <xmax>997</xmax>
    </bndbox>
  </object>
</annotation>
411.jpg
411.jpg

Преобразование данных

Необходимо скачать и распаковать LADD. Таким образом, у нас есть 4 отдельных директории:

  • winter_moscow_2018

  • summer_moscow_2019

  • spring_korolev_2019

  • summer_tambov_2019

Для упрощения работы с этим набором данных, скопируем все содержимое в одну папку.

import os
from shutil import copyfile, copytree, move
from tqdm.notebook import tqdm
import random

save_dir = 'LADD'
os.makedirs(save_dir, exist_ok=True)

dirs = ['summer_moscow_2019', 'spring_korolev_2019', 'summer_tambov_2019', 'winter_moscow_2018']
for directory in dirs:
    copytree(directory, save_dir, dirs_exist_ok=True)

Конвертация в YOLO-формат

Так как у нас весь набор данных в формате VOC, то напишем функцию, которая преобразует разметку из VOC-формата в YOLO.

import xmltodict

def write_txt(data, save_path):
    with open(save_path, 'w') as f:
        for line in data:
            f.write(f"{line}\n")

def xml2yolo(filepath, save_path):
    with open(filepath, 'r') as f:
        data = f.readlines()
    annot = xmltodict.parse(' '.join(data))
    img_h = int(annot['annotation']['size']['height'])
    img_w = int(annot['annotation']['size']['width'])
    peoples = []
    if 'object' not in annot['annotation'].keys():
        return
    if isinstance(annot['annotation']['object'], dict):
        annot['annotation']['object'] = [annot['annotation']['object']]
    for obj in annot['annotation']['object']:
        bbox = obj['bndbox']
        y_min, x_min, y_max, x_max = int(bbox['ymin']),  int(bbox['xmin']),  int(bbox['ymax']),  int(bbox['xmax'])
        w, h = x_max - x_min, y_max - y_min
        x_c = x_min + w//2
        y_c = y_min + h//2
        w /= img_w
        h /= img_h
        x_c /= img_w
        y_c /= img_h
        peoples.append(f"0 {x_c} {y_c} {w} {h}")
    
    write_txt(peoples, save_path)

А теперь вызовем нашу функцию для всех файлов с аннотацией.

labels_dir = 'LADD/labels'
annot_dir = 'LADD/Annotations'
os.makedirs(labels_dir, exist_ok=True)

for file in tqdm(os.listdir(annot_dir)):
    xml2yolo(os.path.join(annot_dir, file), os.path.join(labels_dir, file.split('.')[0]+'.txt'))

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

Work smart, not hard
Work smart, not hard

Ещё один парсер.

import pandas as pd
import json
import cv2

def parse_shape(shape):
    data, start, end = [], [], []
    
    for index, s in enumerate(shape):
        if s=='{':
            start.append(index)
        if s=='}':
            end.append(index+1)
    
    for s, e in zip(start, end):
        data.append(json.loads(shape[s:e]))
    return data

def shape2yolo(shape, img_path, save_path):
    data = parse_shape(shape)
    h, w, _ = cv2.imread(img_path).shape
    peoples = []
    
    for i in data:
        x_c, y_c, w_2 = i['cx'], i['cy'], i['r']
        w_2 = w_2*2/w
        x_c /= w
        y_c /= h
        peoples.append(f"0 {x_c} {y_c} {w_2} {w_2}")
    
    write_txt(peoples, save_path)

А вот причина, по которой нам пришлось писать этот самый парсер.

df = pd.read_csv('train.csv', delimiter=',')
df[df['count_region']>0].head()
Эти кавычки и скобки не оставят равнодушным никого
Эти кавычки и скобки не оставят равнодушным никого

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

train_path_basedataset = 'train'
omsk_dataset_full = 'omsk_full'
LADD_images = 'LADD/JPEGImages'
LADD_labels = 'LADD/labels'

os.makedirs(os.path.join(omsk_dataset_full, 'test/images'), exist_ok=True)
os.makedirs(os.path.join(omsk_dataset_full, 'test/labels'), exist_ok=True)
os.makedirs(os.path.join(omsk_dataset_full, 'train/images'), exist_ok=True)
os.makedirs(os.path.join(omsk_dataset_full, 'train/labels'), exist_ok=True)

for index, row in df[df['count_region']>0].iterrows():
    copyfile(os.path.join(train_path_basedataset, row['ID_img']), 
             os.path.join(omsk_dataset_full, 'train/images', row['ID_img']))
    shape = row['region_shape']
    shape2yolo(shape, os.path.join(train_path_basedataset, row['ID_img']), 
               os.path.join(omsk_dataset_full, 'train/labels', row['ID_img'].split('.')[0]+'.txt'))

Теперь необходимо разбить датасет LADD на train и test. Переместим 20 % изображений вместе с разметкой в test, а все, что осталось, переместим в train.

images = os.listdir(LADD_images)
random.shuffle(images)

for image_name in images[:int(0.2*len(images))]:
    move(os.path.join(LADD_images, image_name), 
         os.path.join(omsk_dataset_full, 'test/images', image_name))
    if os.path.exists(os.path.join(LADD_labels, image_name.split('.')[0]+'.txt')):
        move(os.path.join(LADD_labels, image_name.split('.')[0]+'.txt'), 
             os.path.join(omsk_dataset_full, 'test/labels', image_name.split('.')[0]+'.txt'))

move(LADD_images, os.path.join(omsk_dataset_full, 'train'))
move(LADD_labels, os.path.join(omsk_dataset_full, 'train')) 

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

empty_images = df[df['count_region']==0]['ID_img'].to_list()
random.shuffle(empty_images)

for image in empty_images[:350]:
    copyfile(os.path.join(train_path_basedataset, image), 
             os.path.join(omsk_dataset_full, 'train/images', image))
    
for image in empty_images[-350:]:
    copyfile(os.path.join(train_path_basedataset, image), 
             os.path.join(omsk_dataset_full, 'test/images', image))

Теперь наш датасет полностью готов к работе. По моему субъективному мнению, большая часть работы в Computer Vision, как и в DS в целом, состоит в подготовке, поиске и обработке данных. Обучение модели - почти механическое действие, если только это не Embedded Systems.

Обучение модели и получение предсказаний

Создадим файл "omsk_hack.yaml" с описанием путей и классов, используемых в датасете. Это обязательное требование для YOLOv5.

train: /home/jovyan/omsk_full/train/images  # train images (relative to 'path') 128 images
val: /home/jovyan/omsk_full/test/images  # val images (relative to 'path') 128 images

nc: 1
names: ['man']

Обучение модели

!git clone https://github.com/ultralytics/yolov5
!cd "yolov5"
!pip install -r requirements.txt
!python train.py --img 1280 --batch -1 --epochs 100 --data "/home/jovyan/omsk_hack.yaml" --weights yolov5m6.pt --project "hackaton_omsk_find_people" --name "yolov5m6"

Обученный детектор у нас есть, узнаем, как он справляется со своей задачей.

Предсказания на тестовом наборе данных

!python detect.py --source {путь к тестовому набору} --weights {путь к весам модели} --save-txt --save-conf --name "yolov5m6_people_test" --imgsz 1280 --conf-thres 0.25

Самая сложная часть всего соревнования — научиться преобразовывать результаты YOLO в формат решения задачи. Но мы уже написали два парсера, так что и с этим успешно справимся. YOLO возвращает прямоугольники, а в нашей задаче от нас требуют окружности. Исходя из обучающего набора данных, предоставленных организаторами, можно сделать вывод, что в качестве радиуса следует брать максимум из ширины и длины прямоугольника. А теперь щепотка магии f-строк, чтобы совпали все скобки и кавычки.

def read_txt(label_path, img_path, th=0.3):
    with open(os.path.join(label_path), 'r') as file:
        lines = file.readlines()
    h, w, _ = cv2.imread(img_path).shape
    lines = [line.rstrip().split(' ') for line in lines]
    result = []
    for line in lines:
        cl, xc, yc, w_, h_, t = list(map(float, line))
        xc*=w
        yc*=h
        w_*=w
        h_*=h
        if t>=th:
            result.append(f"{{\"cx\":{xc},\"cy\":{yc},\"r\":{max(w_,h_)}}}")
    return result

Осталось совсем немного: преобразовать txt-файлы с результатами работы детектора на тестовом наборе данных, а затем сохранить предсказания в csv-файл.

labels = '/home/jovyan/yolov5/runs/detect/yolov5m6_people_test/labels'
test_images_path = '/home/jovyan/test'
res = {}
for file in tqdm(os.listdir(labels)):
    if os.path.exists(os.path.join(test_images_path, file.split('.')[0]+'.JPG')):
        predict =read_txt(os.path.join(labels, file), 
                          os.path.join(test_images_path, file.split('.')[0]+'.JPG'))
    else: 
        predict=read_txt(os.path.join(labels, file), 
                         os.path.join(test_images_path, file.split('.')[0]+'.jpg'))
    res[file.split('.')[0]] = predict

Не забудем и про изображения, на которых не обнаружены люди. Для таких изображений в столбце region_shape должен стоять 0.

df = pd.DataFrame(columns=['ID_img','region_shape'])

for file in tqdm(os.listdir(test_images_path)):
    if file.split('.')[0] not in res:
        res[file.split('.')[0]] = 0
    df = df.append({'ID_img':file,'region_shape':res[file.split('.')[0]]}, ignore_index=True)
    
df.to_csv(r'/home/jovyan/omsk_solution.csv', index=False)

Наше решение готово, оценим итоговой результат.

Публичный и приватный лидерборд
Публичный и приватный лидерборд

Задача попасть в топ-10 выполнена, но что же можно было сделать лучше?

Focal Loss в YOLOv5

YOLOv5 позволяет использовать ту же функцией ошибок, что и в RetinaNet, то есть Focal Loss. Для этого достаточно внести изменения в файл с гиперпараметрами. Например, рассмотрим файл hyp.scratch-high.yaml и изменим значение параметра fl_gamma с 0 на 1.5. Обучим ещё одну модель, используя эти гиперпараметры.

lr0: 0.01  # initial learning rate (SGD=1E-2, Adam=1E-3)
lrf: 0.1  # final OneCycleLR learning rate (lr0 * lrf)
momentum: 0.937  # SGD momentum/Adam beta1
weight_decay: 0.0005  # optimizer weight decay 5e-4
warmup_epochs: 3.0  # warmup epochs (fractions ok)
warmup_momentum: 0.8  # warmup initial momentum
warmup_bias_lr: 0.1  # warmup initial bias lr
box: 0.05  # box loss gain
cls: 0.3  # cls loss gain
cls_pw: 1.0  # cls BCELoss positive_weight
obj: 0.7  # obj loss gain (scale with pixels)
obj_pw: 1.0  # obj BCELoss positive_weight
iou_t: 0.20  # IoU training threshold
anchor_t: 4.0  # anchor-multiple threshold
# anchors: 3  # anchors per output layer (0 to ignore)
fl_gamma: 1.5  # focal loss gamma (efficientDet default gamma=1.5)
hsv_h: 0.015  # image HSV-Hue augmentation (fraction)
hsv_s: 0.7  # image HSV-Saturation augmentation (fraction)
hsv_v: 0.4  # image HSV-Value augmentation (fraction)
degrees: 0.0  # image rotation (+/- deg)
translate: 0.1  # image translation (+/- fraction)
scale: 0.9  # image scale (+/- gain)
shear: 0.0  # image shear (+/- deg)
perspective: 0.0  # image perspective (+/- fraction), range 0-0.001
flipud: 0.0  # image flip up-down (probability)
fliplr: 0.5  # image flip left-right (probability)
mosaic: 1.0  # image mosaic (probability)
mixup: 0.1  # image mixup (probability)
copy_paste: 0.1  # segment copy-paste (probability)

Сравним результаты модели с использованием и без использования Focal Loss.

YOLOv5 + Focal Loss
YOLOv5 + Focal Loss
YOLOv5
YOLOv5

Сравним обе модели на том же изображении, что использовалось для демонстрации работы RetinaNet.

YOLOv5 + Focal Loss
YOLOv5 + Focal Loss
YOLOv5
YOLOv5

Таким образом, Focal Loss, как и в случае с RetinaNet, уменьшает количество ошибок первого и второго рода.

Идеи по дальнейшему улучшению решения

  • В этой задаче присутствует дисбаланс классов, поэтому имеет смысл делать несколько итераций обучения. Напомню, что в нашем распоряжении исходный набор данных от организаторов, в котором почти все изображения без людей. Этот набор может уменьшить количество ложных срабатываний: протестируем каждую обученную модель на изображениях без людей. Дополним обучающий набор изображениями, на которых детектор ложноположительно обнаружил человека.

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

  • В данных был лик — люди были только на изображениях, у которых разрешение > 1024x576. В реальности не может быть никакой корреляции между разрешением изображения и тем, есть ли на этом изображении человек, но ведь это соревнование. В задаче про детекцию знаков дорожного движения был подобный лик, но был связан с локацией. Победить любой ценой — это, конечно, славно, но хочется сделать это за счёт качества решения, а не неспособности организаторов проводить хорошие соревнования.

Итоги и выводы

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

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

Весь код доступен в GitHub.

Теги:
Хабы:
Всего голосов 15: ↑14 и ↓1+16
Комментарии39

Публикации

Истории

Работа

Data Scientist
78 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань