Benutzer-Werkzeuge

Webseiten-Werkzeuge


Seitenleiste

projektewise19:unoroboterpublic:start

Mau-Mau Roboter

Themenbeschreibung und Überblick

Der Mau-Mau Roboter ist, wie der unkreative Name schon verrät ein Roboter, der gegen einen menschlichen Gegenspieler Mau-Mau spielt. Dabei kann der Roboter mithilfe einer Webcam seine „Hand“ einlesen, erkennen ob er an der reihe ist, und entsprechend der Mau-Mau Regeln eine Karte auswählen (Sonderkarten werden nicht beachtet). Die ausgewählte karte wird anschließend mithilfe eines armes und einer Saug-Vorrichtung angehoben und zum Spielstapel getragen woraufhin der Roboter auf den Zug des Spielers wartet.

Überblick

Der Roboter lässt sich Grob in drei Bestandteile/Problemquellen aufteilen, zunächst müssen die Karten erkannt, anschließend muss sich ein Arm zu den Karten bewegen und dieser arm muss dann noch in der Lage sein die Karten anzuheben.

Erkennung der Karten

Zur erkennung der Karten verwenden wir OpenCV und NumPy in einem Python script welches auf einem Raspberry Pi 3 läuft. Unser Code basiert dabei auf einem code von EdjeElectronics, den wir aber an vielen stellen verändert und erweitert haben. Nachdem das Bild in ein Schwarz weiß Bild umgewandelt wurde wird es nach Konturen abgesucht, welche karten sein könnten. Konturen beschreiben in einem schwarz-weiß Bild die zusammenhängenden Übergänge in zwischen Schwarzen und Weißen Flächen, also zum Beispiel die Ränder eines Weißen Polygons auf schwarzem Hintergrund.

Überblick des Programmablaufs(nicht ganz aktuell) Abb. 1: Überblick des Programmablaufs

Dazu werden zunächst alle Konturen gesucht und nach Größe sortiert

    dummy, cnts, hier = cv2.findContours(thresh_image, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    index_sort = sorted(range(len(cnts)), key=lambda i: cv2.contourArea(cnts[i]), reverse=True)

Alle von OpenCV erkannten Konturen Abb. 2: Alle erkannten Konturen Anschließend werden alle Konturen überprüft, ob sie:

  1. nicht zu groß sind
  2. nicht zu klein sind
  3. keine parents haben
    1. Das bedeutet sie sind nicht innerhalb einer größeren Kontur.
  4. 4 ecken haben
    1. OpenCV bietet die Möglichkeit konturen als simpleres Polygon zu sehen und nicht jeden Pixel einzeln, somit erhalten wir bei rechteckigen Karen auch eine Rechteckige Kontur mit genau 4 ecken.

    for i in range(len(cnts_sort)):
        size = cv2.contourArea(cnts_sort[i])
        peri = cv2.arcLength(cnts_sort[i], True)
        approx = cv2.approxPolyDP(cnts_sort[i], 0.01 * peri, True)
 
        if ((size < CARD_MAX_AREA) and (size > CARD_MIN_AREA)
                and (hier_sort[i][3] == -1) and (len(approx) == 4)):
            cnt_is_card[i] = 1

Die nach der Filterung noch übrig gebliebenen Konturen Abb. 3: Die nach der Filterung noch übrig gebliebenen Konturen

wenn sie all diese Bedingungen erfüllen, wird die cnt_is_card liste an der entsprechenden Stelle auf 1 gesetzt, wir haben jetzt also eine liste aus Konturen und eine liste welche uns sagt ob die Konturen Karten sind.

Jetzt müssen wir die Karten identifizieren:

zunächst wird für jede Karte aus der Liste ein Query_card objekt erstellt, mit denen ab jetzt weiter gearbeitet wird. zunächst werden dafür die Maße und Position der Karte ermittelt


    def __init__(self):
        self.contour = []  # Contour of card
        self.width, self.height = 0, 0  # Width and height of card
        self.corner_pts = []  # Corner points of card
        self.center = []  # Center point of card
        self.warp = []  # 200x300, flattened, grayed, blurred image
        self.rankval = 0  # Best matched rank
        self.suitval = 0  # Best matched suit
        self.rank_name = "" #name von rank
        self.suit_name = "" #name von suit
 
    def preprocess_card(contour, image):
 
 
        qCard = Query_card()
 
        qCard.contour = contour
 
        #Finde den Umfang der Karte und nutze ihn um die Ecken zu ermitteln
        peri = cv2.arcLength(contour, True)                       #Ermittle den Umfang der Karte
        approx = cv2.approxPolyDP(contour, 0.01 * peri, True)     #Ermittle die Eckpunkte mithilfe des Douglas-Pecker-Algorithmus
        pts = np.float32(approx)
        qCard.corner_pts = pts
 
        #Finde Höhe und Breite eines Die Kontur umschließenden Rechtecks
        x, y, w, h = cv2.boundingRect(contour)
        qCard.width, qCard.height = w, h
 
        #Finde den Mittelpunkt mit den Mittelwerden der 4 Ecken
        average = np.sum(pts, axis=0) / len(pts)
        cent_x = int(average[0][0])
        cent_y = int(average[0][1])
        qCard.center = [cent_x, cent_y]
 
 
    qCard.warp = flattener(image, pts, w, h)
 

die flattener() funktion erzeugt ein 200x300px großes Bild der in eine Top-Down Ansicht gezerrte Karte1)

