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 與 OpenCV 之間傳遞影像時,須明確處理通道順序。

02以 ROI 縮小搜尋範圍#

小地圖偵測先擷取畫面左上方有限區域,再轉灰階、以 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。鄰近高分候選優先;距離過大會合併不同怪物,過小則留下重複結果,影響數量判斷。

06以 HSV 分割小地圖玩家標記#

標記搜尋先嘗試精確 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

百分比由最右有效欄決定,不是全部有色像素的比例。沒有有效欄返回零;此實作將不小於 95 的值歸為 100。區域錯位或雜色會影響結果,開啟自動補給前須先核對裁切。

09讓決策獨立於視覺辨識#

HorizontalHuntPlanner 接收 Observation 而不是截圖。same_line_targets 先依腳底高度容差篩選,再找水平最近目標:範圍內攻擊,較遠靠近,沒有目標則巡航。

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 測試決策,送出按鍵前先使用診斷模式。高分不等於辨識必然正確,還須處理過期觀測、遮擋與標記歧義。執行層會在停止或離開主迴圈時釋放按住的鍵。本文說明實作,不保證相容性,也不代表遊戲允許自動化。