Raspberry Video Camera – Teil 22: SW selbstlernende Farberkennung in Python

Konzeptionell habe ich die selbstlernende Farberkennung zur Kameraauslösung im letzten Artikel bereits besprochen. Nun geht es um die Realisierung in Form eines Python-Programms für den Raspberry Pi. Dabei baue ich auf dem zuletzt besprochenen Kombinationstrigger-Programm auf und erweitere es um ein paar zusätzliche Komponenten. Damit ist es künftig nicht mehr nötig, Farbbereiche für die Erkennung von Hand zu definieren. Das Programm wird die Farben selbständig anhand von Beispielbildern lernen und diese dann verwenden um die Kamera zu triggern.

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

Was wird benötigt?

Mit über 20 Artikeln ist diese Serie inzwischen sehr üppig geworden und vielleicht auch ein wenig unübersichtlich. Deshalb möchte ich an dieser Stelle kurz auflisten, was denn alles benötigt wird, um das nachfolgend beschriebene Programm einsetzen zu können. Und dazu gibt es jeweils den Link zum entsprechenden Artikel.

Andere Programme, die in dieser Artikelserie bereits vorgestellt wurden, wie record.py, motioninterrupt.py, analyze.py oder analyze2.py werden nicht mehr benötigt.

Darüber hinaus empfehle ich zur Lektüre meinen vorangegangenen Artikel, in dem es konzeptionell um die selbstlernende Farberkennung geht.

Python-Programm analyze3.py

Das Analyseprogramm kommt in die dritte Generation, nach Farberkennung und Kombinationstrigger kommt nun die selbstlernende Farberkennung, die auch als Kombinationstrigger ausgeführt ist. Kombinationstrigger bedeutet in diesem Zusammenhang, dass der PIR-Bewegungssensor zusätzlich zur Bildauswertung als zweiter Trigger mitgeführt wird, um die Auslösesignale qualitativ zu verbessern.

Unveränderte Programmteile

Die gute Nachricht ist, dass wir das Python-Programm nicht komplett umschreiben müssen. Einige Klassen schon und es kommen auch Programmteile dazu, aber die folgenden bleiben im Vergleich zu analyze2.py unverändert:

  • class pir
  • class imageloader und
  • class triggerGenerator

Neu ist vor allen Dingen die Lernfunktion, die aus Beispielbildern die typischen Farben der Zielobjekte (hier Eichhörnchen) selbständig ermitteln soll. Und damit geht es gleich los.

Funktion initialLearn

Die initiale Lernfunktion wird nur einmal beim Start des Programms ausgeführt, deshalb mache ich gar kein Objekt daraus, sondern belasse es bei einer einfachen Funktion, die ich hier in Einzelteilen vorstelle. Das gesamte Programm – auch mit den unveränderten Teilen – steht dann nochmal komplett am Ende dieses Artikels, damit es sich einfach am Stück kopieren lässt.

# Initial learning function
# Reads sample images and calculates combined histograms
def initialLearn():
  print('Initial learning from sample images')

  histos = {}
  connections = []

  print('Reading sample images from folder: '+samplePath)
  
# Read sample images and calc histograms
  for f in os.listdir(samplePath):                              # all files in directory
    if not os.path.isfile(os.path.join(samplePath, f)):         # skip directories
      continue
    name = os.path.splitext(f)[0]                               # get name without extension
    img = cv2.imread(samplePath+f,1)                            # load image
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)                  # convert to HSV
    hist = cv2.calcHist([hsv],[0,1],None,[hueBins,satBins],[0,180, 0,256]) # calc histogram satu + hue
    hist[0,0]=0                                                 # delete background color (black))
    cv2.normalize(hist,hist,0,100,cv2.NORM_MINMAX)              # normalize values to 100

Zu Beginn der Lernfunktion definiere ich zwei Variablen, ein Dictionary histos, das die Histogramme aufnehmen wird und eine Liste connections. Letztere brauchen wir, um die Ähnlichkeiten der einzelnen Histogramme zu einander ermitteln zu können, damit sich ähnliche Histogramme zusammenführen lassen.

