Raspberry Video Camera – Teil 19: SW Kombinationstrigger

Das automatische Auslösen der Raspberry Video Camera bleibt weiterhin das Thema. In den vorangegangenen Artikeln hatte ich die Kamerasteuerung per Bewegungssensor vorgestellt und dann die Farberkennung aus dem Videobild heraus. Beides sind brauchbare Trigger, wenn es darum geht, ein Objekt vor der Kamera zu erkennen, aber sie haben auch ihre spezifischen Nachteile. Wie wäre es nun, wenn wir die beiden Methoden kombinieren, von den jeweiligen Vorteilen profitieren, Nachteile ausgleichen und so die Objekterkennung noch treffsicherer machen? In diesem Artikel kombiniere ich die Farbauswertung mit den Signalen des PIR-Bewegungssensors.

Zuerst aber wieder ein Oachkatzl-Video. Mehr davon gibts in meinem YouTube-Kanal.

Zur Wahrung deiner Privatsphäre wird erst eine Verbindung zu YouTube hergestelt, wenn du den Abspielbutton betätigst.

Unzulänglichkeiten der bisherigen Trigger

Wenn es darum geht, automatische Kameraauslöser zu beurteilen, muss man immer stark auf das jeweilige Umfeld schauen. Vor einem sehr statischen Hintergrund kann ein Hardware-Bewegungssensor ausgezeichnete Dienste leisten. Und eine Farberkennung ist immer dann gut geeignet, wenn das Zielobjekt sich farblich gut vom Hintergrund unterscheidet. Mein Umfeld stellt sich – wie auch im Video zu sehen ist – folgendermaßen dar: Eichhörnchen-Erkennung im Freien, mit einem Baum als Hintergrund, dessen Äste sich gelegentlich im Wind bewegen, Änderung der Lichtsituation über den Tag, zwei unterschiedliche Eichhörnchen-Farben, aber gleichbleibender Objekt-Abstand.

PIR-Bewegungssensor

Der Bewegungssensor schwächelt naturgemäß da, wo Bewegung auftritt, die nicht vom Zielobjekt – also dem Eichhörnchen – stammt. Das sind hier die Äste, die sich im Wind bewegen und Gegenlichtsituationen bei bestimmten Sonnenständen, die den PIR-Sensor austricksen. Eine weitere Unzulänglichkeit liegt darin, dass Eichhörnchen beim Fressen gerne mal still sitzen und der Bewegungsmelder mangels Bewegung die Videoaufzeichnung zu früh abschaltet. Der Bewegungssensor erkennt zwar sehr schnell, aber oft falsch.

Farbauswertung

Eichhörnchen anhand ihrer Fellfarbe zu erkennen, funktioniert sehr gut bei den rotbraunen Eichhörnchen. Schwieriger ist es bei den dunkelbraunen, da sich ihre Farbe auch im Hintergrund bei den Ästen der Kiefer wiederfindet. Da die Bilder nur im Sekundenabstand ausgewertet werden, ist diese Erkennung langsamer als der PIR-Sensor, dafür bleibt der Trigger auch aktiv, wenn sich das Eichhörnchen im Bild nicht bewegt.

Wie kann eine Kombination der beiden Methoden die Erkennung verbessern?

Dafür gibt es zwei Ansätze:

Regel 1: Die Videoaufnahme wird nur dann gestartet, wenn beide Sensoren ein Signal geben

Die Unzulänglichkeit beider Erkennungsmethoden ist, dass viele falsch positive Signale generiert werden. Durch einen Doubble-Check, quasi ein Vier-Augen-Prinzip sollen nur noch die Signale an die Kamera weitergegeben werden, die von beiden Sensoren bestätigt werden. So würden bewegte Äste zwar den PIR-Bewegungssensor auslösen, aber nicht die Farberkennung. Und umgekehrt braucht die Erkennung von vielen dunkelbraunen Pixeln im Bild erst die Bestätigung durch den Bewegungsmelder, um ein gültiges Signal zu erzeugen.

Regel 2: Die Videoaufzeichnung wird erst gestoppt, wenn über eine gewisse Zeit keiner der beiden Sensoren mehr ein Signal liefert

