okya for MapleStory
日本語
English한국어日本語简体中文繁體中文EspañolPortuguêsไทยTiếng Việt
記事の目次⌄
okya for MapleStory›仕組みの紹介

実装を読む · Python / OpenCV

仕組みの紹介

10 個の主要コードと視覚アルゴリズムの図解で、Classic MapleStory Bot の画面認識から戦略判断までを学びます。

ピクセル → 観測 → 判断 → 入力

画面の可視画像を読み、視覚層が位置と状態を推定し、判断層が動作を選び、実行層がローカルのキー入力を調整します。検出と入力は別であり、キーを送ってもゲームが受け付けた証拠にはなりません。

  1. 01画面取得
  2. 02視覚認識
  3. 03戦略判断
  4. 04ローカル入力

01ゲームのクライアント領域を取得#

grab_client の Windows 分岐はクライアント領域を特定し、ImageGrab で矩形を取得して RGB に変換します。無効な寸法は例外にして空画像の認識を防ぎます。

Python · vitals.py
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 image
grab_clientL354–363

macOS は専用のネイティブ取得と領域の切り抜きを使います。後続の OpenCV は BGR 配列なので、Pillow との間でチャンネル順序を明示する必要があります。

02ROI で探索を制限#

ミニマップ検出は左上の限定領域から始まり、グレースケール、Canny、輪郭抽出を行います。マーカーの中心も候補矩形の制約になります。

関連領域だけを探索する

ゲーム画面

ミニマップ切り抜き

収録済みのゲーム画像とミニマップの切り抜きで関心領域(ROI)を説明します。強調範囲は例示であり検出器の実測結果ではありません。探索を絞ると無関係な候補が減ります。
Python · map_position.py
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

これは検出器の冒頭であり、矩形の順位付け全体ではありません。画像やマーカーがない場合の旧定義・仮の既定値を、新たに確認された検出と混同しないでください。

03グレースケール相関でキャラクターを検出#

match_player_feature は画面と小さなテンプレートをグレースケール化し、TM_CCORR_NORMED を適用します。マスクで透明な領域を比較対象から除外できます。

テンプレートを各位置で比較

テンプレート · T

スコアマップ · S(x, y)

概念図です。候補位置ごとに類似度を計算し、明るいセルは局所最大を表します。この画像の実測値ではなく、正規化相関スコアは確率ではありません。
Python · vision.py
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–110

呼び出し側で非有限値を除去し、最高点にしきい値を適用します。この版の 0.995 は設定値であって正答率ではありません。左右それぞれ安定した特徴を取得します。

04スコアをモンスター候補へ変換#

detect_template の不透明画像用分岐です。探索画像より大きいテンプレートを除外し、スコアマップからしきい値以上の位置を集めます。

Python · vision.py
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–225

左上位置を水平中心と補正済みの足元へ変換し、重複を除きます。透明画像は別のマスク付き分岐を使うため、同じ照合方式ではありません。

05重複した検出を統合#

_non_maximum は信頼スコア順に候補を並べ、既に残したすべての候補と十分離れたものを保持します。同じ敵への隣接位置・姿勢別の重複を減らします。

Python · vision.py
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–199

バウンディングボックスの IoU ではなく x/y 距離を使います。大きすぎる距離は別の敵を統合し、小さすぎる距離は重複を残します。

06HSV でミニマップのマーカーを分割#

最初に BGR の完全一致を試し、有効な成分がない場合にこの HSV 分岐を使います。サーバープロファイルの色範囲に彩度の許容幅を追加します。

色で分割して形状を確認

RGB → HSV → 二値マスク

連結成分 → マップ内座標 (x, y)

概念図:色範囲でマスクを作り、隣接ピクセルを連結成分にまとめます。幅・高さ・面積も確認し、すべての黄色い点をプレイヤーとは見なしません。
Python · map_position.py
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–86

matching_components は幅・高さ・面積でも成分を選別します。色管理の差を許容しつつ任意の黄色形状は受け付けず、条件はサーバーごとの設定です。

07マップ内座標へ変換#

検出したミニマップの境界で画像を切り出し、成分にサーバー別の足元オフセットを加えます。候補が一つだけの場合に位置を返します。

Python · map_position.py
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–171

x/y はデスクトップではなく切り出したマップの局所座標です。境界は実画像内に制限し、候補がゼロまたは複数なら None を返して推測を避けます。

08色と列の投票で HP/MP を測定#

bar は切り出した RGB バー、pixels は画素アクセサー、valid_columns は空リストです。赤優勢で HP、青優勢で MP に投票し、列ごとに十分な一致行を求めます。

数字ではなく充填の長さを測る

100 × (x_last + 1) / width

ステータスバー → 最も右の有効列

概念図:列内で十分な行が色条件を満たすと有効列になります。最も右の有効列から長さを推定し、HP/MP の数値文字は読みません。
Python · vitals.py
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–308

全有色画素の比率ではなく最右の有効列を使います。有効列なしは 0、この実装の 95 以上は 100 です。自動回復前に切り出し位置とノイズを確認します。

09認識と判断を分離#

HorizontalHuntPlanner は画像ではなく Observation を受けます。足元の高さの許容差で対象を絞り、水平で最も近い敵に攻撃・接近し、対象なしなら巡回します。

Python · horizontal_hunt.py
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–60

Decision は意図だけを表しキーは送りません。人工的な座標で距離境界や方向を単体テストでき、ゲームを起動する必要はありません。

10高コストな照合の頻度を下げる#

モンスター照合とより速い経路追跡の周期を分離します。time.monotonic で更新時刻を判断し、更新間は前の結果を再利用します。

Python · hunt_runtime.py
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_monsters
HorizontalHuntRunner._monsters_for_frameL155–166

検出を抑止するとキャッシュを空にし、古い敵を残し続けません。キャッシュは敵が今も見える証拠ではありません。停止処理やキー解放も実行層の責務です。

認識アルゴリズムから安定した実行へ#

固定画像で認識を、人工的な Observation で判断を検証し、入力前に診断モードを使います。高得点だけで正しい検出とは限りません。古い観測、遮蔽、曖昧なマーカーにも対応が必要です。実行層は停止時や主ループ終了時に押下中のキーを解放します。互換性の保証やゲームの自動化許可を示す記事ではありません。