Нейрокурятник ч.1

Хакатон с мамкиными борщами, куриный хвост, консоль, майские праздники

Posted by yara_tchk on April 30, 2017

 

Статьи цикла

Нейрокурятник Часть 0 - Нейро без курятника

Нейрокурятник Часть 1 - Хакатон

Нейрокурятник Часть 2 - Про бота

Нейрокурятник Часть 3 - Про разметку кур

Нейрокурятник Часть 4 - Итоговая модель и код на прод

 

 

Большой брат следит за тобой
 

Начало

Идея пришла давно. У кого-то мысли отапливать курятники майнящими криптовалюты видеокартами (криптокурятник), что тру, несомненно, а у кого-то мысли в распознавании, нейросетях и их реальном применении.
К слову о реальном применении - вот вам статья про японца, который решил помочь отцу-фермеру сортировать огурцы.
Первое, что пришло в голову нам - все те же пресловутые куры. Почему бы не отправлять маме в телеграм информацию о том, что предположительно какая-то из птиц снесла яйцо?
Вообще планов много. То, что около гнезда произошло шевеление, может значить, что птица залезла в гнездо или вылезла из него. Это делается просто при помощи openCV и это мы уже умеем. Изи на основе этого поста.
А что, если распознавать каждую птицу и анализировать, какая из них не несется? Оценивать продуктивность каждой отдельно взятой курицы? Если птица не несется и не линяет, не имеет никакой другой уважительной причины для отдыха (например, короткий световой день, линька), то может, пора варить куриный суп?
Представьте себе аларму в телеграм: “Нам кажется, что птица ch11 не несется без причины, быть может, нужно рассмотреть ее дальнейшую судьбу”. А потом окажется, что птица ch11 - это наша старая кошка Клюква, которая просто с курами живет и раньше ночевала в гнезде.

Хакатон

Мысли о том, что все это звучит здорово, не давали покоя. Первый опыт в распознавании движения прошел неплохо, и теперь оборудование простаивало.
Все всегда происходит внезапно, поэтому в один прекрасный четверг я купила билеты на пятничную ночь к родителям и полетела на выходные настраивать сбор данных для нейрокурятника.
Хинт: как лишить родителей возможности смотреть телек? Подключаете к нему распберри пай и усиленно кодите с умным видом. Потрясающий эффект от работы в консоли: все смотрят с благоговением. Не работает, если дома есть больше одного телека или у вас молодые tech-savvy родители.
 

Ну вот с такими точно сработает
 
Главная сложность заключалась в отсутствии проводного интернета и принципиальной невозможности его провести (глушь, что поделать). Но, когда не знаешь, на что подписываешься, надеешься на лучшее, да.
Помимо этого в курятнике не оказалось розеток. Подачей света и сигнализацией родители, конечно же, управляют рубильниками прямо из дома, которые отец запилил аки господь. В таких условиях не было сомнений, что розетку для редко наблюдаемой дитяти он тоже запилит. Запилил (аки господь, опять же).
Основная часть оборудования - Raspberry Pi 3 и камера борд к нему, источник питания и usb-вентилятор (ибо процессинг изображений без вентилятора нагревает проц аж до 80 градусов). Помимо этого кто-то должен был обеспечить pi интернетом.
Итак, среди альтернатив для хотспота - 3g/4g модем хуавей, старая xperia на столь же стареньком дроиде. Модем хорош тем, что ему не нужен отдельный источник питания, а плох тем, что работает из коробки только с виндой.
В условиях жестко ограниченного времени (оставались сутки до отъезда) был выбран телефон.
Провайдер, конечно же, не оказывал услугу статического IP в данном регионе (ну ок, сказала я в трубку). IP оказался динамическим, что было решено пофиксить при помощи динамического DNS типа no-ip.com.
И внезапно (кто бы сомневался), это не заработало. Ведь IP не просто динамический, он серый динамический. Это значит, что до него невозможно достучаться извне, порты закрыты. 
Параллельно был перепилен питоновский скрипт для захвата и передачи на сервер изображений, но он все еще был сырой (и вот я тестирую сейчас его новую версию).
Тем временем была потрачена уже половина имевшегося времени.
Знакомый подсказал, что есть прекрасная штука, ssh back connect, чем спас меня от разочарования всей жизни.
 
