| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- /**
- * 图像中心点定位功能(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<void>}
- */
- 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 };
- }
- }
|