Anschließend werden die Codes auf den Karten eingelesen indem fixe punkte in den in schwarz-weiß Bilder umgewandelten Karten eingelesen werden. Bedeutung des Musters Abb. 4: So wird das Muster ausgelesen Gezerrte Karte Mit Markierten Einlese punkten(Die Markierungen sind nur zur Veranschaulichung) Abb. 5: Gezerrte Karte mit markierten einlese Punkten(Die Markierungen dienen nur zur Veranschaulichung)


    ranky = 45
    suity =100
 
    ypos1 = 40
    ypos2 = 80
    ypos3 = 120
    ypos4 = 160
 
    (thresh, qCardB) = cv2.threshold(qCard.warp, 1, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
    rank = 0
    suit = 0
    if qCardB[ranky, ypos1] == 0:
        rank += 8
 
    if qCardB[ranky, ypos2] == 0:
        rank += 4
 
    if qCardB[ranky, ypos3] == 0:
        rank += 2
 
    if qCardB[ranky, ypos4] == 0:
        rank += 1
 
 
    if qCardB[suity, ypos2] == 0:
        suit += 2
 
    if qCardB[suity, ypos3] == 0:
        suit += 1
 
    qCard.suitval = suit
    qCard.rankval = rank

Das einlesen der Karten ist abgeschlossen, jetzt werden die Karten durchgegangen und entweder der Hand oder dem Spielstapel zugeordnet. Zudem wird überprüft ob sich die Karte auf dem Spielstapel geändert hat um zu sehen ob der Spieler seinen Zug gemacht hat.

    if (cards[k].center[0] < 1000 or cards[k].center[1] > 200):
        hand.append(cards[k])
    else:
        if (Game.playstack != cards[k]):
            turned = 1
            print("turned")
        Game.playstack = cards[k]

Die Fertig eingelesene Hand, links steht eine Liste aller eingelesenen Karten Abb. 6: Die Fertig eingelesene Hand, links steht eine Liste aller eingelesenen Karten

Wenn der Roboter seinen Zug macht, geht er seine Hand durch und überprüft ob er eine Karte hat, welch er spielen darf2) und spielt diese dann aus, oder er nimmt eine neue Karte.

def PlayCard(hand):
    print("Finding card to play")
    for i in range(len(hand)):
        if (hand[i].rankval == playstack.rankval or hand[i].suitval == playstack.suitval):
            play(hand[i])
            return
    take(newstack)
 
def play(card):
    print("Playing")
    moveto(card)
    pickup()            #nimm karte setze holding auf 1
    moveto(playstack)
    pickup()            #leg karte wieder ab, da holding jetzt 1  ist und setze holding wieder auf 0
    moveto(reset)
    return
 

3)

Die moveto() Funktion bewegt den Arm zur Position der übergebenen Karte, da die Übersetzung der Steps in Pixel allerdings sehr ungenau ist, wird die Bewegung wiederholt, bis Die Position des Grünen Markers nah genug an der Zielposition liegt. Näheres dazu im Abschnitt „Bewegen des Arms“

Bewegen des Arms

Abb. 7: Grober Aufbau des Roboters Abb. 8: Aufbau der Bewegung Abb. 9: Überblick über den fast fertigen Roboter