So lange bei laufendem Video noch einer der beiden Sensoren ein Signal liefert, bleibt die Kamera an. So darf der Bewegungssensor ruhig ausgehen, wenn sich das Eichhörnchen nicht bewegt. So lange die Fellfarbe noch erkannt wird, läuft die Kamera weiter.

Python-Programm für den Kombinationstrigger (analyze2.py)

Am Video-Aufzeichnungsprogramm (record2.py) brauchen wir keine Änderung vorzunehmen, aus dem Farbanalyse-Programm analyze.py machen wir aber eine neue modifizierte Version analyze2.py. Das komplette Programm kommt nochmal in einem Stück am Ende des Artikels, hier jetzt erst mal die neuen und modifizierten Programmteile.

class imageLoader

Dieser Programmteil bleibt unverändert, das sekündliche Einladen der exportierten Videobilder funktioniert genauso wie vorher.

class pir

Das ist die neue Klasse, die den PIR-Bewegungssensor bedient:

class pir:
  def __init__(self, pin):
    self.timestamp = ''
    GPIO.setmode(GPIO.BOARD)
# Set pin as input
    GPIO.setup(pin,GPIO.IN)
# Bind event to callbac function
    GPIO.add_event_detect(pin, GPIO.BOTH, self.PIR_callback)

  def PIR_callback(self, pin):
    triggertime = datetime.datetime.now()
    if GPIO.input(pin) == 1:
      self.timestamp = triggertime.strftime("%Y-%m-%d-%H-%M-%S")
      print('PIR-Trigger active')
    else:
      print('PIR-Trigger cleared')
      self.timestamp = ''

  def triggered(self):
    return (self.timestamp)

Der Programmteil ist ziemlich simpel und funktioniert im Prinzip genauso, wie wir es von motioninterrupt.py her kennen. Im Konstruktor wird der GPIO-Anschluss konfiguriert und dann ein Event definiert, der bei einer Signaländerung am GPIO-Pin eine Callback-Funktion aufruft. Die Callback-Funktion macht nicht viel: außer ein paar Prints auf die Konsole, wird nur im Falle eines Signals ein entsprechender Timestamp in self.timestamp gespeichert – mehr nicht. Zusätzlich legen wir eine Methode triggered an, mit der der Inhalt von self.timestamp ausgelesen werden kann. Und der ist ein leerer String, wenn keine Bewegung erkannt wird und im positiven Fall der Timestamp zu dem die Erkennung stattgefunden hat.

class imageAnalyzer

Den Imageanalyzer kennen wir bereits, das ist das Farbauswerteprogramm. Das bleibt weitgehend gleich und wird nur dahingehend verändert, dass die Methode detect nun nicht mehr den Triggerstatus zurückliefert, sondern ihn intern als Timestamp speichert, genauso, wie pir das auch macht. Analog zum Bewegungssensor wird auch ein kleiner Timeout mitgeführt, während dem die Farberkennung auch mal ausbleiben kann, ohne dass das Triggersignal auf low geht Und genauso wie bei pir gibt es eine Methode, mit der der Erkennungsstatus abgefragt werden kann.

class imageAnalyzer:
  def __init__(self):
    self.avgPix = -1          # average number of detected pixel
    self.avgVola = -1         # average volatility (gap between number of detected pixel and their average value)
    self.night = False        # indicator for day or night
    self.timestamp = ''       # indicates current active object detection
    self.lastTrigger = datetime.datetime.now()  # stores last time of object detection

  def detect(self, img, ts):
    now = datetime.datetime.now()
# Detect whether day or night
# Convert to grayscale
    gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
# Brightness is the average of all pixels
    brightness = np.average(gray)
# Change from night to day or vice versa
    if self.night and brightness > sunrise: self.night = False
    if not self.night and brightness < sunset: self.night = True
      
# Find matching pixel (for red and brown squrrel) in image
# Convert BGR to HSV
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)

# define range of red color in HSV
    lower_value = np.array([0,70,110])
    upper_value = np.array([11,170,220])
    maskRed = cv2.inRange(hsv, lower_value, upper_value)
    lower_value = np.array([165,70,110])
    upper_value = np.array([179,170,220])
    maskRed2 = cv2.inRange(hsv, lower_value, upper_value)