Dann werden der Reihe nach alle Bilder, die sich im Verzeichnis samplePath befinden, eingelesen. Aus dem Dateinamen ohne Erweiterung ergibt sich der Name des Bilds, bzw seines Histogramms. Das Bild selbst wird in den HSV-Farbraum überführt und dann wird aus ihm ein zweidimensionales Histogramm aus Hue (Farbwert) und Saturation (Sättigung) erzeugt. Eine Farbe muss aus dem Histogramm gelöscht werden, das ist der Hintergrund des freigestellten Objekts, in diesem Fall schwarz [0,0]. Und zum Schluss erfolgt noch eine Normalisierung des Histogramms. Das bewirkt, dass der größte Histogrammwert (also die Pixelanzahl der Farbe, die am häufigsten vorkommt) auf den Wert 100 justiert wird und alle kleineren Werte proportional dazu. Damit liegen alle Histogramme wertmäßig zwischen 0 und 100 und können so gut verglichen werden.

# Build a connection list from distances between each sample
# Distance is a measure of the similarity of two histograms computed from histogram comparison
# with hellinger method: 0 = equal and 1 = total different
    if len(histos) > 0:
      for hi in histos:                                         # with each already existing histogram
        diff = cv2.compareHist(hist,histos[hi],cv2.HISTCMP_HELLINGER) # compare two histograms
        connections.append([diff,name,hi])                      # and store [diff, partner a, partner b]
                                                                # in connection list
    histos[name] = hist                                         # finally store histogram (name is key)
  
  print('{0} sample images loaded - now combining similar images ...\n'.format(len(histos)))

# Sort list of connections (nearest first)  
  connections.sort(key=lambda c: c[0])                          # sort on distance

Im zweiten Teil befinden wir uns zunächst immer noch in der großen For-Schleife, in der die Bilder eingelesen werden. Nun wird aus den Histogrammen eine „Verbindungsliste“ aufgebaut, in der von jedem Histogramm zu jedem anderen eine Verbindung hergestellt wird. Der Wert dieser Verbindung ist eine Distanz und die wiederum ein Maß für die Ähnlichkeit von zwei Histogrammen. Der Vorgang beginnt, sobald wir mindestens ein Histogramm bereits gespeichert haben, mit dem wir das aktuelle vergleichen können.

Der Vergleich erfolgt mit cv2.compareHist nach der Hellinger-Methode. Die ergibt beim Vergleich von zwei Histogrammen den Wert 0, wenn beide Histogramme absolut gleich sind und den Wert 1, wenn sie total verschieden sind. Diesen Unterschiedswert diff speichern wir zusammen mit den Namen beider Histogramme in der Verbindungsliste connections. Und das Histogramm selbst wird schließlich mit seinem Namen ins Directory histos eingetragen.

Am Ende der großen Einleseschleife (also beim print Befehl) haben wir ein Python-Directory aller Histogramme zur Verfügung und eine Verbindungsliste mit allen möglichen Paarbeziehungen zwischen jeweils zwei Histogrammen und deren Ähnlichkeitswert. Und nach letzterem wird die Verbindungsliste abschließend sortiert, so dass die ähnlichsten Paarbeziehungen am Anfang der Liste stehen und die unterschiedlicheren am Ende.

# combine most similar histograms to get a shorter histogram list
  while connections[0][0] < simili and len(histos) > 2:         # while small distances
    a = connections[0][1]                                       # partner a
    b = connections[0][2]                                       # partner b
    print("Similarity: {0:1.3f} combine {1} with {2}" \
          .format(connections[0][0],a,b))
    connections[:] = [x for x in connections if x[1]!=a and x[1]!=b and \
                      x[2]!=a and x[2]!=b]                      # clear a and b from list
#    hist = histos[a] + histos[b]                                # add histograms
    hist = np.maximum(histos[a], histos[b])                     # combine histogram max values
    name = a + b                                                # combine names
    del histos[a]                                               # delete a and b histograms
    del histos[b]
    for hi in histos:                                           # calc new connections
      diff = cv2.compareHist(hist,histos[hi],cv2.HISTCMP_HELLINGER)
      connections.append([diff,name,hi])
    histos[name] = hist                                         # store combined histogram 
    connections.sort(key=lambda c: c[0])                        # sort again on distance
  
  print('\nCombined to {0} detector histograms\n'.format(len(histos)))
  
