Камеры видеонаблюдения стали для многих стран обыденностью, например в Китае, они могут свисать гроздьями, через каждые 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

Комментарии (30)


  1. Maxim_Q
    26.12.2023 16:27
    +4

    Можно было с камеры напрямую поток RTSP смотреть: https://github.com/vladpen/cams

    У меня к этому приложению все домашние камеры подключены, довольно удобно.


    1. SeregaChipset Автор
      26.12.2023 16:27

      Вы правы. Но хотелось, что то свое поковырять, минималистичное=)


      1. eugenex15
        26.12.2023 16:27
        +1

        тогда https://github.com/vladpen/cams-pwa

        минималистичное (но! ssl) нет flask, opencv и т.п.

        или проще через ярлык в vlc смотреть

        + rtsp to jpeg

        зы и в большинстве коробки на которую вы закрепили камеру на солнце гибнут! так можно и камеру потерять.


        1. SeregaChipset Автор
          26.12.2023 16:27

          Спасибо за ссылки. Будет повод взять оттуда интересные идеи для своего проектика


    1. ANDRE888
      26.12.2023 16:27

      Если камера на некоторое время пропала из сети, будет ли это приложение переподключаться к ней до бесконечности?


      1. SeregaChipset Автор
        26.12.2023 16:27

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


  1. Javian
    26.12.2023 16:27
    +1

    А зачем надо было покупать дорогую PTZ-камеру, если управление поворотом и наклоном не используется?


    1. SeregaChipset Автор
      26.12.2023 16:27

      я использую, просто другим не даю)


      1. Javian
        26.12.2023 16:27
        +2

        можно в полдень поворачивать камеру, делать скриншоты и клеить панораму в категорию "Фото дня" :)


        1. SeregaChipset Автор
          26.12.2023 16:27

          хорошая идея????


  1. vesowoma
    26.12.2023 16:27
    +1

    Как с защитой от схода снега и льда с крыши в зимний период?


    1. SeregaChipset Автор
      26.12.2023 16:27

      Думал сделать козырек, но вроде скидывают на противоположную часть дома. Ну если что торчащий кабель, заместо камеры, из коробки, будет уроком)


  1. ABATAPA
    26.12.2023 16:27
    +1

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

    «Подъезжая к сией станцыи и глядя на природу в окно, у меня слетела шляпа» © Чехов А. П., «Жалобная книга»

    Wiki://Анаколуф

    Откройте для себя Frigate или Shinobi.

    И да, внешние стены — общее имущество собственников. Формально для такого размещения нужно решение общего собрания жильцов (или более половины проголосовавших заочно за такое решение).


    1. SeregaChipset Автор
      26.12.2023 16:27

      В чате дома негативных реакций не было. На следующем собрании можно и формально оформить????


    1. Maxim_Q
      26.12.2023 16:27

      Сылки на Frigate и Shinobi если кому нужно, а то у меня при посике "Shinobi" одни нинзя лезут в поиске:

      https://github.com/blakeblackshear/frigate

      https://gitlab.com/Shinobi-Systems/Shinobi


  1. PbIXTOP
    26.12.2023 16:27
    +1

    В свое время для того минимизировать нагрузки, просто ставили nginx и проксировали запрос на получение одиночного кадра, обычно в камерах это есть, который кешировали секунд на 15.


    1. SeregaChipset Автор
      26.12.2023 16:27

      Вероятно, есть различные способы оптимизации, до которых я не дошел, ввиду текущей квалификации????‍♂️ Этот скрипт еще надо допиливать, для использование на другом сервере, так как оформлен отдельный процесс, за пределами приложения фласк. Для меня, это скорее плюсы, добавляет азарта ковыряния домашнего проекта)


  1. jackcrane
    26.12.2023 16:27

    https://github.com/Motion-Project/motion

    но вы продолжайте строить велосипеды. может чего и получится.


    1. SeregaChipset Автор
      26.12.2023 16:27

      Это немного разное. Тут и правда про «велосипеды» - эксперименты, домашний проект, питон и т.д.


      1. jackcrane
        26.12.2023 16:27

        эксперименты, домашний проект

        ну и что. вам кто-то запретил экспериментировать на уровень выше ?

        питон

        питон самый тормозной скриптовый язык (после руби).

        с другой стороны, люди делают серверы видеонаблюдения для винды, продают их за деньги и даже сертифицируют.


        1. SeregaChipset Автор
          26.12.2023 16:27

          Обязательно дорасту до уровней выше, но что поделать, начал с питона????


  1. nkulagin
    26.12.2023 16:27

    А если предложу стриминг в telegram?

    https://github.com/AlexxIT/go2rtc


    1. SeregaChipset Автор
      26.12.2023 16:27

      Вроде неплохой проект на Go. Вообще, у меня была идея написать вариант, с отправкой кадров в WhatsApp. Скрипт из поста прост и возможностей для допиливания масса


  1. boov
    26.12.2023 16:27

    Немного оффтоп. А как часто браузер перезпрашивает новый кадр? Вижу заголовок multipart для кадра, но какого-то регулятора/хинта для периодичности не вижу. Стало интересно как это на клиентской стороне разруливается.


    1. SeregaChipset Автор
      26.12.2023 16:27

      Сейчас я попробую изложить свое понимание происходящего:)

      При открытии страницы браузер начинает загрузку изображения с адреса /video_feed, но вместо того, чтобы получить одно статическое изображение, он продолжает получать новые кадры(благодаря стандарту multipart/x-mixed-replace), посылаемые сервером через тот же самый ответ HTTP, создавая эффект видеопотока.

      Соответственно, в текущем скрипте, это происходит с частотой 15 раз в секунду(или реже, если кадр не обновился)


      1. boov
        26.12.2023 16:27

        Ясно. Т.е. происходит удержание соединения, одни запрос на сервер при загрузке страницы и далее много ответов от него.

        Изначально сложилось впечатление, что работает наоборот - клиент делает периодические запросы к серверу.


      1. itsWoland
        26.12.2023 16:27

        А websocket может ли справиться с данной логикой?


        1. SeregaChipset Автор
          26.12.2023 16:27

          вроде flask, в стандартной поставке, не поддерживает websocket. Но думаю, с использованием других фреймворков, это возможно


        1. boov
          26.12.2023 16:27

          Да, он как раз для таких задач хорошо подходит, когда надо от сервера на клиент что-то слать, не обязывая при этом клиента постоянно дёргать запросами сервер.


  1. CherryPah
    26.12.2023 16:27

    Оставлю ссылочку

    https://github.com/deepch