image-match.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """
  4. 模板匹配:在截图中查找模板图片的位置
  5. 用法1: python image-match.py <screenshot_path> <template_path> [threshold]
  6. 用法2: python image-match.py --adb <adb_path> --device <device_id> --screenshot <out_path> --template <template_path> [--threshold 0.8]
  7. 用法2 会在 Python 内执行 adb 截图,避免 Node 处理二进制数据导致的兼容性问题
  8. 输出: JSON 到 stdout
  9. """
  10. import sys
  11. import os
  12. import json
  13. import subprocess
  14. try:
  15. import cv2
  16. import numpy as np
  17. except ImportError as e:
  18. print(json.dumps({"success": False, "error": f"OpenCV 导入失败: {e}。请安装: pip install opencv-python numpy"}))
  19. sys.exit(1)
  20. try:
  21. from PIL import Image as PILImage
  22. HAS_PIL = True
  23. except ImportError:
  24. HAS_PIL = False
  25. def run_adb_screencap(adb_path, device, output_path):
  26. """在 Python 内执行 adb 截图,直接处理二进制流"""
  27. # Windows 下子进程需要可执行路径,正斜杠也可用
  28. args = [adb_path.replace('/', os.sep), '-s', device, 'exec-out', 'screencap', '-p']
  29. try:
  30. result = subprocess.run(args, capture_output=True, timeout=15)
  31. if result.returncode != 0:
  32. return False, (result.stderr or result.stdout or b'').decode('utf-8', errors='replace')
  33. data = result.stdout
  34. if not data or len(data) < 100:
  35. return False, "截图数据为空"
  36. # 注意:不要对 PNG 数据做 \r\n 替换,会破坏 IDAT 压缩块导致无法解析
  37. out_dir = os.path.dirname(output_path)
  38. if out_dir:
  39. os.makedirs(out_dir, exist_ok=True)
  40. with open(output_path, 'wb') as f:
  41. f.write(data)
  42. return True, output_path
  43. except subprocess.TimeoutExpired:
  44. return False, "截图超时"
  45. except Exception as e:
  46. return False, str(e)
  47. def load_image(path):
  48. """从文件路径加载图片,兼容 OpenCV 无法直接读取的 PNG(如部分 Android 截图)"""
  49. if not os.path.exists(path):
  50. return None
  51. with open(path, 'rb') as f:
  52. data = np.frombuffer(f.read(), dtype=np.uint8)
  53. img = cv2.imdecode(data, cv2.IMREAD_COLOR)
  54. if img is not None:
  55. return img
  56. img = cv2.imread(path)
  57. if img is not None:
  58. return img
  59. if HAS_PIL:
  60. try:
  61. pil_img = PILImage.open(path).convert('RGB')
  62. img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
  63. return img
  64. except Exception:
  65. pass
  66. return None
  67. def main():
  68. screenshot_path = None
  69. template_path = None
  70. threshold = 0.8
  71. adb_path = None
  72. device = None
  73. if len(sys.argv) >= 2 and sys.argv[1] == '--adb':
  74. # 用法2:--adb --device --screenshot --template
  75. i = 1
  76. while i < len(sys.argv):
  77. if sys.argv[i] == '--adb' and i + 1 < len(sys.argv):
  78. adb_path = sys.argv[i + 1]
  79. i += 2
  80. elif sys.argv[i] == '--device' and i + 1 < len(sys.argv):
  81. device = sys.argv[i + 1]
  82. i += 2
  83. elif sys.argv[i] == '--screenshot' and i + 1 < len(sys.argv):
  84. screenshot_path = sys.argv[i + 1]
  85. i += 2
  86. elif sys.argv[i] == '--template' and i + 1 < len(sys.argv):
  87. template_path = sys.argv[i + 1]
  88. i += 2
  89. elif sys.argv[i] == '--threshold' and i + 1 < len(sys.argv):
  90. threshold = float(sys.argv[i + 1])
  91. i += 2
  92. else:
  93. i += 1
  94. if adb_path and device and screenshot_path and template_path:
  95. ok, msg = run_adb_screencap(adb_path, device, screenshot_path)
  96. if not ok:
  97. print(json.dumps({"success": False, "error": f"截图失败: {msg}"}))
  98. sys.exit(1)
  99. else:
  100. print(json.dumps({"success": False, "error": "缺少 --adb/--device/--screenshot/--template 参数"}))
  101. sys.exit(1)
  102. else:
  103. # 用法1:位置参数
  104. if len(sys.argv) < 3:
  105. print(json.dumps({"success": False, "error": "用法: image-match.py <screenshot_path> <template_path> [threshold]"}))
  106. sys.exit(1)
  107. screenshot_path = sys.argv[1]
  108. template_path = sys.argv[2]
  109. threshold = float(sys.argv[3]) if len(sys.argv) > 3 else 0.8
  110. if not os.path.exists(screenshot_path):
  111. print(json.dumps({"success": False, "error": f"截图文件不存在: {screenshot_path}"}))
  112. sys.exit(1)
  113. if not os.path.exists(template_path):
  114. print(json.dumps({"success": False, "error": f"模板文件不存在: {template_path}"}))
  115. sys.exit(1)
  116. screenshot = load_image(screenshot_path)
  117. template = load_image(template_path)
  118. if screenshot is None:
  119. print(json.dumps({"success": False, "error": "无法读取截图(文件损坏或格式不支持)"}))
  120. sys.exit(1)
  121. if template is None:
  122. print(json.dumps({"success": False, "error": f"无法读取模板: {template_path}"}))
  123. sys.exit(1)
  124. t_h, t_w = template.shape[:2]
  125. if t_h > screenshot.shape[0] or t_w > screenshot.shape[1]:
  126. print(json.dumps({"success": False, "error": "模板尺寸大于截图"}))
  127. sys.exit(1)
  128. # 使用 TM_CCOEFF_NORMED 进行模板匹配
  129. result = cv2.matchTemplate(screenshot, template, cv2.TM_CCOEFF_NORMED)
  130. min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
  131. if max_val < threshold:
  132. print(json.dumps({"success": False, "error": f"未找到匹配 (相似度 {max_val:.3f} < {threshold})"}))
  133. sys.exit(1)
  134. x, y = int(max_loc[0]), int(max_loc[1])
  135. center_x = x + t_w // 2
  136. center_y = y + t_h // 2
  137. output = {
  138. "success": True,
  139. "x": x,
  140. "y": y,
  141. "width": t_w,
  142. "height": t_h,
  143. "center_x": center_x,
  144. "center_y": center_y
  145. }
  146. print(json.dumps(output))
  147. sys.exit(0)
  148. if __name__ == "__main__":
  149. main()