Raspberry Pi: видеокамера с детектором движения

Написано

в

В процессе экспериментов с малышкой RPi на просторах инета мне попался скрипт motion.py, который ведет непрерывную видеозапись с камеры и сохраняет в выходном видео только движущиеся изображения. Это очень удобно как с точки зрения экономии места на флешке, так и для просмотра: очень быстро надоест проматывать запись с автомобильной стоянки за всю ночь, когда нужна буквально пара — другая информационных кадров. Если быть точным, то это не совсем видео в современном смысле, а скорее видео в классическом: RPi беспрерывно делает фотографии, которые склеиваются в формат motion jpeg.

Детектор движения у нас есть, сделаем полноценную систему видеонаблюдения того, что происходит за окном. И не только за окном: ночью в магазине, офисе, на складе, дома когда вы в отъезде — применений просто огромное количество. Устроено все очень просто: Raspberry Pi с видеокамерой и WiFi донглом для доступа в интернет, и пара скриптов: motion.py и conv.sh.

Скрипт motion.py: детектор движения

Питоновский скрипт motion.py я немного доработал под систему:

#!/usr/bin/python

# /home/pi/bin/motion.py

# original script by brainflakes, improved by pageauc, peewee2 and Kesthal
# www.raspberrypi.org/phpBB3/viewtopic.php?f=43&t=45235

# You need to install PIL to run this script
# type "sudo apt-get install python-imaging-tk" in an terminal window to do this

import StringIO
import subprocess
import os
import time
from datetime import datetime
from PIL import Image

# Motion detection settings:
# Threshold          - how much a pixel has to change by to be marked as "changed"
# Sensitivity        - how many changed pixels before capturing an image, needs to be higher if noisy view
# ForceCapture       - whether to force an image to be captured every forceCaptureTime seconds, values True or False
# filepath           - location of folder to save photos
# filenamePrefix     - string that prefixes the file name for easier identification of files.
# diskSpaceToReserve - Delete oldest images to avoid filling disk. How much byte to keep free on disk.
# cameraSettings     - "" = no extra settings; "-hf" = Set horizontal flip of image; "-vf" = Set vertical flip; "-hf -vf" = both horizontal and vertical flip
threshold = 10
sensitivity = 20
forceCapture = True
forceCaptureTime = 60 * 60 # Once an hour
# filepath = "/home/pi/picam"
filepath = "/home/pi/cache"
filenamePrefix = "img"
diskSpaceToReserve = 40 * 1024 * 1024 # Keep 40 mb free on disk
cameraSettings = ""

# settings of the photos to save
saveWidth   = 1296
saveHeight  = 972
saveQuality = 15 # Set jpeg quality (0 to 100)

# Test-Image settings
testWidth = 100
testHeight = 75

# this is the default setting, if the whole image should be scanned for changed pixel
testAreaCount = 1
testBorders = [ [[1,testWidth],[1,testHeight]] ]  # [ [[start pixel on left side,end pixel on right side],[start pixel on top side,stop pixel on bottom side]] ]
# testBorders are NOT zero-based, the first pixel is 1 and the last pixel is testWith or testHeight

# with "testBorders", you can define areas, where the script should scan for changed pixel
# for example, if your picture looks like this:
#
#     ....XXXX
#     ........
#     ........
#
# "." is a street or a house, "X" are trees which move arround like crazy when the wind is blowing
# because of the wind in the trees, there will be taken photos all the time. to prevent this, your setting might look like this:

# testAreaCount = 2
# testBorders = [ [[1,50],[1,75]], [[51,100],[26,75]] ] # area y=1 to 25 not scanned in x=51 to 100

# even more complex example
# testAreaCount = 4
# testBorders = [ [[1,39],[1,75]], [[40,67],[43,75]], [[68,85],[48,75]], [[86,100],[41,75]] ]

# in debug mode, a file debug.bmp is written to disk with marked changed pixel an with marked border of scan-area
# debug mode should only be turned on while testing the parameters above
debugMode = False # False or True

# Capture a small test image (for motion detection)
def captureTestImage(settings, width, height):
    command = "raspistill %s -w %s -h %s -t 200 -e bmp -n -o -" % (settings, width, height)
    imageData = StringIO.StringIO()
    imageData.write(subprocess.check_output(command, shell=True))
    imageData.seek(0)
    im = Image.open(imageData)
    buffer = im.load()
    imageData.close()
    return im, buffer

# Save a full size image to disk
def saveImage(settings, width, height, quality, diskSpaceToReserve):
    #keepDiskSpaceFree(diskSpaceToReserve)
    time = datetime.now()
    filename = filepath + "/" + filenamePrefix + "-%04d%02d%02d-%02d%02d%02d.jpg" % (time.year, time.month, time.day, time.hour, time.minute, time.second)
    subprocess.call("raspistill %s -w %s -h %s -t 200 -e jpg -q %s -n -o %s" % (settings, width, height, quality, filename), shell=True)
    #print "Captured %s" % filename

