Technik im Detail · Python / OpenCV
So funktioniert’s
Mit zehn gezielten Codeausschnitten und Algorithmusgrafiken nachvollziehen, wie Classic MapleStory Bot Bildschirmbilder in Strategieentscheidungen verwandelt.
Pixel → Beobachtung → Entscheidung → Eingabe
Bildauswertung schätzt Position und Status, der Planer wählt eine Aktion und die Laufzeit sendet lokale Tasten. Diese Ebenen sind getrennt: Ein erkanntes Monster drückt keine Taste, und eine gesendete Taste beweist nicht, dass das Spiel sie annimmt.
- 01Aufnahme
- 02Erkennung
- 03Entscheidung
- 04Lokale Eingabe
01Spielfenster aufnehmen#
Unter Windows bestimmt grab_client zuerst den Clientbereich, nimmt dessen Rechteck mit ImageGrab auf und konvertiert zu RGB. Ungültige Fenster- oder Bildmaße lösen einen Fehler aus, statt leere Bilder weiterzugeben.
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–363Nur ein Plattformzweig: macOS nutzt native Aufnahme und separaten Zuschnitt. Spätere Erkennung arbeitet mit OpenCV-BGR-Arrays. Beim Austausch zwischen Pillow und OpenCV die Kanalreihenfolge beachten.
02Suchbereich mit ROI begrenzen#
Der Minikartendetektor startet oben links in einem begrenzten Bereich. Graustufen, Canny-Kanten und Konturen liefern Kandidaten; Markermittelpunkte grenzen das passende Rechteck weiter ein.
Den relevanten Bereich auswählen
Spielbild
Minikartenausschnitt
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–106Das ist nur der Einstieg, nicht die vollständige Rechteckbewertung. Bei ungültigen Bildern oder fehlenden Markern wird die vorherige Definition oder ein Standard genutzt. Dieser Rückfall ist keine neue bestätigte Erkennung.
03Figur per Graustufenkorrelation finden#
match_player_feature wandelt Bild und Figurenvorlage in Graustufen um und nutzt TM_CCORR_NORMED. Eine optionale Maske kann transparente Vorlagenpixel ausschließen.
Die Vorlage wandert über das Bild
Vorlage · T
Ähnlichkeitskarte · 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–110Der Aufrufer bereinigt nicht endliche Werte und vergleicht den besten Treffer mit dem Schwellwert. Hier ist er 0,995 – eine Implementierungseinstellung, keine allgemeine Genauigkeitsangabe. Stabiles Merkmal in beiden Richtungen aufnehmen.
04Vorlagentreffer in Ziele umwandeln#
Der Zweig für undurchsichtige Vorlagen in detect_template verwirft zu große Vorlagen, berechnet eine Ähnlichkeitskarte und sammelt Positionen oberhalb des Schwellwerts.
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–225Aus der oberen linken Trefferposition werden horizontales Zentrum und kalibrierte Fußposition berechnet, dann Duplikate entfernt. Transparente Vorlagen verwenden einen eigenen maskierten Korrelationszweig mit anderer Methode.
05Mehrfachtreffer zusammenführen#
Benachbarte Positionen oder Posen können dasselbe Monster treffen. _non_maximum sortiert nach Konfidenz und behält nur Kandidaten mit ausreichendem Abstand zu allen behaltenen Treffern.
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–199Verwendet x/y-Abstand statt Bounding-Box-IoU. Der stärkere nahe Treffer gewinnt. Zu großer Abstand verschmilzt echte Nachbarn; zu kleiner lässt Duplikate stehen und verfälscht die Anzahl.
06Minikartenmarker mit HSV segmentieren#
Zuerst wird eine exakte BGR-Farbe gesucht. Fehlt eine gültige Komponente, verwendet der HSV-Rückfall den Farbbereich des Serverprofils mit zusätzlicher Sättigungstoleranz.
Erst Farbe, dann Geometrie
RGB → HSV → Binärmaske
Komponente → Lokale Position (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 filtert nach Breite, Höhe und Fläche. Das toleriert Farbverwaltungsunterschiede, nicht beliebige gelbe Formen. Farbbereiche und Geometrie gehören zum Serverprofil, nicht zu einer universellen Konstante.
07Marker in lokale Kartenkoordinaten umrechnen#
Bildzuschnitt anhand erkannter Minikartengrenzen. Ein serverspezifischer Fußversatz ergibt den Standpunkt; nur bei genau einem Kandidaten wird eine Position geliefert.
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 bezieht sich auf den Minikartenzuschnitt, nicht den Desktop. Grenzen werden auf das Bild begrenzt. Null oder mehrere Kandidaten ergeben None statt einer erfundenen Position.
08HP/MP über Farbe und Spalten lesen#
bar ist der RGB-Balkenzuschnitt, pixels dessen Pixelzugriff; valid_columns startet leer. Rotdominante Pixel stimmen für HP, blaudominante für MP. Genug passende Zeilen machen eine Spalte gültig.
Fülllänge statt Zahlen lesen
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–308Der Prozentwert folgt der letzten gültigen Spalte, nicht dem Anteil aller Farbpixel. Ohne Spalte ist er null; ab 95 wird hier auf 100 gerundet. Falscher Zuschnitt oder Fremdpixel verfälschen den Wert – vor automatischen Tränken prüfen.
09Planung von Bilderkennung trennen#
HorizontalHuntPlanner bekommt eine Observation statt eines Bilds. same_line_targets filtert Fußhöhen. decide wählt das nächste horizontale Ziel, greift in Reichweite an, nähert sich sonst oder patrouilliert ohne Ziel.
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–60Reine Entscheidungslogik: Decision beschreibt eine Absicht und sendet keine Tasten. Tests mit künstlichen Positionen prüfen Entfernungsgrenzen und Richtungen ohne Spiel.
10Teure Vorlagensuche seltener ausführen#
Die Laufzeit trennt langsame Monstersuche von schneller Routenverfolgung. time.monotonic steuert Aktualisierungen; dazwischen wird das letzte Ergebnis genutzt.
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–166Bei unterdrückter Erkennung wird der Cache geleert, statt alte Ziele unbegrenzt zu behalten. Weniger Rechenarbeit macht das Cachealter relevant: Gespeicherte Beobachtungen beweisen keine aktuelle Sichtbarkeit. Timing, Stoppen und Tastenfreigabe bleiben Laufzeitaufgaben.
Von der Erkennung zum verlässlichen System#
Jede Ebene getrennt testen: Screenshots für Erkennung, synthetische Observation für Planung, Diagnose vor Tasteneingaben. Hohe Trefferwerte beweisen keine korrekte Erkennung. Alte Daten, Verdeckungen und unklare Marker prüfen. Beim Stoppen gibt die Laufzeit Tasten frei. Keine Garantie für Kompatibilität oder erlaubte Nutzung.