Вмоей домашней системе видеонаблюдения используются самые разные видеокамеры, некоторые из них очень дешевые и очень старые, и поэтому неспособны на такие вещи как «обнаружение человека».
А для контроля за пространством вокруг эта функция довольно полезна.
Простой «детектор движения» в камерах тут не поможет — он реагирует на смену картинки, то есть буквально на любые изменения, включая тени от предметов, опавшие листья и прочее подобное.
Идея использовать 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.
Информация о «гостях» немедленно попадает в Телеграм.
Комментарии (11)
KMiNT21
06.12.2024 16:05Perl? Ну надо же, какая экзотика. :) Раз уж пошла такая пьянка, вот тогда заготовка кода на "питоновском лиспе", на Hy (Hylang). Где используется локальная модель Yolo, классификатор. Можно выбрать и другую модель, которая еще боксы будет рисовать вокруг Person. Там еще есть и вариант трассировки, отслеживания перемещения объектов. И все локально, не надо никуда изображения отправлять.
(require hyrule *) (import toolz [first last]) (import ultralytics [YOLO]) (import cv2) (setv model-path "_____________.pt") (setv video-source "rtsp://192.168.____________") (setv model (YOLO model-path)) (setv person-class-index 1) (setv person-confidence-threshold 0.77) (defn process [frame] (setv first-result (-> (.predict model frame :verbose False) first .cpu)) (setv top1 first-result.probs.top1) (setv top1conf (first-result.probs.top1conf.item)) (setv person-detected (and (= top1 person-class-index) (> top1conf person-confidence-threshold))) (if person-detected (cv2.imshow "Person detected!" frame) (cv2.imshow "No events..." frame)) (cv2.waitKey 1)) (while True (setv cap (cv2.VideoCapture video-source)) (while True (setv ret-and-frame-tuple (cap.read)) (if (first ret-and-frame-tuple) (do ; if cap.read() returns (True, Image) (process (last ret-and-frame-tuple))) (do ; if cap.read() returns (False, EmptyImage) (print "End of stream or file. Reopening/reconnecting") (break)))) ; inner while ) ; main while (cap.release) (cv2.destroyAllWindows)
JBFW Автор
06.12.2024 16:05Кому экзотика ))
Это не на одну камеру. Это 16 камер, несколько зон контроля, видеозапись, а отправка картинок в Телеграм - это так, игрушки, чтобы если кто где тусуется оно мне сообщало само, перед тем как на экраны смотреть.
Писать свой собственный регистратор - ну, я не готов пока...
Antra
06.12.2024 16:05Самом такое пилить, конечно, круто. Но может стоит Frigate рассмотреть?
Если к нему добавить Google Coral TPU (~$100) все распознавание (люди, авто, мотоциклы, животные...) будет отлично делаться на энергоэффективном чипе.
Изменение загрузки CPU (картинка Summary с Proxmox Frigate LXC) после подключения Coral TPU:
JBFW Автор
06.12.2024 16:05Может и стоит, и даже попробую.
Но тут смысл был именно в том, что берется самое обычное простое и дешманское железо (регистратор - около 2500 р на Али, такие же камеры, б/у ноутбук 10-летней давности) и из этого лепится на коленке работающая штука.
Причем всё легкозаменяемое в случае выхода из строя - можно заменить регистратор на другой (Озон привезет завтра), камеры (аналогично), другой ноутбук или не ноутбук (о чем будет отдельная статья)...И оно тоже прекрасно определяют людей, мотоциклы и прочее...
slavius
06.12.2024 16:05Про Frigate потом статью:)
А за эту - отдельное спасибо. Даже просто за примеры.
check197
06.12.2024 16:05В CMS iSpy есть плагины для распознавания лиц, каждое новое лицо заносится в базу и есть возможность посмотреть когда конкретный человек посещал место. И еще возможность прикрутить к сервисам ну тем, что ищут профиль в соц сетях и инфу по аватарке
aborouhin
Не вполне понял, зачем изобретать свой велосипед (ну по крайней мере не попробовав готовые решения сначала), когда есть, скажем, Frigate со встроенными AI-детекторами для разного железа, да и для ZoneMinder что-то из этой оперы есть, хоть и не из коробки.
Что вообще используется в качестве софта для видеонаблюдения? Речь про какой-то "китайский регистратор" - у него что внутри и если что-то непонятное/закрытое - есть ли возможность поменять на нормальные опенсорс решения (Zoneminder, Frigate, Shinobi)?
JBFW Автор
Обычный китайский регистратор с облаком XM (это важно, т.к. через XM удобнее смотреть, на телефоне, на панелях и проч. Если есть более интересная альтернатива - то я о ней не в курсе пока)
Внутри у него ARM 32бит и примерно 150 Мб ОЗУ, стандартная Sofia, туда много не наставишь.
aborouhin
У меня под видеонаблюдение уже второй раз отправляется на пенсию один из старых серверов, так что по поводу каких-то готовых регистраторов даже не в курсе, как оно там бывает. Такое железо, как на Вашем регистраторе, конечно, ничего не потянет. Я, честно говоря, вообще удивлён, что он с такими характеристиками тянет 16 камер с записью в облако.
JBFW Автор
Ну, там же всё просто:
- камеры дают поток, только считывай
- он считывает, пишет на диск (в облако НЕ пишет)
- всё.
Ах да, если к нему подключиться - выдает поток от выбранной камеры или читает с диска. Никаких перекодировок, ничего. По сути - одна функция, запись.
Главное преимущество в сравнении с сервером и компом - он компактный и бесшумный, только сам диск гудит.
aborouhin
Ну вот мой текущий сервер-старичок для видеонаблюдения. Xeon X3350, 6Gb RAM, запись на 7 HDD по 1 Tb в аппаратном RAID0 (надёжность тут не нужна). 8 камер FullHD, 10fps, и тоже только локальная запись без детекции объектов (CPU не тянет, нормальную видеокарту в эту древность не вставить, а Coral TPU всё не соберусь купить) и без перекодировки.
Загрузка каждого из ядер CPU в среднем 60% (когда подключаюсь для просмотра - больше), использование памяти около 50%. Это Frigate, но до этого был ZoneMinder, потребление ресурсов было плюс-минус такое же. На 16 камерах этот сервер бы если не загнулся, то работал бы на пределе. А тут на порядок более слабое железо...
Насчёт компактный/бесшумный - так это для квартиры проблема (и то не всегда), а видеонаблюдение на кучу камер нужно в доме, а что за дом без серверной стойки для души и экспериментов :)