Escrevendo um bot para um jogo de quebra-cabeça em Python

Há muito tempo queria experimentar a visão computacional e este momento chegou. É mais interessante aprender com os jogos, então treinaremos em um bot. Neste artigo, tentarei descrever em detalhes o processo de automação do jogo usando o pacote Python + OpenCV.



imagem




Procurando por uma meta



Vamos ao site temático miniclip.com e procuramos um target. A escolha recaiu no quebra-cabeça de cores Coloruid 2 da seção Puzzles, no qual precisamos preencher um campo de jogo redondo com uma cor em um determinado número de movimentos.



Uma área arbitrária é preenchida com a cor selecionada na parte inferior da tela, enquanto áreas adjacentes da mesma cor se fundem em uma única.



imagem


Treinamento



Usaremos Python. O bot foi criado apenas para fins educacionais. O artigo foi desenvolvido para iniciantes em visão computacional, o que eu mesmo sou.



O jogo está localizado aqui

GitHub do bot aqui



Para que o bot funcione, precisamos dos seguintes módulos:



  • opencv-python
  • Travesseiro
  • selênio


O bot foi escrito e testado para Python 3.8 no Ubuntu 20.04.1. Instalamos os módulos necessários em seu ambiente virtual ou via pip install. Além disso, para o Selenium funcionar, precisamos de um geckodriver para FireFox, você pode baixá-lo aqui github.com/mozilla/geckodriver/releases



Controle do navegador



Estamos lidando com um jogo online, então primeiro vamos organizar a interação com o navegador. Para isso, usaremos o Selenium, que nos fornecerá uma API para gerenciar o FireFox. Examinando o código da página do jogo. O quebra-cabeça é uma tela, que por sua vez está localizada em um iframe.



Esperamos que o quadro com id = iframe-game carregue e mudemos o contexto do driver para ele. Então esperamos pela tela. É o único no quadro e está disponível em 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")))


Em seguida, nossa tela estará disponível por meio da propriedade self .__ canvas. Toda a lógica de trabalhar com o navegador se resume em fazer uma captura de tela da tela e clicar nela em uma determinada coordenada.



O código completo do 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 de jogo



Vamos ao jogo em si. Toda a lógica do bot será implementada na classe Robot. Vamos dividir a jogabilidade em 7 estados e atribuir métodos para processá-los. Vamos destacar o nível de treinamento separadamente. Ele contém um grande cursor branco que indica onde clicar, o que impedirá que o jogo seja reconhecido corretamente.



  • Tela de boas vindas
  • Tela de seleção de nível
  • Seleção de cores no nível do tutorial
  • Escolha de uma área no nível de ensino
  • Seleção de cor
  • Seleção de região
  • Resultado da mudança


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 maior estabilidade do bot, iremos verificar se a mudança no estado do jogo ocorreu com sucesso. Se self.state_next_success_condition não retornar True durante self.state_timeout, continuamos a processar o estado atual, caso contrário, mudamos para self.state_next. Também traduziremos a captura de tela recebida da Selenium para um formato que OpenCV entende.




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


Vamos implementar a verificação nos métodos de manipulação de estado. Estamos aguardando o botão Play na tela inicial e clicar nele. Se dentro de 10 segundos não tivermos recebido a tela de seleção de nível, retornamos ao estágio anterior self.STATE_START, caso contrário, passamos ao processamento 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):
        #     
	# …


Visão do bot



Limiar de imagem



Vamos definir as cores que são usadas no jogo. Estas são 5 cores jogáveis ​​e uma cor de cursor para o nível tutorial. Usaremos COLOR_ALL se precisarmos encontrar todos os objetos, independentemente da cor. Para começar, vamos considerar 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 um objeto, primeiro você precisa simplificar a imagem. Por exemplo, vamos pegar o símbolo "0" e aplicar um limite a ele, ou seja, vamos separar o objeto do fundo. Neste estágio, não nos importamos com a cor do símbolo. Primeiro, vamos converter a imagem em preto e branco, tornando-a de 1 canal. A função cv2.cvtColor com o segundo argumento cv2.COLOR_BGR2GRAY nos ajudará com isso , que é responsável pela conversão para tons de cinza. Em seguida, executamos o thresholding usando cv2.threshold . Todos os pixels da imagem abaixo de um certo limite são ajustados para 0, tudo acima - para 255. O segundo argumento da função cv2.threshold é responsável pelo valor do limite . No nosso caso, qualquer número pode estar lá, já que usamos cv2.THRESH_OTSU e a própria função determinará o limite ideal pelo método de Otsu com base no histograma da imagem.



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


imagem


Segmentação de cores



Ainda mais interessante. Vamos complicar a tarefa e encontrar todos os símbolos vermelhos na tela de seleção de nível.