# Normalize all remaining histograms
  for h in histos:
    cv2.normalize(histos[h],histos[h],0,100,cv2.NORM_MINMAX)    # adjust values between 0 and 100
  return(histos)

Im dritten Teil der Funktion geht es nun darum, die vielen Histogrammen zu einigen wenigen zu verdichten. Dazu wählen wir die beiden Histogramme aus, die sich am ähnlichsten sind. Denn wir können annehmen, dass sie zu dem selben Objekt gehören (rotbraunes oder dunkelbraunes Eichhörnchen). Das ist jetzt ganz leicht, dann die beiden ähnlichsten Histogramme stehen in der Verbindungsliste ganz am Anfang. Die beiden Histogramme (a und b) müssen nun vereinigt werden. Das könnte durch simple Numpy-Addition erfolgen, wie in der auskommentierten Zeile angedeutet. Besser ist aber die Variante, mit np.maximum jeweils den höheren Wert in das resultierende Histogramm zu übernehmen.

Vereinigt werden auch die Namen der beiden Histogramme um dem resultierenden Histogramm auch einen Namen zu geben. Zur Erinnerung, der Histogrammname ist der Dateiname des Beispielbilds ohne Dateierweiterung. Und der wurde hoffentlich recht kurz gewählt, denn der Name des neuen Histogramms entsteht, in dem die beiden Einzelnamen von a und b aneinander gehängt werden. (Und in Folge so fort, so dass lange Namen entstehen können.)

Nun müssen noch die beiden ursprünglichen Histogramme a und b aus dem Directory histos gelöscht werden und ebenso alle Paarbeziehungen an denen a oder b beteiligt sind. Dann wird das verdichtete Histogramm in histos aufgenommen und natürlich neue Paarbeziehungen anhand der Ähnlichkeit des neuen Histogramms zu allen anderen berechnet und in der Verbindungsliste gespeichert. Abschließend wird die Verbindungsliste wieder nach Ähnlichkeit sortiert.

Das ganze erfolgt in einer While-Schleife so lange, bis ein gewünschter Grad an Unterschiedlichkeit der verbliebenen Histogramme erreicht ist. Oder, um es anders auszudrücken, es wird so lange verdichtet, bis alle ähnlichen Histogramme zusammengefasst sind. Das Maß für die gewünschte Unterschiedlichkeit ist die Variable simili, die hier mit 0.5 vorbelegt ist. 0.5 deshalb, weil es die Mitte zwischen 0 (gleich) und 1 (unterschiedlich) darstellt. Aber hier kann natürlich experimentiert werden.

Am Ende dieser Funktion haben wir aus vielen Histogrammen einige wenige erhalten, die mit return zurückgegeben werden.

class imageAnalyzer

Die Klasse imageAnalyzer gab es bisher auch schon. Sie hatte für jedes Bild, das sekündlich von der Kamera bereitgestellt wird, die Farbanalyse vorgenommen, um ein Eichhörnchen im Bild zu erkennen. Das bleibt an sich auch so, nur dass wir bisher nur einen Detektor hatten (wenn wir vom Hardware PIR-Sensor mal absehen) und nun haben wir mehrere. Nämlich genau einen für jedes Farbhistogramm, das am Ende von initialLearn übrig geblieben ist. Der imageAnalyzer macht die Farberkennung deshalb nicht mehr selber, sondern steuert für jedes Bild der Reihe nach alle Detektoren an und nimmt deren Prüfergebnis entgegen.

Im Konstruktor __init__ bekommt er zu diesem Zweck eine Liste der Detektorobjekte übergeben und speichert sie in self.detectors.

# Uses multiple detectors to find object colors in the given image
class imageAnalyzer:
  def __init__(self, detectors):
    self.detectors = detectors                  # list ofimage detector histograms
    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
    self.lastHistUpdate = datetime.datetime.now() - datetime.timedelta(hours = 1)

  def detect(self, img, ts):
    now = datetime.datetime.now()
