Notes techniques · Python / OpenCV
Fonctionnement
Découvrez comment Classic MapleStory Bot transforme les images en décisions grâce à dix extraits de code ciblés et des schémas d’algorithmes visuels.
Pixels → observations → décisions → commandes
La perception estime positions et état, le planificateur choisit une action et le moteur envoie les touches locales. Ces couches sont séparées : voir un monstre ne déclenche pas directement une touche, et l’envoi ne garantit pas sa prise en compte par le jeu.
- 01Capture
- 02Perception
- 03Décision
- 04Commandes locales
01Capturer la fenêtre du jeu#
Sous Windows, grab_client repère la zone cliente, la capture avec ImageGrab et convertit en RGB. Des dimensions invalides déclenchent une erreur plutôt que d’envoyer une image vide.
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–363Cet extrait ne couvre qu’une plateforme. macOS utilise une capture native et un recadrage distincts. La détection travaille ensuite en BGR avec OpenCV : gardez l’ordre des canaux explicite entre Pillow et OpenCV.
02Restreindre la recherche avec une ROI#
Le détecteur commence dans une zone bornée en haut à gauche. Gris, contours Canny et formes candidates sont calculés ; les centres des marqueurs contraignent le rectangle retenu.
Se concentrer sur la zone utile
Image du jeu
Mini-carte recadrée
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–106Ce n’est que le début, pas tout le classement des rectangles. Si l’image est invalide ou les marqueurs absents, la définition précédente ou une valeur temporaire est utilisée. Ce repli n’est pas une nouvelle détection validée.
03Localiser le personnage par corrélation en niveaux de gris#
match_player_feature convertit image et modèle en gris, puis applique TM_CCORR_NORMED. Un masque facultatif exclut les pixels transparents du modèle.
Le modèle glisse sur l’image
Modèle · T
Carte des scores · 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–110L’appelant élimine les scores non finis et compare le meilleur au seuil. Ici, 0,995 est un réglage technique, pas un pourcentage de précision universel. Capturez un détail stable dans les deux sens.
04Transformer les scores en cibles#
La branche opaque de detect_template refuse les modèles trop grands, calcule une carte des scores et recueille les positions atteignant le seuil.
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–225Le coin supérieur gauche du résultat devient centre horizontal et position calibrée des pieds, puis les doublons sont retirés. Les modèles transparents utilisent une autre branche de corrélation masquée.
05Regrouper les détections répétées#
Plusieurs positions ou poses peuvent correspondre au même monstre. _non_maximum trie par confiance et ne garde qu’un candidat assez éloigné des résultats retenus.
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–199La méthode utilise la distance x/y, pas l’IoU des boîtes. Le voisin le mieux noté gagne. Une distance excessive fusionne de vrais voisins ; trop faible, elle laisse des doublons.
06Segmenter le marqueur en HSV#
Une couleur BGR exacte est cherchée d’abord. Sans composante valide, le repli HSV utilise la plage du profil serveur avec une tolérance supplémentaire de saturation.
D’abord la couleur, puis la géométrie
RGB → HSV → Masque binaire
Composante → Position locale (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 filtre largeur, hauteur et surface. Cela tolère la gestion des couleurs sans accepter toute forme jaune. Plages et géométrie dépendent du serveur, pas d’une couleur universelle.
07Convertir le marqueur en coordonnées locales#
Le recadrage suit les limites détectées. Un décalage de pied propre au serveur donne le point d’appui ; la position n’est renvoyée qu’avec un candidat unique.
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–171x/y est relatif à la mini-carte, pas au bureau. Les limites sont bornées à l’image. Zéro ou plusieurs candidats renvoient None, sans inventer une position.
08Lire les PV/PM par couleur et vote de colonnes#
bar est le recadrage RGB, pixels l’accès aux pixels et valid_columns est vide au départ. Les pixels rouges votent pour les PV, les bleus pour les PM. Une colonne requiert assez de lignes concordantes.
Mesurer la longueur, pas lire le texte
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–308Le pourcentage vient de la dernière colonne valide, pas du ratio global de pixels colorés. Sans colonne, il vaut zéro ; dès 95, cette version arrondit à 100. Vérifiez le cadrage avant les potions : une zone déplacée ou un pixel parasite peut fausser le résultat.
09Séparer décision et vision#
HorizontalHuntPlanner reçoit une Observation, pas une image. same_line_targets filtre par hauteur des pieds. decide choisit la cible horizontale la plus proche, attaque à portée, approche sinon ou patrouille sans cible.
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–60Logique pure : Decision décrit une intention sans envoyer de touche. Des positions synthétiques testent distances et directions sans ouvrir le jeu.
10Espacer les recherches coûteuses#
Le moteur sépare la détection des monstres du suivi de trajet plus rapide. time.monotonic déclenche les rafraîchissements ; le dernier résultat est réutilisé entre-temps.
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–166Si la détection est suspendue, le cache est vidé plutôt que de garder une cible ancienne. Le gain de calcul impose de surveiller l’âge des données : un cache ne prouve pas la visibilité actuelle. Cadence, arrêt et relâchement des touches restent au moteur.
De la détection à un système fiable#
Testez séparément images, Observation synthétiques et diagnostic avant les touches. Un score élevé ne prouve pas une bonne détection. Vérifiez données périmées, masquages et marqueurs ambigus. Le moteur relâche les touches à l’arrêt. Cet article ne garantit ni compatibilité ni autorisation d’automatiser.