Tekniska anteckningar · Python / OpenCV
Så fungerar det
Se hur Classic MapleStory Bot gör om skärmbilder till strategibeslut, genom 10 fokuserade kodutdrag och visuella algoritmdiagram.
Pixlar → observationer → beslut → inmatning
Klienten läser synliga skärmbilder. Perceptionen uppskattar positioner och status, planeraren väljer en åtgärd och körningen schemalägger lokal tangentbordsutmatning. Lagren är separata: att känna igen ett monster trycker inte i sig på en tangent, och att skicka en tangent bevisar inte att spelet tog emot den.
- 01Fångst
- 02Perception
- 03Beslut
- 04Lokal inmatning
01Fånga spelklienten#
Windows-grenen av grab_client hittar först klientområdet, fångar sedan dess rektangel med ImageGrab och konverterar den till RGB. Ogiltiga fönster- eller bildmått ger ett fel i stället för att skicka en tom bild till igenkänningen.
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–363Utdraget är en plattformsgren. macOS använder en separat inbyggd fångstväg och beskärning av klientområdet. Igenkänningskoden arbetar sedan med OpenCV:s BGR-matriser: håll kanalordningen tydlig när bilder skickas mellan Pillow och OpenCV.
02Begränsa sökningen med en ROI#
Minikartdetektorn börjar i ett avgränsat område uppe till vänster. Den konverterar området till gråskala, tar fram kanter med Canny och hämtar kandidatkonturer. Markörernas mittpunkter ger ytterligare en begränsning för vilken rektangel som kan vara kartan.
Arbeta i ett relevant område
Spelbild
Beskuren minikarta
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–106Detta är början på detektorn, inte hela dess algoritm för att rangordna rektanglar. Ogiltiga bildrutor eller saknade markörer faller tillbaka på den tidigare definitionen eller ett tillfälligt standardvärde; den reservlösningen ska inte förväxlas med en nyss bekräftad igenkänning.
03Hitta en karaktär med gråskalekorrelation#
match_player_feature konverterar både bildrutan och den lilla karaktärsmallen till gråskala och använder sedan TM_CCORR_NORMED. Den valfria masken kan utesluta mallens genomskinliga pixlar från jämförelsen.
En mall glider över bilden
Mall · T
Poängkarta · 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–110Anroparen rensar bort icke-ändliga poäng och väljer den starkaste matchningen innan tröskeln tillämpas. I den här ögonblicksbilden är tröskeln 0,995; det är en implementationsinställning, inte en universell träffsäkerhet i procent. Ta ett stabilt kännetecken på karaktären i båda riktningarna.
04Gör om mallpoäng till mål#
Detta är grenen för ogenomskinliga mallar i detect_template. Den avvisar mallar som är större än sökbilden, beräknar en poängkarta och samlar positioner som når den inställda tröskeln.
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–225En position börjar i mallens övre vänstra hörn. Koden räknar om den till en horisontell mittpunkt och en kalibrerad fotposition och tar sedan bort dubbletter. Genomskinliga mallar använder en separat maskerad korrelationsgren, så de två grenarna använder inte exakt samma matchningsmetod.
05Slå ihop upprepade träffar#
Flera närliggande positioner eller poseringsmallar kan matcha samma monster. _non_maximum sorterar kandidater efter säkerhet och behåller en kandidat bara om den ligger tillräckligt långt från alla redan behållna resultat.
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–199Implementationen använder x/y-avstånd, inte IoU för omslutande rutor. En närliggande kandidat med högre säkerhet vinner. För stort avstånd kan slå ihop två verkliga grannar; för litet avstånd lämnar dubbletter och kan förvränga antalet mål.
06Segmentera minikartans markör i HSV#
Markörsökaren försöker först med en exakt BGR-matchning. När det inte ger någon giltig komponent konverterar reservlösningen bilden till HSV och använder färgintervallet i den valda serverprofilen, med extra tolerans för mättnad.
Färgsegmentering, sedan geometri
RGB → HSV → Binär mask
Komponent → Lokal position (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 filtrerar sedan sammanhängande komponenter efter förväntad bredd, höjd och yta. Reservlösningen tål skillnader i färghantering utan att godta godtyckliga gula former. Nyansintervall och komponentgeometri hör till serverprofilen, inte till en hårdkodad färg för alla servrar.
07Gör om en markör till lokala kartkoordinater#
Bildutsnittet använder den upptäckta minikartans gränser. En serverspecifik fotförskjutning kopplar varje markörkomponent till dess ståpunkt; funktionen returnerar en position bara när exakt en kandidat återstår.
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–171De resulterande x/y-värdena gäller den beskurna minikartan, inte skrivbordets koordinater. Gränserna begränsas till den tillgängliga bildrutan. Noll eller flera kandidater ger None, så att anroparen inte hittar på en position utifrån tvetydiga pixlar.
08Läs HP/MP med färg och kolumnröster#
Här är bar den beskurna RGB-bilden av statusfältet, pixels dess pixelåtkomst och valid_columns börjar tom. Pixlar där rött dominerar röstar för HP; pixlar där blått dominerar röstar för MP. En kolumn räknas först när den har tillräckligt många matchande rader.
Uppskatta fyllnadslängd, inte text
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–308Procentsatsen bygger på den giltiga kolumnen längst till höger, inte andelen av alla färgade pixlar. Ingen giltig kolumn ger noll; i den här ögonblicksbilden avrundas värden på minst 95 till 100. Ett felplacerat område eller en vilsen färgad pixel kan påverka resultatet, så kontrollera beskärningen innan du slår på återställning.
09Håll planeraren oberoende av synen#
HorizontalHuntPlanner får en Observation i stället för en skärmbild. same_line_targets filtrerar först efter tolerans för fothöjd; decide väljer sedan närmaste horisontella mål, attackerar inom räckhåll, närmar sig utanför räckhåll eller patrullerar när inga mål finns kvar.
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–60Detta är ren beslutslogik: det returnerade Decision beskriver en avsikt men skickar ingen tangent själv. Enhetstester kan ge konstgjorda positioner och mål för att kontrollera avståndsgränser och riktningsval utan att öppna spelet.
10Kör dyr matchning mer sällan#
Körningen skiljer takten för monstermatchning från den snabbare ruttföljningen. Den använder time.monotonic för att avgöra när träffarna ska uppdateras och återanvänder det senaste resultatet mellan uppdateringarna.
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–166När igenkänningen pausas rensas de cachade monstren i stället för att ett gammalt mål behålls i all oändlighet. Det minskar upprepat arbete men gör också cachens ålder viktig: planeraren får inte behandla en cachad observation som bevis på att målet fortfarande syns. Tajming, stopphantering och släpp av tangenter är fortfarande körningens ansvar.
Från detektor till pålitligt system#
Testa varje lager för sig: en fast skärmbild för igenkänning, en syntetisk Observation för planering och diagnosläget innan inmatning skickas. En hög matchningspoäng bevisar inte ensam att igenkänningen är rätt. Kontrollera gamla observationer, skymda mål och tvetydiga markörer; körningen släpper nedtryckta tangenter när den stoppas eller lämnar huvudloopen. Artikeln förklarar implementationen och är ingen garanti för kompatibilitet eller tillåtelse att automatisera ett spel.