- PVSM.RU - https://www.pvsm.ru -

Улучшаем систему видеонаблюдения, ч.1

Вмоей домашней системе видеонаблюдения используются самые разные видеокамеры, некоторые из них очень дешевые и очень старые, и поэтому неспособны на такие вещи как «обнаружение человека».

А для контроля за пространством вокруг эта функция довольно полезна.

Простой «детектор движения» в камерах тут не поможет — он реагирует на смену картинки, то есть буквально на любые изменения, включая тени от предметов, опавшие листья и прочее подобное.

Идея использовать PIR‑датчик тоже к успеху не привела: он реагирует на холодные струи дождя и на воздушные потоки разной температуры, что дает массу ложных срабатываний.
Итак, нам нужен «детектор человеков».

Первым вариантом решения стало использование CodeProject.AI [1].

Это AI‑сервер, который способен обрабатывать изображения, идентифицируя на них те или иные объекты. Взаимодействие с ним производится через WebAPI.

Несмотря на то, что на сайте указаны различные варианты использования — работают почему‑то только docker‑образы.

С использованием Docker установка сервера сводится по сути к двум командам:

Скачать образ:

# docker pull codeproject/ai-server

Запустить сервер:

docker run --name CodeProject.AI-Server -d -p 32168:32168 --gpus all ^
 --mount type=bind,source=C:ProgramDataCodeProjectAIdockerdata,target=/etc/codeproject/ai ^
 --mount type=bind,source=C:ProgramDataCodeProjectAIdockermodules,target=/app/modules ^
   codeproject/ai-server:gpu

Поскольку я устанавливал его на старый ноутбук под Linux — вторая команда приняла немного другой вид:

# docker run --name CodeProject.AI -d -p 32168:32168 
 --mount type=bind,source=/etc/codeproject/ai,target=/etc/codeproject/ai 
 --mount type=bind,source=/opt/codeproject/ai,target=/app/modules 
   codeproject/ai-server

Запускаем без использования GPU (codeproject/ai‑server), потому что встроенная видеокарта тут никакой пользы не приносит.

API описано на странице проекта. [2]

Суть простая: отправляем POST‑запрос с файлом — получаем результат.

Например, используем curl:

#!/bin/sh

host='xx.xx.xx.xx'

if [ -f $1 ] ; then
  json=`curl --trace logfile -F image=@$1 http://${host}:32168/v1/vision/detection`

  echo -n "$1|"
  echo $json

fi

Передавая скрипту параметр — графический файл, получим в формате JSON перечень обьектов, обнаруженных на картинке.

Теперь задача посложнее: как в нужный момент получить из видеокамер этот самый графический файл и отправить его для анализа на AI‑сервер.

Тут есть как минимум два разных подхода:

Во‑первых, можно настроить на самих камерах тот самый «детектор движения» с отправкой фотографии на почтовый сервер. Почтовый сервер, понятное дело, наш собственный, по сути просто скрипт, принимающий фотографию и отправляющий ее на детектор.

Минусы в том, что не все камеры позволяют это настроить, и в том, что разные могут отправлять эту фотографию по‑разному, то есть скрипт нужно адаптировать под разные типы камер или настраивать почти полноценный почтовый сервер, адаптируя скрипт теперь уже к нему и к форматам писем.

Второй способ зависит от регистратора. Как выяснилось, в настройках ряда китайских регистраторов есть интересный пункт alarm server: при получении события типа «Motion detect» на указанный адрес этого alarm server‑а отправляется сообщение в JSON‑формате с указанием номера канала и типа события.

{
  'StartTime' => 'XXXX-XX-XX XX:XX:XX',
  'SerialID' => 'XXXXXXXXXXXXXXXXXXXX',
  'Type' => 'Alarm',
  'Channel' => 4,
  'Status' => 'Start',
  'Address' => '0xXXXXXXX',
  'Event' => 'MotionDetect',
  'Descrip' => ''
};

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

У этого способа минусы в том, что у разных камер разные URL для запроса снимка (но это несложно решить), а у некоторых и вовсе нет такой возможности (даже если она как бы есть — снимок не делается).

Конечно можно запросить у камеры видеопоток, но с учетом компрессии вытащить из него полноценную картинку без артефактов не так‑то просто: это требует лишнего времени, а чем быстрее обрабатывается одно событие и чем меньше программ при этом запускается — тем ниже нагрузка на систему.

