Escribir un bot para un juego de rompecabezas en Python

Hace tiempo que quería probar suerte con la visión por computadora y este momento ha llegado. Es más interesante aprender de los juegos, así que entrenaremos con un bot. En este artículo intentaré describir en detalle el proceso de automatización del juego usando el paquete Python + OpenCV.



imagen




Buscando una meta



Vamos al sitio temático miniclip.com y buscamos un target. La elección recayó en el rompecabezas de color Coloruid 2 de la sección Rompecabezas, en el que debemos llenar un campo de juego redondo con un color en un número determinado de movimientos.



Un área arbitraria se rellena con el color seleccionado en la parte inferior de la pantalla, mientras que las áreas adyacentes del mismo color se fusionan en una sola.



imagen


Formación



Usaremos Python. El bot fue creado solo con fines educativos. El artículo está destinado a principiantes en visión artificial, que yo mismo soy.



El juego se encuentra aquí

GitHub del bot aquí



Para que el bot funcione, necesitamos los siguientes módulos:



  • opencv-python
  • Almohada
  • selenio


El bot fue escrito y probado para Python 3.8 en Ubuntu 20.04.1. Instalamos los módulos necesarios en su entorno virtual o mediante pip install. Además, para que Selenium funcione, necesitamos un geckodriver para FireFox, puedes descargarlo aquí github.com/mozilla/geckodriver/releases



Control del navegador



Estamos ante un juego online, así que primero organizaremos la interacción con el navegador. Para ello, utilizaremos Selenium, que nos proporcionará una API para gestionar FireFox. Examinando el código de la página del juego. El rompecabezas es un lienzo, que a su vez se ubica en un iframe.



Esperamos a que se cargue el marco con id = iframe-game y le cambiamos el contexto del controlador. Luego esperamos el lienzo. Es el único en el marco y está disponible a través de 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")))


A continuación, nuestro lienzo estará disponible a través de la propiedad self .__ canvas. Toda la lógica de trabajar con el navegador se reduce a tomar una captura de pantalla del lienzo y hacer clic en él en una coordenada determinada.



El código completo de Browser.py:



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


Estados del juego



Vayamos al juego en sí. Toda la lógica del bot se implementará en la clase Robot. Dividamos el juego en 7 estados y asignemos métodos para procesarlos. Seleccionemos el nivel de entrenamiento por separado. Contiene un gran cursor blanco que indica dónde hacer clic, lo que evitará que el juego sea reconocido correctamente.



  • Pantalla de bienvenida
  • Pantalla de selección de nivel
  • Selección de color a nivel tutorial
  • Elegir un área a nivel docente
  • Selección de color
  • Selección de región
  • Resultado de la mudanza


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


Para una mayor estabilidad del bot, comprobaremos si el cambio en el estado del juego se ha producido correctamente. Si self.state_next_success_condition no devuelve True durante self.state_timeout, continuamos procesando el estado actual; de lo contrario, cambiamos a self.state_next. También traduciremos la captura de pantalla recibida de Selenium a un formato que entienda 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


Implementemos la verificación en los métodos de manejo del estado. Estamos esperando el botón Reproducir en la pantalla de inicio y hacemos clic en él. Si en 10 segundos no hemos recibido la pantalla de selección de nivel, volvemos a la etapa anterior self.STATE_START, en caso contrario procedemos al procesamiento 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):
        #     
	# …


Visión del bot



Umbral de imagen



Definamos los colores que se utilizan en el juego. Estos son 5 colores jugables y un color de cursor para el nivel del tutorial. Usaremos COLOR_ALL si necesitamos encontrar todos los objetos, independientemente del color. Para empezar, consideraremos este caso.



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


Para encontrar un objeto, primero debe simplificar la imagen. Por ejemplo, tomemos el símbolo "0" y le apliquemos un umbral, es decir, separaremos el objeto del fondo. En esta etapa, no nos importa de qué color sea el símbolo. Primero, convierta la imagen a blanco y negro, convirtiéndola en 1 canal. La función cv2.cvtColor con el segundo argumento cv2.COLOR_BGR2GRAY , que se encarga de convertir a escala de grises , nos ayudará con esto . A continuación, realizamos el umbral utilizando cv2.threshold . Todos los píxeles de la imagen por debajo de un cierto umbral se establecen en 0, todo lo que está por encima, en 255. El segundo argumento de la función cv2.threshold es responsable del valor del umbral . En nuestro caso, cualquier número puede estar ahí, ya que usamos cv2.THRESH_OTSU y la función determinará por sí misma el umbral óptimo utilizando el método Otsu basado en el histograma de la imagen.



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


imagen


Segmentación de color



Más interesante. Compliquemos la tarea y encontremos todos los símbolos rojos en la pantalla de selección de nivel.



imagen


