mobile-mcp-ai 2.4.2__py3-none-any.whl → 2.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -387,17 +387,23 @@ class BasicMobileToolsLite:
387
387
 
388
388
  if popup_bounds:
389
389
  px1, py1, px2, py2 = popup_bounds
390
+ popup_width = px2 - px1
391
+ popup_height = py2 - py1
390
392
 
391
393
  # 绘制弹窗边框(蓝色)
392
394
  draw.rectangle([px1, py1, px2, py2], outline=(0, 100, 255, 200), width=3)
393
395
  draw.text((px1 + 5, py1 + 5), f"弹窗区域", fill=(0, 100, 255), font=font)
394
396
 
395
- # 计算可能的 X 按钮位置
397
+ # 计算可能的 X 按钮位置(基于弹窗尺寸动态计算,适配不同分辨率)
398
+ offset_x = max(25, int(popup_width * 0.05)) # 宽度的5%,最小25px
399
+ offset_y = max(25, int(popup_height * 0.04)) # 高度的4%,最小25px
400
+ outer_offset = max(15, int(popup_width * 0.025)) # 外部偏移
401
+
396
402
  close_positions = [
397
- {"name": "右上角外", "x": px2 - 20, "y": py1 - 35, "priority": 1},
398
- {"name": "右上角内", "x": px2 - 35, "y": py1 + 35, "priority": 2},
399
- {"name": "正上方", "x": (px1 + px2) // 2, "y": py1 - 35, "priority": 3},
400
- {"name": "底部下方", "x": (px1 + px2) // 2, "y": py2 + 40, "priority": 4},
403
+ {"name": "右上角内", "x": px2 - offset_x, "y": py1 + offset_y, "priority": 1},
404
+ {"name": "右上角外", "x": px2 + outer_offset, "y": py1 - outer_offset, "priority": 2},
405
+ {"name": "正上方", "x": (px1 + px2) // 2, "y": py1 - offset_y, "priority": 3},
406
+ {"name": "底部下方", "x": (px1 + px2) // 2, "y": py2 + offset_y, "priority": 4},
401
407
  ]
402
408
 
403
409
  # 绘制可能的 X 按钮位置(绿色圆圈 + 数字)
@@ -610,9 +616,8 @@ class BasicMobileToolsLite:
610
616
  'desc': elem['desc']
611
617
  })
612
618
 
613
- # 第3.5步:检测弹窗并标注可能的 X 按钮位置(如果 X 不在元素树中)
619
+ # 第3.5步:检测弹窗区域(用于标注)
614
620
  popup_bounds = None
615
- popup_close_hints = []
616
621
 
617
622
  if not self._is_ios():
618
623
  try:
@@ -644,56 +649,24 @@ class BasicMobileToolsLite:
644
649
  if popup_bounds is None or p_area > (popup_bounds[2] - popup_bounds[0]) * (popup_bounds[3] - popup_bounds[1]):
645
650
  popup_bounds = (px1, py1, px2, py2)
646
651
 
647
- # 如果检测到弹窗,始终添加 X 按钮位置提示
652
+ # 如果检测到弹窗,标注弹窗边界(不再猜测X按钮位置)
648
653
  if popup_bounds:
649
654
  px1, py1, px2, py2 = popup_bounds
650
655
 
651
- # 计算多个可能的 X 按钮位置(基于弹窗边界)
652
- close_positions = [
653
- {"name": "右上内", "x": px2 - 35, "y": py1 + 40},
654
- {"name": "右上外", "x": px2 - 20, "y": py1 - 40},
655
- {"name": "正上方", "x": (px1 + px2) // 2, "y": py1 - 40},
656
- ]
657
-
658
- # 用黄色/金色标注这些可能位置(始终显示)
659
- hint_color = (255, 200, 0) # 金黄色
660
- next_index = len(som_elements) + 1
656
+ # 只画弹窗边框(蓝色),不再猜测X按钮位置
657
+ draw.rectangle([px1, py1, px2, py2], outline=(0, 150, 255, 180), width=3)
661
658
 
662
- for pos in close_positions:
663
- hx, hy = pos["x"], pos["y"]
664
- if 0 <= hx <= img_width and 0 <= hy <= img_height:
665
- # 画圆圈
666
- draw.ellipse([hx-18, hy-18, hx+18, hy+18],
667
- outline=hint_color + (255,), width=3)
668
- # 画编号背景
669
- draw.rectangle([hx-10, hy-22, hx+10, hy-6],
670
- fill=hint_color + (220,))
671
- # 画编号
672
- draw.text((hx-6, hy-20), str(next_index),
673
- fill=(0, 0, 0), font=font_small)
674
- # 标注 "X?"
675
- draw.text((hx-8, hy-5), "X?", fill=hint_color, font=font_small)
676
-
677
- popup_close_hints.append({
678
- 'index': next_index,
679
- 'center': (hx, hy),
680
- 'bounds': f"[{hx-20},{hy-20}][{hx+20},{hy+20}]",
681
- 'desc': f"X?{pos['name']}",
682
- 'is_hint': True
683
- })
684
- next_index += 1
685
-
686
- # 画弹窗边框(蓝色)
687
- draw.rectangle([px1, py1, px2, py2], outline=(0, 150, 255, 180), width=2)
659
+ # 在弹窗边框上标注提示文字
660
+ try:
661
+ draw.text((px1+5, py1-25), "弹窗区域", fill=(0, 150, 255), font=font_small)
662
+ except:
663
+ pass
688
664
 
689
665
  except Exception as e:
690
666
  pass # 弹窗检测失败不影响主功能
691
667
 
692
- # 合并元素列表
693
- all_som_elements = som_elements + popup_close_hints
694
-
695
668
  # 保存到实例变量,供 click_by_som 使用
696
- self._som_elements = all_som_elements
669
+ self._som_elements = som_elements
697
670
 
698
671
  # 第4步:保存标注后的截图
699
672
  filename = f"screenshot_{platform}_som_{timestamp}.jpg"
@@ -721,12 +694,10 @@ class BasicMobileToolsLite:
721
694
 
722
695
  # 构建弹窗提示文字
723
696
  hints_text = ""
724
- if popup_close_hints:
725
- hints_text = "\n🎯 检测到弹窗,可能的 X 按钮位置(黄色圆圈):\n"
726
- hints_text += "\n".join([
727
- f" [{h['index']}] {h['desc']} → ({h['center'][0]}, {h['center'][1]})"
728
- for h in popup_close_hints
729
- ])
697
+ if popup_bounds:
698
+ hints_text = f"\n🎯 检测到弹窗区域(蓝色边框)\n"
699
+ hints_text += f" 如需关闭弹窗,请观察图片中的 X 按钮位置\n"
700
+ hints_text += f" 然后使用 mobile_click_by_percent(x%, y%) 点击"
730
701
 
731
702
  return {
732
703
  "success": True,
@@ -735,15 +706,16 @@ class BasicMobileToolsLite:
735
706
  "screen_height": screen_height,
736
707
  "image_width": img_width,
737
708
  "image_height": img_height,
738
- "element_count": len(all_som_elements),
739
- "elements": all_som_elements,
709
+ "element_count": len(som_elements),
710
+ "elements": som_elements,
740
711
  "popup_detected": popup_bounds is not None,
741
712
  "popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_bounds else None,
742
- "close_hints": popup_close_hints,
743
713
  "message": f"📸 SoM 截图已保存: {final_path}\n"
744
- f"🏷️ 已标注 {len(all_som_elements)} 个元素({len(som_elements)} 个可点击 + {len(popup_close_hints)} 个X按钮提示)\n"
714
+ f"🏷️ 已标注 {len(som_elements)} 个可点击元素\n"
745
715
  f"📋 元素列表:\n{elements_text}{hints_text}\n\n"
746
- f"💡 使用方法:看图后调用 mobile_click_by_som(编号) 点击对应元素"
716
+ f"💡 使用方法:\n"
717
+ f" - 点击标注元素:mobile_click_by_som(编号)\n"
718
+ f" - 点击任意位置:mobile_click_by_percent(x%, y%)"
747
719
  }
748
720
 
749
721
  except ImportError:
@@ -2200,13 +2172,26 @@ class BasicMobileToolsLite:
2200
2172
  # 如果检测到弹窗区域,先尝试点击常见的关闭按钮位置
2201
2173
  if popup_bounds:
2202
2174
  px1, py1, px2, py2 = popup_bounds
2175
+ popup_width = px2 - px1
2176
+ popup_height = py2 - py1
2177
+
2178
+ # 【优化】X按钮有三种常见位置:
2179
+ # 1. 弹窗内靠近顶部边界(内嵌X按钮)- 最常见
2180
+ # 2. 弹窗边界上方(浮动X按钮)
2181
+ # 3. 弹窗正下方(底部关闭按钮)
2182
+ offset_x = max(60, int(popup_width * 0.07)) # 宽度7%
2183
+ offset_y_above = max(35, int(popup_height * 0.025)) # 高度2.5%,在边界之上
2184
+ offset_y_near = max(45, int(popup_height * 0.03)) # 高度3%,紧贴顶边界内侧
2203
2185
 
2204
- # 常见的关闭按钮位置
2205
2186
  try_positions = [
2206
- (px2 - 20, py1 - 30, "弹窗正上方"),
2207
- (px2 - 30, py1 + 30, "弹窗右上角内"),
2208
- (px2 + 20, py1 - 20, "弹窗右上角外"),
2209
- ((px1 + px2) // 2, py2 + 40, "弹窗下方中间"),
2187
+ # 【最高优先级】弹窗内紧贴顶部边界
2188
+ (px2 - offset_x, py1 + offset_y_near, "弹窗右上角"),
2189
+ # 弹窗边界上方(浮动X按钮)
2190
+ (px2 - offset_x, py1 - offset_y_above, "弹窗右上浮"),
2191
+ # 弹窗正下方中间(底部关闭按钮)
2192
+ ((px1 + px2) // 2, py2 + max(50, int(popup_height * 0.04)), "弹窗下方中间"),
2193
+ # 弹窗正上方中间
2194
+ ((px1 + px2) // 2, py1 - 40, "弹窗正上方"),
2210
2195
  ]
2211
2196
 
2212
2197
  for try_x, try_y, position_name in try_positions:
@@ -2671,3 +2656,538 @@ class BasicMobileToolsLite:
2671
2656
  "preview": script[:500] + "..."
2672
2657
  }
2673
2658
 
2659
+ # ========== 模板匹配功能 ==========
2660
+
2661
+ def template_match_close(self, screenshot_path: Optional[str] = None, threshold: float = 0.75) -> Dict:
2662
+ """使用模板匹配查找关闭按钮
2663
+
2664
+ 基于 OpenCV 模板匹配,从预设的X号模板库中查找匹配项。
2665
+ 比 AI 视觉识别更精准、更快速。
2666
+
2667
+ Args:
2668
+ screenshot_path: 截图路径(可选,不提供则自动截图)
2669
+ threshold: 匹配阈值 0-1,越高越严格,默认0.75
2670
+
2671
+ Returns:
2672
+ 匹配结果,包含坐标和点击命令
2673
+ """
2674
+ try:
2675
+ from .template_matcher import TemplateMatcher
2676
+
2677
+ # 如果没有提供截图,先截图
2678
+ if screenshot_path is None:
2679
+ screenshot_result = self.take_screenshot(description="模板匹配", compress=False)
2680
+ screenshot_path = screenshot_result.get("screenshot_path")
2681
+ if not screenshot_path:
2682
+ return {"success": False, "error": "截图失败"}
2683
+
2684
+ matcher = TemplateMatcher()
2685
+ result = matcher.find_close_buttons(screenshot_path, threshold)
2686
+
2687
+ return result
2688
+
2689
+ except ImportError:
2690
+ return {
2691
+ "success": False,
2692
+ "error": "需要安装 opencv-python: pip install opencv-python"
2693
+ }
2694
+ except Exception as e:
2695
+ return {"success": False, "error": f"模板匹配失败: {e}"}
2696
+
2697
+ def template_click_close(self, threshold: float = 0.75) -> Dict:
2698
+ """模板匹配并点击关闭按钮(一步到位)
2699
+
2700
+ 截图 -> 模板匹配 -> 点击最佳匹配位置
2701
+
2702
+ Args:
2703
+ threshold: 匹配阈值 0-1
2704
+
2705
+ Returns:
2706
+ 操作结果
2707
+ """
2708
+ try:
2709
+ # 先截图并匹配
2710
+ match_result = self.template_match_close(threshold=threshold)
2711
+
2712
+ if not match_result.get("success"):
2713
+ return match_result
2714
+
2715
+ # 获取最佳匹配的百分比坐标
2716
+ best = match_result.get("best_match", {})
2717
+ x_percent = best.get("percent", {}).get("x")
2718
+ y_percent = best.get("percent", {}).get("y")
2719
+
2720
+ if x_percent is None or y_percent is None:
2721
+ return {"success": False, "error": "无法获取匹配坐标"}
2722
+
2723
+ # 点击
2724
+ click_result = self.click_by_percent(x_percent, y_percent)
2725
+
2726
+ return {
2727
+ "success": True,
2728
+ "message": f"✅ 模板匹配并点击成功",
2729
+ "matched_template": best.get("template"),
2730
+ "confidence": best.get("confidence"),
2731
+ "clicked_position": f"({x_percent}%, {y_percent}%)",
2732
+ "click_result": click_result
2733
+ }
2734
+
2735
+ except Exception as e:
2736
+ return {"success": False, "error": f"模板点击失败: {e}"}
2737
+
2738
+ def template_add(self, screenshot_path: str, x: int, y: int,
2739
+ width: int, height: int, template_name: str) -> Dict:
2740
+ """从截图中裁剪并添加新模板
2741
+
2742
+ 当遇到新样式的X号时,用此方法添加到模板库。
2743
+
2744
+ Args:
2745
+ screenshot_path: 截图路径
2746
+ x, y: 裁剪区域左上角坐标
2747
+ width, height: 裁剪区域大小
2748
+ template_name: 模板名称(如 x_circle_gray)
2749
+
2750
+ Returns:
2751
+ 结果
2752
+ """
2753
+ try:
2754
+ from .template_matcher import TemplateMatcher
2755
+
2756
+ matcher = TemplateMatcher()
2757
+ return matcher.crop_and_add_template(
2758
+ screenshot_path, x, y, width, height, template_name
2759
+ )
2760
+ except ImportError:
2761
+ return {"success": False, "error": "需要安装 opencv-python"}
2762
+ except Exception as e:
2763
+ return {"success": False, "error": f"添加模板失败: {e}"}
2764
+
2765
+ def template_list(self) -> Dict:
2766
+ """列出所有关闭按钮模板"""
2767
+ try:
2768
+ from .template_matcher import TemplateMatcher
2769
+
2770
+ matcher = TemplateMatcher()
2771
+ return matcher.list_templates()
2772
+ except ImportError:
2773
+ return {"success": False, "error": "需要安装 opencv-python"}
2774
+ except Exception as e:
2775
+ return {"success": False, "error": f"列出模板失败: {e}"}
2776
+
2777
+ def template_delete(self, template_name: str) -> Dict:
2778
+ """删除指定模板"""
2779
+ try:
2780
+ from .template_matcher import TemplateMatcher
2781
+
2782
+ matcher = TemplateMatcher()
2783
+ return matcher.delete_template(template_name)
2784
+ except ImportError:
2785
+ return {"success": False, "error": "需要安装 opencv-python"}
2786
+ except Exception as e:
2787
+ return {"success": False, "error": f"删除模板失败: {e}"}
2788
+
2789
+ def close_ad_popup(self, auto_learn: bool = True) -> Dict:
2790
+ """智能关闭广告弹窗(专用于广告场景)
2791
+
2792
+ 按优先级尝试:
2793
+ 1. 控件树查找关闭按钮(最可靠)
2794
+ 2. 模板匹配(需要积累模板库)
2795
+ 3. 返回视觉信息供 AI 分析(如果前两步失败)
2796
+
2797
+ 自动学习:
2798
+ - 点击成功后,检查这个 X 是否已在模板库
2799
+ - 如果是新样式,自动裁剪并添加到模板库
2800
+
2801
+ Args:
2802
+ auto_learn: 是否自动学习新模板(点击成功后检查并保存)
2803
+
2804
+ Returns:
2805
+ 结果字典
2806
+ """
2807
+ import time
2808
+ import re
2809
+
2810
+ result = {
2811
+ "success": False,
2812
+ "method": None,
2813
+ "message": "",
2814
+ "learned_template": None
2815
+ }
2816
+
2817
+ if self._is_ios():
2818
+ return {"success": False, "error": "iOS 暂不支持此功能"}
2819
+
2820
+ try:
2821
+ import xml.etree.ElementTree as ET
2822
+
2823
+ # ========== 第1步:控件树查找关闭按钮 ==========
2824
+ xml_string = self.client.u2.dump_hierarchy()
2825
+ root = ET.fromstring(xml_string)
2826
+
2827
+ # 关闭按钮的常见特征
2828
+ close_keywords = ['关闭', '跳过', '×', 'X', 'x', 'close', 'skip', '取消']
2829
+ close_content_desc = ['关闭', '跳过', 'close', 'skip', 'dismiss']
2830
+
2831
+ close_candidates = []
2832
+
2833
+ for elem in root.iter():
2834
+ text = elem.attrib.get('text', '').strip()
2835
+ content_desc = elem.attrib.get('content-desc', '').strip()
2836
+ clickable = elem.attrib.get('clickable', 'false') == 'true'
2837
+ bounds_str = elem.attrib.get('bounds', '')
2838
+ resource_id = elem.attrib.get('resource-id', '')
2839
+
2840
+ if not bounds_str:
2841
+ continue
2842
+
2843
+ match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
2844
+ if not match:
2845
+ continue
2846
+
2847
+ x1, y1, x2, y2 = map(int, match.groups())
2848
+ width = x2 - x1
2849
+ height = y2 - y1
2850
+ cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
2851
+
2852
+ score = 0
2853
+ reason = ""
2854
+
2855
+ # 文本匹配
2856
+ for kw in close_keywords:
2857
+ if kw in text:
2858
+ score += 10
2859
+ reason = f"文本含'{kw}'"
2860
+ break
2861
+
2862
+ # content-desc 匹配
2863
+ for kw in close_content_desc:
2864
+ if kw.lower() in content_desc.lower():
2865
+ score += 8
2866
+ reason = f"描述含'{kw}'"
2867
+ break
2868
+
2869
+ # 小尺寸可点击元素(可能是 X 按钮)
2870
+ if clickable and 30 < width < 200 and 30 < height < 200:
2871
+ screen_width = self.client.u2.info.get('displayWidth', 1440)
2872
+ screen_height = self.client.u2.info.get('displayHeight', 3200)
2873
+
2874
+ # 在屏幕右半边上半部分,很可能是 X
2875
+ if cx > screen_width * 0.6 and cy < screen_height * 0.5:
2876
+ score += 5
2877
+ reason = reason or "右上角小按钮"
2878
+ # 在屏幕上半部分的小按钮,也可能是 X
2879
+ elif cy < screen_height * 0.4:
2880
+ score += 2
2881
+ reason = reason or "上部小按钮"
2882
+
2883
+ # 只要是可点击的小按钮都考虑(即使没有文本)
2884
+ if score > 0 or (clickable and 30 < width < 150 and 30 < height < 150):
2885
+ if not reason and clickable:
2886
+ reason = "可点击小按钮"
2887
+ score = max(score, 1) # 确保有分数
2888
+ close_candidates.append({
2889
+ 'score': score,
2890
+ 'reason': reason,
2891
+ 'bounds': (x1, y1, x2, y2),
2892
+ 'center': (cx, cy),
2893
+ 'resource_id': resource_id,
2894
+ 'text': text
2895
+ })
2896
+
2897
+ # 按分数排序
2898
+ close_candidates.sort(key=lambda x: x['score'], reverse=True)
2899
+
2900
+ if close_candidates:
2901
+ best = close_candidates[0]
2902
+ cx, cy = best['center']
2903
+ bounds = best['bounds']
2904
+
2905
+ # 点击前截图(用于自动学习)
2906
+ pre_screenshot = None
2907
+ if auto_learn:
2908
+ pre_result = self.take_screenshot(description="关闭前", compress=False)
2909
+ pre_screenshot = pre_result.get("screenshot_path")
2910
+
2911
+ # 点击
2912
+ self.click_at_coords(cx, cy)
2913
+ time.sleep(0.5)
2914
+
2915
+ result["success"] = True
2916
+ result["method"] = "控件树"
2917
+ result["message"] = f"✅ 通过控件树找到关闭按钮并点击\n" \
2918
+ f" 位置: ({cx}, {cy})\n" \
2919
+ f" 原因: {best['reason']}"
2920
+
2921
+ # 自动学习:检查这个 X 是否已在模板库,不在就添加
2922
+ if auto_learn and pre_screenshot:
2923
+ learn_result = self._auto_learn_template(pre_screenshot, bounds)
2924
+ if learn_result:
2925
+ result["learned_template"] = learn_result
2926
+ result["message"] += f"\n📚 自动学习: {learn_result}"
2927
+
2928
+ return result
2929
+
2930
+ # ========== 第2步:模板匹配 ==========
2931
+ screenshot_path = None
2932
+ try:
2933
+ from .template_matcher import TemplateMatcher
2934
+
2935
+ # 截图用于模板匹配
2936
+ screenshot_result = self.take_screenshot(description="模板匹配", compress=False)
2937
+ screenshot_path = screenshot_result.get("screenshot_path")
2938
+
2939
+ if screenshot_path:
2940
+ matcher = TemplateMatcher()
2941
+ match_result = matcher.find_close_buttons(screenshot_path, threshold=0.75)
2942
+
2943
+ # 直接使用最佳匹配(已按置信度排序)
2944
+ if match_result.get("success") and match_result.get("best_match"):
2945
+ best = match_result["best_match"]
2946
+ x_pct = best["percent"]["x"]
2947
+ y_pct = best["percent"]["y"]
2948
+
2949
+ # 点击
2950
+ self.click_by_percent(x_pct, y_pct)
2951
+ time.sleep(0.5)
2952
+
2953
+ result["success"] = True
2954
+ result["method"] = "模板匹配"
2955
+ result["message"] = f"✅ 通过模板匹配找到关闭按钮并点击\n" \
2956
+ f" 模板: {best.get('template', 'unknown')}\n" \
2957
+ f" 置信度: {best.get('confidence', 'N/A')}%\n" \
2958
+ f" 位置: ({x_pct:.1f}%, {y_pct:.1f}%)"
2959
+ return result
2960
+
2961
+ except ImportError:
2962
+ pass # OpenCV 未安装,跳过模板匹配
2963
+ except Exception:
2964
+ pass # 模板匹配失败,继续下一步
2965
+
2966
+ # ========== 第3步:返回截图供 AI 分析 ==========
2967
+ if not screenshot_path:
2968
+ screenshot_result = self.take_screenshot(description="需要AI分析", compress=True)
2969
+
2970
+ result["success"] = False
2971
+ result["method"] = None
2972
+ result["message"] = "❌ 控件树和模板匹配都未找到关闭按钮\n" \
2973
+ "📸 已截图,请 AI 分析图片中的 X 按钮位置\n" \
2974
+ "💡 找到后使用 mobile_click_by_percent(x%, y%) 点击"
2975
+ result["screenshot"] = screenshot_result if not screenshot_path else {"screenshot_path": screenshot_path}
2976
+ result["need_ai_analysis"] = True
2977
+
2978
+ return result
2979
+
2980
+ except Exception as e:
2981
+ return {"success": False, "error": f"关闭弹窗失败: {e}"}
2982
+
2983
+ def _detect_popup_region(self, root) -> tuple:
2984
+ """从控件树中检测弹窗区域
2985
+
2986
+ Args:
2987
+ root: 控件树根元素
2988
+
2989
+ Returns:
2990
+ 弹窗边界 (x1, y1, x2, y2) 或 None
2991
+ """
2992
+ import re
2993
+
2994
+ screen_width = self.client.u2.info.get('displayWidth', 1440)
2995
+ screen_height = self.client.u2.info.get('displayHeight', 3200)
2996
+
2997
+ popup_candidates = []
2998
+
2999
+ for elem in root.iter():
3000
+ bounds_str = elem.attrib.get('bounds', '')
3001
+ if not bounds_str:
3002
+ continue
3003
+
3004
+ match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
3005
+ if not match:
3006
+ continue
3007
+
3008
+ x1, y1, x2, y2 = map(int, match.groups())
3009
+ width = x2 - x1
3010
+ height = y2 - y1
3011
+
3012
+ # 弹窗特征:
3013
+ # 1. 不是全屏
3014
+ # 2. 在屏幕中央
3015
+ # 3. 有一定大小
3016
+ is_fullscreen = (width >= screen_width * 0.95 and height >= screen_height * 0.9)
3017
+ is_centered = (x1 > screen_width * 0.05 and x2 < screen_width * 0.95)
3018
+ is_reasonable_size = (width > 200 and height > 200 and
3019
+ width < screen_width * 0.95 and
3020
+ height < screen_height * 0.8)
3021
+
3022
+ if not is_fullscreen and is_centered and is_reasonable_size:
3023
+ # 计算"弹窗感"分数
3024
+ area = width * height
3025
+ center_x = (x1 + x2) / 2
3026
+ center_y = (y1 + y2) / 2
3027
+ center_dist = abs(center_x - screen_width/2) + abs(center_y - screen_height/2)
3028
+
3029
+ score = area / 1000 - center_dist / 10
3030
+ popup_candidates.append({
3031
+ 'bounds': (x1, y1, x2, y2),
3032
+ 'score': score
3033
+ })
3034
+
3035
+ if popup_candidates:
3036
+ # 返回分数最高的弹窗
3037
+ popup_candidates.sort(key=lambda x: x['score'], reverse=True)
3038
+ return popup_candidates[0]['bounds']
3039
+
3040
+ return None
3041
+
3042
+ def _auto_learn_template(self, screenshot_path: str, bounds: tuple, threshold: float = 0.6) -> str:
3043
+ """自动学习:检查 X 按钮是否已在模板库,不在就添加
3044
+
3045
+ Args:
3046
+ screenshot_path: 截图路径
3047
+ bounds: X 按钮的边界 (x1, y1, x2, y2)
3048
+ threshold: 判断是否已存在的阈值(高于此值认为已存在)
3049
+
3050
+ Returns:
3051
+ 新模板名称,如果是新模板的话;已存在或失败返回 None
3052
+ """
3053
+ try:
3054
+ from .template_matcher import TemplateMatcher
3055
+ from PIL import Image
3056
+ import time
3057
+
3058
+ x1, y1, x2, y2 = bounds
3059
+ cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
3060
+ width = x2 - x1
3061
+ height = y2 - y1
3062
+
3063
+ # 扩展一点边界,确保裁剪完整
3064
+ padding = max(10, int(max(width, height) * 0.2))
3065
+
3066
+ # 打开截图
3067
+ img = Image.open(screenshot_path)
3068
+
3069
+ # 裁剪 X 按钮区域
3070
+ crop_x1 = max(0, x1 - padding)
3071
+ crop_y1 = max(0, y1 - padding)
3072
+ crop_x2 = min(img.width, x2 + padding)
3073
+ crop_y2 = min(img.height, y2 + padding)
3074
+
3075
+ cropped = img.crop((crop_x1, crop_y1, crop_x2, crop_y2))
3076
+
3077
+ # 保存临时文件用于匹配检查
3078
+ temp_path = self.screenshot_dir / "temp_new_x.png"
3079
+ cropped.save(str(temp_path))
3080
+
3081
+ # 检查是否已在模板库中(用模板匹配检测相似度)
3082
+ matcher = TemplateMatcher()
3083
+
3084
+ import cv2
3085
+ new_img = cv2.imread(str(temp_path), cv2.IMREAD_GRAYSCALE)
3086
+ if new_img is None:
3087
+ return None
3088
+
3089
+ is_new = True
3090
+ for template_file in matcher.template_dir.glob("*.png"):
3091
+ template = cv2.imread(str(template_file), cv2.IMREAD_GRAYSCALE)
3092
+ if template is None:
3093
+ continue
3094
+
3095
+ # 将两个图都调整到合适大小,然后用小模板在大图中搜索
3096
+ # 这样比较更接近实际匹配场景
3097
+
3098
+ # 新图作为搜索区域(稍大一点)
3099
+ new_resized = cv2.resize(new_img, (100, 100))
3100
+ # 模板调整到较小尺寸
3101
+ template_resized = cv2.resize(template, (60, 60))
3102
+
3103
+ # 在新图中搜索模板
3104
+ result = cv2.matchTemplate(new_resized, template_resized, cv2.TM_CCOEFF_NORMED)
3105
+ _, max_val, _, _ = cv2.minMaxLoc(result)
3106
+
3107
+ if max_val >= threshold:
3108
+ is_new = False
3109
+ break
3110
+
3111
+ # 清理临时文件
3112
+ if temp_path.exists():
3113
+ temp_path.unlink()
3114
+
3115
+ if is_new:
3116
+ # 生成唯一模板名
3117
+ timestamp = time.strftime("%m%d_%H%M%S")
3118
+ template_name = f"auto_x_{timestamp}.png"
3119
+ template_path = matcher.template_dir / template_name
3120
+
3121
+ # 保存新模板
3122
+ cropped.save(str(template_path))
3123
+
3124
+ return template_name
3125
+ else:
3126
+ return None # 已存在类似模板
3127
+
3128
+ except Exception as e:
3129
+ return None # 学习失败,不影响主流程
3130
+
3131
+ def template_add_by_percent(self, x_percent: float, y_percent: float,
3132
+ size: int, template_name: str) -> Dict:
3133
+ """通过百分比坐标添加模板(更方便!)
3134
+
3135
+ 自动截图 → 根据百分比位置裁剪 → 保存为模板
3136
+
3137
+ Args:
3138
+ x_percent: X号中心的水平百分比 (0-100)
3139
+ y_percent: X号中心的垂直百分比 (0-100)
3140
+ size: 裁剪区域大小(正方形边长,像素)
3141
+ template_name: 模板名称
3142
+
3143
+ Returns:
3144
+ 结果
3145
+ """
3146
+ try:
3147
+ from .template_matcher import TemplateMatcher
3148
+ from PIL import Image
3149
+
3150
+ # 先截图(不带 SoM 标注的干净截图)
3151
+ screenshot_result = self.take_screenshot(description="添加模板", compress=False)
3152
+ screenshot_path = screenshot_result.get("screenshot_path")
3153
+
3154
+ if not screenshot_path:
3155
+ return {"success": False, "error": "截图失败"}
3156
+
3157
+ # 读取截图获取尺寸
3158
+ img = Image.open(screenshot_path)
3159
+ img_w, img_h = img.size
3160
+
3161
+ # 计算中心点像素坐标
3162
+ cx = int(img_w * x_percent / 100)
3163
+ cy = int(img_h * y_percent / 100)
3164
+
3165
+ # 计算裁剪区域
3166
+ half = size // 2
3167
+ x1 = max(0, cx - half)
3168
+ y1 = max(0, cy - half)
3169
+ x2 = min(img_w, cx + half)
3170
+ y2 = min(img_h, cy + half)
3171
+
3172
+ # 裁剪并保存
3173
+ cropped = img.crop((x1, y1, x2, y2))
3174
+
3175
+ matcher = TemplateMatcher()
3176
+ output_path = matcher.template_dir / f"{template_name}.png"
3177
+ cropped.save(str(output_path))
3178
+
3179
+ return {
3180
+ "success": True,
3181
+ "message": f"✅ 模板已保存: {template_name}",
3182
+ "template_path": str(output_path),
3183
+ "center_percent": f"({x_percent}%, {y_percent}%)",
3184
+ "center_pixel": f"({cx}, {cy})",
3185
+ "crop_region": f"({x1},{y1}) - ({x2},{y2})",
3186
+ "size": f"{cropped.size[0]}x{cropped.size[1]}"
3187
+ }
3188
+
3189
+ except ImportError as e:
3190
+ return {"success": False, "error": f"需要安装依赖: {e}"}
3191
+ except Exception as e:
3192
+ return {"success": False, "error": f"添加模板失败: {e}"}
3193
+