# Keep free space above given level
def keepDiskSpaceFree(bytesToReserve):
    if (getFreeSpace() < bytesToReserve):
        for filename in sorted(os.listdir(filepath + "/")):
            if filename.startswith(filenamePrefix) and filename.endswith(".jpg"):
                os.remove(filepath + "/" + filename)
                print "Deleted %s/%s to avoid filling disk" % (filepath,filename)
                if (getFreeSpace() > bytesToReserve):
                    return

# Get available disk space
def getFreeSpace():
    st = os.statvfs(filepath + "/")
    du = st.f_bavail * st.f_frsize
    return du

# Get first image
image1, buffer1 = captureTestImage(cameraSettings, testWidth, testHeight)

# Reset last capture time
lastCapture = time.time()

while (True):

    # Get comparison image
    #ti = datetime.now()
    #print "t-> %d" % ti.second
    image2, buffer2 = captureTestImage(cameraSettings, testWidth, testHeight)

    #ti = datetime.now()
    #print "T-> %d" % ti.second
    # Count changed pixels
    changedPixels = 0
    takePicture = False

    if (debugMode): # in debug mode, save a bitmap-file with marked changed pixels and with visible testarea-borders
        debugimage = Image.new("RGB",(testWidth, testHeight))
        debugim = debugimage.load()

    for z in xrange(0, testAreaCount): # = xrange(0,1) with default-values = z will only have the value of 0 = only one scan-area = whole picture
        for x in xrange(testBorders[z][0][0]-1, testBorders[z][0][1]): # = xrange(0,100) with default-values
            for y in xrange(testBorders[z][1][0]-1, testBorders[z][1][1]):   # = xrange(0,75) with default-values; testBorders are NOT zero-based, buffer1[x,y] are zero-based (0,0 is top left of image, testWidth-1,testHeight-1 is botton right)
                if (debugMode):
                    debugim[x,y] = buffer2[x,y]
                    if ((x == testBorders[z][0][0]-1) or (x == testBorders[z][0][1]-1) or (y == testBorders[z][1][0]-1) or (y == testBorders[z][1][1]-1)):
                        # print "Border %s %s" % (x,y)
                        debugim[x,y] = (0, 0, 255) # in debug mode, mark all border pixel to blue
                # Just check green channel as it's the highest quality channel
                pixdiff = abs(buffer1[x,y][1] - buffer2[x,y][1])
                if pixdiff > threshold:
                    changedPixels += 1
                    if (debugMode):
                        debugim[x,y] = (0, 255, 0) # in debug mode, mark all changed pixel to green
                # Save an image if pixels changed
                if (changedPixels > sensitivity):
                    takePicture = True # will shoot the photo later
                if ((debugMode == False) and (changedPixels > sensitivity)):
                    break  # break the y loop
            if ((debugMode == False) and (changedPixels > sensitivity)):
                break  # break the x loop
        if ((debugMode == False) and (changedPixels > sensitivity)):
            break  # break the z loop

    if (debugMode):
        debugimage.save(filepath + "/debug.bmp") # save debug image as bmp
        print "debug.bmp saved, %s changed pixel" % changedPixels
    # else:
    #     print "%s changed pixel" % changedPixels

    # Check force capture
    if forceCapture:
        if time.time() - lastCapture > forceCaptureTime:
            takePicture = True

    if takePicture:
        lastCapture = time.time()
        #ti = datetime.now()
        #print "s-> %d" % ti.second
        saveImage(cameraSettings, saveWidth, saveHeight, saveQuality, diskSpaceToReserve)
        #ti = datetime.now()
        #print "S-> %d" % ti.second

    # Swap comparison buffers
    image1 = image2
    buffer1 = buffer2

Скрипт работает так. В цикле через утилиту raspistill, которая работает с камерой, непрерывно делаются фотоснимки, после чего сравнивается каждый текущий и предыдущий кадр. Сравнение выполняется в модуле Imagemagic. Если кадры различаются начиная с некоторого уровня порога, значит в зоне наблюдения присутствует движение и снимки начинают записываться в директорию cache SD — карты RPi в формате jpeg, причем название файла образовано от отметки текущего времени.

Скрипт motion.py стартует с загрузкой RPi и работает непрерывно. Частота появления файлов в директории cache будет зависеть от движения в кадре: если картинка сильно меняется то снимки будут появляться постоянно; если движения нет — то новых файлов не будет.

Далее, с определенной периодичностью, нужно смонтировать полученные кадры в видеоролик и отправить его в сеть. Эту задачу выполняет скрипт conv.sh.

Скрипт conv.sh: формирование видеороликов и выкладка их на файловый сервер

Задача второго скрипта — conv.sh — периодически запускать на обработку полученные снимки. Для этого он выполняет следующую последовательность действий:

  • собирает снимки, накопленные к данному времени скриптом motion.py;
  • вставляет в снимки титры с указанием даты и времени снимка;
  • переименовывает снимки для того, чтобы к ним можно было применить групповую операцию;
  • преобразует последовательность снимков в видеоролик;
  • стирает обработанные снимки;
  • выкладывает видеоролик  на файловый сервер типа box.net или Яндекс.

