AI-powered Security Cam Part 5 : Detection, Tracking, Code

[Reading Time: 28 minutes]

Recap

Mit einer Übersicht über das Post-Auto Projekt und den cutting-edge Technologien, die ich im Projekt angesprochen habe, sind wir gestartet. Dann haben wir die Custom Vision API kennen gelernt, mit der wir Postauto trainiert haben, sowie das Containerisieren des ML-Modells, um das Postauto auch auf einem Gerät erkennen zu können.
Danach haben wir den Jetson Nano aufgesetzt und eingerichtet sowie mit IoT Edge/ -Hub eine IoT Basis geschaffen. Im letzen Beitrag dann, haben wir uns die IoT Edge Lösung im Detail angeschaut. Nun fehlt noch die Seele des Ganzen – Der Code (hier nochmal der Link zum Repo). Also lass uns den letzten Teil dieser Serie fertig machen!

Der allgmeine Programmablauf

Wie funktioniert denn nun die Software? Schauen wir mal auf die verwendeten Komponenten.

Modul-/Komponentenübersicht

Alle diese Module im Bild oben sind Bestandteile des Containers YoloModule. Als Entrypoint dient die main.py. In Main werden verschiedene Parameter (DetectionSampleRate, Inferencing-Boolean, Video-Quelle,…) von Außen übergeben und die IoTHub Zugriffe mit dem HubManager instanziiert. Ebenso wird von hier aus auch dem Modul VideoCapture verschiedenen Parametern von außen bereitgestellt. Es dient dem Ansprechen der übergebenen Videoquelle.

Das VideoCapture Modul erzeugt eine Instanz vom Videostream Modul, um die Videoquelle in Frames (Einzelbilder) zu zerlegen. Diese Frames werden dann einer Instanz von DetectAndTrack übergeben.

DetectAndTrack ist das Herzstück des YoloModules. Es verarbeitet das übergebene Frame mit einer Objectdetection und führt ein Objecttracking durch. Für die Detection wird das Modul YoloInference genutzt, mit der dann die einzelnen Objekte erkannt werden. YoloInference macht die eigentliche Arbeit hinsichtlich “KI”. Weiterhin werden in DetectAndTrack Metadaten in das Frame geschrieben/gemalt und eine Nachricht an den IoTHub über den HubManager abgeschickt.

Kommunikation innerhalb des YoloModules

Video! Ohne Bilder geht es nicht

Fangen wir vorne an. Zuerst benötigen wir Bilder, die wir analysieren können. Dazu habe ich in meiner Lösung mehrere mögliche Quellen eingebaut. Es kann eine WebCam, ein RTSP-Videostream, ein Einzelbild-Download, Youtube-Download und sogar eine HoloLens angezapft werden.

Der interessante Codeanteil liegt unter /modules/YoloModule/app. Für die Videoverarbeitung kannst du dir das File VideoCapture.py und VideoStream.py anschauen. Diese, wie die Name bereits erkennen lassen, sind die Module VideoCapture und VideoStream.

VideoCapture macht die Videoquelle, die über externe Parameter reingegeben wurde verfügbar und bereitet Diese für das Modul VideoStream vor. Im nachfolgenden Code kannst du die unterschiedlichen Quellen ablesen.

    def setVideoSource(self, newVideoPath):

        if self.captureInProgress:
            self.captureInProgress = False
            time.sleep(1.0)
            if self.vCapture:
                self.vCapture.release()
                self.vCapture = None
            elif self.vStream:
                self.vStream.stop()
                self.vStream = None
            elif self.imageResp:
                self.imageResp.close()
                self.imageResp = None

        if self.__IsRtsp(newVideoPath):
            print("\r\n===> RTSP Video Source")

            self.useStream = True
            self.useStreamHttp = False
            self.useMovieFile = False
            self.videoPath = newVideoPath

            if self.vStream:
                self.vStream.start()
                self.vStream = None

            if self.vCapture:
                self.vCapture.release()
                self.vCapture = None

            self.vStream = VideoStream(newVideoPath).start()
            # Needed to load at least one frame into the VideoStream class
            time.sleep(1.0)
            self.captureInProgress = True

        elif self.__IsHttp(newVideoPath):
            print("IsHttp")
            # Use urllib to get the image and convert into a cv2 usable format
            self.url = newVideoPath
            self.useStreamHttp = True
            self.useStream = False
            self.useMovieFile = False
            self.captureInProgress = True

        elif self.__IsYoutube(newVideoPath):
            print("\r\n===> YouTube Video Source")
            self.useStream = False
            self.useStreamHttp = False
            self.useMovieFile = True
            # This is video file
            self.downloadVideo(newVideoPath)
            self.videoPath = newVideoPath
            if self.vCapture.isOpened():
                self.captureInProgress = True
            else:
                print(
                    "===========================\r\nWARNING : Failed to Open Video Source\r\n===========================\r\n")

        elif self.__IsCaptureDev(newVideoPath):
            print("===> Webcam Video Source")
            if self.vStream:
                self.vStream.start()
                self.vStream = None

            if self.vCapture:
                self.vCapture.release()
                self.vCapture = None

            self.videoPath = newVideoPath
            self.useMovieFile = False
            self.useStream = False
            self.useStreamHttp = False
            self.vCapture = cv2.VideoCapture(newVideoPath)
            if self.vCapture.isOpened():
                self.captureInProgress = True
            else:
                print(
                    "===========================\r\nWARNING : Failed to Open Video Source\r\n===========================\r\n")
        else:
            print(
                "===========================\r\nWARNING : No Video Source\r\n===========================\r\n")
            self.useStream = False
            self.useYouTube = False
            self.vCapture = None
            self.vStream = None
        return self

