image-center-location.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. /**
  2. * 图像中心点定位功能(Node.js 实现)
  3. * 在截图中查找模板图片的位置,返回中心点坐标
  4. *
  5. * 直接调用 Python 的 OpenCV,通过内联 Python 代码实现
  6. */
  7. import { exec } from 'child_process';
  8. import { promisify } from 'util';
  9. import { join, isAbsolute, dirname } from 'path';
  10. import { fileURLToPath } from 'url';
  11. import { writeFile, readFile } from 'fs/promises';
  12. import { existsSync } from 'fs';
  13. const execAsync = promisify(exec);
  14. const __filename = fileURLToPath(import.meta.url);
  15. const __dirname = dirname(__filename);
  16. /**
  17. * 确保 pyvenv.cfg 文件使用当前系统的 Python 路径
  18. * @param {string} projectRoot - 项目根目录
  19. * @returns {Promise<void>}
  20. */
  21. async function ensurePyvenvConfig(projectRoot) {
  22. const pyvenvCfgPath = join(projectRoot, 'py', 'venv', 'pyvenv.cfg');
  23. if (!existsSync(pyvenvCfgPath)) {
  24. return; // 如果文件不存在,跳过
  25. }
  26. try {
  27. // 读取现有配置
  28. const currentContent = await readFile(pyvenvCfgPath, 'utf8');
  29. // 尝试从现有配置中提取系统 Python 路径
  30. const homeMatch = currentContent.match(/^home\s*=\s*(.+)$/m);
  31. const executableMatch = currentContent.match(/^executable\s*=\s*(.+)$/m);
  32. // 如果配置文件中已经有路径,检查路径是否存在
  33. if (homeMatch && executableMatch) {
  34. const existingHome = homeMatch[1].trim();
  35. const existingExecutable = executableMatch[1].trim();
  36. // 检查系统 Python 是否存在
  37. if (existsSync(existingExecutable)) {
  38. // 路径存在,不需要更新
  39. return;
  40. }
  41. }
  42. // 如果配置文件中的路径不存在,使用系统 Python 检测
  43. // 使用系统 Python(不是虚拟环境中的),因为我们需要检测系统 Python 路径
  44. const { stdout } = await execAsync('python -c "import sys; import os; print(os.path.dirname(sys.executable))"', {
  45. encoding: 'utf8',
  46. timeout: 5000,
  47. cwd: projectRoot
  48. });
  49. const pythonHome = stdout.trim();
  50. if (!pythonHome) {
  51. return; // 如果无法检测,跳过
  52. }
  53. const pythonExe = join(pythonHome, 'python.exe');
  54. // 检查系统 Python 是否存在
  55. if (!existsSync(pythonExe)) {
  56. return; // 系统 Python 不存在,跳过
  57. }
  58. // 检测 Python 版本(使用系统 Python)
  59. const { stdout: versionOutput } = await execAsync('python -c "import sys; print(\"{}.{}.{}\".format(sys.version_info.major, sys.version_info.minor, sys.version_info.micro))"', {
  60. encoding: 'utf8',
  61. timeout: 5000,
  62. cwd: projectRoot
  63. });
  64. const pythonVersion = versionOutput.trim();
  65. // 更新配置
  66. const newContent = `home = ${pythonHome}
  67. include-system-site-packages = false
  68. version = ${pythonVersion}
  69. executable = ${pythonExe}
  70. command = ${pythonExe} -m venv py/venv
  71. `;
  72. await writeFile(pyvenvCfgPath, newContent, 'utf8');
  73. } catch (error) {
  74. // 静默失败,不影响主流程
  75. console.warn('无法更新 pyvenv.cfg:', error.message);
  76. }
  77. }
  78. /**
  79. * 匹配图像
  80. * @param {string} screenshotPath - 截图路径
  81. * @param {string} templatePath - 模板图片路径
  82. * @param {number} width - 设备宽度(可选)
  83. * @param {number} height - 设备高度(可选)
  84. * @returns {Promise<{success: boolean, x?: number, y?: number, width?: number, height?: number, error?: string}>}
  85. */
  86. export async function matchImage(screenshotPath, templatePath, width, height) {
  87. // 获取项目根目录:从 main-js/func 目录向上两级到项目根目录
  88. const projectRoot = join(__dirname, '..', '..');
  89. // 优先使用项目下的便携版 Python
  90. let pythonExePath = join(projectRoot, 'python', 'python.exe');
  91. let isPortablePython = false;
  92. // 如果便携版 Python 不存在,回退到虚拟环境
  93. if (!existsSync(pythonExePath)) {
  94. isPortablePython = false;
  95. pythonExePath = join(projectRoot, 'py', 'venv', 'Scripts', 'python.exe');
  96. // 确保 pyvenv.cfg 使用当前系统的 Python 路径
  97. await ensurePyvenvConfig(projectRoot);
  98. // 检查虚拟环境中的 python.exe 是否存在
  99. if (!existsSync(pythonExePath)) {
  100. return {
  101. success: false,
  102. error: `Python 可执行文件不存在。\n尝试的路径:\n1. ${join(projectRoot, 'python', 'python.exe')}\n2. ${pythonExePath}\n\n请运行 download-portable-python.js 下载便携版 Python,或确保虚拟环境已正确安装。`
  103. };
  104. }
  105. } else {
  106. isPortablePython = true;
  107. }
  108. // 使用绝对路径执行 Python 命令(确保路径正确)
  109. const pythonCommand = pythonExePath;
  110. // 调试信息:记录使用的 Python 路径
  111. console.log(`[图像匹配] 使用 Python: ${pythonCommand} (${isPortablePython ? '便携版' : '虚拟环境'})`);
  112. console.log(`[图像匹配] 项目根目录: ${projectRoot}`);
  113. // 虚拟环境路径(用于设置环境变量)
  114. // 如果使用便携版 Python,使用 python 目录;否则使用 py/venv
  115. const venvAbsolutePath = isPortablePython ? join(projectRoot, 'python') : join(projectRoot, 'py', 'venv');
  116. const venvScriptsAbsolutePath = isPortablePython ? join(projectRoot, 'python', 'Scripts') : join(projectRoot, 'py', 'venv', 'Scripts');
  117. // 构建内联 Python 脚本
  118. const pythonCode = `
  119. import sys
  120. import os
  121. import site
  122. # 确保 site-packages 被加载(对于便携版 Python 很重要)
  123. site.main()
  124. import cv2
  125. import numpy as np
  126. import json
  127. from pathlib import Path
  128. def read_image_safe(image_path):
  129. """安全读取包含 Unicode 字符的图片"""
  130. abs_path = os.path.abspath(str(image_path))
  131. try:
  132. with open(abs_path, 'rb') as f:
  133. image_data = f.read()
  134. img_array = np.frombuffer(image_data, np.uint8)
  135. img = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
  136. if img is None:
  137. raise ValueError(f"cv2.imdecode 无法解码图片: {abs_path}")
  138. return img
  139. except FileNotFoundError:
  140. raise FileNotFoundError(f"图片文件不存在: {abs_path}")
  141. except Exception as e:
  142. raise Exception(f"读取图片失败: {abs_path}, 错误: {str(e)}")
  143. def match_image(image1_path, image2_path, image1_size=None):
  144. """
  145. 在 image1 中查找 image2 的位置
  146. Args:
  147. image1_path: 截图路径(大图)
  148. image2_path: 模板图片路径(小图)
  149. image1_size: image1 的目标尺寸 (width, height),可选
  150. Returns:
  151. 如果找到,返回 (x, y, width, height, similarity),否则返回 (None, similarity)
  152. """
  153. img1_path = Path(image1_path)
  154. img2_path = Path(image2_path)
  155. if not img1_path.exists():
  156. raise FileNotFoundError(f"图片1不存在: {image1_path}")
  157. if not img2_path.exists():
  158. raise FileNotFoundError(f"图片2不存在: {image2_path}")
  159. # 安全读取图片(支持 Unicode 路径)
  160. img1 = read_image_safe(img1_path)
  161. img2 = read_image_safe(img2_path)
  162. # 如果指定了 image1 的目标尺寸,先调整大小
  163. if image1_size is not None:
  164. target_width, target_height = image1_size
  165. img1 = cv2.resize(img1, (target_width, target_height))
  166. # 转换为灰度图以提高匹配速度
  167. img1_gray = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
  168. img2_gray = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
  169. # 获取模板尺寸
  170. template_height, template_width = img2_gray.shape
  171. # 如果模板比截图大,无法匹配
  172. if template_height > img1_gray.shape[0] or template_width > img1_gray.shape[1]:
  173. return None
  174. # 执行模板匹配
  175. # cv2.TM_CCOEFF_NORMED 方法返回 0-1 的值,1 表示完全匹配
  176. result = cv2.matchTemplate(img1_gray, img2_gray, cv2.TM_CCOEFF_NORMED)
  177. # 找到最佳匹配位置
  178. min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
  179. # 设置阈值(0.5 表示 50% 相似度,降低阈值以提高匹配成功率)
  180. threshold = 0.5
  181. if max_val >= threshold:
  182. # 找到匹配位置
  183. top_left = max_loc
  184. x, y = top_left
  185. # 返回坐标和尺寸 (x, y, width, height, similarity)
  186. return (x, y, template_width, template_height, max_val)
  187. else:
  188. # 未找到匹配,返回 (None, None, None, None, similarity) 以便获取匹配度
  189. return (None, None, None, None, max_val)
  190. # 主逻辑
  191. if __name__ == '__main__':
  192. image1_path = sys.argv[1]
  193. image2_path = sys.argv[2]
  194. image1_width = int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3] else None
  195. image1_height = int(sys.argv[4]) if len(sys.argv) > 4 and sys.argv[4] else None
  196. image1_size = None
  197. if image1_width and image1_height:
  198. image1_size = (image1_width, image1_height)
  199. try:
  200. result = match_image(image1_path, image2_path, image1_size)
  201. if result and len(result) >= 5:
  202. x, y, w, h, max_similarity = result[0], result[1], result[2], result[3], result[4]
  203. if x is not None and y is not None:
  204. # 匹配成功
  205. output = {
  206. 'success': True,
  207. 'x': int(x),
  208. 'y': int(y),
  209. 'width': int(w),
  210. 'height': int(h),
  211. 'similarity': float(max_similarity)
  212. }
  213. else:
  214. # 匹配失败,但返回了匹配度信息
  215. similarity_percent = round(max_similarity * 100, 2)
  216. output = {
  217. 'success': False,
  218. 'error': f'未找到匹配的图像(相似度: {similarity_percent}%,阈值: 50%)',
  219. 'similarity': float(max_similarity)
  220. }
  221. else:
  222. # 意外情况
  223. output = {
  224. 'success': False,
  225. 'error': '图像匹配失败:无法获取匹配结果'
  226. }
  227. print(json.dumps(output, ensure_ascii=False))
  228. except Exception as e:
  229. output = {
  230. 'success': False,
  231. 'error': f'图像匹配失败: {str(e)}'
  232. }
  233. print(json.dumps(output, ensure_ascii=False))
  234. sys.exit(1)
  235. `;
  236. // 将 Python 代码写入临时文件(使用相对路径)
  237. const tempScriptPath = 'temp_img_reg.py';
  238. const tempScriptAbsolutePath = join(projectRoot, tempScriptPath);
  239. await writeFile(tempScriptAbsolutePath, pythonCode, 'utf8');
  240. // 临时脚本路径也使用相对路径(相对于项目根目录)
  241. const tempScriptRelativePath = tempScriptPath;
  242. // 使用相对路径(相对于项目根目录)
  243. // 如果传入的是绝对路径,转换为相对路径
  244. let relativeScreenshotPath = screenshotPath;
  245. let relativeTemplatePath = templatePath;
  246. // 如果路径是绝对路径,尝试转换为相对路径
  247. if (isAbsolute(screenshotPath)) {
  248. try {
  249. relativeScreenshotPath = require('path').relative(projectRoot, screenshotPath).replace(/\\/g, '/');
  250. } catch (e) {
  251. relativeScreenshotPath = screenshotPath.replace(/\\/g, '/');
  252. }
  253. } else {
  254. relativeScreenshotPath = screenshotPath.replace(/\\/g, '/');
  255. }
  256. if (isAbsolute(templatePath)) {
  257. try {
  258. relativeTemplatePath = require('path').relative(projectRoot, templatePath).replace(/\\/g, '/');
  259. } catch (e) {
  260. relativeTemplatePath = templatePath.replace(/\\/g, '/');
  261. }
  262. } else {
  263. relativeTemplatePath = templatePath.replace(/\\/g, '/');
  264. }
  265. // 构建命令
  266. // 使用绝对路径的 Python 命令(用引号包裹,确保路径中的空格被正确处理),传递给 Python 的参数使用相对路径
  267. const command = `"${pythonCommand}" "${tempScriptRelativePath}" "${relativeScreenshotPath}" "${relativeTemplatePath}" ${width || ''} ${height || ''}`;
  268. const env = {
  269. ...process.env,
  270. DISABLE_MODEL_SOURCE_CHECK: 'True'
  271. };
  272. try {
  273. const { stdout, stderr } = await execAsync(command, {
  274. timeout: 30000,
  275. maxBuffer: 10 * 1024 * 1024,
  276. cwd: projectRoot, // 设置工作目录为项目根目录,这样相对路径才能正确解析
  277. encoding: 'utf8',
  278. env: {
  279. ...env,
  280. PYTHONIOENCODING: 'utf-8',
  281. PYTHONUTF8: '1',
  282. // 设置虚拟环境相关环境变量
  283. VIRTUAL_ENV: venvAbsolutePath,
  284. // 将虚拟环境的 Scripts 目录添加到 PATH 前面,确保使用虚拟环境中的工具
  285. PATH: `${venvScriptsAbsolutePath};${process.env.PATH}`
  286. }
  287. });
  288. // 清理临时文件
  289. try {
  290. await import('fs/promises').then(fs => fs.unlink(tempScriptAbsolutePath));
  291. } catch (e) {
  292. // 忽略删除失败
  293. }
  294. // 解析输出
  295. const cleanStdout = stdout.replace(/\[33m.*?\[0m/g, '').replace(/DeprecationWarning.*?\n/g, '');
  296. try {
  297. const result = JSON.parse(cleanStdout.trim());
  298. return result;
  299. } catch (parseError) {
  300. return { success: false, error: `解析图像匹配结果失败: ${parseError.message}` };
  301. }
  302. } catch (error) {
  303. // 如果有 stderr 输出,包含在错误信息中
  304. let errorMsg = error.message || '图像匹配失败';
  305. // 检查是否是 Python 路径问题
  306. if (error.message && (error.message.includes('No Python') || error.message.includes('python.exe'))) {
  307. errorMsg = `Python 环境配置错误: ${error.message}\n` +
  308. `项目根目录: ${projectRoot}\n` +
  309. `使用的 Python 命令: ${pythonCommand}\n` +
  310. `Python 文件是否存在: ${existsSync(pythonCommand) ? '是' : '否'}\n` +
  311. `请确保虚拟环境已正确安装在项目根目录的 py/venv 目录下。`;
  312. }
  313. if (error.stderr) {
  314. errorMsg += `\nPython stderr: ${error.stderr}`;
  315. }
  316. if (error.stdout) {
  317. errorMsg += `\nPython stdout: ${error.stdout.substring(0, 500)}`;
  318. }
  319. if (error.message && error.message.includes('timeout')) {
  320. return { success: false, error: '图像匹配超时,请检查网络连接或稍后重试' };
  321. }
  322. return { success: false, error: errorMsg };
  323. }
  324. }