Времени оставалось совсем мало, поэтому не удавалось до конца разобраться, как все работает, нужно было, чтобы работало хоть как-то.
Перед самым отъездом были настроены крон с прокидыванием ssh туннеля, замером температуры и алармой на почту в случае чего, и весь сетап отправился в курятник. С интернетом там все равно плохо, но он есть. Выяснилось, что там достаточно темно и на фотках ничего не видно. Отец пообещал настроить освещение, как только вернется из командировки. До поры до времени камера была выключена.
В самолете надо мной висела едкая смесь из 
“завтра на работу”, 
“хочу спать”, 
“надо будет скрипт дописать”, 
“жаль, мало времени, не успели даже фокус камеры настроить”, 
“не успела с родителями нормально поболтать, обиделись наверное”, 
“ухо заложило, господь, что ж так больно-то”, 
“почему ssh back connect работает через раз? ладно хоть так работает, потом настрою”,
“куры - это тема”.
 

Подробнее о настройке.

Немного отойдя от хакатона - марш-броска, я взялась донастраивать это дело дальше.
Итак, почитав гайды (по ключевым словам permanent autossh), я попыталась наладить autossh вместо reverse ssh, которое работало нестабильно и поддерживалось при помощи крона. Ничего с autossh у меня не вышло, хотя там должно было бы быть все просто, но просто для заметки упомяну. Я продолжаю использовать свое первое решение, благо оказалось, что тоннель надо просто чаще обновлять (у меня он обновлялся раз в пять минут ранее). Есть еще нерешенная проблема с плодящимися коннектами из-за динамического IP, которые надо закрывать.
Итак, чтобы сделать неправильно (как у меня) нужно лишь создать исполняемый файл (кто не умеет, гуглит create executable file linux) на распберри и запихнуть туда такую строчку:
 
ssh -nNTf -R 2222:localhost:22 [email protected] -p portB
 
В этой строчке 2222 можно заменить на любой ненужный вам порт, нужно заменить userB на юзера на вашем домашнем сервере (то есть на том, который не в курятнике), hostB - на хоста на вашем домашнем сервере, portB - порт вашего домашнего сервера, если отличен от стандартного (22).
Про параметры команды можете сами почитать, если интересно или хочется что-то поменять.
Далее добавляем в крон (crontab -e) такую строку, которая будет обновлять тоннель:
 
* * * * * /home/pi/cameroid/bscripts/sshtunnel.sh
 
Если в распбиан все с кроном работает из коробки, в других системах, возможно, не так. Вот вам гайд на всякий случай: 
 
Как начать использовать крон в линуксе 1 2 3 4
Итак, теперь, если вы заходите на домашний сервер с другой удаленной машины, позаботьтесь о том, чтобы сессия не разрывалась. То есть я захожу на сервер с ноутбука, а уже с сервера стучусь в курятник, в таком случае я прописываю параметры для вечной сессии и при подключении к серверу, и при подключении к курятнику (распберри).
Делается это по такому шаблону:
 
ssh -o TCPKeepAlive=yes -o ServerAliveInterval=50 [email protected]
 
К распберри я подключаюсь с домашнего сервера такой командой
 