# Detect whether day or night
    gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)                 # convert to grayscale
    brightness = np.average(gray)                               # calc average of all pixels
# 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

    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)                  # convert BGR to HSV
    imgDetected = False
    for d in self.detectors:                                    # loop over detector objects
      imgDetected = d.detect(hsv, ts) or imgDetected

    print()
# no object detection because it is night or amount of detected pixels is below threshold
    if self.night or not imgDetected:
# clearing of trigger signal if timed out
      if self.timestamp:
        if (now - self.lastTrigger).total_seconds() > objectDetTriggerTO:
          self.timestamp = ''
          print('Object-Detection-Trigger cleared')
      elif not self.night:                                      # day, but no current and no previous trigger signal
        if now - self.lastHistUpdate > histLifeTime:            # histograms timed out
          negativeHist = cv2.calcHist([hsv],[0,1],None,[hueBins,satBins],[0,180,0,256])  # calc histogram satu + hue
          cv2.normalize(negativeHist,negativeHist,0,2000,cv2.NORM_MINMAX) # normalize to 2000
          for d in self.detectors:                              # loop over detector objects
            d.updateHist(negativeHist)
          self.lastHistUpdate = now
      return

# at this point we have a valid object detection
# 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)

Die Methode detect wird dann mit jedem neuen Bild aufgerufen. Darin erfolgt zuerst die bereits bekannte Tag-Nacht-Erkennung, die verhindert, dass bei zu geringem Tageslicht die Kamera ausgelöst wird. Dann wird das Bild nach HSV überführt und in der nachfolgenden For-Schleife werden die einzelnen Detektoren aufgerufen:

    for d in self.detectors: 
      imgDetected = d.detect(hsv, ts) or imgDetected

Die Variable imgDetected, die anfänglich mit False vorbesetzt ist, wird dabei mit jedem Rückgabewert eines Detektors oder-verknüpft, so dass sie am Ende in Summe für Erkennung oder Nichterkennung steht. In den nachfolgenden If-Ausscheidungen wird dann anhand von imgDetected und der Nacht-Erkennung festgestellt, ob ein gültiger Farberkennungstrigger anliegt, ob er ggf. verlängert oder gelöscht werden muss. Das kennen wir im Prinzip alles schon von der vorhergehenden Version.

Neu und interessant ist der Fall, dass Tageslicht herrscht, aber aktuell keine Triggersituation vorliegt. Wenn die Histogram-Lebensdauer (15min) abgelaufen ist, wird das Kamerabild, das nun lediglich den aktuellen Hintergrund darstellt, genommen und daraus ein Negativ-Histogramm gebildet. „Negativ“ heißt es deshalb, weil es genau die Farben beinhaltet, auf die es uns nicht ankommt, nämlich die Hintergrundfarben. Im Gegensatz dazu steht das Positiv-Histogramm für die Eichhörnchenfarben. Das Negativ-Histogramm wird auf einen sehr hohen Maximalwert (20x höher als sonst) normalisiert, um auch wenig vertretenen Hintergrundfarben ein gewisses Gewicht zu geben. Und dann wird das Histogramm an alle Detektoren übergeben, die mit diesen Negativwerten ihre Farberkennung optimieren können.

class detector

Diese neue Klasse bildet nun die eigentliche Farberkennung ab und zur Laufzeit wird davon für jedes Detektor-Histogramm ein Objekt erzeugt. Im Konstruktor wird das Histogramm und dessen Name übergeben und beides gespeichert. Das Histogramm wird auch gleich noch kopiert, so dass das Objekt zwei Histogramme beinhaltet.self.positiveHist ist das originale Histogramm, das aus einem oder mehreren Beispielbildern erzeugt wurde. Das wird während der Programmlaufzeit periodisch um die Farben des Hintergrunds bereinigt und das Ergebnis dann als self.hist geführt. Initial sind beide Histogramme erst mal gleich.