Nach dem VideoCapture die Quelle an VideoStream übergeben hat, wird die Videoquelle geöffnet und der Stream an VideoCapture zurück gegeben; insofern eine Videoquelle einen Stream bereitstellt. Bei Youtube Videos, zum Beispiel, verhält es sich etwas anders (siehe Zeile 365). Das Modul VideoCapture lädt das Video herunter. Entsprechend werden dann die Bilder noch vorverarbeitet. Zum Beispiel werden Sie, falls zu groß, herunter skaliert und die Frames per second (FPS) errechnet.
In jedem Fall werden im Anschluss die Einzelbilder in einer Schleife weiter verarbeitet.

        detectionTracker = DetectAndTrack(self.detectionSampleRate, self.confidenceLevel, self.imageProcessingEndpoint, self.yoloInference)
        while True:

            # Get current time before we capture a frame
            tFrameStart = time.time()
            detectionTracker.SKIP_FRAMES = self.detectionSampleRate
           
...
            try:
                # Read a frame
                if self.useStream:
                    # Timeout after 10s
                    signal.alarm(10)
                    frame = self.vStream.read()
                    signal.alarm(0)
                    
                elif self.useStreamHttp:
                    self.imageResp = urllib2.urlopen(self.url)
                    imgNp = np.array(bytearray(self.imageResp.read()),dtype=np.uint8)
                    frame = cv2.imdecode(imgNp,-1)
                    
                else:
                    frame = self.vCapture.read()[1]
            except Exception as e:
                print("ERROR : Exception during capturing")
                raise(e)

            # Resize frame if flagged
            if needResizeFrame:
                frame = cv2.resize(frame, (self.videoW, self.videoH))

In Zeile 1 wird das DetectAndTrack Modul instanziiert. Mit Zeile 2 beginnt die Schleife – eine Endlosschleife. Zeilen 5, 6 und folgende berechnen die FPS. In den Zeilen 20, 23 holen wir uns endlich das Frame, das wir dann, falls notwendig in Zeile 30 verkleinern.

Für die grafischen Operationen verwende ich OpenCV. Das kannst du an der Verwendung der Modulinstanz cv2 (z.B. cv2.resize(…) ) sehen.

Object detection & tracking

Für jedes Einzelbild wird das DetectAndTrack Modul aufgerufen. Dieses Modul führt eine Liste mit bereits bekannten (detektierten) Objekten und macht eine Object Detection, um neue Objekte in einem Bild zu erkennen.

Ein Kernproblem, dass dieses Modul lösen soll, ist die Tatsache, dass ein erkanntes Objekt pro Frame, in unserer Vorstellung nicht unbedingt ein “neues” Objekt sein muss.
Nehmen wir an, ein schwarzes kleines Auto fährt in das Bild. Dann verstehen wir als Mensch “neues Objekt (Auto)”. Da wir aber nicht in Frames unterscheiden, erwarten wir immernoch das selbe kleine schwarze Auto, wenn es sich einige “Pixel” im Bild weiter bewegt hat.
Macht der Computer an dieser Stelle eine Object Detection, gibt es stets neue kleine schwarze Autos. Das ist erstmal kein Problem, wenn wir es nicht besonders weiter verarbeiten wollten (zum Beispiel zählen). Damit ich aber Zusammenhänge, Richtungen oder Anzahl von verschiedenen Autos auswerten kann, muss das Device eben dies verstehen. Hier kommt das Tracking ins Spiel. Die Programmlogik muss nun “verstehen”, dass das eine Objekt in dem ersten Frame das “selbe” Objekt im nächsten Frame ist. Schauen wir uns nun den Code an.

            # Run Object Detection -- GUARD
            if self.inference:
                #yoloDetections = self.yoloInference.runInference(frame, frameW, frameH, self.confidenceLevel)
                detectionTracker.doStuff(frame, frameW, frameH)

            # Calculate FPS
            timeElapsedInMs = (time.time() - tFrameStart) * 1000
            currentFPS = 1000.0 / timeElapsedInMs

            if (currentFPS > cameraFPS):
                # Cannot go faster than Camera's FPS
                currentFPS = cameraFPS

            # Add FPS Text to the frame
...
            self.displayFrame = cv2.imencode('.jpg', frame)[1].tobytes()

            timeElapsedInMs = (time.time() - tFrameStart) * 1000

...

Zeile 35: Das DetectAndTrack Modul wird mit der Methode doStuff(frame, Width, Height) aufgerufen. Das Frame wird als Referenz übergeben, daher können wir davon ausgehen, dass wir ab Zeile 45 (Kommentar) mit dem Ergebnis-Frame weiter arbeiten. Die Code-Teile, welche Text usw. in das Bild einfügen, habe ich für die Übersichtlichkeit weg gelassen (… markierte Auslassung). Doch schauen wir uns nun das DetectAndTrack Modul an.

        if self.totalFrames % self.SKIP_FRAMES == 0:
            # set the status and initialize our new set of object trackers
            status = "Detecting"
            self.trackers = []

            yoloDetections = self.yoloInference.runInference(
                frame, W, H, self.CONFIDENCE_LIMIT)
            # loop over the detections
            for detection in yoloDetections:
                class_type = detection.classType

                if class_type not in ['car','truck','person', 'bicycle','motorbike','bus','bird','cat','dog','umbrella']:
                    break;

                # compute the (x, y)-coordinates of the bounding box
                # for the object
                box = detection.box[0:4] * np.array([1, 1, 1, 1])
                (startX, startY, endX, endY) = box.astype("int")

                # construct a dlib rectangle object from the bounding
                # box coordinates and then start the dlib correlation
                # tracker
                tracker = dlib.correlation_tracker()
                rect = dlib.rectangle(startX, startY, endX, endY)

                if __myDebug__:
                    cv2.rectangle(frame, (startX, startY),
                                  (endX, endY), (0, 0, 0), 1)
                    cv2.putText(frame, class_type, (startX, startY),
                                cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)

                tracker.start_track(rgb, rect)

                container = TrackerExt(class_type, tracker, (startX, startY, endX, endY))

                # add the tracker to our list of trackers so we can
                # utilize it during skip frames
                self.trackers.append(container)

