Камеры видеонаблюдения стали для многих стран обыденностью, например в Китае, они могут свисать гроздьями, через каждые 5 метров, по улице. Но в провинции России это все еще может быть в новинку. Я отношусь к видеонаблюдению по большей мере положительно. Ведь вид камеры, даже превентивно может предотвратить хулиганство (однажды я использовал муляжи камер в офисе:)), а главное это возможность контролировать объект наблюдения.

Этот пост про монтаж уличной камеры, на стену многоквартирного дома и программную реализацию - вывод изображения, без использования стандартной программы, оптимизацию, для размещения на raspberry pi.

Монтаж

коробка
коробка

Внутри помещения, я уже успешно использовал камеры фирмы vstarcam, по этому, лояльное отношение, подтолкнуло сделать заказ на али vstarcam CS64. Забегая вперед скажу, что это не лучший выбор - мыльная картинка, как будто нет даже заявленных 3 МегаПикселей.
План таков: повесить на внешнюю стену электрическую распределительную коробку, внутрь нее поместить блок питания, на крышку прикрепить камеру. Сигнал передается по wi-fi, питание - провести кабель через раму окна.


Примерный бюджет: ip-камера 3500р., коробка 600р., винтики-гаечки (продаются в леруа на развес) 5р., кабель/вилка/клеммы 200р.

Порядок работ:

  1. Блок питания закинут в коробку(не стал его там крепить), отрезан кабель питания. На клеммы прикрутил новый кусок кабеля(брал его с запасом, но в итоге понадобилась только половина), кабель вывел из коробки;

  2. В крышке коробки(она съемная), просверлил 4 отверстия и закрепил на ней камеру болтами с гайками;

  3. Вылез из окна во внешний мир и под окном просверлил отверстия в стене, вбил дюпеля. Прикрутил открытую коробку, из которой, пока что, болтается моток кабеля.

  4. Взял крышку с камерой, продел и подключил внутрь коробки кабеля(питание и не нужный lan), закрыл крышку, таким образом смонтировав камеру.

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

    улица
    улица

Мотивом для дальнейшей части повествование было желание поделится с соседями видом со стены, ну и желание разобраться как захватывать видеопоток. Не было желания объяснять старшему поколению, как работает стандартное приложение eye4, по этому я решил реализовать веб страничку. Деплой будет на, уже обитавшую для домашних проектов, raspberry pi 4 4Gb.

eye4
eye4


В спецификации камеры было указано что она умеет в rtsp, его и выбрал. ip адрес камеры было просто вычислить в настройках маршрутизатора и задать его статичным. Предварительно надо было получить ссылку на видеопоток - а его нет! Я аж вспомнил nmap, а то мало ли с портом промахнулся. В документации нет ни слова, оказывается, в отличии от предыдущих моделей, в программе eye4, зайдя в настройки камеры надо включить опцию "незащищенный пароль". И как то напахнуло старыми китайскими девайсами, с непонятными настройками.

nmap
nmap

Итоговая ссылка rtsp://admin:password@192.168.0.119:10554/tcp/av0_0
Можно проверить ее подключившись например vlc
Пароль задавался в фирменной утилите.

Код

Программная часть будет использовать python (не судите строго, только год приручаю питона:)). Веб фреймворк Flask был выбран из-за простоты (для одностраничника больше и не надо); Для оптимизации, захват и генерацию кадров было решено разделить на разные процессы, с помощью multiprocessing (в надежде, что это поможет хилому rpi); Для захвата кадров видеопотока и их кодирования, оказалось лучшим вариантом будет использование библиотеки OpenCV.

Непосредственно код:

  1. Файл скрипта на питоне 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()  # Убедимся, что процесс завершился
  1. Файл шаблона 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 сервера. Это сделано, что бы не раздувать текущий текст и на своем гитхабе я постараюсь выложить доработанную версию.

Перекинув файлы на распберри пай и запустив их, нагрузка составила:

ps aux
ps aux

Я посчитал, что чуть более 20% использования cpu(BCM2711), хороший результат, не стеснит остальные проекты.

Осталось только пробросить порт на маршрутизаторе и можно делиться видео наблюдениями. Соседи рады, я рад :-)

Этот текст я написал, так как увидел скудность ру доков по rtsp+python. Возможно кого то это мотивирует на эксперименты с наблюдением и обработкой видеозахвата:) Всем удачи!

browser
browser