Ecrire un bot pour un jeu de puzzle en Python

J'ai longtemps voulu m'essayer à la vision par ordinateur et ce moment est venu. Il est plus intéressant d'apprendre des jeux, nous allons donc nous entraîner sur un robot. Dans cet article, je vais essayer de décrire en détail le processus d'automatisation du jeu à l'aide du bundle Python + OpenCV.



image




À la recherche d'un objectif



Nous allons sur le site thématique miniclip.com et cherchons une cible. Le choix s'est porté sur le puzzle de couleur Coloruid 2 de la section Puzzles, dans lequel nous devons remplir un terrain de jeu rond avec une couleur en un nombre donné de coups.



Une zone arbitraire est remplie avec la couleur sélectionnée en bas de l'écran, tandis que les zones adjacentes de la même couleur se fondent en une seule.



image


Entraînement



Nous utiliserons Python. Le bot a été créé à des fins éducatives uniquement. L'article est destiné aux débutants en vision par ordinateur, ce que je suis moi-même.



Le jeu se trouve ici

GitHub du bot ici



Pour que le bot fonctionne, nous avons besoin des modules suivants:



  • opencv-python
  • Oreiller
  • sélénium


Le bot est écrit et testé pour Python 3.8 sur Ubuntu 20.04.1. Nous installons les modules nécessaires dans votre environnement virtuel ou via pip install. De plus, pour que Selenium fonctionne, nous avons besoin d'un geckodriver pour FireFox, vous pouvez le télécharger ici github.com/mozilla/geckodriver/releases



Contrôle du navigateur



Nous avons affaire à un jeu en ligne, nous allons donc d'abord organiser l'interaction avec le navigateur. Pour cela, nous utiliserons Selenium, qui nous fournira une API pour gérer FireFox. Examen du code de la page de jeu. Le puzzle est une toile, qui à son tour est située dans une iframe.



Nous attendons le chargement de la trame avec id = iframe-game et y basculons le contexte du pilote. Ensuite, nous attendons la toile. C'est le seul dans le cadre et est disponible via XPath / html / body / canvas.



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")))


Ensuite, notre canevas sera disponible via la propriété self .__ canvas. Toute la logique du travail avec le navigateur se résume à prendre une capture d'écran du canevas et à cliquer dessus à une coordonnée donnée.



Code Browser.py complet:



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()


États du jeu



Passons au jeu lui-même. Toute la logique du bot sera implémentée dans la classe Robot. Divisons le gameplay en 7 états et attribuons-leur des méthodes pour les traiter. Choisissons le niveau de formation séparément. Il contient un grand curseur blanc indiquant où cliquer, ce qui empêchera le jeu d'être reconnu correctement.



  • Ecran d'accueil
  • Écran de sélection de niveau
  • Sélection des couleurs au niveau du didacticiel
  • Choisir un domaine au niveau de l'enseignement
  • Sélection de couleur
  • Sélection de la région
  • Résultat du déménagement


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,
        }


Pour une plus grande stabilité du bot, nous vérifierons si le changement d'état du jeu s'est produit avec succès. Si self.state_next_success_condition ne renvoie pas True pendant self.state_timeout, nous continuons à traiter l'état actuel, sinon nous passons à self.state_next. Nous traduirons également la capture d'écran reçue de Selenium dans un format compréhensible pour OpenCV.




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


Implémentons la vérification dans les méthodes de gestion des états. Nous attendons le bouton Play sur l'écran de démarrage et cliquez dessus. Si dans les 10 secondes nous n'avons pas reçu l'écran de sélection de niveau, nous revenons à l'étape précédente self.STATE_START, sinon nous procédons au traitement de self.STATE_SELECT_LEVEL.




# …

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):
        #     
	# …


Vision du bot



Seuil d'image



Définissons les couleurs utilisées dans le jeu. Ce sont 5 couleurs jouables et une couleur de curseur pour le niveau tutoriel. Nous utiliserons COLOR_ALL si nous devons trouver tous les objets, quelle que soit leur couleur. Pour commencer, nous examinerons ce cas.



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


Pour trouver un objet, vous devez d'abord simplifier l'image. Par exemple, prenons le symbole "0" et appliquons-lui un seuil, c'est-à-dire que nous séparerons l'objet de l'arrière-plan. À ce stade, nous ne nous soucions pas de la couleur du symbole. Commençons par convertir l'image en noir et blanc, ce qui en fait un canal. La fonction cv2.cvtColor avec le deuxième argument cv2.COLOR_BGR2GRAY nous aidera avec cela , qui est responsable de la conversion en niveaux de gris. Ensuite, nous effectuons un seuillage à l'aide de cv2.threshold . Tous les pixels de l'image en dessous d'un certain seuil sont mis à 0, tout au-dessus - à 255. Le deuxième argument de la fonction cv2.threshold est responsable de la valeur de seuil . Dans notre cas, n'importe quel nombre peut être là, puisque nous utilisons cv2.THRESH_OTSU et la fonction déterminera elle-même le seuil optimal par la méthode Otsu basée sur l'histogramme de l'image.



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


image


Segmentation des couleurs



Encore plus intéressant. Compliquons la tâche et trouvons tous les symboles rouges sur l'écran de sélection de niveau.



image


Par défaut, toutes les images OpenCV sont stockées au format BGR. HSV (teinte, saturation, valeur - teinte, saturation, valeur) est plus approprié pour la segmentation des couleurs. Son avantage par rapport au RVB est que HSV sépare la couleur de la saturation et de la luminosité. La teinte est codée par un canal Hue. Prenons un rectangle vert clair comme exemple et diminuons progressivement sa luminosité.



image


Contrairement à RVB, cette transformation semble intuitive dans HSV - nous diminuons simplement la valeur du canal Valeur ou Luminosité. Il convient de noter ici que dans le modèle de référence, l'échelle de teinte Hue varie de 0 à 360 °. Notre couleur vert clair correspond à 90 °. Afin d'adapter cette valeur dans un canal 8 bits, elle doit être divisée par 2.

La segmentation des couleurs fonctionne avec des plages, pas une seule couleur. Vous pouvez déterminer la plage de manière empirique, mais il est plus facile d'écrire un petit script.



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()


Lançons-le avec notre capture d'écran.



image


Cliquez sur la couleur rouge et regardez le masque résultant. Si la sortie ne nous convient pas, on choisit les nuances de rouge, en augmentant la portée et la surface du masque. Le script est basé sur la fonction cv2.inRange , qui agit comme un filtre de couleur et renvoie une image de seuil pour une plage de couleurs donnée.

Arrêtons-nous sur les gammes suivantes:




    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)),
}


Trouver des contours



Revenons à notre écran de sélection de niveau. Appliquons le filtre de couleur de plage rouge que nous venons de définir et passons le seuil trouvé à cv2.findContours . La fonction nous trouvera les contours des éléments rouges. Nous spécifions cv2.RETR_EXTERNAL comme deuxième argument - nous n'avons besoin que de contours externes, et comme troisième cv2.CHAIN_APPROX_SIMPLE - nous nous intéressons aux contours droits, économisons de la mémoire et ne stockons que leurs sommets.



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


image


Suppression du bruit



Les contours résultants contiennent beaucoup de bruit de fond. Pour le supprimer, nous utiliserons la propriété de nos nombres. Ils sont constitués de rectangles parallèles aux axes de coordonnées. Nous itérons sur tous les chemins et adaptons chacun dans le rectangle minimum en utilisant cv2.minAreaRect . Le rectangle est défini par 4 points. Si notre rectangle est parallèle aux axes, alors l'une des coordonnées de chaque paire de points doit correspondre. Cela signifie que nous aurons un maximum de 4 valeurs uniques si nous représentons les coordonnées du rectangle comme un tableau unidimensionnel. De plus, nous filtrerons les rectangles trop longs, dont le rapport hauteur / largeur est supérieur à 3 pour 1. Pour ce faire, nous trouverons leur largeur et leur longueur en utilisant 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]]]))