# Single detector (holds one histogram)
class detector:
  def __init__(self, name, hist):
    self.name = name                              # histogram name
    self.positiveHist = hist                      # histogram of sample positive images
    self.hist = np.copy(self.positiveHist)        # histogram to work with (initially copy of above) 
    self.avgPix = -1                              # average number of detected pixel
    self.avgVola = -1                             # average volatility (gap between number of 
                                                  # detected pixel and their average value)
# search for histogram colors in given hsv image
  def detect(self, hsv, ts):
    backPro = cv2.calcBackProject([hsv],[0,1],self.hist,[0,180,0,256],1)  # histogram back projection
    ret, mask = cv2.threshold(backPro, bProThresh,255,cv2.THRESH_BINARY)  # delete pixel with too low value
    pixDetected = cv2.countNonZero(mask)                        # count detected pixel

# special case: average values not yet computed or resetted due to histogram update
    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} ({2:3.0f}% of Thresh) {3} {4}" \
      .format(ts, pixDetected, pixDetected/threshold*100, \
      "T" if pixDetected >= threshold else " ", self.name))

# no object detection because amount of detected pixels is below threshold
    if pixDetected < threshold:
# update average values
      self.avgVola = abs(pixDetected-self.avgPix)*alpha + self.avgVola*(1-alpha)
      self.avgPix = pixDetected*alpha + self.avgPix*(1-alpha)
      return(False)
    else:
# at this point we have a valid object detection
      return(True)      

# Build new detection histogram considering changed background colors
  def updateHist(self, negativeHist):
    self.hist = self.positiveHist - negativeHist              # elininate background colors 
    self.hist[self.hist<0] = 0                                # set all negative values to zero
    cv2.normalize(self.hist,self.hist,0,100,cv2.NORM_MINMAX)  # and normalize
    self.avgPix = -1                                          # reset average values
    print('Detector histogram updated', self.name)

Die Methode detect bekommt das aktuelle HSV-Image von der Kamera übergeben und geht gleich zur Sache. cv2.calcBackProject führt die Histogramm-Rückprojektion durch, dabei wird quasi das Detektor-Histogramm mit den Eichhörnchenfarben auf das aktuelle Bild angewendet. Das Ergebnis ist ein Graustufenbild (backpro), bei dem die Helligkeit eines Pixels als Erkennungsgrad für die Eichhörnchenfarben verstanden werden kann. Das Graustufenbild wird nun mittels eines Schwellwerts (bProThresh) in ein Schwarzweißbild überführt. Dabei werden dunkle Pixel (mit niedrigen Werten unter dem Schwellwert) zu schwarz (0) und helle Pixel zu weiß (255). Das dient nur zur Beseitigung von unterschwelligen Gekräusel (also eher zufälligen niedrigen Werten). Und schließlich werden die weißen Pixel gezählt.- je mehr erkannte Pixel, desto mehr Eichhörnchen – könnte man sagen.

Der Rest funktioniert wie gehabt: es wird über die Zeit ein gleitender Durchschnitt gebildet, die Volatilität bestimmt, also die Schwankungsbreite der jeweils erkannten Pixel um den Durchschnittswert und ein Vielfaches der Volatilität über dem gleitenden Durchschnitt stellt dann die Erkennungsschwelle dar. Liegt die aktuelle Anzahl der erkannten Pixel über dieser Schwelle, gilt das Eichhörnchen (oder was auch immer) als erkannt. Durch diesen etwas umständlichen Mechanismus, kann sich das Programm ein wenig an langsam wechselnde Lichtsituationen anpassen.

Die Methode updateHist wird nur alle 15 Minuten aufgerufen und bekommt ein Negativhistogramm übergeben. Wie wir oben beim imageAnalyzer bereits gesehen haben, ist das Negativhistogramm ein Ausdruck der aktuellen Hintergrundfarben, der zahlenmäßig auch noch auf das 20-fache aufgeblasen wurde. Dieses Negativhistogramm wird nun vom ursprünglichen Positivhistogramm subtrahiert. Das das Negativhistogramm zahlenmäßig überhöht ist, gehen durch sie Subtraktion nahezu alle Farben verloren, die im Hintergrund vertreten sind. Übrig bleiben nur die Eichhörnchenfarben, die sich vom Hintergrund unterscheiden. (Siehe Bild.)