Das If-Statement in Zeile 1 wird nur bei jedem N-ten Frame ausgeführt. Damit werden wertvolle Resourcen gespart. Denn innerhalb des If-Statements wird die Object Detection angewandt (Zeile 6 auf das YoloInference Module). Das Inferencing eines ML-Modells benötigt schon einiges an Performance. Möchtest du etwas über das ML-Model erfahren, dann ließ unten über Yolo weiter.

Nach dem Abfragen des ML-Modells erhalten wir 0 bis x Objekte zurück. Ein Element dieser Liste hat dabei für uns folgenden interessanten Inhalt.
– ClassName – Typ des Objektes (car, person,…)
– probability – Sicherheit der Erkennung in Prozent
– boundingBoy – ein Rechteck, dass das Objekt im Frame/Bild markiert
Aus dieser Liste interessiert uns aber lediglich ein kleiner Teil an Typen. Über die For-Schleife in Zeile 9 iterieren wir über die einzelnen erkannten Objekte und püfen, ob diese in unseren Interessensbereich passen (Zeile 12). Falls ja, holen wir uns in Zeile 17 die entsprechende Bounding Box und fügen diese einem Objecttracker zu (Zeilen 23 – 38). Mehr zum Tracker hier.

Was ist mit den Zwischen-Frames? Also jene Frames, zu denen keine Object Detection gemacht wird? Der nachfolgende Code zeigt dies. Wir gehen mal davon aus, dass schon eine Object Detection gelaufen ist, dann haben wir bereits ein Objekt in unserer Trackingliste (Siehe oben Zeile 38). So müssen wir jetzt über die zu trackenden Objekte in der Liste iterieren.

        else:
            # loop over the trackers
            for trackerContainer in self.trackers:
                # set the status of our system to be 'tracking' rather
                # than 'waiting' or 'detecting'
                status = "Tracking"
                tracker = trackerContainer.tracker

                # update the tracker and grab the updated position
                tracker.update(rgb)
                pos = tracker.get_position()

                # unpack the position object
                startX = int(pos.left())
                startY = int(pos.top())
                endX = int(pos.right())
                endY = int(pos.bottom())

                trackerContainer.rect = (startX, startY, endX, endY)

                if __myDebug__:
                    cv2.rectangle(frame, (startX, startY),
                                  (endX, endY), (0, 0, 0), 2)
                    cv2.putText(frame, trackerContainer.class_type, (startX, startY),
                                cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)

Zeile 3 beginnt das Iterieren. Das dlib Modul übernimmt für uns das Tracking. D.h. wir müssen lediglich das aktuelle Frame in den Tracker geben und die Methode update aufrufen (Zeile 10). Damit erhalten wir die neuen Koordinaten des bereits erkannten Objektes (Zeile 11). Mit den neu gewonnenen Positionsdaten können wir das aktuelle Element in der Trackingliste aktualisieren (Zeile 19).

Nach dem nun die beiden Fälle Detection/ Tracking in den jeweiligen For-Schleifen behandelt wurden, können wir nun mit der Logik für das Postauto und Anderem beginnen. Schauen wir uns dazu die, auf das für uns Wichtige reduzierte, Code-Zeilen an.

        extractedRects = [
            trackerContainer for trackerContainer in self.trackers]

        objects = self.ct.update(extractedRects)

In Zeile 1 und 2 besorge ich mir eine Liste bestehend aus einzelnen “tracking container” Objekten. Diese beinhalten die ID eines Objektes und dessen Koordinaten. Diese Liste wird in Zeile 4 einem Centroid-Tracker übergeben.

Centroids sind “Mittelpunkte”. Gemeint ist hier die Reduktion eines durch ein Rechteck markiertes Objekt auf dessen Mittelpunkt.

Der Centroidtracker legt neue Centroids an oder aktualisiert bereits Vorhandene mit den neuen Koordinaten aus dem Update über den dlib-Tracker (weiter oben). Zurück gibt es eine Liste von Objekten mit ihrer jeweiligen ID und zugehörigen Centroids (ebenfalls Zeile 4).

        # loop over the tracked objects
        for (objectID, centroidTrackerData) in objects.items():
            # check to see if a trackable object exists for the current
            # object ID
            to = self.trackableObjects.get(objectID, None)

            centroid = centroidTrackerData[0]
            className = centroidTrackerData[1]
            rect = centroidTrackerData[2]
            
            directionX = 0
            directionY = 0
            
            # if there is no existing trackable object, create one
            if to is None:

Als nächstes wird über alle Objekte iteriert (ab Zeile 2). Innerhalb dieser Schleife wird grundlegend abgefragt, ob das aktuelle Objekt ein neues erkanntes Objekt ist, oder bereits getrackt wird. Mit Zeile 5 wird in der Liste ein Lookup gemacht und das Ergebnis (Zeile 15) auf Vorhanden/Nichtvorhanden geprüft.

                clipped = clipImage(origFrame, rect)
                
                if className == 'car' or className == 'truck':
                    details = self.__getCarDetails__(clipped)
                    if details and len(details) > 0:
                        predictions = details["predictions"]
                        try:
                          
                

Ist das Objekt nicht in der Liste, werden folgende Operationen ausgeführt.
In Zeile 1 wird vom aktuellen Frame der Bereich mit dem erkannten Objekt “ausgeschnitten”. Bildich habe ich den Ablauf mal so dargestellt:

Fluss innerhalb des If to is None:

Der Typ des Objekts der aktuellen Iteration wird auf “car” oder “truck” (Zeile 3) geprüft. Insofern das Objekt als Truck oder Car erkannt wurde, wird das zweite ML-Modell (das, was wir mit der Custom Vision API erzeugt haben – du kannst hier nochmals nachlesen) mit dem Bildausschnitt abgefragt. Mit Zeile 4 startet die nächste Stufe ML-Model-Abfrage. Das ausgeschnittene (geclippte) Bild wird an die Methode __getCarDetails__ übergeben. Innerhalb dieser Methode wird dann ein REST Call auf ein weiteres IoT Edge Modul – dem PostcarDetector Module.

