Как мы писали приложение на хакатоне NASA Space Apps Challenge +8


20 — 21 октября в Москве проходил международный хакатон NASA Space Apps Challenge. Его организаторами в России выступили ребята из сообщества Russian.Hackers. В рамках мероприятия участникам было предложено решить 20 кейсов по различным тематикам: от съемки фильма о хакатоне до разработки приложений мониторинга и проектирования автономных летательных аппаратов. Полный список тем можно изучить по ссылке или в статье на Хабре.

Наша команда “Space Monkeys”, в которую входили Олег Бородин (Front-end developer в Singularis lab), Владислав Плотников (QA engineer в Singularis lab), Егор Швецов, Дмитрий Петров, Юрий Бедеров и Николай Денисенко, выбрала для решения проблему под броским названием “Spot that fire!”, которая сформулирована следующим образом: “Применить краудсорсинг, чтобы люди могли вносить свой вклад в обнаружение, подтверждение и отслеживание лесных пожаров. Решением может быть мобильное или веб приложение.

В силу того, что в команде было собрано 5 разработчиков с опытом разработки под различные платформы, сразу же было решено, что прототип нашего приложения будет реализовываться под Web и Mobile платформы.

Какие данные NASA мы использовали?


Все-таки хакатон проводился под эгидой Национального управления по аэронавтике и исследованию космического пространства, поэтому не использовать открытые данные из кладовых NASA было бы неправильно. Кроме того, мы сразу же нашли нужный нам датасет Active Fire Data. Данный датасет содержит в себе информацию о координатах пожаров по всему миру (можно скачать информацию по конкретному континенту). Данные обновляются каждые сутки (можно получать данные за 24 часа, 48 часов, 7 дней).


Файл содержит информацию по следующим полям: latitude,longitude, brightness, scan, track, acq_date, acq_time,satellite, confidence, version, bright_t31, frp, daynight, из которых мы использовали только координаты точек пожаров (latitude и longitude).


Принцип работы приложения


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


  1. Пользователь, обнаружив пожар, фотографирует его (с геометкой) и загружает с помощью сервиса. Фото с геометкой и координаты отправки уходят на сервер приложения. Фотография может загружаться с Web или Mobile версии приложения.

  2. Полученная фотография обрабатывается на сервере обученной нейросетью для подтверждения того, что на фотографии действительно пожар. Результат выполнения скрипта — точность предсказания, если >0.7, то на фото действительно пожар. Иначе не фиксируем данную информацию и просим пользователя загрузить другую фотографию.

  3. В случае, если скрипт анализа картинки дал положительный результат, то координаты из геометки добавляются в датасет со всеми координатами. Далее рассчитываются расстояния между i-ой точкой из датасета NASA и точкой от пользователя. Если расстояние между точками ?3 км, то точка из сета NASA добавляется в словарь. Так проходим по всем точкам. После этого на клиентскую часть приложения возвращаем json с координатами, удовлетворяющими условию. Если координат, находящихся по заданному условию, не нашлось, то возвращаем обратно единственную точку, которую мы получили от пользователя.

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


Используемый стек технологий


Front-end часть Web-приложения


Web-приложение, доступное из браузера, ориентировалось на экраны компьютеров, и не было адаптивно, однако, используемые технологии легко позволяли доработать этот аспект и для мобильных устройств. Мы использовали следующий стек технологий на web-стороне:


  • фреймворк Angular 6 от компании Google на языке TypeScript
  • CSS&JS фреймворк Materialize
  • модуль для загрузки файлов ng2-file-upload
  • карты OpenStreetMap, библиотека Leaflet

Сценарий работы


Пользователь открывает приложение и видит своё расположение:




Инициализация карты и геометки пользователя:


this.map = L.map('map').setView([latitude, longitude], 17);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '& copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
      }).addTo(this.map);

L.circle([latitude, longitude]).addTo(this.map)
        .bindPopup('You are here')
        .openPopup();

Если в радиусе n (настраиваемая переменная) километров есть пожар, то он будет отображен в виде полигона со сводкой дополнительной информации:




Пользователь выбирает место пожара на карте:




Установка метки пожара:


let marker; 
this.map.on('click', function (e) {
       if (marker) {
          self.map.removeLayer(marker);
        }
        marker = L.circle([e.latlng.lat, e.latlng.lng], { 
          color: 'red',
          fillColor: '#f03',
          fillOpacity: 0.5,
          radius: 15
        }).addTo(self.map)
          .bindPopup('Метка пожара')
          .openPopup();
        self.appService.coordinatesStorage.latitude = e.latlng.lat;
        self.appService.coordinatesStorage.longitude = e.latlng.lng;
        console.log('fire', self.appService.coordinatesStorage); 
      });

Далее пользователь загружает фото пожара с помощью ng2-file-upload.


В результате этих действий серверу передаются следующие данные:


  • координаты пользователя
  • координаты указанного пожара
  • фото пожара