Скрипт conv.sh содержит следующую последовательность команд:

#!/bin/bash

prefix=/home/pi/cache
tmp=/tmp
out=https://dav.box.com/dav/rpi

now=`date +"%Y%m%d-%H%M"`

i=0
for f in `ls -tr $prefix/*.jpg 2>/dev/null`
do
  newf=$prefix/`printf %06d $i`.jpg
  echo $f "-->" $newf
  #mv $f $newf
  timestamp=`stat -c %y $f`
  /usr/bin/convert $f -fill black -draw "rectangle 10,10 150,40" -fill white -pointsize 15 -draw "text 15,25  '${timestamp:0:19}'" $newf
  rm $f
  i=$((i+1))
done

/home/pi/bin/ffmpeg -y -r 5 -i $prefix/%06d.jpg -r 5 -vcodec mjpeg -q:v 1  $tmp/$now.avi
curl -u myname:mypasswd -T $tmp/$now.avi $out/$now.avi
rm $tmp/$now.avi
rm $prefix/0*.jpg

Первым делом, составляется список всех снимков накопленных в директории cache и каждый снимок из списка пропускается через утилиту convert, которая вставляет в снимок отметку времени. Сразу замечу, что это накладная процедура, поэтому лучше обходиться без нее. В этом случае надо просто закомментировать вызов convert и раскомментировать вызов команды mv. И в том и другом случае файл снимка получает новое имя  — возрастающее число. Это необходимо сделать для того, чтобы конвертор ffmpeg мог выполнить групповую операцию над файлами с маской %06d.jpg.

Поскольку ffmpeg из пакетов RPi имеет ограниченную функциональность, я компилировал утилиту из исходников и разместил полную версию в каталоге /home/pi/bin. После монтажа снимков в видеоролик, который будет временно располагаться в директории /tmp/*.avi, другая команда — curl отправляет ролик на файловый сервер.  В нашем случае это сервер dav.box.com.

После этого подчищаются обработанные файлы и сам отправленный ролик.

Скрипт conv.sh запускается каждые 15 минут по cron. За это время не должно быть переполнения файловой системы; ну и сам размер клипа должен быть вменяемым для передачи через сеть.

В результате, мы можем залогиниться на box.net и посмотреть нарезку роликов, которые будут появляться каждые 15 минут. Само собой, это время можно изменить в настройках cron. Косвенно, размер каждого ролика определяет активность в кадре: чем больше движений тем длиннее ролик,  а если ничего не происходит и картинка статичная, длина ролика будет вплоть до нулевой.

Во всей этой истории есть слабое место, а именно сборка клипа с помощью ffmpeg. Поскольку он не использует аппаратное ускорение, то загрузка ARM идет под 100%, и это весьма печально; хуже всего что при интенсивном движении в кадре и как следствие большом количестве файлов сборка может не завершиться к тому моменту когда conv.sh должен быть запущен в очередной раз. С этим приходилось мириться, пока я не нашел это решение, заточенное под GPU.

RPi style: как малышка Raspberry Pi уделает Intel при монтаже клипа

Как мы знаем, несмотря на свою миниатюрность, RPi обладает мощным ресурсом: это GPU, Graphic Processig Unit, в котором реализована аппаратная поддержка такого известного кодека как H.264. На программном уровне с GPU работает программная прослойка OpenMax, а в свою очередь реализация мультимедийной утилиты gstreamer на RPi использует OpenMax и соответственно ресурсы GPU.

Не буду описывать установку дополнительных пакетов, как это описано по ссылке: я поставил их без проблем. Что нам нужно сделать — это теперь выкинуть из скрипта conv.sh утилиту ffmpeg и заменить ее следующей строкой:

gst-launch-1.0 multifilesrc location=$prefix/%06d.jpg index=1 caps="image/jpeg,framerate=24/1" ! jpegdec ! omxh264enc ! avimux ! filesink location=$tmp/$now.avi

Теперь все стало любо — дорого: выходной ролик вместо mjpeg получил навороченный формат h.264, а время монтажа сократилось с минут до нескольких секунд! Вот она, мощь рациональной архитектуры по сравнению с много-много CPU GHz.

В сети можно найти сравнительные тесты обработки видео в Raspberry Pi с использованием аппаратного ускорения и с использованием платформы Intel. Посмотрите, не поленитесь, результаты вас удивят.

Комментарии

2 ответа для «Raspberry Pi: видеокамера с детектором движения»

  1. Аватар пользователя Оля

    Решила тоже прикупить себе Raspberry Pi, но для дачи, так как постоянная запись там не нужна, а такая маленькая палочка выручалочка всегда пригодиться, так как страшно оставлять вещи без присмотра. Спасибо, ваши скрипты очень помогли, все работает)

    1. Аватар пользователя Lao

      Здорово ) Пользуйтесь на здоровье!

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *