Phân tích kỹ thuật · Python / OpenCV
Nguyên lý hoạt động
Tìm hiểu cách Classic MapleStory Bot chuyển hình ảnh màn hình thành quyết định qua 10 đoạn mã cốt lõi và sơ đồ thuật toán thị giác.
Điểm ảnh → quan sát → quyết định → đầu vào
Ứng dụng đọc hình ảnh đang hiển thị. Tầng thị giác ước tính vị trí và trạng thái, bộ lập kế hoạch chọn hành động, tầng thực thi điều phối phím cục bộ. Phát hiện quái không tự gửi phím; gửi phím cũng không chứng minh trò chơi đã nhận hành động.
- 01Chụp ảnh
- 02Nhận diện
- 03Quyết định
- 04Phím cục bộ
01Chụp vùng khách của trò chơi#
Nhánh Windows của grab_client tìm vùng khách, chụp hình chữ nhật bằng ImageGrab và chuyển sang RGB. Kích thước không hợp lệ phát sinh lỗi thay vì đưa ảnh trống vào nhận diện.
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 dùng đường chụp gốc và cắt vùng riêng. OpenCV xử lý mảng BGR ở bước sau, nên phải rõ thứ tự kênh màu khi truyền ảnh từ Pillow.
02Giới hạn tìm kiếm bằng ROI#
Bộ phát hiện bắt đầu trong vùng giới hạn ở góc trên trái. Chuyển xám, dùng Canny và tìm đường bao; tâm dấu người chơi ràng buộc thêm các hình chữ nhật ứng viên.
Chỉ tìm trong vùng liên quan
Ảnh trò chơi
Cắt bản đồ nhỏ
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Đây là phần đầu, không phải toàn bộ bước xếp hạng hình chữ nhật. Ảnh không hợp lệ hoặc thiếu dấu dùng định nghĩa trước hay giá trị tạm, không phải phát hiện mới đã xác minh.
03Tìm nhân vật bằng tương quan ảnh xám#
match_player_feature chuyển ảnh và mẫu nhân vật nhỏ sang xám rồi dùng TM_CCORR_NORMED. Mặt nạ tùy chọn loại điểm ảnh trong suốt khỏi phép so sánh.
So mẫu tại từng vị trí
Mẫu ảnh · T
Bản đồ điểm · 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–110Hàm gọi xử lý điểm không hữu hạn rồi áp ngưỡng lên cực đại. Giá trị 0.995 của bản này là cấu hình, không phải tỷ lệ chính xác. Chụp đặc trưng ổn định ở cả hai hướng.
04Đổi điểm khớp thành mục tiêu#
Nhánh detect_template này xử lý mẫu không trong suốt. Loại mẫu lớn hơn ảnh tìm kiếm và thu các vị trí có điểm đạt ngưỡng cấu hình.
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Đổi góc trên trái thành tâm ngang và vị trí chân đã hiệu chỉnh, rồi bỏ kết quả trùng. Mẫu trong suốt dùng nhánh có mặt nạ và phương pháp tương quan khác.
05Gộp phát hiện trùng#
_non_maximum sắp ứng viên theo độ tin cậy và giữ những điểm đủ xa mọi kết quả đã giữ. Nhờ vậy bớt trùng cùng một quái từ vị trí gần nhau hoặc nhiều tư thế.
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–199Dùng khoảng cách x/y, không phải IoU hộp. Khoảng cách quá lớn gộp hai quái thật; quá nhỏ giữ kết quả trùng và làm sai số lượng.
06Tách dấu bản đồ nhỏ bằng HSV#
Trước tiên thử màu BGR chính xác. Nếu không có thành phần hợp lệ, nhánh dự phòng dùng HSV với khoảng màu của máy chủ và thêm dung sai độ bão hòa.
Tách theo màu rồi kiểm tra hình dạng
RGB → HSV → Mặt nạ nhị phân
Thành phần → Tọa độ cục bộ (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 lọc theo rộng, cao và diện tích. Chấp nhận khác biệt quản lý màu nhưng không nhận mọi hình vàng; điều kiện lấy từ hồ sơ từng máy chủ.
07Chuyển sang tọa độ bản đồ#
Cắt ảnh theo biên bản đồ nhỏ và cộng độ lệch chân theo máy chủ. Chỉ trả vị trí khi có đúng một ứng viên.
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–171x/y thuộc vùng bản đồ đã cắt, không phải màn hình máy tính. Biên được giới hạn trong ảnh thực; không có hoặc nhiều ứng viên trả None thay vì đoán vị trí mơ hồ.
08Đọc HP/MP bằng phiếu màu theo cột#
bar là thanh RGB đã cắt, pixels dùng truy cập điểm ảnh, valid_columns ban đầu rỗng. Đỏ trội bỏ phiếu HP, xanh lam trội cho MP; một cột cần đủ hàng khớp.
Đo chiều dài thay vì đọc số
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–308Dùng cột hợp lệ ngoài cùng bên phải, không phải tỷ lệ mọi điểm ảnh có màu. Không có cột trả 0; bản này làm tròn từ 95 thành 100. Kiểm tra vùng cắt và nhiễu trước khi bật hồi phục.
09Tách quyết định khỏi thị giác#
HorizontalHuntPlanner nhận Observation thay vì ảnh. Lọc theo dung sai độ cao chân rồi chọn mục tiêu ngang gần nhất: đánh, tiến lại gần hoặc tuần tra khi không có mục tiêu.
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 chỉ mô tả ý định và không gửi phím. Tọa độ giả lập giúp kiểm thử biên khoảng cách và hướng mà không cần mở trò chơi.
10Giãn tần suất so khớp tốn tài nguyên#
Tầng thực thi tách nhịp nhận diện quái khỏi theo dõi đường đi nhanh hơn. time.monotonic quyết định khi nào cập nhật và tái dùng kết quả cũ giữa hai lần.
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–166Khi ngừng phát hiện sẽ xóa bộ đệm để không giữ mục tiêu cũ mãi. Dữ liệu đệm không chứng minh mục tiêu vẫn đang thấy. Dừng và thả phím cũng là trách nhiệm của tầng thực thi.
Từ nhận diện đến hệ thống đáng tin cậy#
Kiểm thử thị giác bằng ảnh cố định, bộ lập kế hoạch bằng Observation giả lập và dùng chế độ chẩn đoán trước khi gửi phím. Điểm cao không bảo đảm nhận diện đúng. Cần xử lý dữ liệu cũ, che khuất và dấu đánh mơ hồ. Tầng thực thi thả phím khi dừng hoặc thoát vòng lặp chính. Bài viết không bảo đảm tương thích hay cho phép tự động hóa trò chơi.