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

3174fae448058a36c40a88398c95aff5

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

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

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

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

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

Это 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:\ProgramData\CodeProject\AI\docker\data,target=/etc/codeproject/ai ^
 --mount type=bind,source=C:\ProgramData\CodeProject\AI\docker\modules,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 описано на странице проекта.

Суть простая: отправляем 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 +0300\r\n";

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

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

          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 "Error\n";

          process_entity($entity,$from);

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

        if($str =~ /^EHLO/ || $str =~ /^HELO/){
          print $client "250 OK\r\n";
        }
        elsif($str =~ /^MAIL FROM/ ){
          print $client "250 OK\r\n";
        }
        elsif($str =~ /^RCPT TO/ ){
          print $client "250 OK\r\n";
        }
        elsif($str =~ /^AUTH/ ){
          print $client "334 DYT3jf4sdDR5\r\n";
          $umode = 1;
        }
        elsif($str =~ /^QUIT/ ){
          print $client "221 OK\r\n";
        }
        elsif($str =~ /^DATA/ ){
          print $client "354 OK\r\n";
          $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&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&channel=1',
  #'10' => 'http://192.168.1.211/webcapture.jpg?command=snap&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 = "--$boundary\r\n".
    "Content-Disposition: form-data; name=\"image\"; filename=\"xx.jpg\""."\r\n".
    'Content-Type: image/jpeg'."\r\n\r\n".
    $in.
    "\r\n--$boundary--\r\n\r\n";

    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 = "\r\n--$boundary\r\n".
    "Content-Disposition: form-data; name=\"photo\"; filename=\"xx.jpg\""."\r\n".
    'Content-Type: image/jpeg'."\r\n\r\n".
    $image.
    "\r\n--$boundary\r\n".
    "Content-Disposition: form-data; name=\"chat_id\""."\r\n\r\n".
    $chat_id.
    "\r\n--$boundary--\r\n\r\n";

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

}

1;

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

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

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

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

© Habrahabr.ru