Dieses Module ist ein WebService, der auf einen in der Deployment-Config eingestellten Port lauscht. Es ist in meiner Lösung Port 80. Daruf kann ich innerhalb meiner IoT Edge stets zugreifen. Das Modul bekommt beim Aufruf einen Base64 codiertes Byte-Package (Image) geliefert (siehe Nachfolgende Liste). Das ist das unmodifizierte Python File, dass du von der CustomVision API als Export bekommen hast, falls du dieses Tutorial nachgemacht hattest.

# Like the CustomVision.ai Prediction service /image route handles either
#     - octet-stream image file 
#     - a multipart/form-data with files in the imageData parameter
@app.route('/image', methods=['POST'])
@app.route('/<project>/image', methods=['POST'])
@app.route('/<project>/image/nostore', methods=['POST'])
@app.route('/<project>/classify/iterations/<publishedName>/image', methods=['POST'])
@app.route('/<project>/classify/iterations/<publishedName>/image/nostore', methods=['POST'])
@app.route('/<project>/detect/iterations/<publishedName>/image', methods=['POST'])
@app.route('/<project>/detect/iterations/<publishedName>/image/nostore', methods=['POST'])
def predict_image_handler(project=None, publishedName=None):
    try:
        imageData = None
        if ('imageData' in request.files):
            imageData = request.files['imageData']
        elif ('imageData' in request.form):
            imageData = request.form['imageData']
        else:
            imageData = io.BytesIO(request.get_data())

        img = Image.open(imageData)
        results = predict_image(img)
        return jsonify(results)
    except Exception as e:
        print('EXCEPTION:', str(e))
        return 'Error processing image', 500

Die nächsten Zeilen führen die Klassifikation nach Postauto Nicht-Postauto durch. und bauen ein Result-Json zusammen, welche dann zurück gegeben werden können.

def predict_image(image):
    log_msg('Predicting image')

    w, h = image.size
    log_msg("Image size: {}x{}".format(w, h))

    predictions = od_model.predict_image(image)

    response = {
        'id': '',
        'project': '',
        'iteration': '',
        'created': datetime.utcnow().isoformat(),
        'predictions': predictions }

Ich habe mich hier gegen eine Object-Detection entschieden, die ich bereits vorher schon einmal mit der CustomVision API gebaut hatte, da die Export Files sehr imperformant waren. Da dort das übergebene Bild in verschiedenen For-Schleifen bikubisch resized und gecroppt wird, geht sehr viel CPU Zeit dafür drauf. Ich hatte anfangs begonnen alles mit OpenCV umgeschrieben, um hier etwas mehr Performance raus zu holen, es jedoch bald bleiben lassen, da der Ansatz im Allgemeinen einfach nicht schön ist. Kurzerhand habe ich aus einem Objectdetection Ansatz ein Classification Ansatz gemacht. Nun muss nicht mehr soviel rum gerechnet werden.

Wie hatte es sich ausgewirkt? Mit dem Objectdetection Ansatz hatte ich stets eine Abfrage Latenz von ca. 15 Sekunden. Schlecht, wenn ich potenziell jedes Auto auf Postauto prüfen musste. Mit dem Classification Ansatz nur noch 1 – 2 Sekunden, was ich als ansich gute Lösung hin nehmen kann.

  isPost = next((match for match in predictions if float(
                                match["probability"]) > 0.7 and match["tagName"] == "Postauto"), None)
                        except GeneratorExit:
                            pass
                        if isPost:
                            className = "postcar"
                            messageIoTHub = IoTHubMessage("""{"Name":"Postauto"}""")
                            AppState.HubManager.send_event_to_output("output2", messageIoTHub, 0)

Das PostcarDetector Modul gibt nun ein JSON Objekt an den Caller, dem DetectAndTrack Modul, zurück, dass eine Liste mit möglich erkannten Objekttypen beinhaltet (“predictions”). Das Konstrukt in Zeile 8 liefert das erste Objekt aus der “predictions”-Liste, das als Postauto mit einer Mindestwahrscheinlichkeit von 70% erkannt wurde, oder eben nichts. Zeile 12 prüft darauf ab. Die Zeile 15 versendet eine entsprechende Message über den HubManager an den EdgeHub zu Output 2.

