2026-01-21 11:50:34 +08:00
|
|
|
|
# coding=utf-8
|
|
|
|
|
|
import uiautomator2 as u2
|
|
|
|
|
|
import time
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import os
|
|
|
|
|
|
import cv2
|
|
|
|
|
|
import numpy as np
|
2026-01-21 14:13:26 +08:00
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
# 添加项目根目录到 sys.path 以便导入 Util
|
|
|
|
|
|
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
if project_root not in sys.path:
|
|
|
|
|
|
sys.path.append(project_root)
|
|
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
import json
|
2026-01-26 16:56:09 +08:00
|
|
|
|
from datetime import datetime
|
2026-01-25 12:52:52 +08:00
|
|
|
|
from Util.EasyOcrKit import EasyOcrKit
|
|
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
# 初始化 EasyOcrKit
|
2026-01-25 12:52:52 +08:00
|
|
|
|
ocr_kit = EasyOcrKit()
|
2026-01-21 11:50:34 +08:00
|
|
|
|
|
|
|
|
|
|
# 配置日志
|
2026-01-25 12:52:52 +08:00
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
2026-01-25 09:21:48 +08:00
|
|
|
|
logger = logging.getLogger("WxUtil")
|
2026-01-21 11:50:34 +08:00
|
|
|
|
|
2026-01-26 09:42:25 +08:00
|
|
|
|
# 目录配置
|
|
|
|
|
|
BASE_DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
LOG_DIR = os.path.join(BASE_DATA_DIR, "Logs")
|
|
|
|
|
|
OUTPUT_DIR = os.path.join(BASE_DATA_DIR, "Output")
|
|
|
|
|
|
TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Templates")
|
|
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# 全局调试图片计数器
|
|
|
|
|
|
_debug_counter = 0
|
|
|
|
|
|
|
|
|
|
|
|
def get_next_debug_path(desc="step"):
|
|
|
|
|
|
"""获取下一个顺序命名的调试图片路径 (debug_N_desc.jpg)"""
|
|
|
|
|
|
global _debug_counter
|
|
|
|
|
|
_debug_counter += 1
|
|
|
|
|
|
filename = f"debug_{_debug_counter}_{desc}.jpg"
|
|
|
|
|
|
return os.path.join(OUTPUT_DIR, filename)
|
|
|
|
|
|
|
|
|
|
|
|
def clear_directory(dir_path, exclude_files=None):
|
|
|
|
|
|
"""清理指定目录下的所有文件,支持排除特定文件"""
|
2026-01-26 09:42:25 +08:00
|
|
|
|
if not os.path.exists(dir_path):
|
|
|
|
|
|
os.makedirs(dir_path)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
if exclude_files is None:
|
|
|
|
|
|
exclude_files = []
|
|
|
|
|
|
|
2026-01-26 09:42:25 +08:00
|
|
|
|
import shutil
|
|
|
|
|
|
for filename in os.listdir(dir_path):
|
2026-01-26 16:56:09 +08:00
|
|
|
|
if filename in exclude_files:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-01-26 09:42:25 +08:00
|
|
|
|
file_path = os.path.join(dir_path, filename)
|
|
|
|
|
|
try:
|
|
|
|
|
|
if os.path.isfile(file_path) or os.path.islink(file_path):
|
|
|
|
|
|
os.unlink(file_path)
|
|
|
|
|
|
elif os.path.isdir(file_path):
|
|
|
|
|
|
shutil.rmtree(file_path)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Failed to delete {file_path}. Reason: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
def setup_script_environment():
|
|
|
|
|
|
"""运行前清理日志和输出目录"""
|
|
|
|
|
|
logger.info("清理运行环境: Logs 和 Output 目录...")
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# 重置调试计数器
|
|
|
|
|
|
global _debug_counter
|
|
|
|
|
|
_debug_counter = 0
|
|
|
|
|
|
# 排除当前正在使用的日志文件
|
|
|
|
|
|
clear_directory(LOG_DIR, exclude_files=["T2_ChatMonitor.log", "WxUtil.log"])
|
2026-01-26 09:42:25 +08:00
|
|
|
|
clear_directory(OUTPUT_DIR)
|
|
|
|
|
|
|
2026-01-26 09:15:45 +08:00
|
|
|
|
def connect_device():
|
|
|
|
|
|
"""
|
|
|
|
|
|
连接设备并返回设备对象,同时打印详细的设备信息
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
d = u2.connect()
|
2026-01-26 13:43:12 +08:00
|
|
|
|
# 强制检查连接是否可用
|
|
|
|
|
|
if not d.info:
|
|
|
|
|
|
logger.error("设备连接不可用 (d.info is empty)")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-01-26 09:15:45 +08:00
|
|
|
|
# 获取可靠的序列号
|
|
|
|
|
|
device_serial = d.serial if hasattr(d, 'serial') else "未知"
|
|
|
|
|
|
logger.info(f"设备连接成功: {device_serial}")
|
|
|
|
|
|
|
|
|
|
|
|
# 获取并打印详细设备信息
|
|
|
|
|
|
device_info = d.device_info
|
|
|
|
|
|
logger.info(f"详细设备信息: 品牌={device_info.get('brand')}, 型号={device_info.get('model')}, SDK={device_info.get('sdk')}")
|
|
|
|
|
|
return d
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"设备连接失败: {e}")
|
|
|
|
|
|
return None
|
2026-01-25 10:28:31 +08:00
|
|
|
|
|
2026-01-26 13:43:12 +08:00
|
|
|
|
def safe_device_click(d, x, y):
|
|
|
|
|
|
"""
|
|
|
|
|
|
安全的点击操作,包含简单的异常捕获和重试逻辑
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
d.click(x, y)
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"点击操作失败 ({x}, {y}): {e},尝试重新连接并重试...")
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 尝试重新初始化连接
|
|
|
|
|
|
new_d = u2.connect()
|
|
|
|
|
|
new_d.click(x, y)
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e2:
|
|
|
|
|
|
logger.error(f"重试点击操作依然失败: {e2}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
async def analyze_chat_image(image_path, output_path, device=None, target_name="对方", only_process_last_voice=False):
|
2026-01-25 13:42:17 +08:00
|
|
|
|
"""
|
2026-01-26 10:50:11 +08:00
|
|
|
|
全面采用 CV + OCR 识别微信聊天截图中的最后一条消息
|
|
|
|
|
|
不再使用 VLM
|
2026-01-26 16:56:09 +08:00
|
|
|
|
:param only_process_last_voice: 如果为 True,仅处理(转文字)屏幕上最后一条未转换的语音消息
|
2026-01-25 13:42:17 +08:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-01-26 10:50:11 +08:00
|
|
|
|
# 1. 初始化
|
|
|
|
|
|
d = device if device else connect_device()
|
|
|
|
|
|
if not d:
|
|
|
|
|
|
return [], None
|
2026-01-25 13:42:17 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
# 2. 读取图片
|
|
|
|
|
|
img = cv2.imread(image_path)
|
|
|
|
|
|
if img is None:
|
|
|
|
|
|
logger.error(f"无法读取图片: {image_path}")
|
|
|
|
|
|
return [], None
|
|
|
|
|
|
h, w = img.shape[:2]
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
# 3. 模板匹配寻找语音图标和红点
|
|
|
|
|
|
audio_template = os.path.join(TEMPLATE_DIR, "audio.jpg")
|
|
|
|
|
|
red_point_template = os.path.join(TEMPLATE_DIR, "red_point.jpg")
|
2026-01-25 12:52:52 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
audio_matches = find_all_template_matches(image_path, audio_template, threshold=0.8)
|
|
|
|
|
|
red_points = find_all_template_matches(image_path, red_point_template, threshold=0.8)
|
|
|
|
|
|
|
|
|
|
|
|
# 4. OCR 识别所有文本
|
|
|
|
|
|
logger.info("正在执行 OCR 识别...")
|
|
|
|
|
|
ocr_results = ocr_kit.read_text(image_path)
|
|
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# 微信菜单关键字(用于排除干扰)
|
|
|
|
|
|
MENU_KEYWORDS = ["听筒播放", "收藏", "背景播放", "删除", "多选", "取消转文字", "转文字", "引用", "提醒"]
|
|
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
# 5. 整合所有消息
|
|
|
|
|
|
messages = []
|
|
|
|
|
|
debug_img = img.copy() # 初始化调试图
|
2026-01-25 11:52:06 +08:00
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# 绘制过滤区域边界 (可视化)
|
|
|
|
|
|
cv2.line(debug_img, (0, 150), (w, 150), (255, 0, 255), 2) # 顶部线
|
|
|
|
|
|
cv2.line(debug_img, (0, h - 100), (w, h - 100), (255, 0, 255), 2) # 底部线 (从 180 改为 100)
|
|
|
|
|
|
cv2.putText(debug_img, "TOP_FILTER", (10, 140), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 255), 1)
|
|
|
|
|
|
cv2.putText(debug_img, "BOTTOM_FILTER", (10, h - 110), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 255), 1)
|
|
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
# A. 添加语音消息
|
|
|
|
|
|
for ax, ay in audio_matches:
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# 标记所有找到的语音图标 (用于调试)
|
|
|
|
|
|
cv2.circle(debug_img, (ax, ay), 10, (255, 255, 0), -1) # 青色实心圆表示原始匹配点
|
|
|
|
|
|
|
|
|
|
|
|
# 过滤掉顶部和底部的非聊天区域
|
|
|
|
|
|
# 顶部标题栏通常在 150 像素以内
|
|
|
|
|
|
# 底部输入栏通常在 100 像素以内 (捕捉最底部的文字)
|
|
|
|
|
|
if ay < 150 or ay > h - 100:
|
|
|
|
|
|
logger.info(f"忽略区域外语音图标: ({ax}, {ay})")
|
|
|
|
|
|
cv2.rectangle(debug_img, (ax-35, ay-35), (ax+35, ay+35), (128, 128, 128), 1) # 灰色框表示被过滤
|
|
|
|
|
|
cv2.putText(debug_img, "FILTERED", (ax - 40, ay - 45), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (128, 128, 128), 1)
|
2026-01-26 10:50:11 +08:00
|
|
|
|
continue
|
2026-01-25 12:52:52 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
sender = "对方" if ax < w / 2 else "我"
|
|
|
|
|
|
is_unread = False
|
|
|
|
|
|
for rx, ry in red_points:
|
|
|
|
|
|
# 红点通常在语音图标右侧且 Y 轴相近
|
|
|
|
|
|
if abs(ry - ay) < 50 and rx > ax:
|
|
|
|
|
|
is_unread = True
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
# 根据已读/未读画框:未读红框,已读绿框
|
|
|
|
|
|
color = (0, 0, 255) if is_unread else (0, 255, 0)
|
|
|
|
|
|
cv2.rectangle(debug_img, (ax-30, ay-30), (ax+30, ay+30), color, 2)
|
2026-01-25 12:52:52 +08:00
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# --- 改进:判断是否已转文字 ---
|
2026-01-26 10:50:11 +08:00
|
|
|
|
is_converted = False
|
2026-01-26 16:56:09 +08:00
|
|
|
|
converted_trigger_text = ""
|
2026-01-26 10:50:11 +08:00
|
|
|
|
for bbox, text, conf in ocr_results:
|
|
|
|
|
|
c_x = int((bbox[0][0] + bbox[2][0]) / 2)
|
|
|
|
|
|
c_y = int((bbox[0][1] + bbox[2][1]) / 2)
|
2026-01-26 16:56:09 +08:00
|
|
|
|
|
|
|
|
|
|
# 判定逻辑:
|
|
|
|
|
|
# 1. 转换后的文字通常在语音图标下方 30-600 像素 (增加到 600 以适配长文本)
|
|
|
|
|
|
# 2. 水平位置偏移在 600 像素内 (增加到 600 以适配宽文本泡)
|
|
|
|
|
|
# 3. 关键:确保这两个坐标之间没有其他的语音图标(防止第一个语音“偷”了第二个语音的文字)
|
|
|
|
|
|
if 30 < c_y - ay < 600 and abs(c_x - ax) < 600:
|
|
|
|
|
|
# 检查中间是否有其他语音图标
|
|
|
|
|
|
has_intermediate_audio = False
|
|
|
|
|
|
for other_ax, other_ay in audio_matches:
|
|
|
|
|
|
# 增加 20 像素缓冲区,防止判定到自身或极近的干扰点
|
|
|
|
|
|
if ay + 20 < other_ay < c_y - 10:
|
|
|
|
|
|
has_intermediate_audio = True
|
|
|
|
|
|
logger.info(f"语音({ax},{ay}) 被中间语音图标({other_ax},{other_ay}) 阻断,无法关联文本 '{text[:10]}...'")
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if has_intermediate_audio:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
clean_text = text.strip()
|
|
|
|
|
|
# 判定是否为时间戳 (如 13:49, 09:26)
|
|
|
|
|
|
# 增强:同时支持 "昨天 14:15" 这种格式
|
|
|
|
|
|
is_timestamp = re.search(r'(\d{1,2}:\d{2})', clean_text) and (len(clean_text) < 15)
|
|
|
|
|
|
|
|
|
|
|
|
# 判定是否为纯数字或时长 (如 5", 3", 少3")
|
|
|
|
|
|
# 增强:允许前面有少量杂讯字符,只要结尾是数字或 "
|
|
|
|
|
|
is_duration = re.search(r'\d{1,2}"?$', clean_text) and len(clean_text) < 6
|
|
|
|
|
|
|
|
|
|
|
|
# 排除掉语音时长、时间戳和菜单关键字的干扰
|
|
|
|
|
|
if not is_duration and not is_timestamp and clean_text not in MENU_KEYWORDS:
|
|
|
|
|
|
is_converted = True
|
|
|
|
|
|
converted_trigger_text = clean_text
|
|
|
|
|
|
logger.info(f"语音({ax},{ay}) 判定为已转换,关联到有效文本: '{clean_text}'")
|
|
|
|
|
|
break
|
|
|
|
|
|
else:
|
|
|
|
|
|
if is_timestamp:
|
|
|
|
|
|
logger.info(f"语音({ax},{ay}) 忽略下方时间戳文本: '{clean_text}'")
|
|
|
|
|
|
elif is_duration:
|
|
|
|
|
|
logger.info(f"语音({ax},{ay}) 忽略时长文本: '{clean_text}'")
|
|
|
|
|
|
|
|
|
|
|
|
if is_converted:
|
|
|
|
|
|
logger.info(f"语音消息 ({ax}, {ay}) 已有转换文字: '{converted_trigger_text}',跳过")
|
|
|
|
|
|
|
|
|
|
|
|
# --- 恢复绘图反馈 ---
|
|
|
|
|
|
# 根据已读/未读画框:未读红框,已读绿框
|
|
|
|
|
|
color = (0, 0, 255) if is_unread else (0, 255, 0)
|
|
|
|
|
|
cv2.rectangle(debug_img, (ax-30, ay-30), (ax+30, ay+30), color, 2)
|
2026-01-26 10:50:11 +08:00
|
|
|
|
|
|
|
|
|
|
label = "YES" if is_converted else "NO"
|
|
|
|
|
|
# 在框的右侧标注 YES 或 NO
|
|
|
|
|
|
cv2.putText(debug_img, label, (ax + 40, ay + 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2)
|
|
|
|
|
|
# --- 结束 ---
|
2026-01-25 12:52:52 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
messages.append({
|
|
|
|
|
|
"type": "voice",
|
|
|
|
|
|
"sender": sender,
|
|
|
|
|
|
"center": (ax, ay),
|
|
|
|
|
|
"y": ay,
|
|
|
|
|
|
"is_unread": is_unread,
|
2026-01-26 16:56:09 +08:00
|
|
|
|
"is_converted": is_converted,
|
|
|
|
|
|
"content": None
|
2026-01-26 10:50:11 +08:00
|
|
|
|
})
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
# B. 添加文本消息
|
|
|
|
|
|
for bbox, text, conf in ocr_results:
|
|
|
|
|
|
c_x = int((bbox[0][0] + bbox[2][0]) / 2)
|
|
|
|
|
|
c_y = int((bbox[0][1] + bbox[2][1]) / 2)
|
2026-01-26 09:50:09 +08:00
|
|
|
|
|
2026-01-26 13:43:12 +08:00
|
|
|
|
# 过滤区域 (顶部标题栏和底部输入栏)
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# 底部输入栏通常在 100 像素以内 (捕捉最底部的文字)
|
|
|
|
|
|
if c_y < 150 or c_y > h - 100:
|
2026-01-26 13:43:12 +08:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# 过滤掉明显的系统词 (通常是日期或时间)
|
|
|
|
|
|
time_pattern = r'(\d{4}年|\d{1,2}月|\d{1,2}日|\d{1,2}:\d{2}|昨天|今天|星期|上午|下午|晚上)'
|
|
|
|
|
|
if len(text) < 20 and (re.search(time_pattern, text) or re.match(r'^[0-9:\s日年月\-]+$', text)):
|
2026-01-26 10:50:11 +08:00
|
|
|
|
continue
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# 排除干扰:语音时长、菜单关键字、系统提示
|
|
|
|
|
|
clean_text = text.strip()
|
|
|
|
|
|
if re.match(r'^.?[0-9]{1,2}"?$', clean_text): continue
|
|
|
|
|
|
if clean_text in MENU_KEYWORDS: continue
|
|
|
|
|
|
if "撤回了一条消息" in text or "打招呼的消息" in text: continue
|
2026-01-26 10:50:11 +08:00
|
|
|
|
|
2026-01-26 13:43:12 +08:00
|
|
|
|
# 改进发送者判定:查看文本块的左边界
|
|
|
|
|
|
left_x = bbox[0][0]
|
2026-01-26 16:56:09 +08:00
|
|
|
|
sender = "对方" if left_x < w * 0.5 else "我"
|
2026-01-26 13:43:12 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
messages.append({
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"sender": sender,
|
2026-01-26 13:43:12 +08:00
|
|
|
|
"content": text.strip(),
|
2026-01-26 10:50:11 +08:00
|
|
|
|
"center": (c_x, c_y),
|
|
|
|
|
|
"y": c_y
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# 6. 排序并找出最后一条消息
|
|
|
|
|
|
if not messages:
|
|
|
|
|
|
logger.warning("未发现任何消息")
|
|
|
|
|
|
if output_path:
|
|
|
|
|
|
cv2.imwrite(output_path, debug_img)
|
|
|
|
|
|
return [], None
|
|
|
|
|
|
|
|
|
|
|
|
# 按 Y 坐标从上到下排序
|
|
|
|
|
|
messages.sort(key=lambda x: x['y'])
|
|
|
|
|
|
last_msg = messages[-1]
|
|
|
|
|
|
|
|
|
|
|
|
if output_path:
|
|
|
|
|
|
cv2.imwrite(output_path, debug_img)
|
|
|
|
|
|
logger.info(f"调试图已保存: {output_path}")
|
2026-01-25 12:52:52 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
dialogue_log = []
|
|
|
|
|
|
input_field_coordinates = (w // 2, int(h * 0.9)) # 默认输入框位置
|
|
|
|
|
|
|
2026-01-26 13:43:12 +08:00
|
|
|
|
# 7. 自动处理所有尚未转换的语音消息
|
|
|
|
|
|
# 获取所有语音消息(不论已读未读,只要没转换成文字就处理)
|
|
|
|
|
|
unconverted_voices = [m for m in messages if m['type'] == 'voice' and not m.get('is_converted')]
|
2026-01-26 10:50:11 +08:00
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# 按 Y 坐标排序,确保从上到下顺序
|
|
|
|
|
|
unconverted_voices.sort(key=lambda x: x['y'])
|
|
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
if unconverted_voices:
|
2026-01-26 16:56:09 +08:00
|
|
|
|
if only_process_last_voice:
|
|
|
|
|
|
logger.info(f"策略限制:仅处理最后一条未转换语音 (共发现 {len(unconverted_voices)} 条)")
|
|
|
|
|
|
unconverted_voices = [unconverted_voices[-1]]
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info(f"发现 {len(unconverted_voices)} 条未转换的语音,开始全部处理...")
|
2026-01-26 10:50:11 +08:00
|
|
|
|
|
|
|
|
|
|
for v_msg in unconverted_voices:
|
|
|
|
|
|
vx, vy = int(v_msg['center'][0]), int(v_msg['center'][1])
|
|
|
|
|
|
logger.info(f"--- 正在处理语音消息 ({vx}, {vy}) ---")
|
2026-01-26 09:50:09 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
# A. 长按语音
|
|
|
|
|
|
logger.info(f"正在长按语音消息 ({vx}, {vy})...")
|
|
|
|
|
|
d.long_click(vx, vy, 1.5)
|
|
|
|
|
|
time.sleep(1.5)
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
# B. 截图寻找“转文字”按钮
|
2026-01-26 16:56:09 +08:00
|
|
|
|
menu_shot = get_next_debug_path("step_long_press")
|
2026-01-26 10:50:11 +08:00
|
|
|
|
d.screenshot(menu_shot)
|
|
|
|
|
|
zhuan_template = os.path.join(TEMPLATE_DIR, "zhun_wen_zi.jpg")
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
btn_pos = find_template_match(menu_shot, zhuan_template, threshold=0.7)
|
|
|
|
|
|
|
|
|
|
|
|
if btn_pos:
|
|
|
|
|
|
btn_x, btn_y = int(btn_pos[0]), int(btn_pos[1])
|
|
|
|
|
|
logger.info(f"✅ 找到'转文字'按钮: ({btn_x}, {btn_y}),点击中...")
|
2026-01-26 13:43:12 +08:00
|
|
|
|
safe_device_click(d, btn_x, btn_y)
|
2026-01-25 12:52:52 +08:00
|
|
|
|
|
2026-01-26 13:43:12 +08:00
|
|
|
|
# 等待转换完成
|
2026-01-26 10:50:11 +08:00
|
|
|
|
logger.info("等待语音转文字完成...")
|
|
|
|
|
|
time.sleep(5.0)
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
# C. 再次截图 OCR 获取转换后的文字
|
2026-01-26 16:56:09 +08:00
|
|
|
|
after_convert_shot = get_next_debug_path("step_convert_result")
|
2026-01-26 13:43:12 +08:00
|
|
|
|
try:
|
|
|
|
|
|
d.screenshot(after_convert_shot)
|
|
|
|
|
|
convert_ocr = ocr_kit.read_text(after_convert_shot)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"截图或 OCR 失败: {e}")
|
|
|
|
|
|
convert_ocr = []
|
2026-01-25 12:52:52 +08:00
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# ... (中间提取文字逻辑不变) ...
|
2026-01-26 10:50:11 +08:00
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# D. (已移除) 不再执行“取消转文字”操作,保留文字以避免重复识别
|
|
|
|
|
|
# 之前此处会执行 long_click -> cancel_template -> safe_device_click
|
|
|
|
|
|
# 为了解决“反复打开”的问题,现在改为保留转出来的文字
|
|
|
|
|
|
logger.info("保留语音转换后的文字,不进行恢复界面操作")
|
2026-01-26 13:43:12 +08:00
|
|
|
|
else:
|
|
|
|
|
|
logger.warning("❌ 未能找到'转文字'按钮,点击空白处退出")
|
|
|
|
|
|
safe_device_click(d, vx + 300, vy)
|
|
|
|
|
|
|
|
|
|
|
|
# 8. 重新排序并生成完整的对话日志
|
|
|
|
|
|
# 先合并已经处理好的语音消息内容
|
|
|
|
|
|
# 排除掉转换文字本身产生的 OCR 文本干扰(如果 OCR 识别结果包含在文本消息中,需要过滤)
|
|
|
|
|
|
final_messages = []
|
|
|
|
|
|
# 1. 识别并归档所有属于语音转换出来的文字
|
|
|
|
|
|
for v_msg in messages:
|
|
|
|
|
|
if v_msg['type'] == 'voice':
|
|
|
|
|
|
vx, vy = v_msg['center']
|
|
|
|
|
|
v_content_blocks = []
|
|
|
|
|
|
# 找出所有在语音图标下方且水平相近的文本块,且发送者一致
|
|
|
|
|
|
for msg in messages:
|
|
|
|
|
|
if msg['type'] == 'text':
|
|
|
|
|
|
cx, cy = msg['center']
|
|
|
|
|
|
# 1. 垂直距离在合理范围内 (30 到 600 像素)
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# 2. 水平偏移在合理范围内 (增加到 600 像素以适配宽文本泡)
|
|
|
|
|
|
# 3. 关键:确保这两个坐标之间没有其他的语音图标(防止第一个语音“偷”了第二个语音的文字)
|
|
|
|
|
|
v_dist = cy - vy
|
|
|
|
|
|
h_dist = abs(cx - vx)
|
|
|
|
|
|
if 30 < v_dist < 600 and h_dist < 600:
|
|
|
|
|
|
# 检查中间是否有其他语音图标
|
|
|
|
|
|
has_intermediate_audio = False
|
|
|
|
|
|
for other_ax, other_ay in audio_matches:
|
|
|
|
|
|
if vy < other_ay < cy:
|
|
|
|
|
|
has_intermediate_audio = True
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if has_intermediate_audio:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# 发送者判定
|
|
|
|
|
|
if msg['sender'] == v_msg['sender']:
|
2026-01-26 13:43:12 +08:00
|
|
|
|
v_content_blocks.append(msg)
|
|
|
|
|
|
msg['is_voice_part'] = True
|
2026-01-26 16:56:09 +08:00
|
|
|
|
logger.info(f"关联成功: 语音({vx}, {vy}) -> 文本('{msg['content']}') [h_dist={h_dist:.1f}, v_dist={v_dist:.1f}]")
|
2026-01-26 10:50:11 +08:00
|
|
|
|
|
2026-01-26 13:43:12 +08:00
|
|
|
|
# 如果有内容块,按 Y 排序并合并
|
|
|
|
|
|
if v_content_blocks:
|
|
|
|
|
|
v_content_blocks.sort(key=lambda x: x['y'])
|
|
|
|
|
|
combined_content = "".join([m['content'] for m in v_content_blocks])
|
|
|
|
|
|
v_msg['content'] = combined_content
|
|
|
|
|
|
v_msg['is_converted'] = True
|
2026-01-26 16:56:09 +08:00
|
|
|
|
else:
|
|
|
|
|
|
if not v_msg.get('content'):
|
|
|
|
|
|
logger.warning(f"语音({vx}, {vy}) 未能关联到任何文本块")
|
2026-01-26 13:43:12 +08:00
|
|
|
|
|
|
|
|
|
|
# 2. 收集最终要显示的消息(排除被标记为语音部分的文本)
|
|
|
|
|
|
for msg in messages:
|
|
|
|
|
|
if msg['type'] == 'text':
|
|
|
|
|
|
if not msg.get('is_voice_part', False):
|
|
|
|
|
|
final_messages.append(msg)
|
2026-01-26 10:50:11 +08:00
|
|
|
|
else:
|
2026-01-26 13:43:12 +08:00
|
|
|
|
final_messages.append(msg)
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-26 13:43:12 +08:00
|
|
|
|
# 按 Y 坐标排序
|
|
|
|
|
|
final_messages.sort(key=lambda x: x['y'])
|
|
|
|
|
|
|
|
|
|
|
|
# 格式化输出到控制台
|
|
|
|
|
|
print("\n" + "="*50)
|
|
|
|
|
|
print(" --- 微信聊天记录提取结果 ---")
|
|
|
|
|
|
print("="*50)
|
|
|
|
|
|
|
2026-01-26 10:50:11 +08:00
|
|
|
|
dialogue_log = []
|
2026-01-26 13:43:12 +08:00
|
|
|
|
for msg in final_messages:
|
|
|
|
|
|
sender = msg['sender']
|
|
|
|
|
|
content = msg.get('content') or (msg.get('text') if 'text' in msg else "[未识别内容]")
|
|
|
|
|
|
if msg['type'] == 'voice':
|
|
|
|
|
|
content = f"[语音] {content}"
|
|
|
|
|
|
|
|
|
|
|
|
log_line = f"{sender}: {content}"
|
|
|
|
|
|
dialogue_log.append(log_line)
|
|
|
|
|
|
print(log_line)
|
|
|
|
|
|
|
|
|
|
|
|
print("="*50 + "\n")
|
2026-01-26 10:50:11 +08:00
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
return dialogue_log, input_field_coordinates
|
2026-01-26 13:43:12 +08:00
|
|
|
|
|
2026-01-21 14:13:26 +08:00
|
|
|
|
except Exception as e:
|
2026-01-26 10:50:11 +08:00
|
|
|
|
logger.error(f"analyze_chat_image 失败: {e}", exc_info=True)
|
2026-01-25 12:52:52 +08:00
|
|
|
|
return [], None
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
def clean_screenshots_dir():
|
|
|
|
|
|
"""清理截图目录"""
|
2026-01-26 09:42:25 +08:00
|
|
|
|
if not os.path.exists(OUTPUT_DIR):
|
|
|
|
|
|
os.makedirs(OUTPUT_DIR)
|
2026-01-25 12:52:52 +08:00
|
|
|
|
return
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-26 09:42:25 +08:00
|
|
|
|
for f in os.listdir(OUTPUT_DIR):
|
2026-01-25 12:52:52 +08:00
|
|
|
|
if f.lower().endswith(('.jpg', '.png', '.jpeg')):
|
|
|
|
|
|
try:
|
2026-01-26 09:42:25 +08:00
|
|
|
|
os.remove(os.path.join(OUTPUT_DIR, f))
|
2026-01-25 12:52:52 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Failed to delete {f}: {e}")
|
2026-01-25 11:52:06 +08:00
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
def is_in_chat_interface(d):
|
|
|
|
|
|
"""
|
|
|
|
|
|
检查是否在微信聊天界面
|
|
|
|
|
|
"""
|
2026-01-25 11:52:06 +08:00
|
|
|
|
try:
|
2026-01-25 12:52:52 +08:00
|
|
|
|
# 1. 底部语音/键盘切换按钮
|
|
|
|
|
|
if d(description="切换到语音").exists or d(description="切换到键盘").exists:
|
2026-01-25 11:52:06 +08:00
|
|
|
|
return True
|
2026-01-25 12:52:52 +08:00
|
|
|
|
# 2. 底部输入框
|
|
|
|
|
|
if d(className="android.widget.EditText").exists:
|
|
|
|
|
|
return True
|
|
|
|
|
|
# 3. 底部“按住说话”按钮
|
|
|
|
|
|
if d(text="按住说话").exists:
|
|
|
|
|
|
return True
|
|
|
|
|
|
# 4. 右上角更多按钮
|
|
|
|
|
|
if d(description="聊天信息").exists:
|
2026-01-25 11:52:06 +08:00
|
|
|
|
return True
|
2026-01-21 14:13:26 +08:00
|
|
|
|
except Exception as e:
|
2026-01-25 12:52:52 +08:00
|
|
|
|
logger.warning(f"is_in_chat_interface check failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
return False
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
def find_input_box_center(image_path):
|
2026-01-25 11:52:06 +08:00
|
|
|
|
"""
|
2026-01-25 12:52:52 +08:00
|
|
|
|
寻找输入框中心坐标 (兜底策略)
|
|
|
|
|
|
优先使用几何特征 (底部 88% 处)
|
2026-01-25 11:52:06 +08:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-01-25 12:52:52 +08:00
|
|
|
|
if not os.path.exists(image_path):
|
|
|
|
|
|
return (540, 2100), None
|
|
|
|
|
|
|
|
|
|
|
|
img = cv2.imread(image_path)
|
|
|
|
|
|
if img is None:
|
|
|
|
|
|
return (540, 2100), None
|
|
|
|
|
|
|
|
|
|
|
|
h, w = img.shape[:2]
|
2026-01-25 11:52:06 +08:00
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
# 策略:直接返回屏幕底部 88% 处的中心点
|
|
|
|
|
|
center_x = int(w * 0.5)
|
|
|
|
|
|
center_y = int(h * 0.88)
|
2026-01-25 11:52:06 +08:00
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
logger.info(f"find_input_box_center fallback: ({center_x}, {center_y})")
|
|
|
|
|
|
return (center_x, center_y), None
|
2026-01-25 11:52:06 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-01-25 12:52:52 +08:00
|
|
|
|
logger.error(f"find_input_box_center error: {e}")
|
|
|
|
|
|
return (540, 2100), None
|
2026-01-25 11:52:06 +08:00
|
|
|
|
|
2026-01-25 13:42:17 +08:00
|
|
|
|
def find_template_match(screen_path, template_path, threshold=0.8):
|
|
|
|
|
|
"""
|
|
|
|
|
|
使用 OpenCV 模板匹配寻找按钮中心坐标
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not os.path.exists(template_path):
|
|
|
|
|
|
logger.error(f"Template file not found: {template_path}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
img = cv2.imread(screen_path)
|
|
|
|
|
|
template = cv2.imread(template_path)
|
|
|
|
|
|
if img is None or template is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
h, w = template.shape[:2]
|
|
|
|
|
|
res = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED)
|
|
|
|
|
|
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
|
|
|
|
|
|
|
|
|
|
|
|
if max_val >= threshold:
|
|
|
|
|
|
center_x = max_loc[0] + w // 2
|
|
|
|
|
|
center_y = max_loc[1] + h // 2
|
|
|
|
|
|
logger.info(f"Template matched! Score: {max_val:.2f}, Center: ({center_x}, {center_y})")
|
|
|
|
|
|
return (center_x, center_y)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"Template not matched. Max score: {max_val:.2f}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Template matching failed: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-01-25 14:40:30 +08:00
|
|
|
|
def find_all_template_matches(screen_path, template_path, threshold=0.8):
|
|
|
|
|
|
"""
|
|
|
|
|
|
使用 OpenCV 模板匹配寻找**所有**符合条件的坐标
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not os.path.exists(template_path):
|
|
|
|
|
|
logger.error(f"Template file not found: {template_path}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
img = cv2.imread(screen_path)
|
|
|
|
|
|
template = cv2.imread(template_path)
|
|
|
|
|
|
if img is None or template is None:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
h, w = template.shape[:2]
|
|
|
|
|
|
res = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED)
|
|
|
|
|
|
|
2026-01-26 16:56:09 +08:00
|
|
|
|
# 记录最大匹配度,方便调试阈值
|
|
|
|
|
|
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
|
|
|
|
|
|
logger.info(f"模板匹配 {os.path.basename(template_path)}: 最大相似度 = {max_val:.4f} (阈值={threshold})")
|
|
|
|
|
|
|
2026-01-25 14:40:30 +08:00
|
|
|
|
# 找到所有大于阈值的点
|
|
|
|
|
|
loc = np.where(res >= threshold)
|
|
|
|
|
|
|
|
|
|
|
|
points = []
|
|
|
|
|
|
for pt in zip(*loc[::-1]): # Switch collumns and rows
|
|
|
|
|
|
center_x = pt[0] + w // 2
|
|
|
|
|
|
center_y = pt[1] + h // 2
|
|
|
|
|
|
points.append((center_x, center_y))
|
|
|
|
|
|
|
|
|
|
|
|
# 简单的去重(非极大值抑制的简化版,合并相近的点)
|
|
|
|
|
|
# 这里假设红点不会重叠,暂时直接返回,或者做一个简单的聚类
|
|
|
|
|
|
# 实际应用中,matchTemplate 对同一个目标周围可能会有多个连续的匹配点
|
|
|
|
|
|
# 我们需要合并它们
|
|
|
|
|
|
|
|
|
|
|
|
unique_points = []
|
|
|
|
|
|
for p in points:
|
|
|
|
|
|
is_close = False
|
|
|
|
|
|
for up in unique_points:
|
|
|
|
|
|
if abs(p[0] - up[0]) < 10 and abs(p[1] - up[1]) < 10:
|
|
|
|
|
|
is_close = True
|
|
|
|
|
|
break
|
|
|
|
|
|
if not is_close:
|
|
|
|
|
|
unique_points.append(p)
|
|
|
|
|
|
|
|
|
|
|
|
if unique_points:
|
|
|
|
|
|
logger.info(f"Found {len(unique_points)} matches for {os.path.basename(template_path)}")
|
|
|
|
|
|
|
|
|
|
|
|
return unique_points
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"find_all_template_matches failed: {e}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
def perform_input_action(d, center_point, text, auto_send=True):
|
2026-01-21 14:13:26 +08:00
|
|
|
|
"""
|
2026-01-25 12:52:52 +08:00
|
|
|
|
执行输入操作
|
2026-01-21 11:50:34 +08:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-01-25 14:40:30 +08:00
|
|
|
|
# --- 新增逻辑:确保处于文字输入模式 ---
|
|
|
|
|
|
logger.info("正在检查输入模式...")
|
2026-01-26 09:42:25 +08:00
|
|
|
|
tmp_check_shot = os.path.join(OUTPUT_DIR, "temp_input_check.jpg")
|
2026-01-25 14:40:30 +08:00
|
|
|
|
d.screenshot(tmp_check_shot)
|
|
|
|
|
|
|
2026-01-26 09:42:25 +08:00
|
|
|
|
wen_zi_template = os.path.join(TEMPLATE_DIR, "wen_zi_input.jpg")
|
|
|
|
|
|
input_text_template = os.path.join(TEMPLATE_DIR, "input_text.jpg")
|
2026-01-25 14:40:30 +08:00
|
|
|
|
|
|
|
|
|
|
# 1. 检查是否存在 '切换到文字' 图标 (表示当前是语音模式)
|
|
|
|
|
|
# 注意:这里假设 wen_zi_input.jpg 是那个“键盘”图标
|
|
|
|
|
|
wen_zi_pos = find_template_match(tmp_check_shot, wen_zi_template, threshold=0.8)
|
|
|
|
|
|
|
|
|
|
|
|
if wen_zi_pos:
|
|
|
|
|
|
logger.info(f"检测到语音模式 (找到切换文字图标: {wen_zi_pos}),点击切换...")
|
|
|
|
|
|
d.click(wen_zi_pos[0], wen_zi_pos[1])
|
|
|
|
|
|
time.sleep(1.0) # 等待 UI 切换
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 2. 如果没找到切换图标,假设是文字模式,尝试点击输入区域标识
|
|
|
|
|
|
logger.info("未检测到语音模式切换图标,尝试寻找文字输入区域...")
|
|
|
|
|
|
input_text_pos = find_template_match(tmp_check_shot, input_text_template, threshold=0.8)
|
|
|
|
|
|
if input_text_pos:
|
|
|
|
|
|
logger.info(f"找到文字输入区域标识 (input_text.jpg): {input_text_pos},点击激活...")
|
|
|
|
|
|
d.click(input_text_pos[0], input_text_pos[1])
|
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info("未找到特定的输入区域标识,将使用默认坐标或控件查找。")
|
|
|
|
|
|
|
|
|
|
|
|
# 清理临时文件
|
|
|
|
|
|
if os.path.exists(tmp_check_shot):
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.remove(tmp_check_shot)
|
|
|
|
|
|
except:
|
|
|
|
|
|
pass
|
|
|
|
|
|
# --- 新增逻辑结束 ---
|
|
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
# 1. 尝试找到原生输入框并输入
|
|
|
|
|
|
edit_text = d(className="android.widget.EditText")
|
|
|
|
|
|
input_success = False
|
2026-01-25 11:52:06 +08:00
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
if edit_text.exists:
|
|
|
|
|
|
logger.info("Found native EditText, using set_text")
|
|
|
|
|
|
try:
|
|
|
|
|
|
edit_text.click()
|
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
edit_text.set_text(text)
|
|
|
|
|
|
input_success = True
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Native input failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 如果原生输入失败,使用坐标点击 + 粘贴/输入
|
|
|
|
|
|
if not input_success:
|
|
|
|
|
|
cx, cy = center_point
|
|
|
|
|
|
logger.info(f"Using coordinate input: {center_point}")
|
|
|
|
|
|
d.click(cx, cy)
|
|
|
|
|
|
time.sleep(1.0)
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
try:
|
|
|
|
|
|
d.send_keys(text)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.warning("send_keys failed, trying set_clipboard")
|
|
|
|
|
|
d.set_clipboard(text)
|
|
|
|
|
|
d.click(cx, cy)
|
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
# 尝试粘贴
|
|
|
|
|
|
d.press("paste")
|
2026-01-25 11:52:06 +08:00
|
|
|
|
|
2026-01-25 12:52:52 +08:00
|
|
|
|
time.sleep(1.0)
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 发送
|
|
|
|
|
|
if auto_send:
|
2026-01-25 13:42:17 +08:00
|
|
|
|
# 优先使用模板匹配寻找“发送”按钮
|
|
|
|
|
|
logger.info("尝试使用模板匹配寻找'发送'按钮...")
|
|
|
|
|
|
tmp_screen = os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp_send_check.jpg")
|
|
|
|
|
|
d.screenshot(tmp_screen)
|
|
|
|
|
|
|
2026-01-26 09:42:25 +08:00
|
|
|
|
# 使用相对路径
|
|
|
|
|
|
template_path = os.path.join(TEMPLATE_DIR, "send.jpg")
|
2026-01-25 13:42:17 +08:00
|
|
|
|
send_btn_pos = find_template_match(tmp_screen, template_path, threshold=0.7) # 稍微降低阈值以提高召回
|
|
|
|
|
|
|
|
|
|
|
|
if send_btn_pos:
|
|
|
|
|
|
logger.info(f"通过模板匹配找到发送按钮: {send_btn_pos}, 点击...")
|
|
|
|
|
|
d.click(send_btn_pos[0], send_btn_pos[1])
|
2026-01-25 12:52:52 +08:00
|
|
|
|
else:
|
2026-01-25 13:42:17 +08:00
|
|
|
|
logger.warning("模板匹配未找到发送按钮,尝试原生控件查找...")
|
|
|
|
|
|
if d(text="发送").exists:
|
|
|
|
|
|
d(text="发送").click()
|
|
|
|
|
|
logger.info("Clicked '发送'")
|
|
|
|
|
|
else:
|
|
|
|
|
|
d.press("enter")
|
|
|
|
|
|
logger.info("Pressed Enter")
|
|
|
|
|
|
|
|
|
|
|
|
# 清理临时文件
|
|
|
|
|
|
if os.path.exists(tmp_screen):
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.remove(tmp_screen)
|
|
|
|
|
|
except:
|
|
|
|
|
|
pass
|
2026-01-26 09:25:18 +08:00
|
|
|
|
|
|
|
|
|
|
return True
|
2026-01-25 12:52:52 +08:00
|
|
|
|
|
2026-01-21 11:50:34 +08:00
|
|
|
|
except Exception as e:
|
2026-01-25 12:52:52 +08:00
|
|
|
|
logger.error(f"perform_input_action error: {e}")
|
2026-01-26 09:25:18 +08:00
|
|
|
|
return False
|
2026-01-21 14:13:26 +08:00
|
|
|
|
|
2026-01-25 13:42:17 +08:00
|
|
|
|
def match_template_center(image_path, template_path, threshold=0.8):
|
|
|
|
|
|
"""
|
|
|
|
|
|
使用 OpenCV 模板匹配寻找目标图片中心坐标
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not os.path.exists(image_path) or not os.path.exists(template_path):
|
|
|
|
|
|
logger.error(f"Image or template not found: {image_path}, {template_path}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
img = cv2.imread(image_path)
|
|
|
|
|
|
template = cv2.imread(template_path)
|
|
|
|
|
|
|
|
|
|
|
|
if img is None or template is None:
|
|
|
|
|
|
logger.error("Failed to read image or template")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# 转换为灰度图进行匹配
|
|
|
|
|
|
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
|
|
|
|
template_gray = cv2.cvtColor(template, cv2.COLOR_BGR2GRAY)
|
|
|
|
|
|
|
|
|
|
|
|
# 模板匹配
|
|
|
|
|
|
result = cv2.matchTemplate(img_gray, template_gray, cv2.TM_CCOEFF_NORMED)
|
|
|
|
|
|
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
|
|
|
|
|
|
|
|
|
|
|
if max_val >= threshold:
|
|
|
|
|
|
h, w = template_gray.shape
|
|
|
|
|
|
top_left = max_loc
|
|
|
|
|
|
center_x = int(top_left[0] + w / 2)
|
|
|
|
|
|
center_y = int(top_left[1] + h / 2)
|
|
|
|
|
|
logger.info(f"Template matched with confidence {max_val:.2f} at ({center_x}, {center_y})")
|
|
|
|
|
|
return (center_x, center_y)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(f"Template match failed. Max confidence: {max_val:.2f} < Threshold: {threshold}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"match_template_center error: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-01-25 11:52:06 +08:00
|
|
|
|
|