# define range of brown color in HSV
    lower_value = np.array([12,40,220])
    upper_value = np.array([17,65,255])
    maskBrown = cv2.inRange(hsv, lower_value, upper_value)
    lower_value = np.array([125,30,45])
    upper_value = np.array([170,65,110])
    maskBrown2 = cv2.inRange(hsv, lower_value, upper_value)
# Mask for red and brown
    mask = maskRed+maskRed2+maskBrown+maskBrown2
    pixDetected = cv2.countNonZero(mask)

# special case: average values not yet computed
    if self.avgPix == -1:
      self.avgPix = pixDetected
      self.avgVola = minPix * sigma

# calc threshold (pixDetected must be higher for a valid object detection)
    movingBand = self.avgVola * sigma
    if movingBand < minPix:
      movingBand = minPix
    threshold = self.avgPix + movingBand

    print("{0} Pix: {1:5d} Avg: {2:5.0f} Vola: {3:5.0f} Thresh: {4:5.0f} Bright: {5:3.0f} {6}{7}" \
      .format(ts, pixDetected, self.avgPix, self.avgVola, threshold, brightness, \
      "-" if self.night==True else "", \
      "T" if pixDetected >= threshold else ""))

#    cv2.imwrite('result.jpg',mask)
#    cv2.imwrite('result1.jpg',img)

# no object detection because it is night or amount of detected pixels is below threshold
    if self.night or pixDetected < threshold:
# clearing of trigger signal if timed out 
      if self.timestamp:
        if (now - self.lastTrigger).total_seconds() > objectDetTriggerTO:
          self.timestamp = ''
          print('Object-Detection-Trigger cleared')
      else:
# update average values
        self.avgVola = abs(pixDetected-self.avgPix)*alpha + self.avgVola*(1-alpha)
        self.avgPix = pixDetected*alpha + self.avgPix*(1-alpha)
      return

# at this point we have a valid object detection
#    cv2.imwrite('Videos/'+ts+'-1.jpg',mask)
#    cv2.imwrite('Videos/'+ts+'-2.jpg',img)
# activation or prolongation of trigger signal when new object is detected
    if not self.timestamp:
      self.timestamp = ts
      print('Object-Detection-Trigger active')
    self.lastTrigger = now
    return

  def triggered(self):
    return (self.timestamp)

class triggerGenerator

Den Triggergenerator gab es bisher auch schon. Das ist der Programmteil, der die Triggerdatei schreibt und damit die Videoaufzeichnung ansteuert. Im Unterschied zur vorhergehenden Version gibt es nun zwei Signalquellen (PIR und Bildauswertung), die von diesem Programmteil kombiniert werden müssen.

# Combines PIR- and ObjectDetection-Trigger and generates trigger signal for the camera
class triggerGenerator:
  def __init__(self):
    self.triggered = False                      # Trigger currently active?
    self.lastTrigger = datetime.datetime.now()  # stores last time of trigger detection

  def trigger(self, ObjDet, Pir):
    now = datetime.datetime.now()
    if self.triggered:
# Trigger is already active
      if ObjDet or Pir:
# One or both trigger (imageAnalyzer and PIR) still high
# prolongation of timeout
        self.lastTrigger = datetime.datetime.now()
      else:
# Both trigger (imageAnalyzer and PIR) are low
# Remove trigger signal (zero length file)
        if (now - self.lastTrigger).total_seconds() > triggerTimeout:
          triggerFiles = [f for f in os.listdir(triggerPath) if f.endswith(triggerFileExt)]
          for f in triggerFiles:
            os.remove(triggerPath+f)
          self.triggered = False
          print('Video stopped')
    else:
# Trigger is inactive    
      if ObjDet and Pir:
# Both trigger (imageAnalyzer and PIR) high
# Set trigger signal (zero length file) and use the earlier timestamp
        open(triggerPath + min(ObjDet, Pir) + triggerFileExt, 'w').close()
        self.triggered = True
        self.lastTrigger = datetime.datetime.now()
        print('Video started')