Выходными данными приложения является результат распознавания.



Mobile-app приложения


Используемые технологии


  • React native — фреймворк для разработки кроссплатформенных приложений для iOS и Android
  • Redux — управление потоком данных в приложениях
  • Redux-saga — библиотека использующая side эффекты в Redux

Сценарий работы


Выбор фото пожара


Комментарий от пользователя


Метка для пожара



Back-end часть приложения


  • Язык программирования — JAVA 8

  • Облачная платформа — Microsoft Azure

  • Web application framework — Play Framework

  • Object-relational mapping — Ebean framework


На сервере располагаются 2 скрипта, написанные на Python: predict.py и getZone.py, для их работы были установлены следующие Python-библиотеки:


  • pandas — для обработки и анализа данных
  • geopandas — для работы с геоданными
  • numpy — для работы с многомерными массивами
  • matplotlib — для визуализации данных двумерной (2D) графикой (3D графика также поддерживается)
  • shapely — для манипулирования и анализа плоских геометрических объектов.

API сервера: fire.iconx.app/api


  • загрузка координат

post /pictures {}
return { id }

  • загрузка картинки

post /pictures/:id

Скрипт predict.py


Скрипт на вход получал картинку, происходил простой препроцессинг картинки (о нем подробнее в блоке “Обучение модели”) и на основе сохраненного файла с весами, который также находится на сервере, выдавалось предсказание. Если модель выдала точность > 0.7, то пожар фиксируется, иначе — нет.


Скрипт запускается классическим образом

$ python predict.py image.jpg 

Листинг кода:
import keras
import sys
from keras.layers import Dense
from keras.models import model_from_json
from sklearn.externals import joblib
from PIL import Image
import numpy as np
from keras import models, layers, optimizers
from keras.applications import MobileNet
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, MaxPooling2D

def crop_resize(img_path, img_size_square): 
    # Get dimensions      
    mysize = img_size_square
    image = Image.open(img_path) 
    width, height = image.size
    # resize
    if (width and height) >= img_size_square:
        if width > height:
            wpercent = (mysize/float(image.size[1]))
            vsize = int((float(image.size[0])*float(wpercent)))
            image = image.resize((vsize, mysize), Image.ANTIALIAS)
        else:
            wpercent = (mysize/float(image.size[0]))
            hsize = int((float(image.size[1])*float(wpercent)))
            image = image.resize((mysize, hsize), Image.ANTIALIAS)
        # crop
        width, height = image.size
        left = (width - mysize)/2
        top = (height - mysize)/2
        right = (width + mysize)/2
        bottom = (height + mysize)/2
        image=image.crop((left, top, right, bottom))
  
        return image

conv_base = MobileNet(weights='imagenet', include_top=False, input_shape=(224, 224, 3))

def build_model():
    model = models.Sequential()
    model.add(conv_base)
    model.add(layers.Flatten())
    model.add(layers.Dense(256, activation='relu'))
    model.add(layers.Dense(64, activation='relu'))
    model.add(layers.Dense(1, activation='sigmoid'))
    model.compile(loss='binary_crossentropy',
    optimizer=optimizers.RMSprop(lr=2e-5),
    metrics=['acc'])
    return model
image=crop_resize(sys.argv[1],224)
image = np.reshape(image,[1,224,224,3])

#Loading models and text processing
model = build_model()
print('building a model')
model.load_weights('./models/mobile_weights.h5')
print('model loaded')
pred_cat=model.predict(image)
if pred_cat > 0.7:
  print('fire {}'.format(pred_cat))
else: print('no fire {}'.format(pred_cat))



Скрипт getZone.py


Входными данными скрипта служат координаты точки, пришедшие с клиентской части приложения. Скрипт подтягивает все координаты от NASA, добавляет в этот файл новую широту и долготу, перезаписывает исходный файл и начинает искать ближайшие точки. Расстояние между точками считается по формуле гаверсинуса (англ. Haversine formula).


Для этого широта и долгота точек переводятся в радианы:


pt1_lon, pt1_lat, pt2_lon, pt2_lat = map(radians, [pt1_lon, pt1_lat, pt2_lon, pt2_lat])

Находятся разности между широтой и долготой для каждой из точек:


d_lon = pt2_lon - pt1_lon
d_lat = pt2_lat - pt1_lat

Все это подставляется в формулу гаверсинуса:


a = sin(d_lat/2)**2 + cos(pt1_lat) * cos(pt2_lat) * sin(d_lon/2)**2

Берем корень от результата вычислений, вычисляем арксинус и умножаем результат на 2.


c = 2 * asin(sqrt(a))

Расстоянием будет произведение радиуса Земли (6371 км) на результат предыдущего вычисления.


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


Для анализа картинки на предмет пожара нам потребовался тренировочный набор фотографий с пожарами. Фотографии собирались скриптом с сайта https://www.flickr.com/ и размечались вручную.