В общем, не пытайтесь обьять необьятное, как завещали классики.
Ограничимся тем, что работает.

Итак, нужны два скрипта‑сервера: alarm server и «почтовый».
Почтовый в кавычках потому что никакую почту никуда он пересылать не будет, его задача просто принять файл.

#!/usr/bin/perl

use lib '/home/user/lib' ;
use MyImgAI;
use MyTelegram;

use IO::Socket;
use bytes;
use Net::MQTT::Simple;

use JSON;
use Data::Dumper;

use Email::Simple;
use MIME::Parser;
use File::Basename;

$SIG{CHLD} = "IGNORE";

$| = 1;

# ===============================================
sub process_entity {
  my ($entity, $from) = @_;
  print " parse ($from) ";

  # Если это multipart (несколько частей), рекурсивно обрабатываем каждую часть
  if ($entity->is_multipart) {
    foreach my $part ($entity->parts) {
      process_entity($part,$from);  # рекурсия для обработки всех частей
    }
  }
  # Если это вложение (и оно закодировано как файл)
  else {
    my $filename = $entity->head->recommended_filename;
    if ($filename) {

      my $str = $entity->bodyhandle->as_string;
      my $res = MyImgAI::detect($str,$from);

      if(defined $res){
        MyTelegram::send_image($res);
      }

    }
  }
}

# ===============================================
my $server = IO::Socket::INET->new(LocalPort => 2525, Type => SOCK_STREAM, Reuse => 1, Listen => 10 ) or die "Couldn't be a tcp server : $@n";

while (my $client = $server->accept()) {

  my $pid = fork;
  if($pid == 0){

    ## новое подключение
    binmode $client;
    my $mode = 0;
    my $umode = 0;
    my $text = '';

    ## притворяемся почтовым сервером Exim
    print $client "220 lo.lo ESMTP Exim 4.92.3 Fri, 04 Oct 2024 14:18:44 +0300rn";

    while(my $str = <$client>){

      ## в режиме чтения тела письма - читаем и сохраняем
      if($mode){
        if($str =~ /^.[rn]/){                                                ## конец письма
          $mode = 0;
          print $client "250 OKrn";

          my $email = Email::Simple->new($text);                                ## разбор письма

          my $parser = MIME::Parser->new;
          $parser->output_to_core(1);
          #$parser->output_dir($output_dir);

          my $entity = $parser->parse_data($text) || die "Errorn";

          process_entity($entity,$from);

        }
        else{
          $text .= $str;
        }
      }
     ## режим общения с клиентом
      else {
        if($umode == 1){
          print $client "334 DYT3jf4sdDR5rn";
          $umode = 2;
        }
        elsif($umode == 2){
          print $client "235 OKrn";
          $umode = 0;
        }

        if($str =~ /^EHLO/ || $str =~ /^HELO/){
          print $client "250 OKrn";
        }
        elsif($str =~ /^MAIL FROM/ ){
          print $client "250 OKrn";
        }
        elsif($str =~ /^RCPT TO/ ){
          print $client "250 OKrn";
        }
        elsif($str =~ /^AUTH/ ){
          print $client "334 DYT3jf4sdDR5rn";
          $umode = 1;
        }
        elsif($str =~ /^QUIT/ ){
          print $client "221 OKrn";
        }
        elsif($str =~ /^DATA/ ){
          print $client "354 OKrn";
          $mode = 1;
          $text = '';
        }
      }
    }
    exit;
  }
}

close($server);

«Почтовый» сервер просто слушает заданный порт, при подключении клиента — камеры — обменивается стандартными сообщениями, принимает тело письма, выделяет из него файл, если он там есть, сохраняя его в памяти, и передает дальше для обработки в модуль MyImgAI.

Если обработка прошла успешно и что‑то там такое найдено — это что‑то в виде образа файла отправляется в модуль отправки сообщений в Телеграм.
Если ничего нет — то больше ничего и не происходит.

Используется Perl, потому что это достаточно просто, а поскольку сервер запускается всего один раз — и достаточно быстро.

#!/usr/bin/perl

use lib '/home/user/lib' ;
use MyImgAI;
use MyTelegram;

use IO::Socket;
use bytes;
use Net::MQTT::Simple;

use JSON;
use Data::Dumper;

# =====================================================
$SIG{CHLD} = "IGNORE";

$| = 1;

# список каналов с URL
my $snap_urls = {
  '0' => {
    url => 'http://192.168.1.221/cgi-bin/getsnapshot.cgi',
    delay => 0,
  },
  '1' => {
    url => 'http://192.168.1.222/webcapture.jpg?user=admin&password=secret&command=snap&amp;channel=0',
    delay => 1,
  },
  '2' => {
    url => 'http://192.168.1.223/cgi-bin/getsnapshot.cgi',
    delay => 0,
  },
  '3' => {
    url => 'http://192.168.1.224/cgi-bin/getsnapshot.cgi',
    delay => 0,
  },

  #'9' => 'http://192.168.1.203/webcapture.jpg?command=snap&amp;channel=1',
  #'10' => 'http://192.168.1.211/webcapture.jpg?command=snap&amp;channel=1',
  #'11' => 'http://192.168.1.216/cgi-bin/getsnapshot.cgi', #pir
  #'7' => 'http://192.168.1.210:80/tmpfs/auto.jpg',
};

# =====================================================
sub send_message {
  my $ch = shift;

  ## форк для обработки
  my $pid = fork();

  if(!defined $pid || $pid > 0){
    return;
  }

  ## если этого канала нет в списках - завершаем процесс
  my $param = $snap_urls->{ $ch };
  exit if(!defined $param);

  ## иногда требуется задержка для запроса - чтобы цель подошла ближе к камере
  my $url = $param->{url};
  sleep( $param->{delay} ) if($param->{delay});

  my $tiny = HTTP::Tiny->new;
  my $response = $tiny->get($url);

  exit unless $response->{success};

  ## если запрос успешен и фото получено - детекция и отправка
  if (length $response->{content}) {
    print STDERR "+";

    my $now = time;

    my $res = MyImgAI::detect($response->{content},$ch);

    if(defined $res){
        MyTelegram::send_image($res);
    }
  }

  exit(0);
}

# =====================================================
my $server = IO::Socket::INET->new(LocalPort => 15002, Type => SOCK_STREAM, Reuse => 1, Listen => 10 ) or die "Couldn't be a tcp server : $@n";

while (my $client = $server->accept()) {
  # $client is the new connection
  binmode $client;
  while(my $str = <$client>){
    my $j_str = substr($str,20);
    if($j_str =~ /({.+})/){
      my $data = from_json($1);
      send_message( $data->{Channel} );
    }
  }
}
close($server);

Сервер слушает порт, получает сообщение, выделяет номер канала, находит соответствующий URL запроса, получает картинку, и отправляет на детектор

Модуль детектора:

#!/usr/bin/perl

package MyImgAI;

use HTTP::Tiny;
use Data::Dumper;
use GD;
use JSON;
use Net::MQTT::Simple;
use Cache::Memcached::Fast;


my $url_ai = "http://127.0.0.1:32168/v1/vision/custom/ipcam-combined";
my $boundary = "------------------------74ff4ba03552faa9";


#==================================================================
sub detect {
  my ($in,$ch) = @_;

  my $ret = undef;

  if (defined $in && length $in) {

    # отправка картинки на AI
    my $data = "--$boundaryrn".
    "Content-Disposition: form-data; name="image"; filename="xx.jpg""."rn".
    'Content-Type: image/jpeg'."rnrn".
    $in.
    "rn--$boundary--rnrn";

    my $l = length($data);

    print STDERR '.';

    my $tiny = HTTP::Tiny->new;
    my $r = $tiny->request('POST', $url_ai, {
        content => $data,
        headers => {
          'Content-Length' => $l,
          'content-type' => "multipart/form-data; boundary=$boundary",
          'Accept' => '*/*',
        },
    });

    ## ответ получен
    if($r->{success} == 1){
      print STDERR 'o';

      my $content = $r->{content};
      if($content =~ /^({.*})$/){

        my $d = from_json($1);
        if($d->{count}){                      ## что-то найдено
          print STDERR "!";

          ## будем рисовать рамки
          my $im = GD::Image->newFromJpegData($in,1);
          my $red = $im->colorAllocate(255,0,0);
          my $blue = $im->colorAllocate(0,0,255);
          my $green = $im->colorAllocate(0,255,0);
          my $black = $im->colorAllocate(0,0,0);

          my ($width,$height) = $im->getBounds();

          ## look for new objects
          my $found_new = 0;
          my $cnt = 0;
          foreach my $x (@{$d->{predictions}}){

            $cnt++;
            my $px_max   = $x->{x_max}; #int(($x->{x_max} * 20 )/$width);
            my $px_min   = $x->{x_min}; #int(($x->{x_min} * 20 )/$width);
            my $py_max   = $x->{y_max}; #int(($x->{y_max} * 20 )/$height);
            my $py_min   = $x->{y_min}; #int(($x->{y_min} * 20 )/$height);

            # поиск такого же обьекта в том же месте за последние N секунд
            my $mm = Cache::Memcached::Fast->new({
              servers => [ { address => 'localhost:11211', weight => 2.5 } ],
              namespace => 'imgai:',
              connect_timeout => 0.2,
              io_timeout => 0.5,
              close_on_error => 1,
              max_failures => 3,
              failure_timeout => 2,
              nowait => 1,
              hash_namespace => 1,
              utf8 => 1,
              max_size => 512 * 1024,
            });

            my $key = $ch.'_'.$x->{label}.'_'.$px_max.'_'.$px_min.'_'.$py_max.'_'.$py_min;

            my $t = $mm->get($key);
            if(!defined $t){                    ## такого не было за 60 сек - значит новый!
              $mm->set($key, time, 60);
              $found_new++;
            }

            if($x->{label} eq 'person'){
              $im->rectangle($x->{x_min},$x->{y_min},$x->{x_max},$x->{y_max},$red);
            }
            elsif($x->{label} eq 'car'){
              $im->rectangle($x->{x_min},$x->{y_min},$x->{x_max},$x->{y_max},$blue);
            }
            else{
              $im->rectangle($x->{x_min},$x->{y_min},$x->{x_max},$x->{y_max},$green);
            }

          } # predictions
          ## если были новые - возвращаем картинку с рамками
          if($found_new > 0){
            $ret = $im->jpeg();
          }
        } # if count > 0
      }# is json
    }
    else{
      #print STDERR "ERROR: $r->{status} $r->{reason}n";
    }
  }
  return $ret;
}

1;

Картинка отправляется на сервер, если ответ получен и что‑то найдено — для каждого обьекта на картинке рисуем рамку, и заодно проверяем, что за последние 60 секунд его там не было. Если найдены новые обьекты — модуль возвращает картинку с рамками, если нет — undef (null);

Модуль Телеграма очень простой: через своего бота отправляем себе картинку

#!/usr/bin/perl

package MyTelegram;

use HTTP::Tiny;
use Data::Dumper;
use GD;
use JSON;
use Net::MQTT::Simple;

my $boundary = "------------------------74ff4ba057eefaa9";

# параметры телеграма
my $token = 'XXXXXXXXXX:XXXXXXXXXXXXXXXXXXXX_XXXXXXXXXXXXXX';
my $chat_id = 'YYYYYYYYY';
my $tlg = "https://api.telegram.org/bot$token/sendPhoto";


sub send_image {
  my ($image) = @_;

  my $data = "rn--$boundaryrn".
    "Content-Disposition: form-data; name="photo"; filename="xx.jpg""."rn".
    'Content-Type: image/jpeg'."rnrn".
    $image.
    "rn--$boundaryrn".
    "Content-Disposition: form-data; name="chat_id""."rnrn".
    $chat_id.
    "rn--$boundary--rnrn";

  my $tiny = HTTP::Tiny->new;
  my $r = $tiny->request('POST', $tlg, {
    content => $data,
    headers => {
      'content-type' => "multipart/form-data; boundary=$boundary",
      'Accept' => '*/*',
    },
  });

}

1;

Оба модуля используются обоими серверами.

Всё в целом позволяет заставить даже примитивные камеры отслеживать появление в зоне контроля людей, машин, животных и так далее.

Что именно контролировать — несложно задать в скрипте, просто игнорируя лишние сущности по полю label.

Информация о «гостях» немедленно попадает в Телеграм.

Автор: JBFW

Источник [3]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/videonablyudenie/404370

Ссылки в тексте:

[1] Первым вариантом решения стало использование CodeProject.AI: https://www.codeproject.com/ai/index.aspx

[2] API описано на странице проекта.: https://www.codeproject.com/ai/docs/api/api_reference.html

[3] Источник: https://habr.com/ru/articles/864588/?utm_source=habrahabr&utm_medium=rss&utm_campaign=864588