Наша команда “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).
Принцип работы приложения
Так как приложение краудсорсинговое, то в идеале оно должно использоваться большим количеством пользователей. Принцип работы приложения следующий:
Пользователь, обнаружив пожар, фотографирует его (с геометкой) и загружает с помощью сервиса. Фото с геометкой и координаты отправки уходят на сервер приложения. Фотография может загружаться с Web или Mobile версии приложения.
Полученная фотография обрабатывается на сервере обученной нейросетью для подтверждения того, что на фотографии действительно пожар. Результат выполнения скрипта — точность предсказания, если >0.7, то на фото действительно пожар. Иначе не фиксируем данную информацию и просим пользователя загрузить другую фотографию.
В случае, если скрипт анализа картинки дал положительный результат, то координаты из геометки добавляются в датасет со всеми координатами. Далее рассчитываются расстояния между i-ой точкой из датасета NASA и точкой от пользователя. Если расстояние между точками ≤3 км, то точка из сета NASA добавляется в словарь. Так проходим по всем точкам. После этого на клиентскую часть приложения возвращаем json с координатами, удовлетворяющими условию. Если координат, находящихся по заданному условию, не нашлось, то возвращаем обратно единственную точку, которую мы получили от пользователя.
Если сервер возвращает массив точек, то клиентская часть приложения отрисовывает зону пожара на карте. В случае, когда сервер вернул одну точку, она отмечается на карте специальной меткой.
Используемый стек технологий
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.
Потратив почти полтора дня на то, чтобы разобраться с этой библиотекой, мы отказались от нее, потому что не смогли найти, в чем она может дать нам реальное преимущество от работы с ней. Наша задача расчета координат была совсем небольшой, поэтому в итоге все реализовали нативно.
Что дальше?
Естественно, все, что мы получили в итоге — крайне нестабильное и сырое приложение, которое имеет право на то, чтобы его доработали.
У нас получилось:
- Реализовать прототипы мобильного и Web-приложений, которые были способны делать фото (только для мобильной версии), загружать и отправлять их на сервер. Также на сервер успешно приходят координаты отправки.
- На сервере удалось развернуть 2 скрипта, которые реализуют основную логику приложения. Была налажена подача данных на вход этим скриптам и получение выходных данных с последующей отправкой на клиентскую часть.
- Реализовать самый настоящий “прототип” нашего приложения.
У нас не получилось реализовать, но хотелось бы решить следующие проблемы и добавить фичи (пункты идут в соответствии с приоритетом задачи):
- Организовать запись всех координат из датасета в базу данных, чтобы взаимодействовать напрямую с БД.
- Организовать автоматическую подгрузку нового файла с сайта NASA, т.е. организовать автоматическое ежедневное обновление координат.
- Добавить нотификацию пользователей, находящихся в зоне, близкой к пожару.
- Добавить регистрацию (необходимо для реализации первого пункта).
- Переписать алгоритм расчета зоны пожара.
- Решить дизайнерские задачи — навести красоту в мобильной и веб-версии приложения.