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 先按脚底高度容差筛选目标,decide 再找水平距离最近的怪物:进入范围就攻击,较远就靠近,没有目标就巡航。

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 测试决策,发送按键前先运行诊断模式。高匹配分数并不等于识别一定正确,还要处理观测过期、遮挡和标记歧义。运行层在停止或退出主循环时释放按住的键。本文介绍实现原理,不代表兼容性保证,也不代表游戏允许自动化操作。