Die Methode trigger bekommt mit ObjDet und Pir jeweils den Status der beiden Detektoren übergeben. Das ist ein Timestamp, wenn aktuell eine Erkennung stattfindet oder andernfalls ein leerer String. Dann wird in einer Kaskade von If-Ausscheidungen ermittelt, ob eine Triggerdatei geschrieben oder gelöscht werden muss. Oder ob im Rahmen der Nachlaufzeit gewartet wird, ob evtl. noch einmal ein Erkennungssignal zurück kommt. Zur Erinnerung, die Nachlaufzeit ist die Zeitspanne, während der das Video nach Verschwinden des Eichhörnchens noch weiter läuft.

Gesamtprogramm (analyze2.py)

Und hier der Code für das gesamte Programm zum einfachen Rauskopieren:

import io
import os
import time
import datetime
import cv2
import numpy as np
import RPi.GPIO as GPIO


imagePath = "/tmp/"            # path to image and timestamp files
tsExt = ".sig"                 # file extension of timestamp file
imageFile = "record.jpg"       # name of image file

alpha = 0.1                    # amount of influence of a single value to the computed average
sigma = 7.0                    # value to multipy volatility with for a higher threshold
minPix = 250                   # minimum pixel to detect 275
sunset = 60                    # lower brightnes switches to night
sunrise = 65                   # higher brightness switches to day
objectDetTriggerTO = 10        # timeout for object detection trigger

triggerTimeout = 20            # min. trigger duration, time extends, when triggered again
triggerFileExt = '.trg'        # file extension for trigger signal file
triggerPath = 'trigger/'       # path for trigger signal file

GPIO_PIR = 12                  # used pin for PIR motion detector


class pir:
  def __init__(self, pin):
    self.timestamp = ''
    GPIO.setmode(GPIO.BOARD)
# Set pin as input
    GPIO.setup(pin,GPIO.IN)
# Bind event to callbac function
    GPIO.add_event_detect(pin, GPIO.BOTH, self.PIR_callback)

  def PIR_callback(self, pin):
    triggertime = datetime.datetime.now()
    if GPIO.input(pin) == 1:
      self.timestamp = triggertime.strftime("%Y-%m-%d-%H-%M-%S")
      print('PIR-Trigger active')
    else:
      print('PIR-Trigger cleared')
      self.timestamp = ''

  def triggered(self):
    return (self.timestamp)


class imageAnalyzer:
  def __init__(self):
    self.avgPix = -1          # average number of detected pixel
    self.avgVola = -1         # average volatility (gap between number of detected pixel and their average value)
    self.night = False        # indicator for day or night
    self.timestamp = ''       # indicates current active object detection
    self.lastTrigger = datetime.datetime.now()  # stores last time of object detection

  def detect(self, img, ts):
    now = datetime.datetime.now()
# Detect whether day or night
# Convert to grayscale
    gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
# Brightness is the average of all pixels
    brightness = np.average(gray)
# Change from night to day or vice versa
    if self.night and brightness > sunrise: self.night = False
    if not self.night and brightness < sunset: self.night = True
      
# Find matching pixel (for red and brown squrrel) in image
# Convert BGR to HSV
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)

# define range of red color in HSV
    lower_value = np.array([0,70,110])
    upper_value = np.array([11,170,220])
    maskRed = cv2.inRange(hsv, lower_value, upper_value)
    lower_value = np.array([165,70,110])
    upper_value = np.array([179,170,220])
    maskRed2 = cv2.inRange(hsv, lower_value, upper_value)
# define range of brown color in HSV
    lower_value = np.array([12,40,220])
    upper_value = np.array([17,65,255])
    maskBrown = cv2.inRange(hsv, lower_value, upper_value)
    lower_value = np.array([125,30,45])
    upper_value = np.array([170,65,110])
    maskBrown2 = cv2.inRange(hsv, lower_value, upper_value)
# Mask for red and brown
    mask = maskRed+maskRed2+maskBrown+maskBrown2
    pixDetected = cv2.countNonZero(mask)

# special case: average values not yet computed
    if self.avgPix == -1:
      self.avgPix = pixDetected
      self.avgVola = minPix * sigma

