Schreiben eines Bots für ein Puzzlespiel in Python

Ich wollte mich schon lange mit Computer Vision versuchen, und dieser Moment ist gekommen. Es ist interessanter, aus Spielen zu lernen, also werden wir auf einem Bot trainieren. In diesem Artikel werde ich versuchen, den Prozess der Automatisierung des Spiels mithilfe des Python + OpenCV-Bundles detailliert zu beschreiben.



Bild




Auf der Suche nach einem Ziel



Wir gehen zur thematischen Seite miniclip.com und suchen nach einem Ziel. Die Wahl fiel auf das Farbpuzzle Coloruid 2 im Abschnitt Puzzles, in dem wir ein rundes Spielfeld mit einer Farbe in einer bestimmten Anzahl von Zügen füllen müssen.



Ein beliebiger Bereich wird mit der am unteren Bildschirmrand ausgewählten Farbe gefüllt, während benachbarte Bereiche derselben Farbe zu einem einzigen zusammengeführt werden.



Bild


Ausbildung



Wir werden Python verwenden. Der Bot wurde ausschließlich zu Bildungszwecken erstellt. Der Artikel richtet sich an Anfänger im Bereich Computer Vision, die ich selbst bin.



Das Spiel befindet sich hier

GitHub des Bots hier



Damit der Bot funktioniert, benötigen wir folgende Module:



  • opencv-python
  • Kissen
  • Selen


Der Bot wurde für Python 3.8 unter Ubuntu 20.04.1 geschrieben und getestet. Wir installieren die erforderlichen Module in Ihrer virtuellen Umgebung oder per Pip-Installation. Damit Selen funktioniert, benötigen wir außerdem einen Geckodriver für FireFox. Sie können ihn hier herunterladen: github.com/mozilla/geckodriver/releases



Browsersteuerung



Wir haben es mit einem Online-Spiel zu tun, also organisieren wir zuerst die Interaktion mit dem Browser. Zu diesem Zweck verwenden wir Selenium, das uns eine API zur Verwaltung von FireFox zur Verfügung stellt. Untersuchen des Codes der Spieleseite. Das Puzzle ist eine Leinwand, die sich wiederum in einem Iframe befindet.



Wir warten, bis der Frame mit id = iframe-game geladen ist, und schalten den Treiberkontext darauf um. Dann warten wir auf Leinwand. Es ist das einzige im Frame und über XPath / html / body / canvas verfügbar.



wait(self.__driver, 20).until(EC.frame_to_be_available_and_switch_to_it((By.ID, "iframe-game")))
self.__canvas = wait(self.__driver, 20).until(EC.visibility_of_element_located((By.XPATH, "/html/body/canvas")))


Als nächstes wird unsere Zeichenfläche über die Eigenschaft self .__ canvas verfügbar sein. Die gesamte Logik der Arbeit mit dem Browser besteht darin, einen Screenshot der Leinwand zu erstellen und an einer bestimmten Koordinate darauf zu klicken.



Vollständiger Browser.py-Code:



from selenium import webdriver
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait as wait
from selenium.webdriver.common.by import By

class Browser:
    def __init__(self, game_url):
        self.__driver = webdriver.Firefox()
        self.__driver.get(game_url)
        wait(self.__driver, 20).until(EC.frame_to_be_available_and_switch_to_it((By.ID, "iframe-game")))
        self.__canvas = wait(self.__driver, 20).until(EC.visibility_of_element_located((By.XPATH, "/html/body/canvas")))

    def screenshot(self):
        return self.__canvas.screenshot_as_png

    def quit(self):
        self.__driver.quit()

    def click(self, click_point):
        action = webdriver.common.action_chains.ActionChains(self.__driver)
        action.move_to_element_with_offset(self.__canvas, click_point[0], click_point[1]).click().perform()


Spielzustände



Kommen wir zum Spiel selbst. Die gesamte Bot-Logik wird in der Robot-Klasse implementiert. Teilen wir das Gameplay in 7 Zustände ein und weisen ihnen Methoden zu, um sie zu verarbeiten. Lassen Sie uns die Trainingsstufe separat auswählen. Es enthält einen großen weißen Cursor, der angibt, wo geklickt werden soll, wodurch verhindert wird, dass das Spiel korrekt erkannt wird.



  • Begrüßungsbildschirm
  • Ebenenauswahlbildschirm
  • Farbauswahl auf Tutorial-Ebene
  • Auswahl eines Bereichs auf Unterrichtsebene
  • Farbauswahl
  • Regionsauswahl
  • Ergebnis des Umzugs


