![imagen](https://habrastorage.org/webt/_c/wh/hu/_cwhhuxeiezf-tg291le37acb7g.png)
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](https://habrastorage.org/webt/zj/wn/j9/zjwnj9ltmc-fgny_iblnhtqf1xm.png)
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](https://habrastorage.org/webt/l4/uh/uj/l4uhuj_fvrl9oznsdwl_t0filfi.gif)
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](https://habrastorage.org/webt/im/ol/kk/imolkkjifdg23kbjyxrksrqyi_o.png)
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](https://habrastorage.org/webt/q4/af/bs/q4afbselgmeevwdm_x0qab8twfy.gif)
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](https://habrastorage.org/webt/ri/tk/b3/ritkb36gjvw6nxdba91jwims7gy.png)
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](https://habrastorage.org/webt/hd/iw/k8/hdiwk8x7xfwmnil4pu5hni9z7xo.png)
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](https://habrastorage.org/webt/fa/rw/mz/farwmznlhhq4zbusq3sw1ykuiz0.gif)
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](https://habrastorage.org/webt/wq/ux/zp/wquxzpag_lcjzlpqgfvtzgu2bzg.gif)
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](https://habrastorage.org/webt/sj/le/wb/sjlewb5g0nmskipb_tato0g1jrq.png)
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?
# …
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](https://habrastorage.org/webt/yk/bu/0m/ykbu0mio2vgiu1_hvmkxgfflucy.png)
![imagen](https://habrastorage.org/webt/tc/ge/mb/tcgembizd552-mknsxatsyuzjuo.png)
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](https://habrastorage.org/webt/zb/un/3n/zbun3nnxn2yybetysoqaqnc9wsq.gif)
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](https://habrastorage.org/webt/dd/lv/3i/ddlv3iet-ziy2tdtsn3ji7nwnwm.gif)
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.