Таймлапс собственными силами с облачного сервиса видеонаблюдения IPEYE

в 14:43, , рубрики: python, python3, видеонаблюдение, видеотехника, програмиирование, Работа с видео, таймлапс

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

Intro

Мой отец решил переехать и построить дом в другом регионе. Попросил меня подсобить с видеонаблюдением. Вводные данные:

  • Нет технического помещения.
  • Оборудование могут своровать.
  • Нужна качественная картинка.
  • Камеры должны быть уличными.
  • Нужно всего 2 камеры.
  • Очень хочется поворотные камеры с зумом.
  • Очень хочется мобильное приложение.

Взглянув на цены в магазинах на брендированное оборудование было принято решение приобрести noname PoE камеры со всеми плюшками на али. Камеры обошлись достаточно дешево — около 5тыс за штуку.

Размещать видеорегистратор на стройке не хотелось, поэтому было принято решение использовать облачное решение. После того, как пришли камеры я попытался их подружить с разными сервисами так, чтобы всё работало, включая PTZ. Из всех сервисов, что я попробовал, получилось подружить китайские камеры только с IPEYE.
На этом закончу интро. Думаю, что теперь всем будет понятно почему разговор пойдет именно про этот сервис.

Опыт с IPEYE

Сервис как сервис. Всё обещанное выполняет. Техподдержка на вопросы отвечает. Ключевой момент — можно указать ссылку на rstp поток, покрутить настройки PTZ и всё будет работать. Мобильное приложение на android работает. Есть возможность создать гостевых пользователей для своих камер и каждому родственнику раздать права доступа. Веб интерфейс в Vivaldi иногда глючит, в Chrome такие глюки проявляются реже. Немного подтупливает просмотр архива.
Архив с камеры можно скачать, но отрезком до 3 часов. Процедура достаточно затратная по времени.

Вроде всё хорошо, но душой чувствуешь, что что-то не то. И не хватает глубины архива. Увеличить глубину можно, но самый дорогой вариант — 12 месяцев будет обходится 25тыс рублей в год за одну камеру (при записи по детекции).

Давай что-нибудь придумаем?

Именно такой вопрос задал мне отец. Отец захотел запечатлеть все этапы строительства.
Какие есть варианты решения данной задачи? Можно ежедневно заходить в веб интерфейс и экспортировать несколько кусочков видео с каждой камеры. Кто будет выполнять такую муторную задачу? Никто! Несколько раз в день открывать трансляцию камер и делать скриншоты? Ну тоже маразм. Увеличить глубину архива до 1 года? Ну очень не дешевое решение. Гугл мне подсказал, что в этом сервисе уже встроена функция TimeLapse, но разрешение низкое и для архива на будущее не скачать :(

Было принято решение — написать что-то, что бы как-то сохранять скриншоты с камеры и формировать итоговое видео.

Disclaimer

Автор данного опуса не является программистом и не стремится им стать. Основы ООП знает поверхностно. Agile и т.п. не изучал. Он месяц назад посмотрел короткий видеокурс по питону и решил его применить для решения текущей задачи.

API

Очень приятно было обнаружить, что сервиса IPEYE публично доступно API. В API приведены примеры только для PHP, но и это оказалось полезным.

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

  1. Windows планировщик раз в 30 минут запускает скрипт.
  2. Скрипт через API определяет uuid моих камер.
  3. Скрипт через API получает фото с камер и сохраняет в каталог.
  4. Скрипт раз в сутки формирует видео файл для каждой камеры.
  5. Каталог с исходными фотками и финальными видео привязан к облаку и расшарен.
  6. Родня когда хочет смотрит таймлапс и оперирует файлами как им хочется.

Для работы с API я написал пару функций: логирование и выполнение запросов к API серверу.

writeLog()

def writeLog(logdata):
	if LogEnable == 1:
		log_time = datetime.now()
		log_time = log_time.isoformat(timespec='seconds')
		log_file = open(log_file_path, "a+")
		log_file.write(log_time + ": " + str(logdata) + "n")
		log_file.close
	else:
		return True

getApiResponse()
def getApiResponse(method, api_uri):
	if method == "GET":
		try:
			r = requests.get(api_url + api_uri, timeout = api_timeout)
			r.raise_for_status() # включаем обработку HTTP ошибок в эксепшенах
		except requests.exceptions.Timeout:
			writeLog("Error. Timeout. Request Uri:" + api_uri)
		except requests.exceptions.TooManyRedirects:
			writeLog("Error. TooManyRedirects or bad URL. Request Uri:" + api_uri) 
		except requests.exceptions.RequestException as e:
			writeLog("Error. Fatal error: " + str(e) + " Request Uri:" + api_uri) 
			sys.exit(1)
		except requests.exceptions.HTTPError as e:
			writeLog("Error. HTTP error: " + str(e) + " Request Uri:" + api_uri)
	if method == "POST":
		try:
			r = requests.post(api_url + api_uri, timeout = api_timeout)
			r.raise_for_status() # включаем обработку HTTP ошибок в эксепшенах
		except requests.exceptions.Timeout:
			writeLog("Error. Timeout. Request Uri:" + api_uri)
		except requests.exceptions.TooManyRedirects:
			writeLog("Error. TooManyRedirects or bad URL. Request Uri:" + api_uri) 
		except requests.exceptions.RequestException as e:
			writeLog("Error. Fatal error: " + str(e) + " Request Uri:" + api_uri) 
			sys.exit(1)
		except requests.exceptions.HTTPError as e:
			writeLog("Error. HTTP error: " + str(e) + " Request Uri:" + api_uri)
	return r

Согласно API у нас есть возможность обратиться к /devices/all и получить инфу по всем потокам. Сразу же смутило, что не требуется авторизация, если верить документации по API… При запросе к /devices/all я получил ошибку:

Fatal error: 401 Client Error: Unauthorized for url: api.ipeye.ru:8111/devices/all

Я модифицировал свою функцию getApiResponse, что бы передавать свои учетные данные, но всё также я получал ошибку 401. Листинг функции не привожу, т.к. она в дальнейшем не пригодилась.

Пришлось обратиться в техподдержку по этому поводу. Саппорты разъяснили, что для использования API с авторизацией надо сперва заключить договор с IPEYE и настроить свой веб сервер. Что за веб сервер и для чего мне не разъяснили, но при этом они дали подсказку где взять uuid камер и обращаться к API без авторизации.

Переписка с саппортом

Я: Добрый день. Возможно ли использование API? uuid камер в web интерфейсе не обнаружил. Хотел получить список своих камер при запросе к api.ipeye.ru:8111/devices/all но моя связка логин/пароль не подходит.

IPEYE: В качестве логина/пароля используются авторизационные данные пользователя API, доступ мы предоставляем на договорной основе.

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

Я: Сколько стоит получение доступа к API двух камер?

IPEYE: Там все сложнее, вам надо будет запустить свой сайт и используя наш APIдобавить на него камеры и далее работать с ними.
Стоит ли это делать ради 2-х камер?

Я: Вообще, для понимания, мне доступ к API нужен только для автоматического скачивания скриншотов с камер. Для дальнейшего формирования таймлапса.

IPEYE: А чем в таком случае не устраивает api.ipeye.ru/doc#AppDeviceJPEGOnline?

Я: А где можно найти UUID моих камер?

IPEYE: Из адресной строки браузера, например. Этот же UUID фигурирует в «Коде для сайта» как параметр devcode.

Что мы имеем в сухом остатке? Действительно uuid камеры можно найти в личном кабинете, если присмотреться к GET параметрам. Есть метод /device/jpeg/online/:uuid/:name для получения скриншота, есть uuid, а название камеры мы и так знали.

Создаю функцию для сохранения изображений из потока.

saveJpegFromStream()

def saveJpegFromStream(uuid, name):
	# api_uri = "/device/thumb/online/" + uuid + "/1920/" + name
	api_uri = "/device/jpeg/online/" + uuid + "/" + name
	writeLog("Trying save Stream screenshot for camera: " + name)
	response = getApiResponse("GET", api_uri)
	content_type = response.headers.get('content-type')
	if content_type is None:
		writeLog("Nothing to save")
		return False
	if 'text' in content_type.lower() or 'html' in content_type.lower():
		writeLog("Received text data: " + response.content.decode("utf-8")) 
		return False
	else:
		filename = dirToSave + "\" + name + "-" + today.strftime("%Y-%m-%d-%H-%M-%S") + ".jpg"
		screenshot = open(filename, "wb")
		screenshot.write(response.content)
		screenshot.close()
		writeLog("File saved as: " + filename)
		return True

Делаю запрос и понимаю, что что-то не то… Файл получили. Изображение с моей камеры, но размер подозрительно маленький — 66Кб. Смотрю свойства и понимаю, что 608*342 ну никак не 1920*1080. При запросе /device/thumb/online/uuid/1920/name получаю файл 1920*1080, но это просто растянутый до нужного масштаба предыдущий файл 608*342. Само собой меня такое положение вещей не устроило.

Также во время экспериментов обнаруживается, что параметр name никак не проверяется и не используется. Можно отправлять что душе угодно.

После этого я сделал вывод, что самая полезная команда /device/url/rtsp/ — получение ссылки на RTSP поток. Мне пришлось обратиться к гуглу, т.к. понимания как работать с RTSP вообще не было.

getStreamRTSP()

def getStreamRTSP(uuid, name):
	api_uri = "/device/url/rtsp/" + uuid
	writeLog("Trying get stream RTSP link for " + name + " " + uuid)
	response = json.loads(getApiResponse("GET", api_uri).text)
	writeLog("Stream RTSP link for " + name + ": " + str(response["message"]))
	return str(response["message"])

saveJpegFromRTSP

def saveJpegFromRTSP(name, rtspLink):
	writeLog("Trying save RTSP screenshot for camera: " + name)
	rtspClient = cv2.VideoCapture(rtspLink)
	
	if rtspClient.isOpened():
		_,frame = rtspClient.read()
		rtspClient.release() # закрываем поток сразу после получения карда
		if _ and frame is not None:
			filename = dirToSave + "\" + name + "-" + today.strftime("%Y-%m-%d-%H-%M-%S") + ".jpg"
			cv2.imwrite(filename, frame)
			writeLog("File saved as: " + filename)
			return True
	else:
		writeLog("Can't read RTSP stream")
		return False

После того как я написал пару функций выше — пришло осознание, что для выполнения начальной задачи использовать API вообще не было потребности :) У меня есть прямая ссылка на rtsp поток камеры, а при помощи последней функции я могу захватить изображение с камеры. Но менять концепцию на данном шаге я не стал.