De forma predeterminada, todas las imágenes OpenCV se almacenan en formato BGR. HSV (Matiz, Saturación, Valor - matiz, saturación, valor) es más adecuado para la segmentación de color. Su ventaja sobre RGB es que HSV separa el color de su saturación y brillo. El tono está codificado por un canal de tono. Tomemos un rectángulo verde claro como ejemplo y disminuyamos gradualmente su brillo.



imagen


A diferencia de RGB, esta transformación parece intuitiva en HSV: simplemente disminuimos el valor del canal Valor o Brillo. Cabe señalar aquí que en el modelo de referencia, la escala de tono Hue varía en el rango de 0-360 °. Nuestro color verde claro corresponde a 90 °. Para ajustar este valor en un canal de 8 bits, debe dividirse por 2.

La segmentación de color funciona con rangos, no con un solo color. Puede determinar el rango empíricamente, pero es más fácil escribir un guión pequeño.



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


Lanzémoslo con nuestra captura de pantalla.



imagen


Haga clic en el color rojo y observe la máscara resultante. Si la salida no nos conviene, elegimos las tonalidades de rojo, aumentando el rango y área de la máscara. El script se basa en la función cv2.inRange , que actúa como un filtro de color y devuelve una imagen de umbral para un rango de color determinado.

Detengámonos en los siguientes rangos:




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


Encontrar contornos



Volvamos a nuestra pantalla de selección de nivel. Apliquemos el filtro de color de rango rojo que acabamos de definir y pasemos el umbral encontrado a cv2.findContours . La función nos encontrará los contornos de los elementos rojos. Especificamos cv2.RETR_EXTERNAL como segundo argumento ; solo necesitamos contornos externos y cv2.CHAIN_APPROX_SIMPLE como el tercero ; estamos interesados ​​en contornos rectos, ahorramos memoria y almacenamos solo sus vértices.



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


imagen


Eliminando ruido



Los contornos resultantes contienen mucho ruido de fondo. Para eliminarlo, usaremos la propiedad de nuestros números. Están formados por rectángulos paralelos a los ejes de coordenadas. Repetimos todas las rutas y ajustamos cada una en el rectángulo mínimo usando cv2.minAreaRect . El rectángulo está definido por 4 puntos. Si nuestro rectángulo es paralelo a los ejes, entonces una de las coordenadas para cada par de puntos debe coincidir. Esto significa que tendremos un máximo de 4 valores únicos si representamos las coordenadas del rectángulo como una matriz unidimensional. Además, filtramos los rectángulos que son demasiado largos, donde la relación de aspecto es mayor de 3 a 1. Para hacer esto, encontramos su ancho y largo usando 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]]]))


imagen


Combinar contornos



Mejor. Ahora necesitamos combinar los rectángulos encontrados en un esquema común de símbolos. Necesitamos una imagen intermedia. Creámoslo con numpy.zeros_like . La función crea una copia de la imagen de la matriz mientras mantiene su forma y tamaño, luego la llena con ceros. En otras palabras, obtuvimos una copia de nuestra imagen original llena de un fondo negro. Lo convertimos a 1 canal y aplicamos los contornos encontrados usando cv2.drawContours , rellenándolos de blanco. Obtenemos un umbral binario al que podemos aplicar cv2.dilate . La función expande el área blanca conectando rectángulos separados, la distancia entre los cuales es de 5 píxeles. Una vez más llamo a cv2.findContours y obtengo los contornos de los números rojos.




        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)


imagen


El ruido restante se filtra por el área de contorno utilizando cv2.contourArea . Elimine todo lo que tenga menos de 500 píxeles².



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


imagen


Eso es genial. Implementemos todo lo anterior en nuestra clase de 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


Reconocimiento de números