class Robot:
    STATE_START = 0x01
    STATE_SELECT_LEVEL = 0x02
    STATE_TRAINING_SELECT_COLOR = 0x03
    STATE_TRAINING_SELECT_AREA = 0x04
    STATE_GAME_SELECT_COLOR = 0x05
    STATE_GAME_SELECT_AREA = 0x06
    STATE_GAME_RESULT = 0x07

    def __init__(self):
        self.states = {
            self.STATE_START: self.state_start,
            self.STATE_SELECT_LEVEL: self.state_select_level,
            self.STATE_TRAINING_SELECT_COLOR: self.state_training_select_color,
            self.STATE_TRAINING_SELECT_AREA: self.state_training_select_area,
            self.STATE_GAME_RESULT: self.state_game_result,
            self.STATE_GAME_SELECT_COLOR: self.state_game_select_color,
            self.STATE_GAME_SELECT_AREA: self.state_game_select_area,
        }


Für eine größere Stabilität des Bots prüfen wir, ob die Änderung des Spielstatus erfolgreich erfolgt ist. Wenn self.state_next_success_condition während self.state_timeout nicht True zurückgibt, verarbeiten wir den aktuellen Status weiter, andernfalls wechseln wir zu self.state_next. Wir werden auch den von Selenium erhaltenen Screenshot in ein Format übersetzen, das OpenCV versteht.




import time
import cv2
import numpy
from PIL import Image
from io import BytesIO

class Robot:

    def __init__(self):

	# …

	self.screenshot = []
        self.state_next_success_condition = None  
        self.state_start_time = 0  
        self.state_timeout = 0 
        self.state_current = 0 
        self.state_next = 0  

    def run(self, screenshot):
        self.screenshot = cv2.cvtColor(numpy.array(Image.open(BytesIO(screenshot))), cv2.COLOR_BGR2RGB)
        if self.state_current != self.state_next:
            if self.state_next_success_condition():
                self.set_state_current()
            elif time.time() - self.state_start_time >= self.state_timeout
                    self.state_next = self.state_current
            return False
        else:
            try:
                return self.states[self.state_current]()
            except KeyError:
                self.__del__()

    def set_state_current(self):
        self.state_current = self.state_next

    def set_state_next(self, state_next, state_next_success_condition, state_timeout):
        self.state_next_success_condition = state_next_success_condition
        self.state_start_time = time.time()
        self.state_timeout = state_timeout
        self.state_next = state_next


Lassen Sie uns die Prüfung in den Statusbehandlungsmethoden implementieren. Wir warten auf die Schaltfläche Wiedergabe auf dem Startbildschirm und klicken darauf. Wenn wir innerhalb von 10 Sekunden den Ebenenauswahlbildschirm nicht erhalten haben, kehren wir zur vorherigen Stufe self.STATE_START zurück, andernfalls fahren wir mit der Verarbeitung von self.STATE_SELECT_LEVEL fort.




# …

class Robot:
   DEFAULT_STATE_TIMEOUT = 10
   
   # …
 
   def state_start(self):
        #     Play
        # …

        if button_play is False:
            return False
        self.set_state_next(self.STATE_SELECT_LEVEL, self.state_select_level_condition, self.DEFAULT_STATE_TIMEOUT)
        return button_play

    def state_select_level_condition(self):
        #     
	# …


Bot Vision



Bildschwellenwert



Definieren wir die Farben, die im Spiel verwendet werden. Dies sind 5 spielbare Farben und eine Cursorfarbe für das Tutorial-Level. Wir werden COLOR_ALL verwenden, wenn wir alle Objekte finden müssen, unabhängig von der Farbe. Zunächst werden wir diesen Fall betrachten.



    COLOR_BLUE = 0x01  
    COLOR_ORANGE = 0x02
    COLOR_RED = 0x03
    COLOR_GREEN = 0x04
    COLOR_YELLOW = 0x05
    COLOR_WHITE = 0x06
    COLOR_ALL = 0x07


Um ein Objekt zu finden, müssen Sie zuerst das Bild vereinfachen. Nehmen wir zum Beispiel das Symbol "0" und wenden einen Schwellenwert an, dh wir trennen das Objekt vom Hintergrund. In diesem Stadium ist es uns egal, welche Farbe das Symbol hat. Zuerst konvertieren wir das Bild in Schwarzweiß und machen es 1-Kanal. Die Funktion cv2.cvtColor mit dem zweiten Argument cv2.COLOR_BGR2GRAY hilft uns dabei , die für die Konvertierung in Graustufen verantwortlich ist. Als nächstes führen wir einen Schwellenwert mit cv2.threshold durch . Alle Pixel des Bildes unterhalb eines bestimmten Schwellenwerts werden auf 0 gesetzt, alles oberhalb - auf 255. Das zweite Argument der Funktion cv2.threshold ist für den Schwellenwert verantwortlich . In unserem Fall kann eine beliebige Zahl vorhanden sein, da wir cv2.THRESH_OTSU verwenden und die Funktion selbst bestimmt den optimalen Schwellenwert durch die Otsu-Methode basierend auf dem Bildhistogramm.



image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(image, 0, 255, cv2.THRESH_OTSU)


Bild


Farbsegmentierung



Weiter interessanter. Lassen Sie uns die Aufgabe komplizieren und alle roten Symbole auf dem Ebenenauswahlbildschirm finden.



Bild


Standardmäßig werden alle OpenCV-Bilder im BGR-Format gespeichert. HSV (Farbton, Sättigung, Wert - Farbton, Sättigung, Wert) eignet sich besser für die Farbsegmentierung. Sein Vorteil gegenüber RGB ist, dass HSV Farbe von seiner Sättigung und Helligkeit trennt. Der Farbton wird von einem Farbtonkanal codiert. Nehmen wir als Beispiel ein hellgrünes Rechteck und verringern dessen Helligkeit allmählich.



Bild


Im Gegensatz zu RGB sieht diese Transformation im HSV intuitiv aus - wir verringern lediglich den Wert des Wert- oder Helligkeitskanals. Hierbei ist zu beachten, dass im Referenzmodell die Farbton-Farbskala im Bereich von 0 bis 360 ° variiert. Unsere hellgrüne Farbe entspricht 90 °. Um diesen Wert in einen 8-Bit-Kanal einzupassen, sollte er durch 2 geteilt werden.

Die Farbsegmentierung funktioniert mit Bereichen, nicht mit einer einzelnen Farbe. Sie können den Bereich empirisch bestimmen, aber es ist einfacher, ein kleines Skript zu schreiben.



import cv2
import numpy as numpy

image_path = "tests_data/SELECT_LEVEL.png"
hsv_max_upper = 0, 0, 0
hsv_min_lower = 255, 255, 255


def bite_range(value):
    value = 255 if value > 255 else value
    return 0 if value < 0 else value


def pick_color(event, x, y, flags, param):
    if event == cv2.EVENT_LBUTTONDOWN:
        global hsv_max_upper
        global hsv_min_lower
        global image_hsv
        hsv_pixel = image_hsv[y, x]
        hsv_max_upper = bite_range(max(hsv_max_upper[0], hsv_pixel[0]) + 1), \
                        bite_range(max(hsv_max_upper[1], hsv_pixel[1]) + 1), \
                        bite_range(max(hsv_max_upper[2], hsv_pixel[2]) + 1)
        hsv_min_lower = bite_range(min(hsv_min_lower[0], hsv_pixel[0]) - 1), \
                        bite_range(min(hsv_min_lower[1], hsv_pixel[1]) - 1), \
                        bite_range(min(hsv_min_lower[2], hsv_pixel[2]) - 1)
        print('HSV range: ', (hsv_min_lower, hsv_max_upper))
        hsv_mask = cv2.inRange(image_hsv, numpy.array(hsv_min_lower), numpy.array(hsv_max_upper))
        cv2.imshow("HSV Mask", hsv_mask)


image = cv2.imread(image_path)
cv2.namedWindow('Original')
cv2.setMouseCallback('Original', pick_color)
image_hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
cv2.imshow("Original", image)
cv2.waitKey(0)
cv2.destroyAllWindows()


Lassen Sie es uns mit unserem Screenshot starten.



Bild


Klicken Sie auf die rote Farbe und sehen Sie sich die resultierende Maske an. Wenn die Ausgabe nicht zu uns passt, wählen wir die Rottöne, um den Bereich und die Fläche der Maske zu vergrößern. Das Skript basiert auf der Funktion cv2.inRange , die als Farbfilter fungiert und ein Schwellenwertbild für einen bestimmten Farbbereich zurückgibt.

Lassen Sie uns auf die folgenden Bereiche eingehen:




    COLOR_HSV_RANGE = {
   COLOR_BLUE: ((112, 151, 216), (128, 167, 255)),
   COLOR_ORANGE: ((8, 251, 93), (14, 255, 255)),
   COLOR_RED: ((167, 252, 223), (171, 255, 255)),
   COLOR_GREEN: ((71, 251, 98), (77, 255, 211)),
   COLOR_YELLOW: ((27, 252, 51), (33, 255, 211)),
   COLOR_WHITE: ((0, 0, 159), (7, 7, 255)),
}


Konturen finden



Kehren wir zu unserem Level-Auswahlbildschirm zurück. Wenden wir den soeben definierten Rotbereichsfarbfilter an und übergeben den gefundenen Schwellenwert an cv2.findContours . Die Funktion findet die Umrisse der roten Elemente. Wir geben cv2.RETR_EXTERNAL als zweites Argument an - wir brauchen nur äußere Konturen und als drittes cv2.CHAIN_APPROX_SIMPLE - wir sind an geraden Konturen interessiert, speichern Speicher und speichern nur ihre Eckpunkte.



thresh = cv2.inRange(image, self.COLOR_HSV_RANGE[self.COLOR_RED][0], self.COLOR_HSV_RANGE[self.COLOR_RED][1])
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE


Bild


Lärm entfernen



Die resultierenden Konturen enthalten viel Hintergrundgeräusch. Um es zu entfernen, verwenden wir die Eigenschaft unserer Zahlen. Sie bestehen aus Rechtecken, die parallel zu den Koordinatenachsen verlaufen. Wir durchlaufen alle Pfade und passen sie mit cv2.minAreaRect in das minimale Rechteck ein . Das Rechteck wird durch 4 Punkte definiert. Wenn unser Rechteck parallel zu den Achsen ist, muss eine der Koordinaten für jedes Punktpaar übereinstimmen. Dies bedeutet, dass wir maximal 4 eindeutige Werte haben, wenn wir die Koordinaten des Rechtecks ​​als eindimensionales Array darstellen. Außerdem filtern wir zu lange Rechtecke heraus, bei denen das Seitenverhältnis größer als 3 zu 1 ist. Dazu ermitteln wir ihre Breite und Länge mithilfe von cv2.boundingRect .




squares = []
        for cnt in contours:
            rect = cv2.minAreaRect(cnt)
            square = cv2.boxPoints(rect)
            square = numpy.int0(square)
            (_, _, w, h) = cv2.boundingRect(square)
            a = max(w, h)
            b = min(w, h)
            if numpy.unique(square).shape[0] <= 4 and a <= b * 3:
                squares.append(numpy.array([[square[0]], [square[1]], [square[2]], [square[3]]]))


Bild


Konturen kombinieren



Schon besser. Jetzt müssen wir die gefundenen Rechtecke zu einem gemeinsamen Umriss von Symbolen kombinieren. Wir brauchen ein Zwischenbild. Erstellen wir es mit numpy.zeros_like . Die Funktion erstellt eine Kopie der Bildmatrix unter Beibehaltung ihrer Form und Größe und füllt sie dann mit Nullen. Mit anderen Worten, wir haben eine Kopie unseres Originalbildes mit einem schwarzen Hintergrund erhalten. Wir konvertieren es in einen Kanal und wenden die gefundenen Konturen mit cv2.drawContours an und füllen sie mit Weiß. Wir erhalten einen binären Schwellenwert, auf den wir cv2.dilate anwenden können . Die Funktion erweitert den weißen Bereich durch Verbinden separater Rechtecke, deren Abstand innerhalb von 5 Pixeln liegt. Ich rufe noch einmal cv2.findContours auf und erhalte die Konturen der roten Zahlen.




        image_zero = numpy.zeros_like(image)
        image_zero = cv2.cvtColor(image_zero, cv2.COLOR_BGR2RGB)
        cv2.drawContours(image_zero, contours_of_squares, -1, (255, 255, 255), -1)
	  _, thresh = cv2.threshold(image_zero, 0, 255, cv2.THRESH_OTSU)
	  kernel = numpy.ones((5, 5), numpy.uint8)
        thresh = cv2.dilate(thresh, kernel, iterations=1)	
        dilate_contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)


Bild


Das verbleibende Rauschen wird mit cv2.contourArea durch den Konturbereich gefiltert . Entfernen Sie alles, was weniger als 500 Pixel² groß ist.



digit_contours = [cnt for cnt in digit_contours if cv2.contourArea(cnt) > 500]


Bild


Das ist großartig. Lassen Sie uns all das in unserer Robot-Klasse implementieren.




# ...

class Robot:
     
    # ...
    
    def get_dilate_contours(self, image, color_inx, distance):
        thresh = self.get_color_thresh(image, color_inx)
        if thresh is False:
            return []
        kernel = numpy.ones((distance, distance), numpy.uint8)
        thresh = cv2.dilate(thresh, kernel, iterations=1)
        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        return contours

    def get_color_thresh(self, image, color_inx):
        if color_inx == self.COLOR_ALL:
            image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
            _, thresh = cv2.threshold(image, 0, 255, cv2.THRESH_OTSU)
        else:
            image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
            thresh = cv2.inRange(image, self.COLOR_HSV_RANGE[color_inx][0], self.COLOR_HSV_RANGE[color_inx][1])
        return thresh
			
	def filter_contours_of_rectangles(self, contours):
        squares = []
        for cnt in contours:
            rect = cv2.minAreaRect(cnt)
            square = cv2.boxPoints(rect)
            square = numpy.int0(square)
            (_, _, w, h) = cv2.boundingRect(square)
            a = max(w, h)
            b = min(w, h)
            if numpy.unique(square).shape[0] <= 4 and a <= b * 3:
                squares.append(numpy.array([[square[0]], [square[1]], [square[2]], [square[3]]]))
        return squares

    def get_contours_of_squares(self, image, color_inx, square_inx):
        thresh = self.get_color_thresh(image, color_inx)
        if thresh is False:
            return False
        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        contours_of_squares = self.filter_contours_of_rectangles(contours)
        if len(contours_of_squares) < 1:
            return False
        image_zero = numpy.zeros_like(image)
        image_zero = cv2.cvtColor(image_zero, cv2.COLOR_BGR2RGB)
        cv2.drawContours(image_zero, contours_of_squares, -1, (255, 255, 255), -1)
        dilate_contours = self.get_dilate_contours(image_zero, self.COLOR_ALL, 5)
        dilate_contours = [cnt for cnt in dilate_contours if cv2.contourArea(cnt) > 500]
        if len(dilate_contours) < 1:
            return False
        else:
            return dilate_contours


Erkennung von Zahlen



Fügen wir die Fähigkeit hinzu, Zahlen zu erkennen. Warum brauchen wir das? Weil wir können... Diese Funktion ist nicht zwingend erforderlich, damit der Bot funktioniert. Falls gewünscht, können Sie sie sicher ausschneiden. Aber da wir lernen, werden wir es hinzufügen, um die erzielten Punkte zu berechnen und den Bot zu verstehen, in welchem ​​Schritt er sich auf dem Level befindet. Wenn der Bot den letzten Zug des Levels kennt, sucht er nach einem Knopf, um zum nächsten zu gelangen oder den aktuellen zu wiederholen. Andernfalls müssten Sie nach jedem Zug nach ihnen suchen. Lassen Sie uns die Verwendung von Tesseract aufgeben und alles mit OpenCV implementieren. Die Erkennung von Zahlen basiert auf dem Vergleich von hu-Momenten, mit denen wir Zeichen in verschiedenen Maßstäben scannen können. Dies ist wichtig, da die Spieloberfläche unterschiedliche Schriftgrößen enthält. Die aktuelle Ebene, in der wir die Ebene auswählen, definiert SQUARE_BIG_SYMBOL: 9, wobei 9 die mittlere Seite des Quadrats in Pixeln ist, aus denen die Ziffer besteht. Beschneiden Sie die Bilder von Zahlen und speichern Sie sie im Datenordner. Im Wörterbuch selbst.dilate_contours_bi_data Wir enthalten Konturreferenzen, mit denen verglichen werden soll. Der Index ist der Name der Datei ohne Erweiterung (zum Beispiel "digit_0").



# …

class Robot:

    # ...

    SQUARE_BIG_SYMBOL = 0x01

    SQUARE_SIZES = {
        SQUARE_BIG_SYMBOL: 9,  
    }

    IMAGE_DATA_PATH = "data/" 

    def __init__(self):

        # ...

        self.dilate_contours_bi_data = {} 
        for image_file in os.listdir(self.IMAGE_DATA_PATH):
            image = cv2.imread(self.IMAGE_DATA_PATH + image_file)
            contour_inx = os.path.splitext(image_file)[0]
            color_inx = self.COLOR_RED
            dilate_contours = self.get_dilate_contours_by_square_inx(image, color_inx, self.SQUARE_BIG_SYMBOL)
            self.dilate_contours_bi_data[contour_inx] = dilate_contours[0]

    def get_dilate_contours_by_square_inx(self, image, color_inx, square_inx):
        distance = math.ceil(self.SQUARE_SIZES[square_inx] / 2)
        return self.get_dilate_contours(image, color_inx, distance)


OpenCV verwendet die Funktion cv2.matchShapes , um Konturen basierend auf Hu-Momenten zu vergleichen . Es verbirgt die Implementierungsdetails vor uns, indem es zwei Pfade als Eingabe verwendet und das Vergleichsergebnis als Zahl zurückgibt. Je kleiner es ist, desto ähnlicher sind die Konturen.



cv2.matchShapes(dilate_contour, self.dilate_contours_bi_data['digit_' + str(digit)], cv2.CONTOURS_MATCH_I1, 0)


Vergleichen Sie die aktuelle Kontur digit_contour mit allen Standards und ermitteln Sie den Mindestwert von cv2.matchShapes. Wenn der Mindestwert weniger als 0,15 beträgt, wird die Ziffer als erkannt betrachtet. Die Schwelle des Minimalwertes wurde empirisch ermittelt. Kombinieren wir auch eng beieinander liegende Zeichen zu einer Zahl.



# …

class Robot:

    # …

    def scan_digits(self, image, color_inx, square_inx):
        result = []
        contours_of_squares = self.get_contours_of_squares(image, color_inx, square_inx)
        before_digit_x, before_digit_y = (-100, -100)
        if contours_of_squares is False:
            return result
        for contour_of_square in reversed(contours_of_squares):
            crop_image = self.crop_image_by_contour(image, contour_of_square)
            dilate_contours = self.get_dilate_contours_by_square_inx(crop_image, self.COLOR_ALL, square_inx)
            if (len(dilate_contours) < 1):
                continue
            dilate_contour = dilate_contours[0]
            match_shapes = {}
            for digit in range(0, 10):
                match_shapes[digit] = cv2.matchShapes(dilate_contour, self.dilate_contours_bi_data['digit_' + str(digit)], cv2.CONTOURS_MATCH_I1, 0)
            min_match_shape = min(match_shapes.items(), key=lambda x: x[1])
            if len(min_match_shape) > 0 and (min_match_shape[1] < self.MAX_MATCH_SHAPES_DIGITS):
                digit = min_match_shape[0]
                rect = cv2.minAreaRect(contour_of_square)
                box = cv2.boxPoints(rect)
                box = numpy.int0(box)
                (digit_x, digit_y, digit_w, digit_h) = cv2.boundingRect(box)
                if abs(digit_y - before_digit_y) < digit_y * 0.3 and abs(
                        digit_x - before_digit_x) < digit_w + digit_w * 0.5:
                    result[len(result) - 1][0] = int(str(result[len(result) - 1][0]) + str(digit))
                else:
                    result.append([digit, self.get_contour_centroid(contour_of_square)])
                before_digit_x, before_digit_y = digit_x + (digit_w / 2), digit_y
        return result


Bei der Ausgabe gibt die Methode self.scan_digits ein Array zurück, das die erkannte Ziffer und die Koordinate des Klicks darauf enthält. Der Klickpunkt ist der Schwerpunkt des Umrisses.



# …

class Robot:

    # …

def get_contour_centroid(self, contour):
        moments = cv2.moments(contour)
        return int(moments["m10"] / moments["m00"]), int(moments["m01"] / moments["m00"])


Wir freuen uns über das empfangene Ziffernerkennungstool, aber nicht lange. Die Hu-Momente sind neben der Skalierung auch für Rotation und Spiegelung unveränderlich. Daher wird der Bot die Zahlen 6 und 9/2 und 5 verwechseln. Fügen wir eine zusätzliche Überprüfung dieser Symbole an den Eckpunkten hinzu. 6 und 9 werden durch den oberen rechten Punkt unterschieden. Wenn es unterhalb der horizontalen Mitte liegt, ist es 6 und 9 für das Gegenteil. Überprüfen Sie für Paar 2 und 5, ob sich der obere rechte Punkt am rechten Rand des Symbols befindet



if digit == 6 or digit == 9:
    extreme_bottom_point = digit_contour[digit_contour[:, :, 1].argmax()].flatten()
    x_points = digit_contour[:, :, 0].flatten()
    extreme_right_points_args = numpy.argwhere(x_points == numpy.amax(x_points))
    extreme_right_points = digit_contour[extreme_right_points_args]
    extreme_top_right_point = extreme_right_points[extreme_right_points[:, :, :, 1].argmin()].flatten()
    if extreme_top_right_point[1] > round(extreme_bottom_point[1] / 2):
        digit = 6
    else:
        digit = 9
if digit == 2 or digit == 5:
    extreme_right_point = digit_contour[digit_contour[:, :, 0].argmax()].flatten()
    y_points = digit_contour[:, :, 1].flatten()
    extreme_top_points_args = numpy.argwhere(y_points == numpy.amin(y_points))
    extreme_top_points = digit_contour[extreme_top_points_args]
    extreme_top_right_point = extreme_top_points[extreme_top_points[:, :, :, 0].argmax()].flatten()
    if abs(extreme_right_point[0] - extreme_top_right_point[0]) > 0.05 * extreme_right_point[0]:
        digit = 2
    else:
        digit = 5


Bild


Bild


Das Spielfeld analysieren



Lassen Sie uns die Trainingsstufe überspringen. Sie wird per Skript erstellt, indem Sie auf den weißen Cursor klicken und mit dem Spielen beginnen.



Stellen wir uns das Spielfeld als Netzwerk vor. Jeder Farbbereich ist ein Knoten, der mit benachbarten Nachbarn verbunden ist. Erstellen wir eine Klasse self.ColorArea , die den Farbbereich / Knoten beschreibt.



class ColorArea: 
        def __init__(self, color_inx, click_point, contour):
            self.color_inx = color_inx  #  
            self.click_point = click_point  #   
            self.contour = contour  #  
            self.neighbors = []  #  


Definieren wir eine Liste der Knoten self.color_areas und eine Liste, wie oft die Farbe auf dem Spielfeld self.color_areas_color_count erscheint . Beschneiden Sie das Spielfeld aus dem Leinwand-Screenshot.



image[pt1[1]:pt2[1], pt1[0]:pt2[0]]


Wobei pt1, pt2 die Extrempunkte des Rahmens sind. Wir durchlaufen alle Farben des Spiels und wenden die Methode self.get_dilate_contours auf jede an . Das Finden der Kontur des Knotens ähnelt dem Suchen nach der allgemeinen Kontur von Symbolen, mit dem Unterschied, dass auf dem Spielfeld kein Rauschen auftritt. Die Form der Knoten kann konkav sein oder ein Loch haben, sodass der Schwerpunkt aus der Form herausfällt und nicht als Koordinate für einen Klick geeignet ist. Suchen Sie dazu den äußersten oberen Punkt und legen Sie ihn um 20 Pixel ab. Die Methode ist nicht universell, aber in unserem Fall funktioniert sie.



        self.color_areas = []
        self.color_areas_color_count = [0] * self.SELECT_COLOR_COUNT
        image = self.crop_image_by_rectangle(self.screenshot, numpy.array(self.GAME_MAIN_AREA))
        for color_inx in range(1, self.SELECT_COLOR_COUNT + 1):
            dilate_contours = self.get_dilate_contours(image, color_inx, 10)
            for dilate_contour in dilate_contours:
                click_point = tuple(
                    dilate_contour[dilate_contour[:, :, 1].argmin()].flatten() + [0, int(self.CLICK_AREA)])
                self.color_areas_color_count[color_inx - 1] += 1
                color_area = self.ColorArea(color_inx, click_point, dilate_contour)
                self.color_areas.append(color_area)


Bild


Bereiche verknüpfen



Wir werden Bereiche als Nachbarn betrachten, wenn der Abstand zwischen ihren Konturen innerhalb von 15 Pixeln liegt. Wir durchlaufen jeden Knoten mit jedem Knoten und überspringen den Vergleich, wenn ihre Farben übereinstimmen.



        blank_image = numpy.zeros_like(image)
        blank_image = cv2.cvtColor(blank_image, cv2.COLOR_BGR2GRAY)
        for color_area_inx_1 in range(0, len(self.color_areas)):
            for color_area_inx_2 in range(color_area_inx_1 + 1, len(self.color_areas)):
                color_area_1 = self.color_areas[color_area_inx_1]
                color_area_2 = self.color_areas[color_area_inx_2]
                if color_area_1.color_inx == color_area_2.color_inx:
                    continue
                common_image = cv2.drawContours(blank_image.copy(), [color_area_1.contour, color_area_2.contour], -1, (255, 255, 255), cv2.FILLED)
                kernel = numpy.ones((15, 15), numpy.uint8)
                common_image = cv2.dilate(common_image, kernel, iterations=1)
                common_contour, _ = cv2.findContours(common_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
                if len(common_contour) == 1:
self.color_areas[color_area_inx_1].neighbors.append(color_area_inx_2)
self.color_areas[color_area_inx_2].neighbors.append(color_area_inx_1)


Bild


Wir suchen den optimalen Umzug



Wir haben alle Informationen über das Spielfeld. Beginnen wir mit der Auswahl eines Zuges. Dazu benötigen wir einen Knotenindex und eine Farbe. Die Anzahl der Bewegungsoptionen kann durch die Formel bestimmt werden:



Bewegungsoptionen = Anzahl der Knoten * Anzahl der Farben - 1



Für das vorherige Spielfeld haben wir 7 * (5-1) = 28 Optionen. Es gibt nicht viele von ihnen, daher können wir alle Bewegungen durchlaufen und die optimale auswählen. Definieren wir die Optionen als Matrix

select_color_weights , in der die Zeile der Knotenindex, die Farbindexspalte und die Verschiebungsgewichtszelle ist. Wir müssen die Anzahl der Knoten auf eins reduzieren, damit Bereiche, die eine eindeutige Farbe auf der Platine haben und die verschwinden, nachdem wir zu ihnen gewechselt sind, Vorrang haben. Geben wir dem Gewicht für alle Knotenzeilen mit einer eindeutigen Farbe +10. Wie oft die Farbe auf dem Spielfeld erscheint, haben wir zuvor gesammeltself.color_areas_color_count



if self.color_areas_color_count[color_area.color_inx - 1] == 1:
   select_color_weight = [x + 10 for x in select_color_weight]


Schauen wir uns als nächstes die Farben benachbarter Bereiche an. Wenn der Knoten Nachbarn von color_inx hat und deren Anzahl der Gesamtzahl dieser Farbe auf dem Spielfeld entspricht, weisen Sie dem Gewicht der Zelle +10 zu. Dadurch wird auch color_inx aus dem Feld entfernt.



for color_inx in range(0, len(select_color_weight)):
   color_count = select_color_weight[color_inx]
   if color_count != 0 and self.color_areas_color_count[color_inx] == color_count:
      select_color_weight[color_inx] += 10


Geben wir dem Gewicht der Zelle für jeden Nachbarn derselben Farbe +1. Das heißt, wenn wir 3 rote Nachbarn haben, erhält die rote Zelle +3 auf ihr Gewicht.



for select_color_weight_inx in color_area.neighbors:
   neighbor_color_area = self.color_areas[select_color_weight_inx]
   select_color_weight[neighbor_color_area.color_inx - 1] += 1


Nachdem wir alle Gewichte gesammelt haben, finden wir die Bewegung mit dem maximalen Gewicht. Definieren wir, zu welchem ​​Knoten und zu welcher Farbe er gehört.




max_index = select_color_weights.argmax()
self.color_area_inx_next = max_index // self.SELECT_COLOR_COUNT
select_color_next = (max_index % self.SELECT_COLOR_COUNT) + 1
self.set_select_color_next(select_color_next)


Vervollständigen Sie den Code, um den optimalen Zug zu bestimmen.



# …

class Robot:

    # …

def scan_color_areas(self):
        self.color_areas = []
        self.color_areas_color_count = [0] * self.SELECT_COLOR_COUNT
        image = self.crop_image_by_rectangle(self.screenshot, numpy.array(self.GAME_MAIN_AREA))
        for color_inx in range(1, self.SELECT_COLOR_COUNT + 1):
            dilate_contours = self.get_dilate_contours(image, color_inx, 10)
            for dilate_contour in dilate_contours:
                click_point = tuple(
                    dilate_contour[dilate_contour[:, :, 1].argmin()].flatten() + [0, int(self.CLICK_AREA)])
                self.color_areas_color_count[color_inx - 1] += 1
                color_area = self.ColorArea(color_inx, click_point, dilate_contour, [0] * self.SELECT_COLOR_COUNT)
                self.color_areas.append(color_area)
        blank_image = numpy.zeros_like(image)
        blank_image = cv2.cvtColor(blank_image, cv2.COLOR_BGR2GRAY)
        for color_area_inx_1 in range(0, len(self.color_areas)):
            for color_area_inx_2 in range(color_area_inx_1 + 1, len(self.color_areas)):
                color_area_1 = self.color_areas[color_area_inx_1]
                color_area_2 = self.color_areas[color_area_inx_2]
                if color_area_1.color_inx == color_area_2.color_inx:
                    continue
                common_image = cv2.drawContours(blank_image.copy(), [color_area_1.contour, color_area_2.contour],
                                                -1, (255, 255, 255), cv2.FILLED)
                kernel = numpy.ones((15, 15), numpy.uint8)
                common_image = cv2.dilate(common_image, kernel, iterations=1)
                common_contour, _ = cv2.findContours(common_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
                if len(common_contour) == 1:
                    self.color_areas[color_area_inx_1].neighbors.append(color_area_inx_2)
                    self.color_areas[color_area_inx_2].neighbors.append(color_area_inx_1)

    def analysis_color_areas(self):
        select_color_weights = []
        for color_area_inx in range(0, len(self.color_areas)):
            color_area = self.color_areas[color_area_inx]
            select_color_weight = numpy.array([0] * self.SELECT_COLOR_COUNT)
            for select_color_weight_inx in color_area.neighbors:
                neighbor_color_area = self.color_areas[select_color_weight_inx]
                select_color_weight[neighbor_color_area.color_inx - 1] += 1
            for color_inx in range(0, len(select_color_weight)):
                color_count = select_color_weight[color_inx]
                if color_count != 0 and self.color_areas_color_count[color_inx] == color_count:
                    select_color_weight[color_inx] += 10
            if self.color_areas_color_count[color_area.color_inx - 1] == 1:
                select_color_weight = [x + 10 for x in select_color_weight]
            color_area.set_select_color_weights(select_color_weight)
            select_color_weights.append(select_color_weight)
        select_color_weights = numpy.array(select_color_weights)
        max_index = select_color_weights.argmax()
        self.color_area_inx_next = max_index // self.SELECT_COLOR_COUNT
        select_color_next = (max_index % self.SELECT_COLOR_COUNT) + 1
        self.set_select_color_next(select_color_next)


Fügen wir die Möglichkeit hinzu, zwischen Ebenen zu wechseln und das Ergebnis zu genießen. Der Bot arbeitet stabil und beendet das Spiel in einer Sitzung.





Ausgabe



Der erstellte Bot hat keinen praktischen Nutzen. Der Autor des Artikels hofft jedoch aufrichtig, dass eine detaillierte Beschreibung der Grundprinzipien von OpenCV Anfängern helfen wird, diese Bibliothek in der Anfangsphase zu verstehen.



All Articles