# calc threshold (pixDetected must be higher for a valid object detection)
    movingBand = self.avgVola * sigma
    if movingBand < minPix:
      movingBand = minPix
    threshold = self.avgPix + movingBand

    print("{0} Pix: {1:5d} Avg: {2:5.0f} Vola: {3:5.0f} Thresh: {4:5.0f} Bright: {5:3.0f} {6}{7}" \
      .format(ts, pixDetected, self.avgPix, self.avgVola, threshold, brightness, \
      "-" if self.night==True else "", \
      "T" if pixDetected >= threshold else ""))

#    cv2.imwrite('result.jpg',mask)
#    cv2.imwrite('result1.jpg',img)

# no object detection because it is night or amount of detected pixels is below threshold
    if self.night or pixDetected < threshold:
# clearing of trigger signal if timed out 
      if self.timestamp:
        if (now - self.lastTrigger).total_seconds() > objectDetTriggerTO:
          self.timestamp = ''
          print('Object-Detection-Trigger cleared')
      else:
# update average values
        self.avgVola = abs(pixDetected-self.avgPix)*alpha + self.avgVola*(1-alpha)
        self.avgPix = pixDetected*alpha + self.avgPix*(1-alpha)
      return

# at this point we have a valid object detection
#    cv2.imwrite('Videos/'+ts+'-1.jpg',mask)
#    cv2.imwrite('Videos/'+ts+'-2.jpg',img)
# activation or prolongation of trigger signal when new object is detected
    if not self.timestamp:
      self.timestamp = ts
      print('Object-Detection-Trigger active')
    self.lastTrigger = now
    return

  def triggered(self):
    return (self.timestamp)


# Combines PIR- and ObjectDetection-Trigger and generates trigger signal for the camera
class triggerGenerator:
  def __init__(self):
    self.triggered = False                      # Trigger currently active?
    self.lastTrigger = datetime.datetime.now()  # stores last time of trigger detection

  def trigger(self, ObjDet, Pir):
    now = datetime.datetime.now()
    if self.triggered:
# Trigger is already active
      if ObjDet or Pir:
# One or both trigger (imageAnalyzer and PIR) still high
# prolongation of timeout
        self.lastTrigger = datetime.datetime.now()
      else:
# Both trigger (imageAnalyzer and PIR) are low
# Remove trigger signal (zero length file)
        if (now - self.lastTrigger).total_seconds() > triggerTimeout:
          triggerFiles = [f for f in os.listdir(triggerPath) if f.endswith(triggerFileExt)]
          for f in triggerFiles:
            os.remove(triggerPath+f)
          self.triggered = False
          print('Video stopped')
    else:
# Trigger is inactive    
      if ObjDet and Pir:
# Both trigger (imageAnalyzer and PIR) high
# Set trigger signal (zero length file) and use the earlier timestamp
        open(triggerPath + min(ObjDet, Pir) + triggerFileExt, 'w').close()
        self.triggered = True
        self.lastTrigger = datetime.datetime.now()
        print('Video started')



pi = pir(GPIO_PIR)
il = imageLoader()
ia = imageAnalyzer()
tg = triggerGenerator()

while True:
  timeStamp, img = il.getImg()
  ia.detect(img, timeStamp)
  tg.trigger(ia.triggered(), pi.triggered())

Wenn diese Programmversion produktiv gesetzt und automatisch gestartet werden soll, muss natürlich die Datei /etc/rc.local wie folgt angepasst werden:

# Autostart RaspiCam
cd /home/pi
rm -f trigger/*
su pi -c 'python3 -u record2.py &> record.log &'
su pi -c 'python3 -u analyze2.py &> analyze.log &'

exit 0

Erfahrungen

In der Tat verbessert die Kombination aus Farberkennung und Bewegungssensor die Treffsicherheit des Kameraauslösers signifikant. Die Anzahl der falsch positiven Erkennungen geht zurück und vorzeitige Kameraabschaltungen bei bewegungslosem Eichhörnchen ebenfalls. Schwierig bleibt es aber für den Kameratrigger, wenn Wind (also bewegte Äste) und Gegenlichtsituationen zeitlich zusammenfallen.

 


Weitere Artikel in dieser Kategorie:

Schreiben Sie einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert