Notas de engenharia · Python / OpenCV
Como funciona
Entenda como o Classic MapleStory Bot transforma imagens da tela em decisões com 10 trechos essenciais e diagramas dos algoritmos visuais.
Pixels → observações → decisões → entrada
O cliente lê imagens visíveis. A percepção estima posições e estado, o planejador escolhe uma ação e o runtime coordena as teclas locais. Detectar um monstro não pressiona uma tecla; enviar uma tecla também não confirma que o jogo a aceitou.
- 01Captura
- 02Percepção
- 03Decisão
- 04Entrada local
01Capturar a área do jogo#
A ramificação Windows de grab_client localiza a área cliente, captura o retângulo com ImageGrab e converte para RGB. Dimensões inválidas geram erro em vez de enviar uma imagem vazia à visão.
window = find_client_origin(title)
if window is None:
raise RuntimeError(active_translate("error_game_window_not_found", title=title))
left, top, width, height = window
if width <= 1 or height <= 1:
raise RuntimeError(active_translate("error_game_capture_unavailable"))
image = ImageGrab.grab((left, top, left + width, top + height)).convert("RGB")
if image.width <= 1 or image.height <= 1:
raise RuntimeError(active_translate("error_empty_game_capture_retry"))
return imagegrab_clientL354–363O macOS usa captura nativa e recorte próprios. Depois, o OpenCV trabalha com matrizes BGR: respeite a ordem dos canais ao transferir imagens do Pillow.
02Limitar a busca com uma ROI#
O detector começa numa região limitada do canto superior esquerdo. Converte para cinza, aplica Canny e extrai contornos; os centros dos marcadores restringem os retângulos candidatos.
Pesquisar apenas na região relevante
Imagem do jogo
Recorte do minimapa
if frame_bgr.size == 0 or frame_bgr.ndim != 3:
return previous or generic_minimap_definition()
search = frame_bgr[:min(frame_bgr.shape[0], 420), :min(frame_bgr.shape[1], 430)]
edges = cv2.Canny(cv2.cvtColor(search, cv2.COLOR_BGR2GRAY), 45, 135)
contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
marker_centers = [
(x + width / 2.0, y + height / 2.0)
for x, y, width, height, _ in _marker_components(search, player_pixels)
]
if not marker_centers:
return previous or generic_minimap_definition()detect_minimap_definitionL95–106É o começo, não toda a classificação de retângulos. Quadros inválidos ou sem marcadores usam a definição anterior ou uma provisória, o que não equivale a uma nova detecção validada.
03Localizar o personagem por correlação#
match_player_feature transforma imagem e modelo pequeno em cinza e aplica TM_CCORR_NORMED. Uma máscara opcional exclui pixels transparentes da comparação.
Comparar o modelo em cada posição
Modelo · T
Mapa de pontuação · S(x, y)
def match_player_feature(image_bgr: np.ndarray, template_bgr: np.ndarray,
mask: np.ndarray | None) -> np.ndarray:
"""Match normal player feature templates with tolerant grayscale correlation."""
screen_gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
template_gray = cv2.cvtColor(template_bgr, cv2.COLOR_BGR2GRAY)
return cv2.matchTemplate(
screen_gray,
template_gray,
cv2.TM_CCORR_NORMED,
mask=mask,
)match_player_featureL100–110O chamador trata valores não finitos e aplica um limite ao máximo. O 0.995 desta versão é uma configuração, não uma taxa de acerto. Capture características estáveis em ambas as direções.
04Converter pontuações em alvos#
Esta parte de detect_template processa modelos opacos. Rejeita modelos maiores que a imagem de busca e coleta posições cuja pontuação alcança o limite configurado.
if image_bgr.shape[0] < template.shape[0] or image_bgr.shape[1] < template.shape[1]:
return []
scores = cv2.matchTemplate(image_bgr, template, cv2.TM_CCOEFF_NORMED)
ys, xs = np.where(scores >= threshold)
targets = [Target(int(x + template.shape[1] / 2),
int(y) + top_to_feet if top_to_feet is not None else feature_center_to_feet(int(y), template.shape[0]),
float(scores[y, x]))
for y, x in zip(ys, xs)]
return _non_maximum(targets, 15)detect_templateL217–225Converte o canto superior esquerdo em centro horizontal e posição calibrada dos pés, removendo duplicatas. Modelos transparentes usam outra ramificação com máscara e outro método de correlação.
05Unir detecções repetidas#
_non_maximum ordena por confiança e mantém candidatos suficientemente distantes de todos os já aceitos. Reduz resultados repetidos de posições vizinhas ou poses do mesmo monstro.
def _non_maximum(points: list[Target], min_distance: int) -> list[Target]:
kept: list[Target] = []
for point in sorted(points, key=lambda item: item.confidence, reverse=True):
if all(abs(point.center_x - other.center_x) > min_distance or abs(point.feet_y - other.feet_y) > min_distance for other in kept):
kept.append(point)
return kept_non_maximumL194–199Usa distância x/y, não IoU de caixas. Distância excessiva une monstros distintos; distância pequena demais deixa duplicatas e distorce a contagem.
06Separar o marcador em HSV#
Primeiro tenta correspondência BGR exata. Sem componentes válidos, esta alternativa usa HSV com a faixa do perfil do servidor e tolerância extra de saturação.
Separar por cor e verificar a forma
RGB → HSV → Máscara binária
Componente → Posição local (x, y)
hsv = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2HSV)
hue_low, saturation_low, value_low = player_pixels.activity_hsv_lower
lower = np.asarray(
(hue_low, max(0, saturation_low - 40), value_low), dtype=np.uint8,
)
upper = np.asarray(player_pixels.activity_hsv_upper, dtype=np.uint8)
tolerant_mask = cv2.inRange(hsv, lower, upper)
return matching_components(tolerant_mask, exact_area=False)_marker_componentsL79–86matching_components filtra largura, altura e área. Tolera diferenças de gerenciamento de cor sem aceitar qualquer forma amarela; as condições são específicas do servidor.
07Obter coordenadas locais#
Recorta pelos limites do minimapa e aplica o deslocamento dos pés definido pelo servidor. Só retorna posição quando há exatamente um candidato.
right = min(frame_bgr.shape[1], minimap.end_x + 1)
bottom = min(frame_bgr.shape[0], minimap.end_y + 1)
if minimap.start_x >= right or minimap.start_y >= bottom:
return None
image = frame_bgr[minimap.start_y:bottom, minimap.start_x:right]
candidates: list[tuple[int, int]] = []
for x, y, _, _, _ in _marker_components(image, player_pixels):
# Each server profile maps the matched component's origin to the
# marker's standing point with its own vertical foot offset.
candidates.append((x, y + player_pixels.foot_offset))
if len(candidates) != 1:
return None
x, foot_y = candidates[0]
source_width = max(1, right - minimap.start_x)
source_height = max(1, bottom - minimap.start_y)
source_x = min(source_width - 1, x)
source_y = min(source_height - 1, foot_y)
return float(source_x), float(source_y)detect_minimap_playerL154–171As coordenadas pertencem ao recorte, não à área de trabalho. Os limites são ajustados à imagem; nenhum ou vários candidatos retornam None para não inventar uma posição ambígua.
08Medir HP/MP por votos de cor#
bar é a barra RGB recortada, pixels acessa seus pixels e valid_columns começa vazio. Vermelho dominante vota em HP e azul em MP; cada coluna exige linhas coincidentes suficientes.
Medir comprimento, não ler números
valid_columns: list[int] = []
for column in range(bar.width):
matches = 0
for row in range(bar.height):
red, green, blue = pixels[column, row]
if kind == "hp":
colored = red >= 130 and red >= green * 1.5 and red >= blue * 1.5
else:
colored = blue >= 110 and blue >= red * 1.25 and blue >= green * 1.15
matches += int(colored)
if matches >= max(2, int(bar.height * 0.2)):
valid_columns.append(column)
if not valid_columns:
return 0.0
percent = 100 * (valid_columns[-1] + 1) / bar.width
return 100.0 if percent >= 95 else round(percent, 1)_bar_percent_from_imageL293–308Usa a última coluna válida, não a proporção de todos os pixels coloridos. Sem coluna retorna zero; nesta versão valores a partir de 95 viram 100. Confira recorte e ruído antes da recuperação automática.
09Separar visão e planejamento#
HorizontalHuntPlanner recebe Observation, não imagens. Filtra pela tolerância de altura dos pés e escolhe o alvo horizontal mais próximo: atacar, aproximar-se ou patrulhar sem alvo.
def decide(self, observation: Observation) -> Decision:
targets = self.same_line_targets(observation)
if targets:
target = min(targets, key=lambda m: abs(m.center_x - observation.player_x))
delta = target.center_x - observation.player_x
if abs(delta) <= self.config.target_distance:
return Decision(HuntState.ATTACK, "attack_burst", "right" if delta >= 0 else "left", target)
return Decision(HuntState.APPROACH, "move_pulse", "right" if delta > 0 else "left", target)
return Decision(HuntState.PATROL, "move_pulse")HorizontalHuntPlanner.decideL52–60Decision descreve intenção sem enviar teclas. Posições artificiais permitem testar limites de distância e direção sem abrir o jogo.
10Espaçar comparações custosas#
O runtime separa a frequência de detecção de monstros do acompanhamento mais rápido de rotas. time.monotonic controla as atualizações e o último resultado é reutilizado entre elas.
def _monsters_for_frame(self, frame: np.ndarray) -> tuple:
"""Run expensive monster matching at a lower rate than route tracking."""
if self._monster_detection_suppressed():
self.last_detected_monsters = ()
self.last_monster_detection_at = 0.0
return ()
now = time.monotonic()
if (self.last_monster_detection_at <= 0.0
or now - self.last_monster_detection_at >= self.MONSTER_DETECTION_INTERVAL_SECONDS):
self.last_detected_monsters = tuple(self._detect_monsters(frame))
self.last_monster_detection_at = time.monotonic()
return self.last_detected_monstersHorizontalHuntRunner._monsters_for_frameL155–166Suprimir a detecção limpa o cache para não manter alvos antigos. Dados em cache não provam visibilidade atual. Parada e liberação de teclas continuam sendo tarefas do runtime.
Da detecção a um sistema confiável#
Teste a visão com capturas fixas, o planejamento com Observation artificiais e use o diagnóstico antes de enviar entradas. Uma pontuação alta não garante detecção correta. Trate observações antigas, oclusões e marcadores ambíguos. O runtime solta as teclas ao parar ou sair do laço principal. Este artigo não garante compatibilidade nem autoriza automação de jogos.