Skip to content

Commit cf9cd10

Browse files
committed
feat(avatar): MAA 完整流程——点击槽位+OCR 干员名+截取头像存盘
locate_oper: 有缓存→TemplateMatch;无缓存→逐个点击槽位→OCR 干员名(BattleOperName roi)→存头像→关闭。 OCR ROI [5,177,191,37] 从 MAA tasks.json BattleOperName 获取。 executor._locate_oper 简化为调用 avatar.locate_oper(context, ctrl, name)。 ruff/pyright 全过。
1 parent f5ff75a commit cf9cd10

2 files changed

Lines changed: 138 additions & 116 deletions

File tree

custom/core/avatar.py

Lines changed: 131 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,33 @@
11
"""头像定位 + 运行时自学习(MAA 方案)。
22
3-
流程(移植自 MAA BattlefieldMatcher::deployment_analyze):
4-
1. detect_slots: 用 BattleOpersFlag 模板在待部署区找到所有干员槽位
5-
2. locate_avatar: 从每个槽位提取头像,和缓存做 TemplateMatch
6-
3. learn_avatar: 点击未知干员 → OCR 名字 → 截取头像存盘
7-
8-
模板来源:MaaAssistantArknights/resource/template/Battle/BattleFlag/BattleOpersFlag.png
9-
偏移量来源:tasks.json BattleOperAvatar.rectMove = [7, 32, 60, 60]
3+
完整流程(移植自 MAA BattlefieldMatcher + BattleHelper::update_deployment_):
4+
1. detect_slots: BattleOpersFlag 模板匹配 → 所有干员槽位
5+
2. locate_oper: 遍历槽位 → 有缓存则 TemplateMatch → 无缓存则点击+OCR+存头像
6+
3. OCR ROI 来自 MAA BattleOperName task: [5, 177, 191, 37]
107
"""
118

129
from __future__ import annotations
1310

1411
import json
1512
import logging
13+
import time
1614
from pathlib import Path
1715
from typing import TYPE_CHECKING
1816

1917
import numpy as np
2018

2119
if TYPE_CHECKING:
2220
from maa.context import Context
21+
from maa.controller import Controller
2322

2423
logger = logging.getLogger(__name__)
2524

26-
# MAA 常量(1280×720 坐标
27-
_FLAG_ROI = [33, 600, 1245, 18] # BattleOpersFlag roi
25+
# MAA 常量(1280×720)
26+
_FLAG_ROI = [33, 600, 1245, 18]
2827
_FLAG_THRESHOLD = 0.65
29-
_AVATAR_OFFSET = [7, 32, 60, 60] # BattleOperAvatar rectMove(相对 flag)
28+
_AVATAR_OFFSET = [7, 32, 60, 60] # BattleOperAvatar rectMove
29+
_NAME_ROI = [5, 177, 191, 37] # BattleOperName OCR roi
30+
_DETAIL_WAIT = 0.5 # 等详情页打开
3031

3132

3233
def _avatar_dir() -> Path:
@@ -52,11 +53,7 @@ def detect_slots(
5253
image: np.ndarray,
5354
threshold: float = _FLAG_THRESHOLD,
5455
) -> list[dict]:
55-
"""用 MAA TemplateMatch 检测待部署区所有干员槽位。
56-
57-
Returns:
58-
[{"rect": [x, y, w, h], "avatar_rect": [ax, ay, aw, ah]}, ...]
59-
"""
56+
"""用 TemplateMatch 检测待部署区所有干员槽位。"""
6057
reco_detail = context.run_recognition(
6158
"DetectSlots",
6259
image,
@@ -66,15 +63,14 @@ def detect_slots(
6663
"template": "BattleOpersFlag.png",
6764
"threshold": threshold,
6865
"roi": _FLAG_ROI,
69-
"method": 5, # TM_CCOEFF_NORMED
70-
"green_mask": True, # 模板黑色区域已转绿,green_mask 忽略
66+
"method": 5,
67+
"green_mask": True,
7168
"order_by": "Horizontal",
7269
}
7370
},
7471
)
7572

7673
if not reco_detail or not reco_detail.hit:
77-
logger.debug("未检测到干员槽位")
7874
return []
7975

8076
slots = []
@@ -83,124 +79,179 @@ def detect_slots(
8379
if box is None:
8480
continue
8581
fx, fy, fw, fh = box
86-
# 头像区域 = flag 位置 + avatar offset
8782
ax = int(fx + _AVATAR_OFFSET[0])
8883
ay = int(fy + _AVATAR_OFFSET[1])
8984
aw = _AVATAR_OFFSET[2]
9085
ah = _AVATAR_OFFSET[3]
86+
click_x = int(fx - 45 + 75 // 2)
87+
click_y = int(fy + 6 + 120 // 2)
9188
slots.append(
9289
{
9390
"flag_rect": (int(fx), int(fy), int(fw), int(fh)),
9491
"avatar_rect": (ax, ay, aw, ah),
95-
"click_rect": (
96-
int(fx - 45),
97-
int(fy + 6),
98-
75,
99-
120,
100-
), # BattleOperClickRange
92+
"click_pos": (click_x, click_y),
10193
}
10294
)
10395

10496
logger.info("检测到 %d 个干员槽位", len(slots))
10597
return slots
10698

10799

108-
def locate_avatar(
100+
def has_avatar(oper_name: str) -> bool:
101+
char_id = _get_char_id(oper_name)
102+
if not char_id:
103+
return False
104+
return (_avatar_dir() / f"{char_id}.png").exists()
105+
106+
107+
def locate_oper(
109108
context: Context,
110-
image: np.ndarray,
109+
ctrl: Controller,
111110
oper_name: str,
112-
threshold: float = 0.7,
113111
) -> tuple[float, float] | None:
114-
"""在待部署区定位指定干员
112+
"""定位指定干员在待部署区的位置
115113
116-
1. detect_slots 找所有槽位
117-
2. 用缓存的干员头像在每个槽位做 TemplateMatch
114+
MAA 方案:
115+
1. 检测所有槽位
116+
2. 有缓存 → TemplateMatch 匹配
117+
3. 无缓存/匹配失败 → 逐个点击槽位 → OCR 干员名 → 截取头像存盘
118118
"""
119-
char_id = _get_char_id(oper_name)
120-
if not char_id:
121-
logger.warning("干员 %s 不在 operator_mapping", oper_name)
122-
return None
123-
119+
image = ctrl.post_screencap().wait().get()
124120
slots = detect_slots(context, image)
125121
if not slots:
122+
logger.error("未检测到干员槽位")
126123
return None
127124

128-
# 在每个槽位的头像区域做 TemplateMatch
125+
char_id = _get_char_id(oper_name)
126+
h, w = image.shape[:2]
127+
128+
# Step 1: 有缓存 → 在每个槽位做 TemplateMatch
129+
if char_id and (_avatar_dir() / f"{char_id}.png").exists():
130+
for i, slot in enumerate(slots):
131+
ax, ay, aw, ah = slot["avatar_rect"]
132+
roi = [max(0, ax - 5), max(0, ay - 5), aw + 10, ah + 10]
133+
134+
reco = context.run_recognition(
135+
f"MatchAvatar_{char_id}_{i}",
136+
image,
137+
pipeline_override={
138+
f"MatchAvatar_{char_id}_{i}": {
139+
"recognition": "TemplateMatch",
140+
"template": f"avatar/{char_id}.png",
141+
"threshold": 0.7,
142+
"roi": roi,
143+
"method": 5,
144+
}
145+
},
146+
)
147+
148+
if reco and reco.hit:
149+
cx = ax + aw // 2
150+
cy = ay + ah // 2
151+
logger.info("干员 %s 在槽位 %d", oper_name, i)
152+
return (cx / w, cy / h)
153+
154+
logger.info("干员 %s 有缓存但未匹配,转入 OCR 学习", oper_name)
155+
156+
# Step 2: 无缓存或匹配失败 → 点击每个未识别槽位 → OCR → 存头像
129157
for i, slot in enumerate(slots):
130-
ax, ay, aw, ah = slot["avatar_rect"]
131-
# 扩大 ROI 略大于 avatar,给匹配留余量
132-
roi = [max(0, ax - 5), max(0, ay - 5), aw + 10, ah + 10]
133-
134-
reco_detail = context.run_recognition(
135-
f"MatchAvatar_{char_id}_slot{i}",
136-
image,
137-
pipeline_override={
138-
f"MatchAvatar_{char_id}_slot{i}": {
139-
"recognition": "TemplateMatch",
140-
"template": f"avatar/{char_id}.png",
141-
"threshold": threshold,
142-
"roi": roi,
143-
"method": 5,
144-
}
145-
},
146-
)
158+
click_x, click_y = slot["click_pos"]
159+
160+
# 点击打开详情页
161+
logger.debug("点击槽位 %d (%d, %d)", i, click_x, click_y)
162+
ctrl.post_click(click_x, click_y).wait()
163+
time.sleep(_DETAIL_WAIT)
164+
165+
# 截图详情页
166+
detail_img = ctrl.post_screencap().wait().get()
167+
168+
# OCR 干员名
169+
name = _ocr_oper_name(context, detail_img)
170+
logger.info("槽位 %d OCR: %s", i, name or "(空)")
147171

148-
if reco_detail and reco_detail.hit:
149-
# 返回干员的点击位置(相对全屏比例)
150-
cx, cy = ax + aw // 2, ay + ah // 2
151-
h, w = image.shape[:2]
152-
logger.info("干员 %s 在槽位 %d: (%d, %d)", oper_name, i, cx, cy)
153-
return (cx / w, cy / h)
172+
# 关闭详情页(再点一次)
173+
ctrl.post_click(click_x, click_y).wait()
174+
time.sleep(0.3)
154175

155-
logger.warning("干员 %s 在 %d 个槽位中均未匹配", oper_name, len(slots))
176+
if name:
177+
# 存头像(从原始 deployment 截图截取,不是详情页)
178+
_save_avatar_from_image(image, slot, name)
179+
180+
if name == oper_name:
181+
cx = click_x
182+
cy = click_y
183+
logger.info("找到目标干员 %s 在槽位 %d", oper_name, i)
184+
return (cx / w, cy / h)
185+
186+
logger.error("未找到干员 %s", oper_name)
156187
return None
157188

158189

159-
def has_avatar(oper_name: str) -> bool:
160-
"""检查干员是否有缓存头像。"""
161-
char_id = _get_char_id(oper_name)
162-
if not char_id:
163-
return False
164-
return (_avatar_dir() / f"{char_id}.png").exists()
190+
def _ocr_oper_name(context: Context, detail_img: np.ndarray) -> str | None:
191+
"""OCR 读取详情页干员名。"""
192+
reco = context.run_recognition(
193+
"OcrOperName",
194+
detail_img,
195+
pipeline_override={
196+
"OcrOperName": {
197+
"recognition": "OCR",
198+
"roi": _NAME_ROI,
199+
"threshold": 0.3,
200+
"order_by": "Horizontal",
201+
}
202+
},
203+
)
204+
205+
if not reco or not reco.hit:
206+
return None
207+
208+
# 取最高分结果
209+
detail = getattr(reco, "raw_detail", None)
210+
211+
# OCR 结果的文字在 raw_detail 里
212+
if detail and isinstance(detail, dict):
213+
text = detail.get("text", "")
214+
if text:
215+
return text.strip()
165216

217+
# 尝试从 all_results 获取
218+
for result in reco.all_results if hasattr(reco, "all_results") else []:
219+
detail_r = getattr(result, "detail", None) or getattr(result, "raw_detail", None)
220+
if detail_r and isinstance(detail_r, dict):
221+
text = detail_r.get("text", "")
222+
if text:
223+
return text.strip()
166224

167-
def learn_avatar_from_slot(
225+
return None
226+
227+
228+
def _save_avatar_from_image(
168229
image: np.ndarray,
169230
slot: dict,
170231
oper_name: str,
171232
) -> bool:
172-
"""从指定槽位截取头像并存盘。
173-
174-
Args:
175-
image: 全屏截图。
176-
slot: detect_slots 返回的槽位 dict。
177-
oper_name: 干员名。
178-
"""
233+
"""从截图截取槽位头像并存盘。"""
179234
char_id = _get_char_id(oper_name)
180235
if not char_id:
181-
logger.error("干员 %s 不在 operator_mapping", oper_name)
182236
return False
183237

184238
ax, ay, aw, ah = slot["avatar_rect"]
185239
h, w = image.shape[:2]
186-
# 边界检查
187240
x1, y1 = max(0, ax), max(0, ay)
188241
x2, y2 = min(w, ax + aw), min(h, ay + ah)
189242

190243
avatar = image[y1:y2, x1:x2]
191244
if avatar.size == 0:
192-
logger.error("头像区域为空")
193245
return False
194246

195247
from PIL import Image
196248

197249
out_path = _avatar_dir() / f"{char_id}.png"
198-
avatar_rgb = avatar[..., ::-1].copy() # BGR → RGB
250+
avatar_rgb = avatar[..., ::-1].copy()
199251
Image.fromarray(avatar_rgb).save(out_path)
200252
logger.info("头像已缓存: %s → %s", oper_name, out_path)
201253
return True
202254

203255

204256
def list_cached() -> list[str]:
205-
"""列出已缓存的头像文件名。"""
206257
return sorted(p.name for p in _avatar_dir().glob("*.png"))

custom/maa/action/executor.py

Lines changed: 7 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,7 @@
2626
from maa.custom_action import CustomAction
2727

2828
from custom import config
29-
from custom.core.avatar import (
30-
detect_slots,
31-
has_avatar,
32-
learn_avatar_from_slot,
33-
locate_avatar,
34-
)
29+
from custom.core.avatar import locate_oper
3530
from custom.core.battle.action import Action, ActionType, DirectionType
3631
from custom.core.battle.game_time import GameTime
3732
from custom.core.geometry.convert_pos import convert_position
@@ -147,35 +142,11 @@ def _parse_actions(self, raw: list[dict], map_data: dict) -> list[Action]:
147142

148143
return actions
149144

150-
def _locate_oper(self, context: Context, oper_name: str) -> tuple[float, float] | None:
151-
"""定位干员头像。MAA 方案:检测槽位 → TemplateMatch 匹配。
152-
153-
无缓存头像时:检测槽位 → 从 LAST_OPER_RATIO 最近槽位学习。
154-
"""
155-
img = context.tasker.controller.post_screencap().wait().get()
156-
157-
# 有缓存 → 直接匹配
158-
if has_avatar(oper_name):
159-
pos = locate_avatar(context, img, oper_name)
160-
if pos is not None:
161-
return pos
162-
logger.warning("干员 %s 有缓存但未匹配,尝试重新学习", oper_name)
163-
164-
# 无缓存或匹配失败 → 检测槽位 → 从最近槽位学习
165-
logger.info("干员 %s 学习头像...", oper_name)
166-
slots = detect_slots(context, img)
167-
if not slots:
168-
logger.error("未检测到干员槽位")
169-
return None
170-
171-
# 选择最右边的槽位(LAST_OPER_RATIO 附近)学习
172-
last_slot = slots[-1]
173-
if learn_avatar_from_slot(img, last_slot, oper_name):
174-
logger.info("头像学习成功,重新定位")
175-
return locate_avatar(context, img, oper_name)
176-
177-
logger.error("头像学习失败")
178-
return None
145+
def _locate_oper(
146+
self, context: Context, ctrl: Controller, oper_name: str
147+
) -> tuple[float, float] | None:
148+
"""定位干员头像。MAA 方案:检测槽位 → 有缓存 TemplateMatch → 无缓存 点击+OCR+存头像。"""
149+
return locate_oper(context, ctrl, oper_name)
179150

180151
def _detect_platform(self, ctrl: Controller) -> bool:
181152
"""检测是否 PC 客户端(Win32)。"""
@@ -297,7 +268,7 @@ def _deploy(self, context: Context, ctrl: Controller, action: Action, is_pc: boo
297268
logger.info("部署 %s at %s", action.oper, action.pos)
298269

299270
# 定位干员头像(MAA TemplateMatch,回退到 LAST_OPER_RATIO)
300-
avatar_pos = self._locate_oper(context, action.oper)
271+
avatar_pos = self._locate_oper(context, ctrl, action.oper)
301272
if avatar_pos is not None:
302273
avatar_x = int(avatar_pos[0] * 1280)
303274
avatar_y = int(avatar_pos[1] * 720)

0 commit comments

Comments
 (0)