Скачивание происходило при помощи FlikerAPI. В скрипте производились стандартные операции препроцессинга с картинками: кадрирование — квадратное с центровкой (ratio 1: 1), и изменение размеров до формата 256 ? 256.


Листинг кода:
import flickrapi
import urllib.request
from PIL import Image
import pathlib
import os
from tqdm import tqdm
# Flickr api access key
flickr=flickrapi.FlickrAPI('your API key', 'your secret key', cache=True)
def get_links():
    search_term = input("Input keywords for images: ")
    keyword = search_term
    max_pics=2000

    photos = flickr.walk(text=keyword,
                         tag_mode='all',
                         tags=keyword,
                         extras='url_c',
                         per_page=500, # mb you can try different numbers..
                         sort='relevance')
    urls = []
    for i, photo in enumerate(photos):
        url = photo.get('url_c')
        if url is not None:
            urls.append(url)
        if i > max_pics:
            break
    num_of_pics=len(urls)
    print('total urls:',len(urls)) # print number of images available for a keywords
    return urls, keyword, num_of_pics
#resizing  and cropping output images will be besquare
def crop_resize(img_path, img_size_square):
    # Get dimensions
    mysize = img_size_square
    image = Image.open(img_path)
    width, height = image.size
    # resize
    if (width and height) >= img_size_square:
        if width > height:
            wpercent = (mysize/float(image.size[1]))
            vsize = int((float(image.size[0])*float(wpercent)))
            image = image.resize((vsize, mysize), Image.ANTIALIAS)
        else:
            wpercent = (mysize/float(image.size[0]))
            hsize = int((float(image.size[1])*float(wpercent)))
            image = image.resize((mysize, hsize), Image.ANTIALIAS)
        # crop
        width, height = image.size
        left = (width - mysize)/2
        top = (height - mysize)/2
        right = (width + mysize)/2
        bottom = (height + mysize)/2
        image=image.crop((left, top, right, bottom))
        return image
def download_images(urls_,keyword_, num_of_pics_):
    num_of_pics=num_of_pics_
    keyword=keyword_
    urls=urls_
    i=0
    base_path='./flickr_data/' # your base folder to save pics
    for item in tqdm(urls):
        name=''.join([keyword,'_',str(i),'.jpg'])
        i+=1
        keyword_=''.join([keyword,'_',str(num_of_pics)])
        dir_path= os.path.join(base_path,keyword_)
        file_path=os.path.join(dir_path,name)
        pathlib.Path(dir_path).mkdir(parents=True, exist_ok=True)
        urllib.request.urlretrieve(item, file_path)
        resized_img=crop_resize(file_path, 256) #set output image size
        try:
            resized_img.save(file_path)
        except:
            pass
urls, keyword, num_of_pics =get_links()
continue = input("continue  or try other keywords (y,n): ")
if continue =='y':
    download_images(urls, keyword, num_of_pics)
elif continue =='n':
    get_links()
else:
    pass


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


  • Легковесно — важно, чтобы время отклика приложения было минимально.
  • Быстро — важно, чтобы время отклика приложения было минимально.
  • Точно — MobileNet предсказывает с необходимой точностью.

После обучения сеть выдавала точность ~ 0.85.


Для построения модели, обучения и предсказания использовалась связка Keras + Tensorflow. Работа с данными осуществлялась через Pandas.


Так как NASA DataSet представляет собой географические данные, то мы захотели использовать библиотеку GeoPandas. Данная библиотека — расширение возможностей Pandas для обеспечения пространственных методов и операция над геометрическими типами. Геометрические операции реализуются через библиотеку shapely, работа с файлами — fiona, построение графиков — matplotlib.


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


Что дальше?


Естественно, все, что мы получили в итоге — крайне нестабильное и сырое приложение, которое имеет право на то, чтобы его доработали.


У нас получилось:


  1. Реализовать прототипы мобильного и Web-приложений, которые были способны делать фото (только для мобильной версии), загружать и отправлять их на сервер. Также на сервер успешно приходят координаты отправки.
  2. На сервере удалось развернуть 2 скрипта, которые реализуют основную логику приложения. Была налажена подача данных на вход этим скриптам и получение выходных данных с последующей отправкой на клиентскую часть.
  3. Реализовать самый настоящий “прототип” нашего приложения.

У нас не получилось реализовать, но хотелось бы решить следующие проблемы и добавить фичи (пункты идут в соответствии с приоритетом задачи):


  1. Организовать запись всех координат из датасета в базу данных, чтобы взаимодействовать напрямую с БД.
  2. Организовать автоматическую подгрузку нового файла с сайта NASA, т.е. организовать автоматическое ежедневное обновление координат.
  3. Добавить нотификацию пользователей, находящихся в зоне, близкой к пожару.
  4. Добавить регистрацию (необходимо для реализации первого пункта).
  5. Переписать алгоритм расчета зоны пожара.
  6. Решить дизайнерские задачи — навести красоту в мобильной и веб-версии приложения.




К сожалению, не доступен сервер mySQL