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
129from __future__ import annotations
1310
1411import json
1512import logging
13+ import time
1614from pathlib import Path
1715from typing import TYPE_CHECKING
1816
1917import numpy as np
2018
2119if TYPE_CHECKING :
2220 from maa .context import Context
21+ from maa .controller import Controller
2322
2423logger = 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
3233def _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
204256def list_cached () -> list [str ]:
205- """列出已缓存的头像文件名。"""
206257 return sorted (p .name for p in _avatar_dir ().glob ("*.png" ))
0 commit comments