Камеры видеонаблюдения стали для многих стран обыденностью, например в Китае, они могут свисать гроздьями, через каждые 5 метров, по улице. Но в провинции России это все еще может быть в новинку. Я отношусь к видеонаблюдению по большей мере положительно. Ведь вид камеры, даже превентивно может предотвратить хулиганство (однажды я использовал муляжи камер в офисе:)), а главное это возможность контролировать объект наблюдения.
Этот пост про монтаж уличной камеры, на стену многоквартирного дома и программную реализацию - вывод изображения, без использования стандартной программы, оптимизацию, для размещения на raspberry pi.
Монтаж
Внутри помещения, я уже успешно использовал камеры фирмы vstarcam, по этому, лояльное отношение, подтолкнуло сделать заказ на али vstarcam CS64. Забегая вперед скажу, что это не лучший выбор - мыльная картинка, как будто нет даже заявленных 3 МегаПикселей.
План таков: повесить на внешнюю стену электрическую распределительную коробку, внутрь нее поместить блок питания, на крышку прикрепить камеру. Сигнал передается по wi-fi, питание - провести кабель через раму окна.
Примерный бюджет: ip-камера 3500р., коробка 600р., винтики-гаечки (продаются в леруа на развес) 5р., кабель/вилка/клеммы 200р.
Порядок работ:
Блок питания закинут в коробку(не стал его там крепить), отрезан кабель питания. На клеммы прикрутил новый кусок кабеля(брал его с запасом, но в итоге понадобилась только половина), кабель вывел из коробки;
В крышке коробки(она съемная), просверлил 4 отверстия и закрепил на ней камеру болтами с гайками;
Вылез из окна во внешний мир и под окном просверлил отверстия в стене, вбил дюпеля. Прикрутил открытую коробку, из которой, пока что, болтается моток кабеля.
Взял крышку с камерой, продел и подключил внутрь коробки кабеля(питание и не нужный lan), закрыл крышку, таким образом смонтировав камеру.
Просверлил в пластиковой раме окна отверстие наружу и всунул в него кабель питания, положил кабель канал, обрезал кабель до нужной длины и прикрутил вилку. Получилось довольно сурово, но это и к лучшему :)
Мотивом для дальнейшей части повествование было желание поделится с соседями видом со стены, ну и желание разобраться как захватывать видеопоток. Не было желания объяснять старшему поколению, как работает стандартное приложение eye4, по этому я решил реализовать веб страничку. Деплой будет на, уже обитавшую для домашних проектов, raspberry pi 4 4Gb.
В спецификации камеры было указано что она умеет в rtsp, его и выбрал. ip адрес камеры было просто вычислить в настройках маршрутизатора и задать его статичным. Предварительно надо было получить ссылку на видеопоток - а его нет! Я аж вспомнил nmap, а то мало ли с портом промахнулся. В документации нет ни слова, оказывается, в отличии от предыдущих моделей, в программе eye4, зайдя в настройки камеры надо включить опцию "незащищенный пароль". И как то напахнуло старыми китайскими девайсами, с непонятными настройками.
Итоговая ссылка rtsp://admin:password@192.168.0.119:10554/tcp/av0_0
Можно проверить ее подключившись например vlc
Пароль задавался в фирменной утилите.
Код
Программная часть будет использовать python (не судите строго, только год приручаю питона:)). Веб фреймворк Flask был выбран из-за простоты (для одностраничника больше и не надо); Для оптимизации, захват и генерацию кадров было решено разделить на разные процессы, с помощью multiprocessing (в надежде, что это поможет хилому rpi); Для захвата кадров видеопотока и их кодирования, оказалось лучшим вариантом будет использование библиотеки OpenCV.
Непосредственно код:
Файл скрипта на питоне webstreaming.py :
from flask import Response, Flask, render_template
from multiprocessing import Process, Manager
import time
import cv2
app: Flask = Flask(__name__)
source: str = "rtsp://admin:password@192.168.0.119:10554/tcp/av0_1"
def cache_frames(source: str, last_frame: list, running) -> None:
""" Кэширование кадров """
cap = cv2.VideoCapture(source)
#cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) #в некоторых случаях это позволяет избавится от старых кадров
fps = cap.get(cv2.CAP_PROP_FPS)
while running.value:
ret, frame = cap.read() # Чтение кадра
if ret: # Если кадр считан
#frame = cv2.resize(frame, (640, 360)) # Изменение размера кадра, по необходимости
_, buffer = cv2.imencode('.jpg', frame,
[int(cv2.IMWRITE_JPEG_QUALITY), 85]) # Кодирование кадра в JPEG
last_frame[0] = buffer.tobytes() # Кэширование кадра
else:
# Здесь можно обрабатывать ошибки захвата кадра
break # Если не удалось захватить кадр
time.sleep(1 / (fps+1)) # Интервал между кадрами
cap.release()
def generate(shared_last_frame: list):
""" Генератор кадров """
frame_data = None
while True:
if frame_data != shared_last_frame[0]: # Если кадр изменился
frame_data = shared_last_frame[0]
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame_data + b'\r\n') # HTTP ответ для потоковой передачи
time.sleep(1/15) # Задержка
@app.route("/")
def index() -> str:
# Возвращаем отрендеренный шаблон
return render_template("index.html")
@app.route("/video_feed")
def video_feed() -> Response:
return Response(generate(last_frame),
mimetype="multipart/x-mixed-replace; boundary=frame") # Запуск генератора
if __name__ == '__main__':
with Manager() as manager:
last_frame = manager.list([None]) # Кэш последнего кадра
running = manager.Value('i', 1) # Управляемый флаг для контроля выполнения процесса
# Создаём процесс для кэширования кадров
p = Process(target=cache_frames, args=(source, last_frame, running))
p.start()
# Запуск Flask-приложения в блоке try/except
try:
app.run(host='0.0.0.0', port=8000, debug=False, threaded=True, use_reloader=False)
except KeyboardInterrupt:
p.join() # Ожидаем завершения процесса
finally:
running.value = 0 # Устанавливаем флаг в 0, сигнализируя процессу о необходимости завершения
p.terminate() # Принудительно завершаем процесс, если он все еще выполняется
p.join() # Убедимся, что процесс завершился
Файл шаблона templates/index.html :
<html>
<head>
<title>Моя улица вэб стриминг</title>
</head>
<body>
<h1>Моя улица вэб стриминг</h1>
<h3>парковочка</h3>
<img src="{{ url_for('video_feed') }}">
</body>
</html>
Шаблон, состоит из нескольких тегов хтмл и думаю в объяснении не нуждается, по скрипту пройдемся более детально.
Здесь фласк приложение, при открытие страницы клиентом, обращается к генератору кадров, который выбирает изображение, постоянно создаваемое в отдельном процессе, захватывая видеопоток камеры.
Кеширование реализовано с помощью глобальной переменной last_frame, которая для обмена между процессами представляет из себя manager(данные внутри обернуты в list, так как это условие его использования). Это позволяет не генерировать для каждого нового клиента уникальные данные, они смотрят одни и те же картинки, не увеличивая нагрузку.
Сначала запускается процесс p, это позволит параллельно создавать кадры, не нагружая основной процесс.
Далее запускается фласк приложение app.run. Блок try, я добавил для того что бы нормально обработать ctrl-c в терминале. По его завершению, происходят методы завершения созданного процесса.
Функция создания кадра cache_frames. Именно в ней происходит основная нагрузка, которую надо оптимизировать, для маломощного одноплатника. Будем резать качество! Если у Вас будет довольно мощный сервер, вероятно не стоит повторять все советы(оставив хотя бы нормальное разрешение). Для начала я пробовал снижать частоту кадров, это приводило к появлению старых кадров и очевидному замедлению воспроизведения. Обнулить буфер камеры в VideoCapture можно только вытащив из него все кадры. Запускать cap.grab() в цикле это действенный механизм, но это приводит к недопустимой для меня нагрузке. В моей камере есть второй поток с более низким разрешением, это позволило снизить разрешение без cv2.resize, что существенно уменьшило нагрузку, позволив оставить штатную частоту кадров камеры. Все эти моменты могут различаться в разных моделях камер. Давайте пройдемся по строкам главной функции. Сначала мы открываем видеопоток(cap) и узнаем какой у него fps. Далее идет цикл в котором мы читаем кадр(cap.read). Закомментирована строка с изменением размера, так как удалось это сделать на стороне камеры. Далее происходит кодирование в jpeg, с уменьшением качества(imencode). По итогу мы преобразуем массив в необработанную строку байтов, так как именно такой результирующий вид требуется, и размещаем в наш кеш last_frame. Цикл каждый раз засыпает, что бы снизить нагрузку, интервал чуть выше фпс, что бы вычитывать все кадры из буфера камеры. По выходу из цикла ресурсы видеопотока будут освобождены(release).
Функция generate, при подключении клиента, генерирует хттп mjpeg ответ изображения с кадрами из кеша, который будет отображаться в браузере.
Вы можете заметить, что здесь нет работы над стабильностью. Например если соединение с камерой пропадет на время, скрипт просто сломается и такие ситуации надо обрабатывать.
Так же стоит провести работу по адаптации для нормального wsgi сервера. Это сделано, что бы не раздувать текущий текст и на своем гитхабе я постараюсь выложить доработанную версию.
Перекинув файлы на распберри пай и запустив их, нагрузка составила:
Я посчитал, что чуть более 20% использования cpu(BCM2711), хороший результат, не стеснит остальные проекты.
Осталось только пробросить порт на маршрутизаторе и можно делиться видео наблюдениями. Соседи рады, я рад :-)
Этот текст я написал, так как увидел скудность ру доков по rtsp+python. Возможно кого то это мотивирует на эксперименты с наблюдением и обработкой видеозахвата:) Всем удачи!