Kurzer Lookup in die Deployment-Config, um den Effekt dieser Message zu verstehen.
Die Zeilen 5 und 6 in dem Auszug der Deployment Config unten, beschreiben Routen. Zeile 6 ist für uns die Entscheidende, denn diese definiert den Output2. Alles was also an Output2 gesendet wird, steht automatisch als Input dem SpeechModule zur Verfügung. Oder andersherum gelesen… das SpeechModule interessiert sich nur für Messages aus Output2 des YoloModules.

    "$edgeHub": {
      "properties.desired": {
        "schemaVersion": "1.0",
        "routes": {
          "YoloModuleToIoTHub": "FROM /messages/modules/YoloModule/outputs/output1 INTO $upstream",
          "YoloModuleToSpeechModule": "FROM /messages/modules/YoloModule/outputs/output2 INTO BrokeredEndpoint(\"/modules/SpeechModule/inputs/input1\")"
        },
        "storeAndForwardConfiguration": {
          "timeToLiveSecs": 7200
        }
      }

Ok, weiter im Kontext. Schauen wir uns an, was nach dem Prüfen auf “car” oder “truck” passiert.

                self.__saveToBlobStorage(clipped, id=objectID, typeName=className)
                fullName =  className +"-full"
                clipped = clipImage(origFrame, [0,0,W,H])
                self.__saveToBlobStorage(clipped, id=objectID, typeName=fullName)
                    

Das ausgeschnittene Bild wird mit samt seiner ObjektId und dem Typen (kann ja auch was anderes als eine Auto sein) im lokalen Blob Storage abgespeichert (Zeile 1). Ja, Azure Blob Storage! Noch etwas geniales, das man mit IoT Edge zur Verfügung hat. Blob Storage, der auch einen automatischen Upload in die Cloud besitzt. So muss ich mich nicht darum kümmern, die Bilddaten per Hand zu Kopieren und dann noch das Ausfall-Prozedere zu programmieren. Kommt alles mit dem Container mit. In Azure kann man dann viele tolle Dinge damit machen (darüber bringe ich dann noch gern einen separaten Artikel 🙂 ).

Nach dem abspeichern des kleinen Bildausschnitts, lege ich auch das gesamte Bild ab. Das sieht hier in Zeile 3 etwas merkwürdig aus. Ich schneide mit der Methode clipImage(origFrame, [0,0,W,H]) wieder aus dem Frame aus. Nur diesmal über die gesamte Größe. Das mache ich, da ich für die Verarbeitung in der Methode nicht auf dem original Frame arbeiten kann und erzeuge mir damit recht schnell eine Kopie. Das geht in Python flott, da ich mir den Umstand zu nutze machen, dass das Bild ein numpy Array ist und ich nur die Zeilen und Spalten, die mich interessieren, in ein anderes Array übertrage.

                to = TrackableObject(objectID, className, centroid)
                self.__sendToIoTHub__(to, rect, frame)

Die nächsten Zeilen weisen nun eine neues TrackingObject zu, das nachher der allgmeinen Liste zu trackender Objekte angefügt wird. Mit Zeile 2 wird dem IoT Hub eine allgemeine Message mit den Fakten zum neuen Objekt geschickt. (Randbemerkung: Diese Nachricht wird dann im Timeseries Insights aufbereitet, so dass man über die Zeit hinweg Vorkommnisse in der Gegend protokollieren kann – wie im nächsten Bildausschnitt)

Nach dem wir uns angeschaut haben, was passiert, wenn ein Objekt neu erkannt wurde, interessiert uns sicherlich, was mit bereits erkannten Objekten geschieht. Für meinen Anwendungsfalls habe ich mich dafür entschieden, die Richtung eines Objektes auszuwerten. Damit kann ich bestimmen, ob zum Beispiel das Postauto an meinem Grundstück vorbei fährt oder tatsächlich was für mich hat. Genauso gut kann ich damit auch die relative Geschwindigkeit eines Objektes feststellen und vieles mehr.

           # otherwise, there is a trackable object so we can utilize it
            # to determine direction
            else:
                # the difference between the y-coordinate of the *current*
                # centroid and the mean of *previous* centroids will tell
                # us in which direction the object is moving (negative for
                # 'up' and positive for 'down')
                y = [c[1] for c in to.centroids]
                x = [c[0] for c in to.centroids]
                
                directionY = centroid[1] - np.mean(y)
                directionX = centroid[0] - np.mean(x)
                
                if len(to.centroids)>=200:
                    temp = to.centroids[:len(to.centroids)-2]
                    to.centroids = temp
                to.centroids.append(centroid)

            # store the trackable object in our dictionary
            self.trackableObjects[objectID] = to

            # draw both the ID of the object and the centroid of the
            # object on the output frame
            text = "{}: {} ({}, {})".format(objectID, to.type, round(directionX,1),round(directionY,1))
            cv2.putText(frame, text, (centroid[0] - 10, centroid[1] - 10),cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
            cv2.circle(frame, (centroid[0], centroid[1]), 4, (20, 250, 130), -1)

Zuerst entnehmen wir alle y und x Werte aller Centroids seit Erkennen des Objektes (t0) bis letzte (t-jetzt – 1) in Zeile 8. Dann bilden wir den Durchschnitt über aller Y und X Werte. Das ist einfache Vektor-Mathematik. diesen Durchschnitt ziehen wir in Zeilen 11 und 12 von dem jeweiligen X und Y des aktuellen Centroids ab. Damit erhalten wir nun einen Vektor, der die ungefähre Richtung auf Basis der Vergangenheit angibt (letzte bekannte Centroid zu dem aktuellen).

Da es vorkommen kann, dass ein Objekt sich nicht bewegt, weil ein Auto beispielsweise abgestellt wurde, dann würde der Speicher des Jetson Nano etwas beansprucht, wenn alle historischen Objekt-Centroids gespeichert würden. Daher lösche ich die History in den Zeilen 14 bis 15, falls ich mehr als 200 Einträge habe. In der Praxis hat sich der Wert bei mir bewährt. Den akutellen Centroid füge ich dann noch der Liste mit allen verbliebenen Centroids an (Zeile 17). Mit Zeile 20 aktualisiere ich das aktuell getrackte Objekt mit den neuen Werten. Schlussendlich male ich ab Zeile 24 folgendes ins Bild.

Ausgabe des Centroids mit Label und ID (7: person (10.4. -22.0))
        # increment the total number of frames processed thus far and
        # then update the FPS counter
        self.totalFrames += 1
        self.fps.update()

Die letzten Zeilen des DetectAndTrack Modules führen noch ein FPS Update durch, das im VideoCapture Module genutzt wird.

Damit geht es wieder zurück zum VideoCapture Module. Hier werden die FPS neu kalkuliert und in das aktuelle Frame geschrieben (Zeilen 6 bis 11 unten).

            if self.inference:
                #yoloDetections = self.yoloInference.runInference(frame, frameW, frameH, self.confidenceLevel)
                detectionTracker.doStuff(frame, frameW, frameH)

            # Calculate FPS
            timeElapsedInMs = (time.time() - tFrameStart) * 1000
            currentFPS = 1000.0 / timeElapsedInMs

            if (currentFPS > cameraFPS):
                # Cannot go faster than Camera's FPS
                currentFPS = cameraFPS

            # Add FPS Text to the frame
            cv2.putText(frame, "FPS " + str(round(currentFPS, 1)), (10, int(30 *
                                                                            self.fontScale)), cv2.FONT_HERSHEY_SIMPLEX, self.fontScale, (0, 0, 255), 2)

            self.displayFrame = cv2.imencode('.jpg', frame)[1].tobytes()

            timeElapsedInMs = (time.time() - tFrameStart) * 1000

In Zeile 17 wird das fertig gerenderte Frame an ein Property für die Darstellung im Browser übergeben. Wie funktioniert das ? Es gibt eine Methode im VideoCapture Modul…

    def get_display_frame(self):
        return self.displayFrame

…die vom ImageServer Modul aufgerufen wird. Dieses wurde mit Initialisierung des VideoCapture Moduls gestartet und hat eine Referenz auf Dieses erhalten. Im ImageServer Module ist folgendes definiert:

    def on_message(self, msg):
        if msg == 'next':
            frame = self.videoCapture.get_display_frame()
            if frame != None:
                encoded = base64.b64encode(frame)
                self.write_message(encoded, binary=False)

.. und wird mit einer WebSocket Verbindung über einen WebSocketHandler abgerufen.

Solange nun eine Videoquelle Bilder liefert oder sonst nichts schief geht, können wir nun nach belieben Objekte in einem Videostream detektieren und uns das Ergebnis live im Browser anschauen – damit wissen wir, was der Jetson Nano sieht.

Object Detection mit YOLO

Für das generelle Erkennen von Objekten habe ich mich für Yolo (You look only once) entschieden. Es ist ein sehr performantes (F)CNN (Fully Convolutional Neural Network) Network. Für die verschiedenen Objekte, die es zu detektieren gibt, habe ich mich für das COCO Dataset entschieden, da es schon alle für mich relevanten Objekte kennt.

Um es in der aktuellen Lösung verwenden zu können, habe ich einen Ordner mit den relevanten Files angelegt. Das Coco Dataset und Labels, die Config (beschreibt die Netz-Architektur), Gewichte und das ML-Modell selbst.

yolo folder mit dataset und Gewichte DB

Das Modul YoloInference dient dem Ansprechen und Bereitstellen des ML-Modells und den Inferenz Ergebnissen. Da ich die Darknet-Variante verwende, importiere ich natürlich das Python Darknet Modul, sowie auch OpenCV und das YoloDetection Modul.

from darknet import darknet

import cv2
import numpy as np
import time
import os
import json
from datetime import datetime
import YoloDetection
from YoloDetection import YoloDetection

...
    
yolocfg = r'yolo/yolov3-tiny.cfg'
yoloweight = r'yolo/yolov3-tiny.weights'
classesFile = r'yolo/coco.names'
dataFile = r'yolo/coco.data'

Weiterhin gebe ich noch die Yolo-relevanten Files mit (Zeilen 14 – 17). Das Modul YoloDetection dient hier lediglich als DTO (Data transfer object).

Da nun sehr viel Code kommt, der sich darum kümmert das ML-Modell an zu sprechen und die Ergebnisdaten zur Verfügung zustellen, möchte ich an dieser Stelle auf die Zeilen-Analyse verzichten und dafür gern auf die Stellen eingehen, die für das Gesamtkonstrukt interessant sind.

Inferencing ist eine resourcenhungrige Aufgabe, die auch auf dem Jetson Nano sehr schnell zu Latenzen führen kann. D.h. für die Umsetzung solltest du daran denken Alles GPU-basiert einzurichten, um die Performance der Hardware zu nutzen.

Da nachher alles in einem Container laufen wird, benötigst du dazu von dort aus Zugriff auf die GPU. Dies kannst du in der Deployment Config für die IoT Edge Lösung vornehmen. Die Parametrierung geschieht von Zeile 68 biss 77. Das Hardware Binding für den Container findest du ab Zeile 79 bis 125.

"YoloModule": {
            "version": "1.5.$BUILD_VERSION_YOLO",
            "type": "docker",
            "status": "running",
            "restartPolicy": "always",
            "settings": {
              "image": "${MODULES.YoloModule.arm64v8}",
              "createOptions": {
                "Env": [
                  "VIDEO_PATH=$CONTAINER_VIDEO_SOURCE",
                  "VIDEO_WIDTH=640",
                  "VIDEO_HEIGHT=480",
                  "FONT_SCALE=0.8",
                  "NOIOTHUB=False",
                  "DETECTION_SAMPLE_RATE=10",
                  "CONFIDENCE_LEVEL=0.3",
                  "IMAGE_PROCESSING_ENDPOINT=http://postcarmodule/image"
                ],
                "HostConfig": {
                  "Devices": [
                    {
                      "PathOnHost": "/dev/nvhost-ctrl",
                      "PathInContainer":"/dev/nvhost-ctrl",
                      "CgroupPermissions":"rwm"
                    },
                    {
                      "PathOnHost": "/dev/nvhost-ctrl-gpu",
                      "PathInContainer":"dev/nvhost-ctrl-gpu",
                      "CgroupPermissions":"rwm"
                    },
                    {
                      "PathOnHost": "/dev/nvhost-prof-gpu",
                      "PathInContainer":"dev/nvhost-prof-gpu ",
                      "CgroupPermissions":"rwm"
                    },
                    {
                      "PathOnHost": "/dev/nvmap",
                      "PathInContainer":"/dev/nvmap",
                      "CgroupPermissions":"rwm"
                    },
                    {
                      "PathOnHost": "dev/nvhost-gpu",
                      "PathInContainer":"dev/nvhost-gpu",
                      "CgroupPermissions":"rwm"
                    },
                    {
                      "PathOnHost": "/dev/nvhost-as-gpu",
                      "PathInContainer":"/dev/nvhost-as-gpu",
                      "CgroupPermissions":"rwm"
                    },
                    {
                      "PathOnHost": "/dev/nvhost-vic",
                      "PathInContainer":"/dev/nvhost-vic",
                      "CgroupPermissions":"rwm"
                    },
                    {
                      "PathOnHost": "/dev/tegra_dc_ctrl",
                      "PathInContainer":"/dev/tegra_dc_ctrl",
                      "CgroupPermissions":"rwm"                      
                    },
                    {
                       "PathOnHost": "/dev/video0",
                       "PathInContainer":"/dev/video0",
                       "CgroupPermissions":"rwm"                      
                    }
                  ],
                  "PortBindings": {
                    "80/tcp": [
                      {
                        "HostPort": "8080"
                      }
                    ]
                  }
                }
              }
            }

Die Libraries sollten für GPU Unterstützung kompiliert sein. Um hier nicht alles selber zu machen, kommt es nur gelegen wenn bereits jemand an der Stelle schon die notwendigen Schritte getan hat. Schauen wir uns an, was ich meine – das Dockerfile. Diese baut das gesamte YoloModule zusammen und macht es auf dem Jetson Nano lauffähig.

FROM iotcontainertt.azurecr.io/iot-sdk-python-builder:arm64v8 as iot-sdk-python-builder

FROM toolboc/jetson-nano-l4t-cuda-cudnn-opencv-darknet as cuda-opencv-darknet

WORKDIR /app

RUN apt-get update && \
    DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
     wget \
     libcurl4-openssl-dev \
     python3-pip \
     libboost-python-dev \
     libgtk2.0-dev \
     libblas-dev \
     liblapack-dev \
     libatlas-base-dev \
     gfortran \
     python3-setuptools python3-numpy python3-opencv python-opencv && \
    rm -rf /var/lib/apt/lists/* 
      

Zeile 1 stellt das Baseimage für das IoTHub SDK zur Verfügung. Ich habe es als eigenes Image erstellt, da mir der Build-Prozess nach mehrmaligem Bauen dann irgendwann zu lange dauerte. Außerdem ist es schon ganz gut, “statische” Dinge – also Dinge, die sich eigentlich nicht mehr ändern müssen – aus zu lagern. Das Baseimage meines iot-sdk-python-builder:arm64v8 images baut auf dem balenalib/jetson-tx2-ubuntu:bionic baseimage auf.

Zeile 3 ist die wichtigste Zeile, wenn es darum geht das Yolo-Inferencing von Darknet auf einer GPU zum Laufen zu bekommen. Freundlicher weise kann man in den Repositories von “toolboc” ein Image finden, dass GPU, opencv und darknet vorkompiliert hat und für die Verwendung auf dem Jetson Nano zur Verfügungstellt.

Danach (Zeile 7 – 19) wird das System mit allen notwendigen Modulen und Libs vorbereitet, die für das Arbeiten mit der YoloModule-App wichtig sind.

COPY --from=iot-sdk-python-builder /usr/sdk/src/device/doc/package-readme.md /src/device/doc/package-readme.md
COPY --from=iot-sdk-python-builder /usr/sdk/src/build_all/linux/release_device_client /src/build_all/linux/release_device_client 
RUN cd /src/build_all/linux/release_device_client && python3 setup.py install
COPY --from=iot-sdk-python-builder /usr/sdk/src/device/samples/iothub_client.so /app/iothub_client.so

RUN cp /usr/local/src/darknet/libdarknet.so /app/libdarknet.so

All die Zeilen kopieren lediglich die Binaries vom IoT-SDK Baseimage und die darknet lib in das aktuelle Image (da Multistage builds).

COPY /build/requirements.txt ./
COPY /build/pip-cache/* /root/.cache/pip/

Zeile 28 ist dafür da, die Abhängigen Module in der Python Anwendung mit einzufügen. In der Requirements.txt stehen folgende Import-Module drin: requests, json, pickle, netifaces
Welche über pip später installiert werden. Das ist ein reguläres Vorgehen. Klar hätte man auch direkt als Zeile schreiben können pip install ... allerdings würde dann bei einer Änderung an der Zeile, der gesamte Buildprozess mit pip install … neu starten, statt nur die relevanten Module zu installieren. So wird nur neu gebaut, wenn sich wirklich ein Package in requirements.txt ändert (schau dir gern mal den Hashvergleich bei “docker build”).

In Zeile 29 kopiere ich die verschiedensten Wheels, da ein paar Zeilen später sehr zeitaufwendige Kompilierungen laufen würden. Ich habe ein paar Anläufe gebraucht, um zu begreifen… Mein primäres Build-System ist ein Win 10 auf einem amd64 PC. So benötigt das Kompilieren mehr Overhead-Zeit um für die richtige Zielarchitektur zu bauen, als würde ich es direkt auf der Zielarchitektur laufen lassen (Jetson Nano). Ich habe für das pip install imuitils zum Bespiel 4-6h benötigt. Da ich aber nicht ständig alles auf dem Nano Entwickeln wollte, habe ich das pip install einmal auf dem Gerät gemacht und anschließend den Ordner “/root/.cache/pip” in meinen Build Ordner der Entwicklungslösung kopiert (aufpassen, dass Windows nicht anfängt die Encodings zu ändern, dann läuft’s unter Linux nicht mehr).

RUN pip3 install --upgrade pip

RUN pip3 install tornado==4.5.3 \
    trollius \
    scipy==1.3.1 numpy==1.13.3
RUN pip3 install /root/.cache/pip/dlib-19.18.0-cp36-cp36m-linux_aarch64.whl
RUN pip3 install /root/.cache/pip/imutils-0.5.3-py3-none-any.whl 

Ab Zeile 31 geht es dann mit den pip-installs los. Da ich eine ganze Weile damit verbracht habe, den Build Prozess zu beschleunigen, ist das Dockerfile nun etwas “fragmentiert”. die Module in Zeilen 33-35 könnte ich nun wieder zurück in die requirements.txt Datei schreiben und damit drei Zeilen Dockerfile reduzieren, was auch die Layerzahl des Images positiv beeinflußt. Ich hatte ursprünglich die Module einzeln gebaut, um heraus zu finden, was den Prozess so träge machte.

Zeilen 36/37 zeigen das Ergebnis meiner Recherche. Ich habe hier die beiden Module dlib und imutils aus dem fertigen Wheels heraus installiert, was also nun wenige Minuten Installation im schlimmsten Fall ausmacht.

RUN apt-get update && \
    apt-get install -y --no-install-recommends zip pandoc && \
    rm -rf /var/lib/apt/lists/*

RUN git clone --depth=1 https://github.com/ytdl-org/youtube-dl.git && \
    cd youtube-dl && \
    make && \
    make install

Zeilen 39 – 46 bereiten nun die Packages für das Youtube Video Handling vor. Die YoloModule-App kann, zur Erinnerung, auch Youtube Videos als Videoquelle verwenden, was sich bei Tests echt gut macht.

RUN pip3 install -v -r requirements.txt
RUN apt-get update && apt-get install -y --no-install-recommends libffi-dev libssl-dev python-openssl
RUN pip3 install azure-storage-blob

Als nächstes wird das Installieren aller in der requirements.txt festgehaltenen Module durch pip vorgenommen (Zeile 48). Die in Zeile 49 installierten Libs sind für Azure Blob Storage notwendig.

ADD /app/ .


# Expose the port
EXPOSE 80

ENTRYPOINT [ "python3", "-u", "./main.py" ]

Diese Zeilen dienen schlussendlich dem Docker Host zur Bereitstellung. Somit ist der Container auf Port 80 zu erreichen und startet mit Main.py durch, wenn er hochgefahren wird.

Object tracking mit DLIB

Das Objecttracking ist ein wichtiger Bestandteil der Lösung, da, wie ich bereits weiter oben erläutert habe, sonst keine Objektidentität erzeugt werden kann und die Performance durch Tracking erheblich gesteigert wird.

Für meine Lösung habe ich mich für einen State-of-the-Art Tracker entschieden, den man aus der DLIB Bibliothek bekommt. der Objecttracker darin ist ein Correlation-Tracker und funktioniert grob beschrieben so.

Ein Objekt bewegt sich von Frame zu Frame nur geringfügig, so kann, wenn es markiert wurde, die “positionen” des Objekts in den Frames leicht miteinander korreliert werden. Das bedeutet, dass markante Bildeigenschaften die in einem vorhergehenden Frame markiert wurden, im Nachfolgenden schnell gefunden werden können.

User Gehirn kann dies mit Leichtigkeit, es funktioniert aber auf die selbe Art. Stell dir vor, dir werden zwei Bananen gezeigt. Eine ist markellos, die andere hat einen kleinen braunen Fleck. Natürlich könntest du diese in einem Haufen von anderen Bananen wieder finden.

Korrelation zwischen den Pixesl der Frames

In diesem Bild sind zwei Frames abgebildet. Links oben ist das Eingangsframe, welches ein markiertes Objekt dem Tracker übergibt. Dieser bildet über mehrere Features (Bildeigenschaften) eine Art “Karte” (Konvertierung von Zeit in Frequenz – Fourier ist unser Freund). Über Diese läßt sich ein Objekt besser von Rauschen und Ähnlichem unterscheiden. Das Nächste Frame wird ebenfalls konvertiert, worin dann die korrelierenden Features gefunden werden können und markiert. Das ist eigtl. schon alles. Natürlich verbirgt sich dahinter keine Magie sondern viel Mathematik. Falls du dich weitergehend damit beschäftigen möchtest, schau hier mal rein. Der MOSSE Ansatz bildet hier die Basis.

dlib object tracker in action

Abschließend

Mit diesem Video möchte ich diesen Artikel abschließen. Ich hoffe es spannend für dich, die Aspekte dieser Lösung besser kennenlernen zu können. Mir hat es sehr viel Spaß gemacht, mich all diesen Techniken zu beschäftigen. Gleichermaßen habe ich nun das Gefühl, viele neue Ansätze für andere Probleme gefunden zu haben, die ich weiter entwickeln kann.

Beim Schreiben dieses Beitrags habe ich aber auch festgestellt, wieviel ich an dieser Lösung noch gestallten und optimieren kann/muss 🙂

Wenn du alle Teile dieser Serie gelesen hast und noch keinen Kommentar verfasst hast, würde ich mich sehr freuen, wenn du nun einen hinterläßt. Wenn die ein Beitrag besonders gefallen hat, gib mir gern auch ein Like oder leite diesen an Interessierte weiter – ich würde mich sehr freuen.

Ich würde an dieser Stelle gern noch einmal mit dir zusammen zurück blicken und zusammen schreiben, was wir denn nun alles Betrachtet haben.

  • Azure
  • Custom Vision API
  • Container Export eines ML-Models
  • Docker
  • Hardware Jetson Nano
  • VSCode Erweiterungen mit Azure und IoT
  • IoT Hub / IoT Edge Solution
  • IoT Edge Modul Programmierung
  • Python
  • Objectdetection mit Yolo
  • Objecttracking mit dlib
  • OpenCV
  • Videostreaming mit WebSocket
  • Videoframe grabbing
  • Time Series Insight
  • Azure Blob Storage

… und sicher noch das Eine oder Andere, an das ich jetzt nicht mehr gedacht habe. Das ist doch schon eine große Liste. Vielleicht erinnerst du dich. In meinem ersten “Overview” Beitrag habe ich das angekündigt.

Vielen Dank für’s Lesen! Keep learning and sharing!

Von Thomas

Als Chief Technology Officer bei Xpirit Deutschland. Ich bin dafür verantwortlich, die Produktivität unserer Kunden durch einen Full-Stack von Dev und Technologie in der heutigen Zeit zu steigern. Dabei kümmere ich mich nicht nur um Technologien aus Microsofts Stack wie Azure, AI und IoT, sondern auch um die Bereitstellung von Qualität und Expertise mit DevOps

Schreibe einen Kommentar

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

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.