image


Combiner les contours



Mieux maintenant. Nous devons maintenant combiner les rectangles trouvés dans un contour commun de symboles. Nous avons besoin d'une image intermédiaire. Créons-le avec numpy.zeros_like . La fonction crée une copie de la matrice d'image tout en conservant sa forme et sa taille, puis la remplit de zéros. En d'autres termes, nous avons obtenu une copie de notre image originale remplie d'un fond noir. Nous le convertissons en 1 canal et appliquons les contours trouvés en utilisant cv2.drawContours , en les remplissant de blanc. Nous obtenons un seuil binaire auquel nous pouvons appliquer cv2.dilate . La fonction élargit la zone blanche en connectant des rectangles séparés, dont la distance est inférieure à 5 pixels. Une fois de plus, j'appelle cv2.findContours et j'obtiens les contours des nombres rouges.




        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)


image


Le bruit restant est filtré par la zone de contour à l'aide de cv2.contourArea . Supprimez tout ce qui est inférieur à 500 pixels².



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


image


Maintenant c'est génial. Implémentons tout ce qui précède dans notre classe Robot.




# ...

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


Reconnaissance des nombres



Ajoutons la possibilité de reconnaître les nombres. Pourquoi avons nous besoin de ça? Parce que nous pouvons... Cette fonctionnalité n'est pas obligatoire pour que le bot fonctionne, et si vous le souhaitez, vous pouvez la couper en toute sécurité. Mais puisque nous apprenons, nous l'ajouterons pour calculer les points marqués et pour comprendre le bot à quelle étape il se trouve sur le niveau. Connaissant le mouvement final du niveau, le bot cherchera un bouton pour passer au suivant ou répéter celui en cours. Sinon, vous devrez les rechercher après chaque déplacement. Abandonnons Tesseract et implémentons tout en utilisant OpenCV. La reconnaissance des nombres sera basée sur la comparaison des moments hu, ce qui nous permettra de scanner des caractères à différentes échelles. Ceci est important car il existe différentes tailles de police dans l'interface du jeu. L'actuel, où l'on choisit le niveau, définit SQUARE_BIG_SYMBOL: 9, où 9 est le côté médian du carré en pixels qui composent le chiffre. Recadrez les images des nombres et enregistrez-les dans le dossier de données. Dans le dictionnaire self.dilate_contours_bi_data nous contenons des références de contour à comparer. L'index sera le nom du fichier sans extension (par exemple "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 utilise la fonction cv2.matchShapes pour comparer les contours en fonction des moments Hu . Il nous cache les détails de l'implémentation en prenant deux chemins en entrée et en renvoyant le résultat de la comparaison sous forme de nombre. Plus il est petit, plus les contours sont similaires.



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


Comparez le contour actuel digit_contour avec toutes les normes et trouvez la valeur minimale de cv2.matchShapes. Si la valeur minimale est inférieure à 0,15, le chiffre est considéré comme reconnu. Le seuil de la valeur minimale a été trouvé empiriquement. Combinons également des symboles étroitement espacés en un seul nombre.



# …

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


En sortie, la méthode self.scan_digits retournera un tableau contenant le chiffre reconnu et la coordonnée du clic dessus. Le point de clic sera le centre de gravité de son contour.



# …

class Robot:

    # …

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


Nous nous réjouissons de l'outil de reconnaissance numérique reçu, mais pas pour longtemps. Les moments Hu, mis à part l'échelle, sont également invariants à la rotation et à la spécularité. Par conséquent, le bot confondra les nombres 6 et 9/2 et 5. Ajoutons une vérification de vertex supplémentaire pour ces symboles. 6 et 9 seront distingués par le point supérieur droit. S'il est en dessous du centre horizontal, alors il est 6 et 9 pour le contraire. Pour les paires 2 et 5, vérifiez si le point supérieur droit se trouve sur la bordure droite du symbole.



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


image


image


Analyser le terrain de jeu



Sautons le niveau d'entraînement, il est scripté en cliquant sur le curseur blanc et commencez à jouer.



Imaginons le terrain de jeu comme un réseau. Chaque zone de couleur sera un nœud lié à des voisins adjacents. Créons une classe self.ColorArea qui décrira la zone de couleur / le nœud.



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 = []  #  


Définissons une liste de nœuds self.color_areas et une liste de la fréquence à laquelle la couleur apparaît sur le terrain de jeu self.color_areas_color_count . Recadrez le terrain de jeu à partir de la capture d'écran de la toile.



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


Où pt1, pt2 sont les points extrêmes de l'image. Nous parcourons toutes les couleurs du jeu et appliquons la méthode self.get_dilate_contours à chacune . Trouver le contour du nœud est similaire à la façon dont nous recherchions le contour général des symboles, à la différence qu'il n'y a pas de bruit sur le terrain de jeu. La forme des nœuds peut être concave ou avoir un trou, de sorte que le centre de gravité tombe hors de la forme et ne convient pas comme coordonnée pour un clic. Pour ce faire, trouvez le point le plus haut et déposez-le de 20 pixels. La méthode n'est pas universelle, mais dans notre cas, elle fonctionne.



        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)