Die Subtraktion führt natürlich im Histogramm zu vielen negativen Werten, die erst mal auf Null gezogen werden müssen. Dann erfolgt die übliche Normalisierung auf 100. Damit ist self.hist aktualisiert. Damit in diesem Moment keine falsche Objekterkennung durch das schlagartig geänderte Histogramm erfolgt, wird self.avgPix auf -1 gesetzt. Das bewirkt, dass die Durchschnittewerte neu angeglichen werden müssen und damit auch für ein paar Sekunden zu einer gewissen Blindheit der Farberkennung.

Hauptprogramm

So weit die neuen und geänderten Klassen und nun zum Hauptprogramm, das sich auch verändert hat:

# Main program
histos = initialLearn()                          # read sample images and learn their colors
detectors = []
for h in sorted(histos):                         # create detector objects from all histograms
  detectors.append(detector(h, histos[h]))       # and store them in a list

pi = pir(GPIO_PIR)                               # object for infrared motion sensor
il = imageLoader()                               # object to load image
ia = imageAnalyzer(detectors)                    # object to analyze image
tg = triggerGenerator()                          # object to create camera trigger file

while True:                                      # endless loop
  timeStamp, img = il.getImg()                   # wait for next image and load it
  ia.detect(img, timeStamp)                      # detect colors
  tg.trigger(ia.triggered(), pi.triggered())     # and create camera trigger file

Das Programm beginnt mit der initialen Lernfunktion. Deren Ergebnis ist ein Directory (histos) aus Histogrammen. Daraus werden sortiert Detektorobjekte instanziiert und in der Liste detectors gespeichert. Dann werden auch aus den übrigen Klassen Objekte abgeleitet – das kennen wir bereits. Und schließlich kommt die große Hauptschleife, die ewig durchlaufen wird.

Darin wird zuerst auf ein neues Bild von der Kamera gewartet, das wird dem imageAnalyzer zugeführt, der seinerseits die einzelnen Detektoren aufruft und schließlich kümmert sich der triggerGenerator um das Setzen und Löschen des Kameraauslösers.

Gesamtprogramm analyze3.py

Hier nun das ganze Programm an einem Stück – auch mit den Teilen, die sich nicht geändert haben:

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


samplePath = "samples/"             # path to sample images
hueBins = 30                        # histogram bins for hue
satBins = 30                        # histogram bins for saturation
simili = 0.5                        # similarity limit, smaller values are considered similar
                                    # greater values different

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

alpha = 0.075                       # amount of influence of a single value to the computed average
sigma = 9.0                         # value to multipy volatility with for a higher threshold
minPix = 275                        # minimum pixel to detect
sunset = 60                         # lower brightnes switches to night
sunrise = 65                        # higher brightness switches to day
bProThresh = 50                     # back projection threshold (0-255), lower values will be ignored
histLifeTime = datetime.timedelta(minutes = 15) # histogram lifetime, recalc if timed out
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


# Initial learning function
# Reads sample images and calculates combined histograms
def initialLearn():
  print('Initial learning from sample images')

  histos = {}
  connections = []

  print('Reading sample images from folder: '+samplePath)

# Read sample images and calc histograms
  for f in os.listdir(samplePath):                              # all files in directory
    if not os.path.isfile(os.path.join(samplePath, f)):         # skip directories
      continue
    name = os.path.splitext(f)[0]                               # get name without extension
    img = cv2.imread(samplePath+f,1)                            # load image
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)                  # convert to HSV
    hist = cv2.calcHist([hsv],[0,1],None,[hueBins,satBins],[0,180, 0,256]) # calc histogram satu + hue
    hist[0,0]=0                                                 # delete background color (black))
    cv2.normalize(hist,hist,0,100,cv2.NORM_MINMAX)              # normalize values to 100

# Build a connection list from distances between each sample
# Distance is a measure of the similarity of two histograms computed from histogram comparison
# with hellinger method: 0 = equal and 1 = total different
    if len(histos) > 0:
      for hi in histos:                                         # with each already existing histogram
        diff = cv2.compareHist(hist,histos[hi],cv2.HISTCMP_HELLINGER) # compare two histograms
        connections.append([diff,name,hi])                      # and store [diff, partner a, partner b]
                                                                # in connection list
    histos[name] = hist                                         # finally store histogram (name is key)

  print('{0} sample images loaded - now combining similar images ...\n'.format(len(histos)))

# Sort list of connections (nearest first)  
  connections.sort(key=lambda c: c[0])                          # sort on distance

# combine most similar histograms to get a shorter histogram list
  while connections[0][0] < simili and len(histos) > 2:         # while small distances
    a = connections[0][1]                                       # partner a
    b = connections[0][2]                                       # partner b
    print("Similarity: {0:1.3f} combine {1} with {2}" \
          .format(connections[0][0],a,b))
    connections[:] = [x for x in connections if x[1]!=a and x[1]!=b and \
                      x[2]!=a and x[2]!=b]                      # clear a and b from list
#    hist = histos[a] + histos[b]                                # add histograms
    hist = np.maximum(histos[a], histos[b])                     # combine histogram max values
    name = a + b                                                # combine names
    del histos[a]                                               # delete a and b histograms
    del histos[b]
    for hi in histos:                                           # calc new connections
      diff = cv2.compareHist(hist,histos[hi],cv2.HISTCMP_HELLINGER)
      connections.append([diff,name,hi])
    histos[name] = hist                                         # store combined histogram 
    connections.sort(key=lambda c: c[0])                        # sort again on distance

  print('\nCombined to {0} detector histograms\n'.format(len(histos)))

# Normalize all remaining histograms
  for h in histos:
    cv2.normalize(histos[h],histos[h],0,100,cv2.NORM_MINMAX)    # adjust values between 0 and 100
  return(histos)  



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 imageLoader:
  def __init__(self):
    self.timeout = datetime.timedelta(0, 1)                     # default 1sec
    self.lastTsTime = datetime.datetime.now() - self.timeout    # time of last timestamp
    self.lastTs = ""                                            # last timestamp as a string

  def readTimeStamp(self):
# Read names of all timestamp files, take the first (should be only one) and isolate timestamp
    tsFiles = [f for f in os.listdir(imagePath) if f.endswith(tsExt)]
    if len(tsFiles) > 0:
      return tsFiles[0][0:-4]
    else:
      return False

  def getImg(self):
# sleep until next expected image file, wait for valid time stamp file and then read image file
# adjust sleep time until next image (+/- 0.01sec)
    wTime = (self.timeout - (datetime.datetime.now() - self.lastTsTime)).total_seconds()
    if wTime > 0:
      print("\n",wTime)
      time.sleep(wTime)
    ts=self.readTimeStamp()
    if not ts or (ts == self.lastTs):
      self.timeout += datetime.timedelta(milliseconds=10)
      time.sleep(0.01)
      ts=self.readTimeStamp()
      while not ts or (ts == self.lastTs):
        time.sleep(0.01)
        ts=self.readTimeStamp()
    else:
      self.timeout -= datetime.timedelta(milliseconds=10)
    self.lastTsTime = datetime.datetime.now()
    self.lastTs = ts
    return (ts, cv2.imread('/tmp/record.jpg',1))



# Single detector (holds one histogram)
class detector:
  def __init__(self, name, hist):
    self.name = name                              # histogram name
    self.positiveHist = hist                      # histogram of sample positive images
    self.hist = np.copy(self.positiveHist)        # histogram to work with (initially copy of above) 
    self.avgPix = -1                              # average number of detected pixel
    self.avgVola = -1                             # average volatility (gap between number of 
                                                  # detected pixel and their average value)
# search for histogram colors in given hsv image
  def detect(self, hsv, ts):
    backPro = cv2.calcBackProject([hsv],[0,1],self.hist,[0,180,0,256],1)  # histogram back projection
    ret, mask = cv2.threshold(backPro, bProThresh,255,cv2.THRESH_BINARY)  # delete pixel with too low value
    pixDetected = cv2.countNonZero(mask)                        # count detected pixel