imagem


Por padrão, todas as imagens OpenCV são armazenadas no formato BGR. Para segmentação de cores, HSV (matiz, saturação, valor - matiz, saturação, valor) é mais adequado. Sua vantagem sobre o RGB é que o HSV separa a cor de sua saturação e brilho. O matiz é codificado por um canal de matiz. Vamos pegar um retângulo verde claro como exemplo e diminuir gradualmente seu brilho.



imagem


Ao contrário do RGB, essa transformação parece intuitiva em HSV - apenas diminuímos o valor do canal de Valor ou Brilho. Deve-se notar aqui que, no modelo de referência, a escala de tonalidade Hue varia na faixa de 0-360 °. Nossa cor verde claro corresponde a 90 °. Para ajustar esse valor em um canal de 8 bits, ele deve ser dividido por 2.

A segmentação de cores funciona com faixas, não com uma única cor. Você pode determinar o intervalo empiricamente, mas é mais fácil escrever um pequeno 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()


Vamos iniciá-lo com nossa captura de tela.



imagem


Clique na cor vermelha e observe a máscara resultante. Se a saída não nos agrada, escolhemos os tons de vermelho, aumentando o alcance e a área da máscara. O script é baseado na função cv2.inRange , que atua como um filtro de cores e retorna uma imagem limite para um determinado intervalo de cores.

Vamos nos deter nas seguintes faixas:




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


Encontrando contornos



Vamos voltar para nossa tela de seleção de nível. Vamos aplicar o filtro de cor da faixa vermelha que acabamos de definir e passar o limite encontrado para cv2.findContours . A função nos encontrará os contornos dos elementos vermelhos. Especificamos cv2.RETR_EXTERNAL como o segundo argumento - precisamos apenas de contornos externos, e cv2.CHAIN_APPROX_SIMPLE como o terceiro - estamos interessados ​​em contornos retos, economizamos memória e armazenamos apenas seus 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


imagem


Removendo ruído



Os contornos resultantes contêm muito ruído de fundo. Para removê-lo, usaremos a propriedade de nossos números. Eles são formados por retângulos paralelos aos eixos coordenados. Nós iteramos sobre todos os caminhos e ajustamos cada um no retângulo mínimo usando cv2.minAreaRect . O retângulo é definido por 4 pontos. Se nosso retângulo for paralelo aos eixos, uma das coordenadas de cada par de pontos deve corresponder. Isso significa que teremos no máximo 4 valores únicos se representarmos as coordenadas do retângulo como uma matriz unidimensional. Além disso, filtraremos retângulos muito longos, em que a proporção da imagem é maior do que 3 para 1. Para fazer isso, encontre sua largura e comprimento 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]]]))


imagem


Combinando contornos



Melhor. Agora precisamos combinar os retângulos encontrados em um contorno comum de símbolos. Precisamos de uma imagem intermediária. Vamos criá-lo com numpy.zeros_like . A função cria uma cópia da matriz da imagem, mantendo sua forma e tamanho, e a preenche com zeros. Em outras palavras, obtivemos uma cópia de nossa imagem original preenchida com um fundo preto. Nós o convertemos para 1 canal e aplicamos os contornos encontrados usando cv2.drawContours , preenchendo-os com branco. Obtemos um limite binário ao qual podemos aplicar cv2.dilate . A função expande a área branca conectando retângulos separados, a distância entre os quais é de 5 pixels. Mais uma vez, chamo cv2.findContours e obtenho os contornos dos números vermelhos.




        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)


imagem


O ruído restante é filtrado pela área de contorno usando cv2.contourArea . Remova tudo o que for inferior a 500 pixels².



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


imagem


Agora isso é ótimo. Vamos implementar todos os itens acima em nossa 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


Reconhecimento de números