image


Zones de liaison



Nous considérerons les zones comme voisines si la distance entre leurs contours est inférieure à 15 pixels. Nous itérons sur chaque nœud avec chacun, en sautant la comparaison si leurs couleurs correspondent.



        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)


image


Nous recherchons le déménagement optimal



Nous avons toutes les informations sur le terrain de jeu. Commençons par choisir un mouvement. Pour cela, nous avons besoin d'un index de nœud et d'une couleur. Le nombre d'options de déplacement peut être déterminé par la formule:



Options de déplacement = Nombre de nœuds * Nombre de couleurs - 1



Pour le terrain de jeu précédent, nous avons 7 * (5-1) = 28 options. Il n'y en a pas beaucoup, nous pouvons donc parcourir tous les mouvements et choisir celui qui est optimal. Définissons les options comme une matrice

select_color_weights , dans laquelle la ligne sera l'index du nœud, la colonne d'index de couleur et la cellule de poids de déplacement. Nous devons réduire le nombre de nœuds à un, donc nous donnerons la priorité aux zones qui ont une couleur unique sur le plateau et qui disparaîtront une fois que nous y passerons. Donnons +10 au poids pour toutes les lignes de nœuds avec une couleur unique. À quelle fréquence la couleur apparaît-elle sur le terrain de jeu, nous avons précédemment collecté dansself.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]


Ensuite, regardons les couleurs des zones adjacentes. Si le nœud a des voisins de color_inx et que leur nombre est égal au nombre total de cette couleur sur le terrain de jeu, attribuez +10 au poids de la cellule. Cela supprimera également la couleur color_inx du champ.



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


Donnons +1 au poids de cellule pour chaque voisin de la même couleur. Autrement dit, si nous avons 3 voisins rouges, la cellule rouge recevra +3 à son poids.



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


Après avoir collecté tous les poids, nous trouvons le mouvement avec le poids maximum. Définissons à quel nœud et à quelle couleur il appartient.




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)


Code complet pour déterminer le mouvement optimal.



# …

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)


Ajoutons la possibilité de se déplacer entre les niveaux et de profiter du résultat. Le bot fonctionne de manière stable et termine le jeu en une seule session.





Production



Le bot créé n'a aucune utilité pratique. Mais l'auteur de l'article espère sincèrement qu'une description détaillée des principes de base d'OpenCV aidera les débutants à comprendre cette bibliothèque au stade initial.



All Articles