
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.

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)

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.

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.

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.

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

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

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)

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]

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?
# …
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


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)

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

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.