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으로 판단을 검증하고 입력 전에는 진단 모드를 사용하세요. 높은 점수가 정확한 인식을 보장하지는 않습니다. 오래된 관측, 가림과 모호한 마커를 처리해야 합니다. 런타임은 중지하거나 주 루프를 벗어날 때 누른 키를 해제합니다. 이 문서는 호환성 보장이나 게임 자동화 허가가 아닙니다.