# special case: average values not yet computed or resetted due to histogram update
    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} ({2:3.0f}% of Thresh) {3} {4}" \
      .format(ts, pixDetected, pixDetected/threshold*100, \
      "T" if pixDetected >= threshold else " ", self.name))

# no object detection because amount of detected pixels is below threshold
    if pixDetected < threshold:
# update average values
      self.avgVola = abs(pixDetected-self.avgPix)*alpha + self.avgVola*(1-alpha)
      self.avgPix = pixDetected*alpha + self.avgPix*(1-alpha)
      return(False)
    else:
# at this point we have a valid object detection
      return(True)      

# Build new detection histogram considering changed background colors
  def updateHist(self, negativeHist):
    self.hist = self.positiveHist - negativeHist              # elininate background colors 
    self.hist[self.hist<0] = 0                                # set all negative values to zero
    cv2.normalize(self.hist,self.hist,0,100,cv2.NORM_MINMAX)  # and normalize
    self.avgPix = -1                                          # reset average values
    print('Detector histogram updated', self.name)



# Uses multiple detectors to find object colors in the given image
class imageAnalyzer:
  def __init__(self, detectors):
    self.detectors = detectors                  # list ofimage detector histograms
    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
    self.lastHistUpdate = datetime.datetime.now() - datetime.timedelta(hours = 1)

  def detect(self, img, ts):
    now = datetime.datetime.now()
# Detect whether day or night
    gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)                 # convert to grayscale
    brightness = np.average(gray)                               # calc average of all pixels
# 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

    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)                  # convert BGR to HSV
    imgDetected = False
    for d in self.detectors:                                    # loop over detector objects
      imgDetected = d.detect(hsv, ts) or imgDetected

    print()
# no object detection because it is night or amount of detected pixels is below threshold
    if self.night or not imgDetected:
# clearing of trigger signal if timed out
      if self.timestamp:
        if (now - self.lastTrigger).total_seconds() > objectDetTriggerTO:
          self.timestamp = ''
          print('Object-Detection-Trigger cleared')
      elif not self.night:                                      # day, but no current and no previous trigger signal
        if now - self.lastHistUpdate > histLifeTime:            # histograms timed out
          negativeHist = cv2.calcHist([hsv],[0,1],None,[hueBins,satBins],[0,180,0,256])  # calc histogram satu + hue
          cv2.normalize(negativeHist,negativeHist,0,2000,cv2.NORM_MINMAX) # normalize to 2000
          for d in self.detectors:                              # loop over detector objects
            d.updateHist(negativeHist)
          self.lastHistUpdate = now
      return

# at this point we have a valid object detection
# 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')



# Main program
histos = initialLearn()                          # read sample images and learn their colors
detectors = []
for h in sorted(histos):                         # create detector objects from all histograms
  detectors.append(detector(h, histos[h]))       # and store them in a list

pi = pir(GPIO_PIR)                               # object for infrared motion sensor
il = imageLoader()                               # object to load image
ia = imageAnalyzer(detectors)                    # object to analyze image
tg = triggerGenerator()                          # object to create camera trigger file

while True:                                      # endless loop
  timeStamp, img = il.getImg()                   # wait for next image and load it
  ia.detect(img, timeStamp)                      # detect colors
  tg.trigger(ia.triggered(), pi.triggered())     # and create camera trigger file

Automatisch starten

Für eigene Entwicklungen empfehle ich, das Modul Matplotlib einzubinden und die Histogramme, die das Programm generiert, in Dateien zu plotten. Nur so bekommt man ein Bild von dem, was im Programm wirklich vorgeht. Allerdings ist das relativ resourcenintensiv, so dass man im Produktivbetrieb darauf besser verzichten sollte.

Nachdem sich der Programmname geändert hat, muss für einen automatischen Programmstart die Datei /etc/rc.local angepasst werden und zwar folgendermaßen:

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

exit 0

Auf das exit 0 am Ende achten, das muss stehen bleiben!

 


Weitere Artikel in dieser Kategorie:

Schreiben Sie einen Kommentar

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