구현 분석 · Python / OpenCV
작동 원리
핵심 코드 10개와 시각 알고리즘 도해로 Classic MapleStory Bot의 화면 인식부터 전략 판단까지 살펴봅니다.
픽셀 → 관측 → 판단 → 입력
클라이언트는 보이는 화면을 읽습니다. 시각 계층은 위치와 상태를 추정하고, 플래너는 행동을 결정하며, 런타임은 로컬 키 입력을 조율합니다. 몬스터를 찾았다고 즉시 입력하지 않으며 키를 보냈다고 게임이 수락했다고 단정할 수도 없습니다.
- 01화면 캡처
- 02시각 인식
- 03전략 판단
- 04로컬 입력
01게임 클라이언트 영역 캡처#
grab_client의 Windows 분기는 클라이언트 영역을 찾고 ImageGrab으로 사각형을 캡처해 RGB로 바꿉니다. 잘못된 크기는 예외로 처리하여 빈 이미지를 인식기에 보내지 않습니다.
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–363macOS는 별도의 네이티브 캡처와 영역 자르기를 사용합니다. 이후 OpenCV는 BGR 배열을 사용하므로 Pillow에서 넘길 때 채널 순서를 명확히 해야 합니다.
02ROI로 탐색 범위 제한#
미니맵 탐색은 왼쪽 위 일부 영역에서 시작합니다. 회색조 변환, Canny 경계 추출과 윤곽 검출을 수행하고 마커 중심으로 후보 사각형을 제한합니다.
관련 영역에서만 탐색
게임 화면
미니맵 자르기
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)
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의 불투명 템플릿 분기입니다. 탐색 이미지보다 큰 템플릿을 거르고 점수 맵에서 기준값 이상인 위치를 수집합니다.
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은 후보를 신뢰 점수순으로 정렬하고 이미 남긴 모든 후보에서 충분히 떨어진 결과만 보존합니다. 인접 위치나 여러 자세 템플릿이 같은 몬스터를 잡는 문제를 줄입니다.
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)
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는 예상 너비·높이·면적으로 연결 요소를 거릅니다. 색상 관리 차이는 허용하되 임의의 노란 모양은 받지 않으며 조건은 서버별 설정입니다.
07맵 내부 좌표로 변환#
검출한 미니맵 경계로 자르고 서버별 발 위치 보정값을 더합니다. 후보가 정확히 하나일 때만 위치를 반환합니다.
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에 투표하며 열마다 충분한 행이 필요합니다.
숫자가 아닌 채워진 길이 측정
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을 받습니다. 발 높이 허용치로 같은 층을 고른 뒤 가로로 가장 가까운 대상을 공격하거나 접근하고, 대상이 없으면 순찰합니다.
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–60Decision은 의도만 나타내고 키를 보내지 않습니다. 가짜 좌표와 목표로 거리 경계와 방향을 단위 테스트할 수 있어 게임을 열 필요가 없습니다.
10비싼 매칭의 실행 빈도 줄이기#
몬스터 매칭과 더 빠른 경로 추적의 주기를 분리합니다. time.monotonic으로 갱신 시점을 판단하고 사이에는 지난 결과를 재사용합니다.
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–166검출 억제 시 캐시를 비워 오래된 대상을 계속 보존하지 않습니다. 캐시는 대상이 현재 보인다는 증거가 아닙니다. 중지 처리와 키 해제도 런타임의 책임입니다.
인식 알고리즘에서 안정적인 실행까지#
고정 스크린샷으로 인식을, 인위적인 Observation으로 판단을 검증하고 입력 전에는 진단 모드를 사용하세요. 높은 점수가 정확한 인식을 보장하지는 않습니다. 오래된 관측, 가림과 모호한 마커를 처리해야 합니다. 런타임은 중지하거나 주 루프를 벗어날 때 누른 키를 해제합니다. 이 문서는 호환성 보장이나 게임 자동화 허가가 아닙니다.