/** * 图像中心点定位功能(Node.js 实现) * 在截图中查找模板图片的位置,返回中心点坐标 * * 直接调用 Python 的 OpenCV,通过内联 Python 代码实现 */ import { exec } from 'child_process'; import { promisify } from 'util'; import { join, isAbsolute, dirname } from 'path'; import { fileURLToPath } from 'url'; import { writeFile, readFile } from 'fs/promises'; import { existsSync } from 'fs'; const execAsync = promisify(exec); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** * 确保 pyvenv.cfg 文件使用当前系统的 Python 路径 * @param {string} projectRoot - 项目根目录 * @returns {Promise} */ async function ensurePyvenvConfig(projectRoot) { const pyvenvCfgPath = join(projectRoot, 'py', 'venv', 'pyvenv.cfg'); if (!existsSync(pyvenvCfgPath)) { return; // 如果文件不存在,跳过 } try { // 读取现有配置 const currentContent = await readFile(pyvenvCfgPath, 'utf8'); // 尝试从现有配置中提取系统 Python 路径 const homeMatch = currentContent.match(/^home\s*=\s*(.+)$/m); const executableMatch = currentContent.match(/^executable\s*=\s*(.+)$/m); // 如果配置文件中已经有路径,检查路径是否存在 if (homeMatch && executableMatch) { const existingHome = homeMatch[1].trim(); const existingExecutable = executableMatch[1].trim(); // 检查系统 Python 是否存在 if (existsSync(existingExecutable)) { // 路径存在,不需要更新 return; } } // 如果配置文件中的路径不存在,使用系统 Python 检测 // 使用系统 Python(不是虚拟环境中的),因为我们需要检测系统 Python 路径 const { stdout } = await execAsync('python -c "import sys; import os; print(os.path.dirname(sys.executable))"', { encoding: 'utf8', timeout: 5000, cwd: projectRoot }); const pythonHome = stdout.trim(); if (!pythonHome) { return; // 如果无法检测,跳过 } const pythonExe = join(pythonHome, 'python.exe'); // 检查系统 Python 是否存在 if (!existsSync(pythonExe)) { return; // 系统 Python 不存在,跳过 } // 检测 Python 版本(使用系统 Python) const { stdout: versionOutput } = await execAsync('python -c "import sys; print(\"{}.{}.{}\".format(sys.version_info.major, sys.version_info.minor, sys.version_info.micro))"', { encoding: 'utf8', timeout: 5000, cwd: projectRoot }); const pythonVersion = versionOutput.trim(); // 更新配置 const newContent = `home = ${pythonHome} include-system-site-packages = false version = ${pythonVersion} executable = ${pythonExe} command = ${pythonExe} -m venv py/venv `; await writeFile(pyvenvCfgPath, newContent, 'utf8'); } catch (error) { // 静默失败,不影响主流程 console.warn('无法更新 pyvenv.cfg:', error.message); } } /** * 匹配图像 * @param {string} screenshotPath - 截图路径 * @param {string} templatePath - 模板图片路径 * @param {number} width - 设备宽度(可选) * @param {number} height - 设备高度(可选) * @returns {Promise<{success: boolean, x?: number, y?: number, width?: number, height?: number, error?: string}>} */ export async function matchImage(screenshotPath, templatePath, width, height) { // 获取项目根目录:从 main-js/func 目录向上两级到项目根目录 const projectRoot = join(__dirname, '..', '..'); // 优先使用项目下的便携版 Python let pythonExePath = join(projectRoot, 'python', 'python.exe'); let isPortablePython = false; // 如果便携版 Python 不存在,回退到虚拟环境 if (!existsSync(pythonExePath)) { isPortablePython = false; pythonExePath = join(projectRoot, 'py', 'venv', 'Scripts', 'python.exe'); // 确保 pyvenv.cfg 使用当前系统的 Python 路径 await ensurePyvenvConfig(projectRoot); // 检查虚拟环境中的 python.exe 是否存在 if (!existsSync(pythonExePath)) { return { success: false, error: `Python 可执行文件不存在。\n尝试的路径:\n1. ${join(projectRoot, 'python', 'python.exe')}\n2. ${pythonExePath}\n\n请运行 download-portable-python.js 下载便携版 Python,或确保虚拟环境已正确安装。` }; } } else { isPortablePython = true; } // 使用绝对路径执行 Python 命令(确保路径正确) const pythonCommand = pythonExePath; // 调试信息:记录使用的 Python 路径 console.log(`[图像匹配] 使用 Python: ${pythonCommand} (${isPortablePython ? '便携版' : '虚拟环境'})`); console.log(`[图像匹配] 项目根目录: ${projectRoot}`); // 虚拟环境路径(用于设置环境变量) // 如果使用便携版 Python,使用 python 目录;否则使用 py/venv const venvAbsolutePath = isPortablePython ? join(projectRoot, 'python') : join(projectRoot, 'py', 'venv'); const venvScriptsAbsolutePath = isPortablePython ? join(projectRoot, 'python', 'Scripts') : join(projectRoot, 'py', 'venv', 'Scripts'); // 构建内联 Python 脚本 const pythonCode = ` import sys import os import site # 确保 site-packages 被加载(对于便携版 Python 很重要) site.main() import cv2 import numpy as np import json from pathlib import Path def read_image_safe(image_path): """安全读取包含 Unicode 字符的图片""" abs_path = os.path.abspath(str(image_path)) try: with open(abs_path, 'rb') as f: image_data = f.read() img_array = np.frombuffer(image_data, np.uint8) img = cv2.imdecode(img_array, cv2.IMREAD_COLOR) if img is None: raise ValueError(f"cv2.imdecode 无法解码图片: {abs_path}") return img except FileNotFoundError: raise FileNotFoundError(f"图片文件不存在: {abs_path}") except Exception as e: raise Exception(f"读取图片失败: {abs_path}, 错误: {str(e)}") def match_image(image1_path, image2_path, image1_size=None): """ 在 image1 中查找 image2 的位置 Args: image1_path: 截图路径(大图) image2_path: 模板图片路径(小图) image1_size: image1 的目标尺寸 (width, height),可选 Returns: 如果找到,返回 (x, y, width, height, similarity),否则返回 (None, similarity) """ img1_path = Path(image1_path) img2_path = Path(image2_path) if not img1_path.exists(): raise FileNotFoundError(f"图片1不存在: {image1_path}") if not img2_path.exists(): raise FileNotFoundError(f"图片2不存在: {image2_path}") # 安全读取图片(支持 Unicode 路径) img1 = read_image_safe(img1_path) img2 = read_image_safe(img2_path) # 如果指定了 image1 的目标尺寸,先调整大小 if image1_size is not None: target_width, target_height = image1_size img1 = cv2.resize(img1, (target_width, target_height)) # 转换为灰度图以提高匹配速度 img1_gray = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY) img2_gray = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY) # 获取模板尺寸 template_height, template_width = img2_gray.shape # 如果模板比截图大,无法匹配 if template_height > img1_gray.shape[0] or template_width > img1_gray.shape[1]: return None # 执行模板匹配 # cv2.TM_CCOEFF_NORMED 方法返回 0-1 的值,1 表示完全匹配 result = cv2.matchTemplate(img1_gray, img2_gray, cv2.TM_CCOEFF_NORMED) # 找到最佳匹配位置 min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) # 设置阈值(0.5 表示 50% 相似度,降低阈值以提高匹配成功率) threshold = 0.5 if max_val >= threshold: # 找到匹配位置 top_left = max_loc x, y = top_left # 返回坐标和尺寸 (x, y, width, height, similarity) return (x, y, template_width, template_height, max_val) else: # 未找到匹配,返回 (None, None, None, None, similarity) 以便获取匹配度 return (None, None, None, None, max_val) # 主逻辑 if __name__ == '__main__': image1_path = sys.argv[1] image2_path = sys.argv[2] image1_width = int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3] else None image1_height = int(sys.argv[4]) if len(sys.argv) > 4 and sys.argv[4] else None image1_size = None if image1_width and image1_height: image1_size = (image1_width, image1_height) try: result = match_image(image1_path, image2_path, image1_size) if result and len(result) >= 5: x, y, w, h, max_similarity = result[0], result[1], result[2], result[3], result[4] if x is not None and y is not None: # 匹配成功 output = { 'success': True, 'x': int(x), 'y': int(y), 'width': int(w), 'height': int(h), 'similarity': float(max_similarity) } else: # 匹配失败,但返回了匹配度信息 similarity_percent = round(max_similarity * 100, 2) output = { 'success': False, 'error': f'未找到匹配的图像(相似度: {similarity_percent}%,阈值: 50%)', 'similarity': float(max_similarity) } else: # 意外情况 output = { 'success': False, 'error': '图像匹配失败:无法获取匹配结果' } print(json.dumps(output, ensure_ascii=False)) except Exception as e: output = { 'success': False, 'error': f'图像匹配失败: {str(e)}' } print(json.dumps(output, ensure_ascii=False)) sys.exit(1) `; // 将 Python 代码写入临时文件(使用相对路径) const tempScriptPath = 'temp_img_reg.py'; const tempScriptAbsolutePath = join(projectRoot, tempScriptPath); await writeFile(tempScriptAbsolutePath, pythonCode, 'utf8'); // 临时脚本路径也使用相对路径(相对于项目根目录) const tempScriptRelativePath = tempScriptPath; // 使用相对路径(相对于项目根目录) // 如果传入的是绝对路径,转换为相对路径 let relativeScreenshotPath = screenshotPath; let relativeTemplatePath = templatePath; // 如果路径是绝对路径,尝试转换为相对路径 if (isAbsolute(screenshotPath)) { try { relativeScreenshotPath = require('path').relative(projectRoot, screenshotPath).replace(/\\/g, '/'); } catch (e) { relativeScreenshotPath = screenshotPath.replace(/\\/g, '/'); } } else { relativeScreenshotPath = screenshotPath.replace(/\\/g, '/'); } if (isAbsolute(templatePath)) { try { relativeTemplatePath = require('path').relative(projectRoot, templatePath).replace(/\\/g, '/'); } catch (e) { relativeTemplatePath = templatePath.replace(/\\/g, '/'); } } else { relativeTemplatePath = templatePath.replace(/\\/g, '/'); } // 构建命令 // 使用绝对路径的 Python 命令(用引号包裹,确保路径中的空格被正确处理),传递给 Python 的参数使用相对路径 const command = `"${pythonCommand}" "${tempScriptRelativePath}" "${relativeScreenshotPath}" "${relativeTemplatePath}" ${width || ''} ${height || ''}`; const env = { ...process.env, DISABLE_MODEL_SOURCE_CHECK: 'True' }; try { const { stdout, stderr } = await execAsync(command, { timeout: 30000, maxBuffer: 10 * 1024 * 1024, cwd: projectRoot, // 设置工作目录为项目根目录,这样相对路径才能正确解析 encoding: 'utf8', env: { ...env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1', // 设置虚拟环境相关环境变量 VIRTUAL_ENV: venvAbsolutePath, // 将虚拟环境的 Scripts 目录添加到 PATH 前面,确保使用虚拟环境中的工具 PATH: `${venvScriptsAbsolutePath};${process.env.PATH}` } }); // 清理临时文件 try { await import('fs/promises').then(fs => fs.unlink(tempScriptAbsolutePath)); } catch (e) { // 忽略删除失败 } // 解析输出 const cleanStdout = stdout.replace(/\[33m.*?\[0m/g, '').replace(/DeprecationWarning.*?\n/g, ''); try { const result = JSON.parse(cleanStdout.trim()); return result; } catch (parseError) { return { success: false, error: `解析图像匹配结果失败: ${parseError.message}` }; } } catch (error) { // 如果有 stderr 输出,包含在错误信息中 let errorMsg = error.message || '图像匹配失败'; // 检查是否是 Python 路径问题 if (error.message && (error.message.includes('No Python') || error.message.includes('python.exe'))) { errorMsg = `Python 环境配置错误: ${error.message}\n` + `项目根目录: ${projectRoot}\n` + `使用的 Python 命令: ${pythonCommand}\n` + `Python 文件是否存在: ${existsSync(pythonCommand) ? '是' : '否'}\n` + `请确保虚拟环境已正确安装在项目根目录的 py/venv 目录下。`; } if (error.stderr) { errorMsg += `\nPython stderr: ${error.stderr}`; } if (error.stdout) { errorMsg += `\nPython stdout: ${error.stdout.substring(0, 500)}`; } if (error.message && error.message.includes('timeout')) { return { success: false, error: '图像匹配超时,请检查网络连接或稍后重试' }; } return { success: false, error: errorMsg }; } }