При работе через API есть 2 преимущества: сервер IPEYE инициализирует поток значительно быстрее чем камера, а также rtsp поток камеры может быть закрыт файрволлом.

jpg2mp4

Последним шагом осталось добавить все изображения одной камеры в видео. Я выбрал кодек mp4v, т.к. MEGA позволяет воспроизводить данные видео файлы в веб интерфейсе.

makeVideoFile()

def makeVideoFile(name):

	height = 1080
	width = 1920
	
	# video = cv2.VideoWriter(dirToSave + "\Video" + name + ".avi", cv2.VideoWriter_fourcc(*'DIVX'), 1,(width,height))
	video = cv2.VideoWriter(dirToSave + "\Video" + name + ".mp4", cv2.VideoWriter_fourcc(*'mp4v'), 1,(width,height))

	files = os.listdir(dirToSave)
	screenshots = list(filter(lambda x: x.startswith(name + "-"), files))

	for screenshot in screenshots:
		origImage = cv2.imread(dirToSave + "\" + screenshot)
		# Если изображение, пихуемое в видео поток, не соответсвует по габаритам потока - ничего не запихнется... Поэтому резайзим
		# Зачем ресайзить, если мы взяли до этого изображение из FullHD потока? Что бы была возможность добавить в видео изображение полученное через API
		heightOrig, widthOrig, channelsOrig = origImage.shape
		if height != heightOrig or width != widthOrig:
			img = cv2.resize(origImage, (width, height))
			video.write(img)
		else:
			video.write(origImage)
	cv2.destroyAllWindows()
	video.release()

Для справки: 45 jpg файлов общим объемом 37,3Мб в формате видео занимают 16,9Мб.

Спасибо, что прочитали мою первую публичную статью. Я постарался всё описать в формате истории, а не простом to do, т.к не хотел получить на выходе сухую статью.

Буду рад замечаниям, т.к с питоном познакомился меньше месяца назад.

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

Автор: Дмитрий Андреев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js