Большой брат следит за тобой, птица!
Идея пришла давно. У кого-то мысли отапливать курятники майнящими криптовалюты видеокартами (криптокурятник), что прекрасно, несомненно, а у кого-то мысли в распознавании изображений, звуков, в нейросетях и их реальном применении.
Когда-то давно читали статью про японца, который помог отцу с сортировкой огурцов; решили, что анализировать, как несутся куры у наших родителей, присылая им отчеты в мессенджер — идея из веселых.
Вообще планов много. То, что около гнезда произошло шевеление, может значить, что птица залезла в гнездо или вылезла из него. Это понять просто при помощи openCV, и это мы уже умеем. Сделать легко при помощи вот этого блога.
А что, если распознавать каждую птицу и анализировать, какая из них не несется? Оценивать продуктивность каждой отдельно взятой курицы? Если птица не несется и не имеет никакой другой уважительной причины для отдыха (например, короткий световой день, линька), то может, пора варить куриный суп?
Только представьте сообщение: “Нам кажется, что птица ch11 не несется без причины, быть может, нужно рассмотреть ее дальнейшую судьбу”. А потом окажется, что птица ch11 — это наша старая кошка Клюква, которая просто с курами живет.
Хакатон
Мысли о том, что все это звучит здорово, не давали покоя. Первый опыт в распознавании движения (на автомобилях за окном) прошел неплохо, и теперь оборудование простаивало.
Все всегда происходит внезапно, поэтому в один прекрасный четверг я купила билеты на пятничную ночь к родителям и полетела на выходные настраивать сбор данных для нейрокурятника.
Главная сложность заключалась в отсутствии проводного интернета и принципиальной невозможности его провести (глушь, что поделать). Но, когда не знаешь, на что подписываешься, надеешься на лучшее, да.
Помимо этого в курятнике не оказалось розеток. Подачей света и сигнализацией родители, конечно же, управляют рубильниками прямо из дома. Отец откликнулся на просьбу воплотить розетку в курятнике, и она, в общем-то, материализовалась там весьма быстро.
Основная часть оборудования — Raspberry Pi 3 и камера борд к нему, источник питания и usb-вентилятор (ибо процессинг изображений без вентилятора нагревает процессор аж до 80 градусов). Помимо этого кто-то должен был обеспечить pi интернетом.
Итак, среди альтернатив для хотспота — 3g/4g модем хуавей, старая xperia на андроиде. Модем хорош тем, что ему не нужен отдельный источник питания, а плох тем, что работает из коробки только с виндой. Есть, конечно же, статьи про то, как завести его на линуксе, но что-то не хотелось.
В условиях жестко ограниченного времени (оставались сутки до отъезда) был выбран телефон.
Провайдер не оказывал услугу статического IP в данном регионе. IP оказался динамическим, что было решено пофиксить при помощи динамического DNS сервиса.
И внезапно (кто бы сомневался), это не заработало. Ведь IP не просто динамический, он серый динамический. Это значит, что до него невозможно достучаться извне, порты закрыты.
Параллельно был перепилен питоновский скрипт для захвата и передачи на сервер изображений, но он все еще был сырой.
Тем временем была потрачена уже половина имевшегося времени.
Знакомый подсказал, что есть прекрасная штука, ssh back connect, что в общем-то спасло нас от разочарования. Времени оставалось совсем мало, поэтому не удавалось до конца разобраться, как все работает, нужно было, чтобы работало хоть как-то.
Перед самым отъездом были настроены крон с прокидыванием ssh туннеля, замером температуры и алармой на почту в случае чего, и весь сетап отправился в курятник. С интернетом там все равно плохо, но он есть. Выяснилось, что там достаточно темно и на фотках ничего не видно. Отец пообещал настроить освещение, как только найдется время. До поры до времени камера была выключена.
Главное, что к pi можно было подключиться отовсюду, где был интернет.
Подробнее о настройке
Немного отойдя от хакатона — марш-броска, я взялась донастраивать это дело дальше.
Почитав гайды (по ключевым словам permanent autossh), я попыталась наладить autossh вместо reverse ssh, которое работало нестабильно и поддерживалось при помощи крона. Поначалу ничего с autossh у меня не вышло, я продолжала использовать первое решение с кроном, но проблема с плодящимися коннектами вынудила меня все-таки подружиться с autossh.
Чтобы все завелось, нужно лишь создать исполняемый файл (кто не умеет, гуглит create executable file linux) на удаленном устройстве с динамическим серым IP и добавить туда такую строчку:
/usr/bin/autossh -M 0 -o ServerAliveInterval=50 -o ServerAliveCountMax=2 -nNTf -R 2222:localhost:22 userB@hostB -p bbbb
В этой строчке 2222 можно заменить на любой ненужный вам порт, нужно заменить userB на юзера на вашем домашнем сервере (то есть на том, который не в курятнике), hostB — на хоста на вашем домашнем сервере, bbbb — порт вашего домашнего сервера, если отличен от стандартного (22).
Про параметры команды можете сами почитать, если интересно или хочется что-то поменять.
Далее добавляем в крон (crontab -e) такую строку (если незнакомы с кроном то тут 1 2 3 4 друг собирал вводные), которая будет запускать autossh при ребуте:
@reboot /path/to/script/autosshtunnel.sh
Итак, теперь, если вы заходите на домашний сервер с другой удаленной машины, позаботьтесь о том, чтобы сессия не разрывалась. То есть я захожу на сервер с ноутбука, а уже с сервера стучусь в курятник, в таком случае я прописываю параметры для вечной сессии и при подключении к серверу, и при подключении к курятнику (распберри).
Делается это по такому шаблону:
ssh -o TCPKeepAlive=yes -o ServerAliveInterval=50 user@box.example.com
К системе в курятнике я подключаюсь так:
ssh -o TCPKeepAlive=yes -o ServerAliveInterval=50 sshuser@localhost -p 2222
Это все касалось возможности удаленного подключения, теперь быстро поговорим про алармы о температуре. Чтобы настроить алармы на почту в debian системах типа убунту и распбиан — достаточно следовать этому гайду, нужно будет всего лишь установить ssmtp и поправить конфиг, это все. Простейший скрипт для аларм про перегрев на почту для распбиан может выглядеть вот так:
TEMPERATURE="$(/opt/vc/bin/vcgencmd measure_temp)"
NTEMPERATURE="$(echo $TEMPERATURE | tr -dc '0-9.')"
LIMIT="61.0"
if [ $(echo "$NTEMPERATURE > $LIMIT" | bc) -ne 0 ]; then
echo "The critical CPU temperature has been reached $NTEMPERATURE" | sudo /usr/bin/ssmtp -vvv somename@somehost.com
fi
Дальше остается этот скрипт упаковать в исполняемый файл и закинуть в крон. Пока не жарко, я выполняю скрипт каждые две минуты.
Теперь поговорим про основной скрипт, которым мы собираем изображения.
Изображения мы считаем условно полезными, если заметили движение. Аналитику и распознавание будем прикручивать уже на эти изображения. Выше уже упоминался полезный блог, из которого мы взяли за основу скрипт, немного его переписав.
В самом гайде уже написано, что нужно для работы, но я повторюсь, что понадобится сделать билд OpenCV. Это может занять много времени (в моем случае заняло 5 часов). Помимо этого необходимо поставить так же и другие библиотеки, тоже там упомянутые, например numpy, imutils, — там не возникало подводных камней.
Основной скрипт мы переписали под свои нужды и внесли следующие изменения:
- сменили Python 2 на Python 3;
- вместо дропбокса использовали свой сервер;
- сохраняются оригинальный и сжатый фрейм.
Готовый вариант pi_surveillance.py выглядит так (ну разве что надо еще сделать вынос констант из скрипта в конфиг):
# import the necessary packages
import sys
sys.path.append('/usr/local/lib/python2.7/site-packages')
from pyimagesearch.tempimage import TempImage
from picamera.array import PiRGBArray
from picamera import PiCamera
import argparse
import warnings
import datetime
import imutils
import json
import time
import cv2
import os
# construct the argument parser and parse the arguments
ap = argparse.ArgumentParser()
ap.add_argument("-c", "--conf", required=True,
help="path to the JSON configuration file")
args = vars(ap.parse_args())
# filter warnings, load the configuration and check if we are going to use server
warnings.filterwarnings("ignore")
conf = json.load(open(args["conf"]))
client = None
if conf["use_server"]:
#we do not use Dropbox
print("[INFO] you are using server")
# initialize the camera and grab a reference to the raw camera capture
camera = PiCamera()
camera.resolution = tuple(conf["resolution"])
camera.framerate = conf["fps"]
rawCapture = PiRGBArray(camera, size=tuple(conf["resolution"]))
# allow the camera to warmup, then initialize the average frame, last
# uploaded timestamp, and frame motion counter
print("[INFO] warming up...")
time.sleep(conf["camera_warmup_time"])
avg = None
lastUploaded = datetime.datetime.now()
motionCounter = 0
# capture frames from the camera
for f in camera.capture_continuous(rawCapture, format="bgr", use_video_port=True):
# grab the raw NumPy array representing the image and initialize
# the timestamp and occupied/unoccupied text
frame = f.array
timestamp = datetime.datetime.now()
text = "Unoccupied"
# resize the frame,
frame = imutils.resize(frame, width=1920)
frameorig = imutils.resize(frame, width=1920)
# convert it to grayscale, and blur it
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (21, 21), 0)
# if the average frame is None, initialize it
if avg is None:
print("[INFO] starting background model...")
avg = gray.copy().astype("float")
rawCapture.truncate(0)
continue
# accumulate the weighted average between the current frame and
# previous frames, then compute the difference between the current
# frame and running average
cv2.accumulateWeighted(gray, avg, 0.5)
frameDelta = cv2.absdiff(gray, cv2.convertScaleAbs(avg))
# threshold the delta image, dilate the thresholded image to fill
# in holes, then find contours on thresholded image
thresh = cv2.threshold(frameDelta, conf["delta_thresh"], 255,
cv2.THRESH_BINARY)[1]
thresh = cv2.dilate(thresh, None, iterations=2)
cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnts = cnts[0] if imutils.is_cv2() else cnts[1]
# loop over the contours
# check if there is at least one contour, which is large enough
# I know this isn't the best practice
# I know about bool variables
# I know about other things too. I just don't actually care
# Yes, I am a liar, 'cause if I did not care,
# I wouldn't write anything of those ^
for c in cnts:
# if the contour is too small, ignore it
if cv2.contourArea(c) < conf["min_area"]:
continue
text = "Occupied"
print("[INFO] room is occupied, motion counter is {mc}".format(mc=motionCounter))
# initiate timestamp
ts = timestamp.strftime("%A-%d-%B-%Y-%I:%M:%S%p")
ts1 = timestamp.strftime("%A-%d-%B-%Y")
# let's create paths on a server
pathorig = "{base_path}/{timestamp}/origs".format(
base_path=conf["server_base_path"], timestamp=ts1)
pathres = "{base_path}/{timestamp}/res".format(
base_path=conf["server_base_path"], timestamp=ts1)
os.system('ssh -p bbbb "%s" "%s %s"' % ("userB@hostB", "sudo mkdir -p", pathorig))
os.system('ssh -p bbbb "%s" "%s %s"' % ("userB@hostB", "sudo mkdir -p", pathres))
# upload images on a server
if (text == "Occupied"):
motionCounter += 1
if motionCounter >= conf["min_motion_frames"] and (timestamp - lastUploaded).seconds >= conf["min_upload_seconds"]:
print("[INFO] time to upload, motion counter is {mc}".format(mc=motionCounter))
# upload original
t = TempImage()
cv2.imwrite(t.path, frameorig)
os.system('scp -P bbbb "%s" "%s:%s"' % (t.path, "userB@hostB", pathorig))
t.cleanup()
# upload resized image of 512 px
framec = imutils.resize(frame, width=512)
tc = TempImage()
cv2.imwrite(tc.path, framec)
os.system('scp -P bbbb "%s" "%s:%s"' % (tc.path, "userB@hostB", pathres))
tc.cleanup()
#reset motionCounter
motionCounter = 0
lastUploaded = datetime.datetime.now()
# otherwise, the room is not occupied
else:
motionCounter = 0
# check to see if the frames should be displayed to screen
if conf["show_video"]:
# display the security feed
cv2.imshow("Security Feed", frame)
key = cv2.waitKey(1) & 0xFF
# if the `q` key is pressed, break from the loop
if key == ord("q"):
break
# clear the stream in preparation for the next frame
rawCapture.truncate(0)
Как сейчас выглядит наш конфиг:
{
"show_video": false,
"use_server": true,
"server_base_path": "/media/server/PIC_LOGS",
"min_upload_seconds": 1.0,
"min_motion_frames": 3,
"camera_warmup_time": 2.5,
"delta_thresh": 5,
"resolution": [1920, 1080],
"fps": 16,
"min_area": 6000
}
А так — tempimage.py:
# import the necessary packages
import uuid
import os
import datetime
class TempImage:
def __init__(self, basePath="./temps", ext=".jpg"):
# construct the file path
timestamp = datetime.datetime.now()
ts = timestamp.strftime("-%I:%M:%S%p")
self.path = "{base_path}/{rand}{tmstp}{ext}".format(base_path=basePath,
rand=str(uuid.uuid4())[:8], tmstp=ts, ext=ext)
def cleanup(self):
# remove the file
os.remove(self.path)
Первым полученным изображением было изображение хвоста курицы в гнезде. Отличный подарок на майские для интроверта по жизни, который в хорошую погоду пялится в консоль. Изображение действительно обрадовало, несмотря на темень, отсутствие головы птицы в кадре и ненастроенность скрипта. Это куриный хвост (Только подумайте, за тысячу километров от тебя курица залезла в гнездо, не подозревая, что ты за ней наблюдаешь.):
Потом было настроено освещение, и я получила заметно более вдохновляющие фотографии.
Запускается скрипт с учетом того, что OpenCV установлен в виртуальной рабочей среде cv, вот так (надо бы еще и придумать, как правильно такое отправлять в бекграунд):
source ~/.profile
workon cv
cd ~/chickencoop
python3 /home/sshuser/chickencoop/pi_surveillance.py --conf conf.json
Продолжение следует...
Автор: snakers4