
Большой брат следит за тобой, птица!
Идея пришла давно. У кого-то мысли отапливать курятники майнящими криптовалюты видеокартами (криптокурятник), что прекрасно, несомненно, а у кого-то мысли в распознавании изображений, звуков, в нейросетях и их реальном применении.
Когда-то давно читали статью про японца, который помог отцу с сортировкой огурцов; решили, что анализировать, как несутся куры у наших родителей, присылая им отчеты в мессенджер — идея из веселых.
Вообще планов много. То, что около гнезда произошло шевеление, может значить, что птица залезла в гнездо или вылезла из него. Это понять просто при помощи 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

Продолжение следует…
ссылка на оригинал статьи https://habrahabr.ru/post/327978/
Добавить комментарий