Vamos adicionar a capacidade de reconhecer números. Por que nós precisamos disso? Porque nós podemos... Esse recurso não é obrigatório para o funcionamento do bot e, se desejar, você pode cortá-lo com segurança. Mas, já que estamos aprendendo, vamos adicioná-lo para calcular os pontos marcados e entender o bot em que etapa ele está no nível. Sabendo o movimento final do nível, o bot irá procurar um botão para ir para o próximo ou repetir o atual. Caso contrário, você teria que procurá-los após cada movimento. Vamos desistir de usar Tesseract e implementar tudo usando OpenCV. O reconhecimento dos números será baseado na comparação dos momentos hu, o que nos permitirá escanear personagens em diferentes escalas. Isso é importante, pois existem diferentes tamanhos de fonte na interface do jogo. O atual onde selecionamos o nível, definimos SQUARE_BIG_SYMBOL: 9, onde 9 é o lado do meio do quadrado em pixels que compõem o dígito. Recorte as imagens dos números e guarde-os na pasta de dados. No próprio dicionário.dilate_contours_bi_data contém referências de contorno a serem comparadas. O índice será o nome do arquivo sem extensão (por exemplo "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 usa a função cv2.matchShapes para comparar contornos com base em momentos Hu . Ele oculta os detalhes de implementação de nós, tomando dois caminhos como entrada e retornando o resultado da comparação como um número. Quanto menor for, mais semelhantes são os contornos.



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


Compare o contorno atual digit_contour com todos os padrões e encontre o valor mínimo de cv2.matchShapes. Se o valor mínimo for menor que 0,15, o dígito é considerado reconhecido. O limite do valor mínimo foi encontrado empiricamente. Também vamos combinar caracteres bem espaçados em um 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


Na saída, o método self.scan_digits retornará um array contendo o dígito reconhecido e a coordenada do clique nele. O ponto de clique será o centroide de seu contorno.



# …

class Robot:

    # …

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


Nós nos alegramos com a ferramenta de reconhecimento de dígitos recebida, mas não por muito tempo. Os momentos Hu, além da escala, também são invariantes à rotação e especularidade. Portanto, o bot vai confundir os números 6 e 9/2 e 5. Vamos adicionar uma verificação adicional desses símbolos nos vértices. 6 e 9 serão distinguidos pelo ponto superior direito. Se estiver abaixo do centro horizontal, é 6 e 9 para o oposto. Para o par 2 e 5, verifique se o ponto superior direito está na borda direita do 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


imagem


imagem


Analisando o campo de jogo



Vamos pular o nível de treinamento, é programado clicando no cursor branco e comece a jogar.



Vamos imaginar o campo de jogo como uma rede. Cada área de cor será um nó vinculado a vizinhos adjacentes. Vamos criar uma classe self.ColorArea que irá descrever a área / nó de cores.



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


Vamos definir uma lista de nós self.color_areas e uma lista de quantas vezes a cor aparece no campo de jogo self.color_areas_color_count . Recorte o campo de jogo da captura de tela da tela.



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


Onde pt1, pt2 são os pontos extremos do quadro. Repetimos todas as cores do jogo e aplicamos o método self.get_dilate_contours a cada uma . Encontrar o contorno de um nó é semelhante a como procurávamos o contorno geral dos símbolos, com a diferença de que não há ruído no campo de jogo. A forma dos nós pode ser côncava ou ter um orifício, portanto, o centróide sairá da forma e não é adequado como uma coordenada para um clique. Para fazer isso, encontre o ponto superior extremo e solte-o em 20 pixels. O método não é universal, mas no nosso 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)


imagem


Áreas de ligação



Consideraremos as áreas como vizinhas se a distância entre seus contornos estiver dentro de 15 pixels. Nós iteramos sobre cada nó com cada um, ignorando a comparação se suas cores corresponderem.



        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)


imagem


Estamos procurando o movimento ideal



Temos todas as informações sobre o campo de jogo. Vamos começar a escolher um movimento. Para isso, precisamos do nó e do índice de cor. O número de opções de movimento pode ser determinado pela fórmula:



Opções de movimento = Número de nós * Número de cores - 1



Para o campo de jogo anterior, temos 7 * (5-1) = 28 opções. Não há muitos deles, então podemos iterar todos os movimentos e escolher o melhor. Vamos definir as opções como uma matriz

select_color_weights , na qual a linha será o índice do nó, a coluna do índice de cor e a célula de peso do movimento. Precisamos reduzir o número de nós para um, então daremos prioridade às áreas que têm uma cor única no tabuleiro e que irão desaparecer depois de movermos para elas. Vamos dar +10 ao peso para todas as linhas de nós com uma cor única. Quantas vezes a cor aparece no campo de jogo, coletamos anteriormente emself.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 seguir, vamos examinar as cores das áreas adjacentes. Se o nó tiver vizinhos de color_inx e seu número for igual ao número total dessa cor no campo de jogo, atribua +10 ao peso da célula. Isso também removerá a cor color_inx do 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


Vamos dar +1 ao peso da célula para cada vizinho da mesma cor. Ou seja, se tivermos 3 vizinhos vermelhos, o glóbulo vermelho receberá +3 do seu 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


Depois de coletar todos os pesos, encontramos o movimento com o peso máximo. Vamos definir a qual nó e a que cor ele pertence.




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 o movimento ideal.



# …

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)


Vamos adicionar a capacidade de se mover entre os níveis e aproveitar o resultado. O bot funciona de forma estável e completa o jogo em uma sessão.





Resultado



O bot criado não tem uso prático. Mas o autor do artigo espera sinceramente que uma descrição detalhada dos princípios básicos do OpenCV ajude os iniciantes a entender esta biblioteca no estágio inicial.



All Articles