Wir haben uns Überlegt die Karten mithilfe eines Roboterarmes über das Spielfeld zu bewegen. Dabei hatten wir die Auswahl zwischen einem vollmotorisierten Arm mit Gelenken, oder einem der sich auf mehreren Achsen bewegt. Wir haben uns für letzteres entschieden, da es uns leichter umzusetzen erschien. Unser Roboterarm besitzt nun also 3 Achsen, auf denen er sich bewegen kann. Auf einer, kann er die Länge des Spielfeldes Abfahren, auf der Anderen, die Tiefe und die letzte wird dazu genutzt, den Saugkopf auf eine der Spielkarten zu senken. Damit der Arm sich auf diesen Achsen bewegen kann haben wir Stepper und Servo Motoren genutzt. Zum Abfahren der Länge befindet sich ein weiterer Arm mit Absenkvorrichtung auf einer beweglichen Plattform, die mithilfe eines Zahngummis von einem Stepper Motor präzise hin und her bewegt werden kann. Der Arm, mit der Absenkvorrichtung am Ende, wird per Zahnstange, von einem Stepper Motor mit Zahnradaufsatz, nach vorne bzw. hinten geschoben. Die Absenkvorrichtung am Ende funktioniert mit einem linearen Servo, der den Schlauch einer Pumpe auf eine der Spielkarten absenkt. Die Stepper Motoren der beiden Hauptachsen, werden beide über DRV 8825 Controller gesteuert, welche wiederum über die GPIO Pins am Raspberry Pi 3 angesteuert werden. Zusätzlich benötigen sie ein externes Netzteil, welches 12 Volt liefert und genug Stromstärke mit sich bringt, um beide Stepper zu versorgen, weshalb ein einfacher Step-up-converter an den 5 Volt die der Raspberry Pi ausgibt, leider nicht reichen würde.

Damit das Spielfeld eingelesen werden kann und je nach Spielsituation, korrekt reagiert werden kann, befindet sich außerdem eine Webcam an einem fest montierten Stativ. Diese ist in etwa 30cm Höhe befestigt und überblickt das gesamte Spielfeld. Dabei kommuniziert sie direkt mit der Bilderkennungssoftware. Die gesamte Hardware wird von einem Raspberry Pi 3, der am Rand des Spielfelds befestigt ist, gesteuert. Dieser wird mit einer Powerbank mit Strom versorgt und kommuniziert per LAN Kabel mit dem Rechner, um befehle entgegenzunehmen. Die Hauptsoftware läuft dabei allerdings immer auf dem Pi selbst, eine detaillierte erklärung der Kartenerkennung ist im Abschnitt „Erkennung der Karten“ zu finden. Aber auch für die Bewegung wird die Kamera benötigt, denn um die Karten zu treffen muss die Position des Armes genau mit den Karten übereinstimmen, doch durch die ungenaue Konstruktion und die nicht exakte Übersetzung zwischen schritten der Motoren und pixeln des Kamerabildes endet die Bewegung nicht genau dort wo der code den Arm hinschickt. Um dieses Problem zu lösen tracken wir mithilfe der Kamera die Position des Arms, indem wir das Bild nach grünen Pixeln filtern und aus dem so entstehendem Schwarz-weiß Bild die größte Kontur heraussuchen, dies ist der Auf der Pumpe montierte Grüne Ball. Wenn wir nun den Arm bewegen wird nach abschluss der Bewegung der Mittelpunkt des Balls als position des Arms übernommen und wenn sie mehr als 10 pixel vom Ziel entfernt ist wird eine neue Bewegung von der Aktuellen zum ziel ausgeführt.

def moveto(posCard):
 
    global  posx
    print("Moving")
    print(posCard.center)
 
    while True:
        posdeltaX = posx -posCard.center[0] #Berechne zurückzulegende Distanz
        print(int(posdeltaX/2.5))           #Pixel in Steps für den Motor umrechnen(Faktor durch Grobe Tests ermittelt)
        if posdeltaX < 0:                   #In welche Richtung auf der X-Achse der Am bewegt werden muss
            posdeltaX *= -1
            GPIO.output(DIR, CW)            #Lege Drehrichtung fest
            setPosX(int(posdeltaX/2.5))     #setPosX() läuft die übergebene anzahl Steps ab
        elif posdeltaX > 0:
            GPIO.output(DIR, CCW)
            setPosX(int(posdeltaX/2.5))
        updatePos()                         #Aktualisiere Die Position mithilfe des Grünen Markers
        if (posx - posCard.center[0] < 10 and posx - posCard.center[0] > -10):  #wenn der Arm nah genug am Ziel ist, ist die Schleife fertig
            break
 
    [...]#Das gleiche für Y
    return
def updatePos():
 
    global posx
    global posy
 
    lowerBound=np.array([30,80,40])            #Grenzen für die Farbe
    upperBound=np.array([100,255,255])
    cam = cv2.VideoCapture(0)                  #Frame holen
    cam.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
    cam.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
 
    ret, image = cam.read()
 
    cam.release()
    imageHSV = cv2.cvtColor(image,cv2.COLOR_BGR2HSV)            #Konvertiere in HSV Farbraum
    mask = cv2.inRange(imageHSV, lowerBound, upperBound)        #Erstelle Maske mit Farbgrenzen, Pixel außerhalb sind schwarz, innerhalb sind weiß
    dummy,gcnts,h = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)  #finde Konturen
    if len(gcnts) != 0:
        mark = max(gcnts, key = cv2.contourArea)          #Suche größte Kontur
        cv2.drawContours(imageHSV,mark,-1,(255,0,0),5)
        cv2.imshow("track",imageHSV)
        x,y,w,h = cv2.boundingRect(mark)                  #Finde box um Die Kontur
        posx = (int)(x+(0.5*w))                           #Lege Mittelpunkt der Box als Position des Arms fest
        posy = (int)(y+(0.5*h))
    return
Anheben der Karten

Zum anheben der Karten saugen wir sie an, dies erreichen wir mithilfe einer Kleinen Vakuumpumpe, praktischer Weise läuft diese pumpe, genau wie die Stepper, mit 12 Volt, sodass wir sie direkt mit an den gleichen Stromkreis anschließen konnten. Um die Pumpe anzuschalten, wenn sie gebraucht wird und auszuschalten, wenn dies nicht der Fall ist, benutzen wir eine einfache Mosfet-Schaltung. Am Ende des Pumpenschlauchs, befindet sich ein zweckentfremdeter Saugnapf, der die Oberfläche vergrößert, mit der die Spielkarte angezogen wird und bietet gleichzeitig, aufgrund der Flexibilität, ein wenig Spielraum beim Absenken des Schlauchs auf die Karte. Der Lineare Servo, zum Absenken des Ansaugschlauchs, kann zum Glück mit 5 Volt betrieben werden, sodass die Stromversorgung, die der Raspberry Pi zur Verfügung stellt, dafür ausreichend ist. Der Servo wird zudem an einen GPIO Pin des Raspberry Pi`s angeschlossen und kann dann mit Hilfe eines PWM Signals gesteuert werden.

Technische Daten

Abb. 10: Schaltplan des Roboters

Bauteile
Steuerung
  • Raspberry pi 3
  • Logitech c270 webcam
Armbewegung
  • 2x Steppermotor
  • * 2x Steppercontroller
  • Zahnriemen + 2 passende Zahnräder
  • Zahnstange + 1 entsprechendes Zahnrad
  • Grüner Ball/Grüne Markierung
  • 2x Kondensator 100µF
Karten anheben
  • Mini Vakuumpumpe 12V
  • * Schlauch
  • * Saugheber/Saugnapf
  • Linear Servo
  • Transistor
  • Widerstände(1xunbekannt, 1xunbekannt)
Ergebnis

Im nachhinein ist die Technik des Roboters sehr simpel, wir sind vieles jedoch zu kompliziert angegangen wodurch die Entwicklung aufgrund von Zeitmangel nicht abgeschlossen ist. Das größte Problem dabei war das Anheben der der Karten, denn alle unsere Ansätze endeten in für uns nicht zu behebenden Problemen. Zunächst verwendeten wir einen angewinkelten arm an einem Servo, doch dieser konnte den Sauger nicht auf den Karten absetzen ohne sie zu verschieben. Unser zweiter Versuch mit einem linearen Servo scheiterte an der Karft des Servos und dem Zeitmangel um eine Stärkere Version zu bauen. Zuletzt verwendeten wir einen Solenoid, doch dieser benötigt zu viel Strom vom Pi um ausreichend Kraft aufzubauen oder er zieht zu viel Strom von unserem Netzteil zudem ist er sehr schwer, was durch die einseitige Halterung des Arms ein Großes Gegengewicht erfordert. Von diesen drei Ansätzen wäre der lineare Servo der vielversprechendste, wenn wir eine stärkere Version davon hätten.

Der weg über den wir die Erkennung implementiert haben bietet allerdings dennoch die Möglichkeit den Roboter einfach mit anderen Spielen zu erweitern, hierzu wären aber in den Meisten fällen noch Wege nötig über die der Roboter mit dem Gegenspieler kommunizieren kann, wie zum Beispiel ein Display. damit wären dann aber alle spiele möglich, die daraus bestehen Karten auf einen festen Stapel zu legen und bei denen der Roboter nicht zu viele Karten auf der Hand halten muss. Beispielsweise möglich wären Skat, Doppelkopf (und andere Stich-basierte Spiele). Spiele bei denen mit vielen Karten gespielt wird, die auch ausgelegt werden müssen wie bei Canasta sind mit unserem Design allerdings nicht möglich.

Vollständiger Code(Stand 3.3.)mau-mau-roboter.zip

1) Die Funktion wurde von uns komplett aus dem ursprünglichen Code übernommen
2) Diese Auswahl ist natürlich nicht sehr intelligent und wird verbessert, falls wir die Zeit dazu haben
3) Die take() funktion existiert noch nicht
projektewise19/unoroboterpublic/start.txt · Zuletzt geändert: 2020/04/07 14:32 von d.golovko