img-center-point-location.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. /**
  2. * fun 标签:img-center-point-location
  3. * 图像匹配:识别模板图片在截图中的位置,返回中心点坐标
  4. */
  5. const path = require('path')
  6. const fs = require('fs')
  7. const os = require('os')
  8. const { spawnSync } = require('child_process')
  9. const configPath = process.env.STATIC_ROOT
  10. ? path.join(path.dirname(process.env.STATIC_ROOT), 'configs', 'config.js')
  11. : path.join(__dirname, '..', '..', '..', '..', 'configs', 'config.js')
  12. const config = fs.existsSync(configPath) ? require(configPath) : {}
  13. // 打包后优先使用 config.projectRoot(如 package/x64/config.js 中的配置),否则按路径推导
  14. const projectRoot = (config.projectRoot && fs.existsSync(config.projectRoot))
  15. ? config.projectRoot
  16. : path.dirname(path.dirname(path.resolve(configPath)))
  17. const imageMatchScriptPath = path.join(projectRoot, 'python', 'scripts', 'image-match.py')
  18. const tagName = 'img-center-point-location'
  19. const schema = {
  20. description: '在屏幕截图中查找模板图片的位置并返回中心点坐标。inVars[1] 缩放比 [min,max]。inVars[2] 可选:数字 0–1 为旧版中心比例;或 [裁剪百分比, "w"|"h"] 表示以模板宽/高的该比例作为正方形边长取中心方形裁剪后匹配,如 [1,"w"]、[0.1,"h"]。',
  21. inputs: { template: '模板图片路径', scaleRange: '缩放比范围数组 [min, max]', centerRatio: '可选:数字 0–1 或 [percent, "w"|"h"] 方形裁剪', variable: '输出变量名' },
  22. outputs: { variable: '中心点坐标(JSON 字符串格式,如:{"x":123,"y":456})' },
  23. }
  24. /** 解析 Python 可执行路径(与 config 中 pythonPath / pythonVenvPath 一致)。优先用 env(venv)以包含 opencv/numpy 等依赖。 */
  25. function getPythonPath() {
  26. const base = config.pythonPath?.path || config.pythonVenvPath || path.join(projectRoot, 'python', process.arch === 'arm64' ? 'arm64' : 'x64')
  27. const envPy = path.join(base, 'env', 'Scripts', 'python.exe')
  28. const scriptsPy = path.join(base, 'Scripts', 'python.exe')
  29. const pyEmbedded = path.join(base, 'py', 'python.exe')
  30. if (fs.existsSync(envPy)) return envPy
  31. if (fs.existsSync(scriptsPy)) return scriptsPy
  32. if (fs.existsSync(pyEmbedded)) return pyEmbedded
  33. return 'python'
  34. }
  35. /** 在设备截图中匹配模板,返回坐标与中心点。scaleRange 为 [min, max]。centerRatio 为数字 0–1(旧版)或 [percent, 'w'|'h'] 方形裁剪。 */
  36. function matchImageAndGetCoordinate(device, imagePath, scaleRange, centerRatio) {
  37. if (!imagePath || typeof imagePath !== 'string') return { success: false, error: '模板路径为空' }
  38. if (!Array.isArray(scaleRange) || scaleRange.length < 2) return { success: false, error: '缩放比范围 scaleRange 必填,且为 [min, max] 数组,如 [0.2, 1.6]' }
  39. const minScale = Number(scaleRange[0])
  40. const maxScale = Number(scaleRange[1])
  41. if (Number.isNaN(minScale) || Number.isNaN(maxScale) || minScale >= maxScale) return { success: false, error: '缩放比范围无效,需为两个数字且 min < max' }
  42. const templatePath = path.isAbsolute(imagePath) ? imagePath : path.resolve(projectRoot, imagePath)
  43. if (!fs.existsSync(templatePath)) return { success: false, error: `模板文件不存在: ${templatePath}` }
  44. const ts = Date.now()
  45. const templateDir = path.dirname(templatePath)
  46. const templateBase = path.basename(templatePath, path.extname(templatePath))
  47. const screenshotPath = path.join(templateDir, `Screenshot-${templateBase}.png`)
  48. try { fs.mkdirSync(templateDir, { recursive: true }) } catch (_) {}
  49. const templateCopyPath = path.join(os.tmpdir(), `ef-template-${ts}.png`)
  50. fs.copyFileSync(templatePath, templateCopyPath)
  51. const pythonPath = getPythonPath()
  52. const adbPath = config.adbPath?.path
  53. ? (path.isAbsolute(config.adbPath.path) ? config.adbPath.path : path.resolve(projectRoot, config.adbPath.path))
  54. : path.join(projectRoot, 'lib', 'scrcpy-adb', process.platform === 'win32' ? 'adb.exe' : 'adb')
  55. const cropOutputPath = path.join(templateDir, `Matched-${templateBase}.png`)
  56. const args = [imageMatchScriptPath, '--adb', adbPath, '--device', device, '--screenshot', screenshotPath.replace(/\\/g, '/'), '--template', templateCopyPath.replace(/\\/g, '/'), '--method', 'feature', '--scale-min', String(minScale), '--scale-max', String(maxScale), '--crop-output', cropOutputPath.replace(/\\/g, '/')]
  57. const isCropSquare = Array.isArray(centerRatio) && centerRatio.length >= 2 && typeof centerRatio[0] === 'number' && centerRatio[0] > 0
  58. const cropBase = isCropSquare ? String(centerRatio[1]).trim().toLowerCase() : ''
  59. const hasCrop = isCropSquare && (cropBase === 'w' || cropBase === 'h') || (centerRatio != null && typeof centerRatio === 'number' && centerRatio > 0 && centerRatio < 1)
  60. if (isCropSquare && (cropBase === 'w' || cropBase === 'h')) {
  61. args.push('--crop-square', String(centerRatio[0]), cropBase)
  62. } else {
  63. const ratio = centerRatio != null && typeof centerRatio === 'number' && centerRatio > 0 && centerRatio <= 1 ? centerRatio : 1
  64. if (ratio < 1) args.push('--center-ratio', String(ratio))
  65. }
  66. if (hasCrop) args.push('--template-output', templatePath.replace(/\\/g, '/'))
  67. const env = { ...process.env, PYTHONIOENCODING: 'utf-8' }
  68. if (process.platform === 'win32') {
  69. const pyDir = path.dirname(pythonPath)
  70. const pyRoot = path.dirname(path.dirname(pyDir))
  71. env.PATH = [pyDir, pyRoot, process.env.PATH].filter(Boolean).join(path.delimiter)
  72. }
  73. const spawnOpts = { encoding: 'utf-8', timeout: 20000, env, cwd: projectRoot }
  74. const r = spawnSync(pythonPath, args, spawnOpts)
  75. try { fs.unlinkSync(templateCopyPath) } catch (_) {}
  76. // 截图保留在模板同级目录(Screenshot-pic0.png 等),便于排查匹配失败原因
  77. if (r.status !== 0) {
  78. const msg = [r.stderr, r.stdout].filter(Boolean).map(s => String(s).trim()).join('\n') || '图像匹配失败'
  79. const extra = r.signal ? ` [signal: ${r.signal}]` : (r.error ? ` [${r.error.message}]` : '')
  80. const scriptExists = fs.existsSync(imageMatchScriptPath)
  81. const pyExists = fs.existsSync(pythonPath)
  82. const diag = ` | projectRoot=${projectRoot} script存在=${scriptExists} python存在=${pyExists} status=${r.status}`
  83. return { success: false, error: msg + extra + diag }
  84. }
  85. let out
  86. try {
  87. out = JSON.parse(r.stdout.trim())
  88. } catch (e) {
  89. return { success: false, error: `脚本输出非 JSON: ${(r.stdout || r.stderr || '').slice(0, 200)}` }
  90. }
  91. if (!out.success) return { success: false, error: out.error || '未找到图片' }
  92. return {
  93. success: true,
  94. coordinate: { x: out.x, y: out.y, width: out.width, height: out.height },
  95. clickPosition: { x: out.center_x, y: out.center_y }
  96. }
  97. }
  98. async function executeImgCenterPointLocation({ device, template, folderPath, scaleRange, centerRatio }) {
  99. if (!device) return { success: false, error: '缺少设备 ID,无法自动获取截图' }
  100. if (!template || typeof template !== 'string') return { success: false, error: '缺少模板图片路径' }
  101. if (!Array.isArray(scaleRange) || scaleRange.length < 2) return { success: false, error: 'img-center-point-location 必须填写 inVars[1] 缩放比范围 [min, max],如 [0.2, 1.6]' }
  102. const baseDir = folderPath && typeof folderPath === 'string' ? folderPath : projectRoot
  103. // 绝对路径或带盘符的保持原样;已含子路径(如 tmp/pic0.png)相对 baseDir;否则视为 resources 下文件名
  104. const isAbsoluteOrDrive = template.startsWith('/') || template.includes(':')
  105. const hasSubPath = template.includes('/') || template.includes(path.sep)
  106. const templatePath = isAbsoluteOrDrive ? template : (hasSubPath ? path.join(baseDir, template) : path.join(baseDir, 'resources', template))
  107. const result = matchImageAndGetCoordinate(device, templatePath, scaleRange, centerRatio)
  108. if (!result.success) return { success: false, error: result.error }
  109. const center = result.clickPosition || { x: result.coordinate.x + result.coordinate.width / 2, y: result.coordinate.y + result.coordinate.height / 2 }
  110. return { success: true, center, coordinate: result.coordinate }
  111. }
  112. module.exports = { tagName, schema, executeImgCenterPointLocation, matchImageAndGetCoordinate }