อธิบายการทำงาน · Python / OpenCV
หลักการทำงาน
เรียนรู้กระบวนการจากการจดจำหน้าจอจนถึงการตัดสินใจของ Classic MapleStory Bot ผ่านโค้ดสำคัญ 10 ส่วนและแผนภาพอัลกอริทึมภาพ
พิกเซล → ข้อมูลสังเกต → การตัดสินใจ → อินพุต
ไคลเอนต์อ่านภาพที่มองเห็น ชั้นการมองเห็นประเมินตำแหน่งและสถานะ ตัววางแผนเลือกการกระทำ และระบบรันจัดจังหวะปุ่มในเครื่อง การพบมอนสเตอร์ไม่ได้กดปุ่มทันที และการส่งปุ่มก็ไม่ได้ยืนยันว่าเกมรับการกระทำนั้นแล้ว
- 01จับภาพ
- 02จดจำภาพ
- 03ตัดสินใจ
- 04ส่งปุ่มในเครื่อง
01จับภาพพื้นที่ไคลเอนต์เกม#
สาขา Windows ของ grab_client หาพื้นที่ไคลเอนต์ จับสี่เหลี่ยมด้วย 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
02จำกัดการค้นหาด้วย ROI#
ตัวตรวจมินิแมปเริ่มจากพื้นที่จำกัดมุมซ้ายบน แปลงเป็นระดับเทา ใช้ 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ใช้ระยะ x/y ไม่ใช่ IoU ของกรอบ หากระยะมากไปจะรวมมอนสเตอร์คนละตัว หากน้อยไปยังเหลือผลซ้ำและกระทบการนับ
06แยกสัญลักษณ์มินิแมปด้วย HSV#
เริ่มจากสี 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–171x/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ใช้คอลัมน์ขวาสุดที่ผ่าน ไม่ใช่สัดส่วนพิกเซลสีทั้งหมด ไม่มีคอลัมน์คืนศูนย์ รุ่นนี้ปัดค่าตั้งแต่ 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 จำลอง และใช้โหมดวินิจฉัยก่อนส่งปุ่ม คะแนนสูงไม่ได้ยืนยันว่าตรวจจับถูก ต้องจัดการข้อมูลเก่า การบดบัง และจุดที่กำกวม ระบบรันจะปล่อยปุ่มเมื่อหยุดหรือออกจากลูปหลัก บทความนี้ไม่รับประกันความเข้ากันได้หรือสิทธิ์ในการใช้ระบบอัตโนมัติในเกม