Agreguemos la capacidad de reconocer números. ¿Porqué necesitamos esto? Porque podemos... Esta característica no es obligatoria para que el bot funcione y, si lo desea, puede eliminarla de forma segura. Pero como estamos aprendiendo, lo agregaremos para calcular los puntos anotados y para entender al bot en qué paso está en el nivel. Conociendo el movimiento final del nivel, el bot buscará un botón para pasar al siguiente o repetir el actual. De lo contrario, tendría que buscarlos después de cada movimiento. Dejemos de usar Tesseract e implementemos todo usando OpenCV. El reconocimiento de números se basará en la comparación de momentos hu, lo que nos permitirá escanear personajes a diferentes escalas. Esto es importante ya que hay diferentes tamaños de fuente en la interfaz del juego. El actual, donde elegimos el nivel, define SQUARE_BIG_SYMBOL: 9, donde 9 es el lado medio del cuadrado en píxeles que componen el dígito. Recorta las imágenes de los números y guárdalas en la carpeta de datos. En el diccionario self.dilate_contours_bi_data tenemos referencias de contorno con las que comparar. El índice será el nombre del archivo sin extensión (por ejemplo, "dígito_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 usa la función cv2.matchShapes para comparar contornos basados ​​en momentos Hu . Nos oculta los detalles de implementación tomando dos rutas como entrada y devolviendo el resultado de la comparación como un número. Cuanto más pequeño es, más similares son los contornos.



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


Compare el contorno actual digit_contour con todos los estándares y encuentre el valor mínimo de cv2.matchShapes. Si el valor mínimo es menor que 0,15, el dígito se considera reconocido. El umbral del valor mínimo se encontró empíricamente. Combinemos también símbolos poco espaciados en un número.



# …

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 la salida, el método self.scan_digits devolverá una matriz que contiene el dígito reconocido y la coordenada del clic en él. El punto de clic será el centroide de su contorno.



# …

class Robot:

    # …

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


Nos regocijamos con la herramienta de reconocimiento de dígitos recibida, pero no por mucho tiempo. Los momentos de Hu, además de la escala, también son invariantes a la rotación y la especularidad. Por lo tanto, el bot confundirá los números 6 y 9/2 y 5. Agreguemos una verificación adicional de estos símbolos en los vértices. 6 y 9 se distinguirán por el punto superior derecho. Si está debajo del centro horizontal, entonces es 6 y 9 para el opuesto. Para los pares 2 y 5, compruebe si el punto superior derecho está en el borde derecho del símbolo.



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


imagen


imagen


Analizando el campo de juego



Saltemos el nivel de entrenamiento, está escrito haciendo clic en el cursor blanco y comenzamos a jugar.



Imaginemos el campo de juego como una red. Cada área de color será un nodo que está vinculado a vecinos adyacentes. Creemos una clase self.ColorArea que describirá el área / nodo de color.



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


Definamos una lista de nodos self.color_areas y una lista de la frecuencia con la que aparece el color en el campo de juego self.color_areas_color_count . Recorta el campo de juego de la captura de pantalla del lienzo.



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


Donde pt1, pt2 son los puntos extremos del marco. Repetimos todos los colores del juego y aplicamos el método self.get_dilate_contours a cada uno . Encontrar el contorno de un nodo es similar a como buscábamos el contorno general de los símbolos, con la diferencia de que no hay ruido en el campo de juego. La forma de los nodos puede ser cóncava o tener un agujero, por lo que el centroide se saldrá de la forma y no será adecuado como coordenada para un clic. Para hacer esto, busque el punto superior extremo y suéltelo 20 píxeles. El método no es universal, pero en nuestro caso funciona.



        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)


imagen


Vinculación de áreas



Consideraremos áreas como vecinas si la distancia entre sus contornos está dentro de los 15 píxeles. Repetimos cada nodo con cada uno, omitiendo la comparación si sus colores coinciden.



        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)


imagen


Buscamos la jugada óptima



Tenemos toda la información sobre el campo de juego. Comencemos a elegir un movimiento. Para esto necesitamos un índice de nodo y un color. El número de opciones de movimiento se puede determinar mediante la fórmula:



Opciones de movimiento = Número de nodos * Número de colores - 1



Para el campo de juego anterior, tenemos 7 * (5-1) = 28 opciones. No hay muchos de ellos, por lo que podemos iterar sobre todos los movimientos y elegir el óptimo. Definamos las opciones como una matriz

select_color_weights , en la que la fila será el índice de nodo, la columna de índice de color y la celda de peso de movimiento. Necesitamos reducir la cantidad de nodos a uno, por lo que daremos prioridad a las áreas que tengan un color único en el tablero y que desaparecerán después de movernos hacia ellas. Démosle +10 al peso para todas las filas de nodos con un color único. ¿Con qué frecuencia ocurre el color en el campo de juego? Hemos recopilado previamente enself.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]


A continuación, veamos los colores de las áreas adyacentes. Si el nodo tiene vecinos de color_inx, y su número es igual al número total de este color en el campo de juego, asigne +10 al peso de la celda. Esto también eliminará el color color_inx del campo.



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


Démosle +1 al peso de la celda para cada vecino del mismo color. Es decir, si tenemos 3 vecinos rojos, el glóbulo rojo recibirá +3 a su peso.



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


Después de recoger todos los pesos, encontramos el movimiento con el peso máximo. Definamos a qué nodo y a qué color pertenece.




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)


Código completo para determinar el movimiento óptimo.



# …

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)


Agreguemos la capacidad de moverse entre niveles y disfrutar del resultado. El bot funciona de forma estable y completa el juego en una sesión.





Salida



El bot creado no tiene ningún uso práctico. Pero el autor del artículo espera sinceramente que una descripción detallada de los principios básicos de OpenCV ayude a los principiantes a comprender esta biblioteca en la etapa inicial.



All Articles