ssh -o TCPKeepAlive=yes -o ServerAliveInterval=50 [email protected] -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 [email protected]
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
#from PIL import Image
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"
 
	# 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
	pathcnts = "{base_path}/{timestamp}/cnts".format(
		base_path=conf["server_base_path"], timestamp=ts1)
	pathorig = "{base_path}/{timestamp}/origs".format(
		base_path=conf["server_base_path"], timestamp=ts1)
	pathblack = "{base_path}/{timestamp}/black".format(base_path=conf["server_base_path"], timestamp=ts1)
	pathcrop = "{base_path}/{timestamp}/crop".format(base_path=conf["server_base_path"], timestamp=ts1)
	
	os.system('ssh -p xxxx "%s" "%s %s"' % ("[email protected]", "sudo mkdir -p", pathcnts))
	os.system('ssh -p xxxx "%s" "%s %s"' % ("[email protected]", "sudo mkdir -p", pathblack))
	os.system('ssh -p xxxx "%s" "%s %s"' % ("[email protected]", "sudo mkdir -p", pathorig))
	os.system('ssh -p xxxx "%s" "%s %s"' % ("[email protected]", "sudo mkdir -p", pathcrop))
	
	# upload images to a server
	if (text == "Occupied" and (timestamp - lastUploaded).seconds >= conf["min_upload_seconds"]):
		# upload original
		t = TempImage()
		cv2.imwrite(t.path, frameorig)

		os.system('scp -P xxxx "%s" "%s:%s"' % (t.path, "[email protected]", pathorig))
		t.cleanup()
		
		# for each contour create its own image with green rectangle
		# and an image with blackened area except for roi (region of interest)
		# and a cropped blackened image of 512 px
		for c in cnts:
			if conf["use_server"]:
				if cv2.contourArea(c) < conf["min_area"]:
					continue
				(x, y, w, h) = cv2.boundingRect(c)
				framec = imutils.resize(frame, width=1920)
				cv2.rectangle(framec, (x, y), (x + w, y + h), (0, 255, 0), 2)

				frameb = imutils.resize(frame, width=1920)
				cv2.rectangle(frameb, (0, 0), (x, conf["resolution"][1] - 1), (0, 0, 0), -1)
				cv2.rectangle(frameb, (x, 0), (conf["resolution"][0] - 1, y), (0, 0, 0), -1)
				cv2.rectangle(frameb, (x + w, y), (conf["resolution"][0] - 1, conf["resolution"][1] - 1), (0, 0, 0), -1)
				cv2.rectangle(frameb, (x, y + h), (x + w, conf["resolution"][1] - 1), (0, 0, 0), -1)
				
				tc = TempImage()
				cv2.imwrite(tc.path, framec)
				os.system('scp -P xxxx "%s" "%s:%s"' % (tc.path, "[email protected]", pathcnts))
				tc.cleanup()

				tb = TempImage()
				cv2.imwrite(tb.path, frameb)
				os.system('scp -P xxxx "%s" "%s:%s"' % (tb.path, "[email protected]", pathblack))
				tb.cleanup()

				tbc = TempImage()
				cv2.imwrite(tbc.path, frameb[y:(y + 512), x:(x + 512)])
				os.system('scp -P xxxx "%s" "%s:%s"' % (tbc.path, "[email protected]", pathcrop))
				tbc.cleanup()
				
 
	# 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)
# batareika!

 

Конфиг у нас сейчас выглядит так:

{
        "show_video": false,
        "use_server": true,
        "server_base_path": "/media/server/PIC_LOGS",
        "min_upload_seconds": 3.0,
        "min_motion_frames": 1,
        "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/pi/cameroid/pi_surveillance.py --conf conf.json

 

При удачном стечении обстоятельств вы увидите аплоды на сервер.

 

А при не очень удачном будет как-то так:

 

Безумно комичная ситуация сложилась, когда родители настраивали освещение и двигали камеру, чтобы было лучше видно птицу (это я смогу проверить уже завтра). Чтобы понять, что же видит камера, я просила родителей водить по гнезду рукой, полученные на сервер изображения перекидывая матери в телеграм, на сонове чего родители понмали, куда двигать камеру. В качестве муляжа курицы выступила отцовская кепка =)

 

Продолжение следует...

 

Статьи цикла

Нейрокурятник Часть 0 - Нейро без курятника

Нейрокурятник Часть 1 - Хакатон

Нейрокурятник Часть 2 - Про бота

Нейрокурятник Часть 3 - Про разметку кур

Нейрокурятник Часть 4 - Итоговая модель и код на прод