From a662c33ecf299d8df2c1ac94a4627663ec26a3e4 Mon Sep 17 00:00:00 2001 From: HuangHai <10402852@qq.com> Date: Mon, 26 Jan 2026 10:50:11 +0800 Subject: [PATCH] 'commit' --- WeiXin/T2_GetHistory.py | 75 ---- WeiXin/T3_InputLlmText.py | 135 ------- WeiXin/T4_CV_Voice_Debug.py | 90 +---- WeiXin/T5_AutoChatMonitor.py | 421 +++---------------- WeiXin/WxUtil.py | 466 +++++++++------------- WeiXin/__pycache__/WxUtil.cpython-310.pyc | Bin 16248 -> 13653 bytes 6 files changed, 263 insertions(+), 924 deletions(-) delete mode 100644 WeiXin/T2_GetHistory.py delete mode 100644 WeiXin/T3_InputLlmText.py diff --git a/WeiXin/T2_GetHistory.py b/WeiXin/T2_GetHistory.py deleted file mode 100644 index d9a03ee..0000000 --- a/WeiXin/T2_GetHistory.py +++ /dev/null @@ -1,75 +0,0 @@ -# coding=utf-8 -import time -import logging -import sys -import os -import asyncio - -project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if project_root not in sys.path: - sys.path.append(project_root) - -from WeiXin import WxUtil -from WeiXin.WxUtil import analyze_chat_image - -# 配置日志 -log_dir = WxUtil.LOG_DIR -if not os.path.exists(log_dir): - os.makedirs(log_dir) - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler(os.path.join(log_dir, "T2_GetHistory.log"), encoding='utf-8'), - logging.StreamHandler() - ] -) -logger = logging.getLogger("T2_GetHistory") - -async def get_history(target_name="对方"): - # 运行前清理 Logs 和 Output - WxUtil.setup_script_environment() - - logger.info("开始执行 T2: 获取当前屏幕对话历史...") - - d = WxUtil.connect_device() - if not d: - return - - # 截图 - screenshot_dir = WxUtil.OUTPUT_DIR - if not os.path.exists(screenshot_dir): - os.makedirs(screenshot_dir) - - timestamp = time.strftime("%Y%m%d_%H%M%S") - filename = f"T2_history_{timestamp}.jpg" - save_path = os.path.join(screenshot_dir, filename) - - try: - d.screenshot(save_path) - logger.info(f"截图已保存: {save_path}") - - analyzed_filename = f"T2_history_{timestamp}_analyzed.jpg" - analyzed_path = os.path.join(screenshot_dir, analyzed_filename) - - # 调用 WxUtil 中的分析函数 - dialogue_log, input_box = await analyze_chat_image(save_path, analyzed_path, device=d, target_name=target_name) - - logger.info("✅ T2 识别结果:") - if dialogue_log: - for log in dialogue_log: - print(log) # 打印到控制台 - else: - logger.info("未提取到对话内容或当前屏幕无对话气泡。") - - # 检查是否触发了转换 - if isinstance(dialogue_log, list) and any("[正在转换语音...]" in str(msg) for msg in dialogue_log): - logger.info("检测到语音正在转文字,建议等待转换完成后重新运行 T2 以获取完整内容。") - return - - except Exception as e: - logger.error(f"❌ T2 执行失败: {e}") - -if __name__ == "__main__": - asyncio.run(get_history()) diff --git a/WeiXin/T3_InputLlmText.py b/WeiXin/T3_InputLlmText.py deleted file mode 100644 index b831944..0000000 --- a/WeiXin/T3_InputLlmText.py +++ /dev/null @@ -1,135 +0,0 @@ -# coding=utf-8 -import time -import logging -import sys -import os -import asyncio - -# 添加项目根目录到 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) - -from WeiXin import WxUtil -from WeiXin.WxUtil import find_input_box_center, perform_input_action, analyze_chat_image, clean_screenshots_dir -from Util.LlmUtil import get_llm_response - -# 配置日志 -log_dir = WxUtil.LOG_DIR -if not os.path.exists(log_dir): - os.makedirs(log_dir) - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler(os.path.join(log_dir, "T3_InputLlmText.log"), encoding='utf-8'), - logging.StreamHandler() - ] -) -logger = logging.getLogger("T3_InputLlmText") - -async def generate_and_input(): - # 运行前清理 Logs 和 Output - WxUtil.setup_script_environment() - - logger.info("开始执行 T3: 生成 LLM 回复并输入...") - - try: - # 1. 连接设备 - d = WxUtil.connect_device() - if not d: - return - - # 2. 截图并识别对话历史 - screenshot_dir = WxUtil.OUTPUT_DIR - - tmp_shot = os.path.join(screenshot_dir, "t4_temp_history_check.jpg") - analyzed_shot = os.path.join(screenshot_dir, "t4_temp_history_analyzed.jpg") - - d.screenshot(tmp_shot) - dialogue_log, input_box = await analyze_chat_image(tmp_shot, analyzed_shot, device=d) - - # 检查是否包含正在转换的标识 - is_converting = any("[正在转换语音...]" in str(msg) for msg in dialogue_log) if isinstance(dialogue_log, list) else False - - if is_converting: - logger.info("检测到语音正在转文字,等待 3 秒后重新截图分析...") - await asyncio.sleep(3) - d.screenshot(tmp_shot) - dialogue_log, input_box = await analyze_chat_image(tmp_shot, analyzed_shot, device=d) - - # 无论第二次结果如何,我们都继续执行,不再跳过 - if any("[正在转换语音...]" in str(msg) for msg in dialogue_log) if isinstance(dialogue_log, list) else False: - logger.warning("语音转换时间较长,将尝试根据当前已有内容生成回复。") - - history_text = "" - if dialogue_log and isinstance(dialogue_log, list): - # 过滤掉系统的转换提示语,避免干扰 LLM - filtered_log = [msg for msg in dialogue_log if "[正在转换语音...]" not in str(msg)] - history_text = "\n".join(filtered_log) - logger.info("提取到对话历史: ") - for msg in filtered_log: - logger.info(msg) - elif dialogue_log: - history_text = str(dialogue_log) - logger.info(f"提取到对话历史: {history_text}") - else: - logger.warning("未提取到对话历史") - history_text = "(无对话历史)" - - # 3. 调用 LLM 生成回复 - persona = ( - "你是一名1999年毕业、拥有27年一线教学经验的小学高级教师,名叫‘大张老师’。你目前在‘长春市少惠林作文素养培养中心’工作。" - "你不仅是一位作文教学专家,批阅过超过2万篇作文,更是一位懂得孩子心理、能与家长共情的教育智者。" - "你的回复风格应该是:温厚、亲切、睿智且极具亲和力,就像一位老邻居、老大哥在和家长坐在长椅上聊天。" - "严禁使用任何列表格式(如 1. 2. 3. 或 A. B. C.),严禁使用‘首先、其次、最后’等刻板的逻辑词。" - "要用连贯、优美、富有生活气息的文字进行叙述。每一句话都要带温度,要善于用‘咱们孩子’、‘作为老师我也理解’等词汇拉近距离。" - "回复内容要有深度,不要只给结论,要讲透背后的教育逻辑。字数必须严格控制在 100-200 字之间。" - "如果涉及到校区信息,必须且只能使用以下真实数据,绝对严禁使用任何占位符:\n" - "- 单位:长春市少惠林作文素养培养中心\n" - "- 地址:南环城路与临河街交汇,TOUCH12街3楼325号\n" - "- 联系人:小张老师(电话:18686619970)\n" - "- 课程:线上/线下作文课、读书营/阅读策略营(假期开设)\n" - "- 上课:作文通常在周二/周四晚上,周六/周日全天" - ) - prompt = ( - f"【教师人设】:{persona}\n\n" - f"【近期聊天记录】:\n{history_text}\n\n" - "【任务要求】:\n" - "请作为大张老师,给家长写一段暖心且有深度的回复。要针对家长最后提到的问题或状态,先给予情感上的安抚和理解," - "然后再结合你27年的教学经验,给出具体的建议。建议要讲得细致、感人,展现出老教师的智慧沉淀。" - "全文必须是一个或两个完整的自然段,绝对禁止分点列项!字数严格在 100-200 字之间。" - "如果需要提供联系方式或地址,请自然地揉进段落中,严禁出现'XX'占位符。直接输出回复的正文内容,不要包含任何多余的修饰词或引号。" - ) - logger.info(f"正在以'亲切而专业的特级教师'身份请求 LLM 生成深度回复...") - - full_response = "" - async for chunk in get_llm_response(prompt, stream=False): - full_response += chunk - - llm_text = full_response.strip().strip('"').strip('“').strip('”') - logger.info(f"LLM 生成的回复内容: {llm_text}") - - if not llm_text: - logger.error("LLM 生成内容为空,停止执行。") - return - - # 4. 识别输入框位置 - center_point, rect_box = find_input_box_center(tmp_shot) - - # 5. 执行输入动作 - # 即使 center_point 为 None,perform_input_action 也会尝试通过原生控件识别输入框 - logger.info(f"正在准备输入回复内容...") - success = perform_input_action(d, center_point, llm_text, auto_send=True) - - if success: - logger.info("✅ T3 执行完成:文字已成功输入并点击发送。") - else: - logger.error("❌ T3 执行失败:输入动作未成功完成。") - - except Exception as e: - logger.error(f"❌ T3 执行出错: {e}", exc_info=True) - -if __name__ == "__main__": - asyncio.run(generate_and_input()) diff --git a/WeiXin/T4_CV_Voice_Debug.py b/WeiXin/T4_CV_Voice_Debug.py index ebe565b..3a080cd 100644 --- a/WeiXin/T4_CV_Voice_Debug.py +++ b/WeiXin/T4_CV_Voice_Debug.py @@ -1,8 +1,8 @@ -# coding=utf-8 -import os -import sys -import time - +# coding=utf-8 +import os +import sys +import time + import cv2 import logging @@ -30,7 +30,9 @@ logging.basicConfig( logger = logging.getLogger("T4_CV_Voice_Debug") -def run_cv_debug(): +import asyncio + +async def run_cv_debug(): # 运行前清理 Logs 和 Output WxUtil.setup_script_environment() @@ -42,7 +44,6 @@ def run_cv_debug(): try: screenshot_dir = WxUtil.OUTPUT_DIR - image_path = os.path.join(screenshot_dir, "t4_live_shot.jpg") output_path = os.path.join(screenshot_dir, "T4_debug_view.jpg") @@ -52,67 +53,20 @@ def run_cv_debug(): logger.error(f"❌ 拍照失败: {e}") return - logger.info(f"🔍 正在分析实时图片...") + logger.info(f"🔍 正在调用 WxUtil.analyze_chat_image 分析最后一条消息...") - # 模板路径 - template_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Templates") - audio_template = os.path.join(template_dir, "audio.jpg") - red_point_template = os.path.join(template_dir, "red_point.jpg") + # 2. 调用新的分析逻辑 + dialogue_log, input_pos = await WxUtil.analyze_chat_image(image_path, output_path, device=d) - if not os.path.exists(audio_template) or not os.path.exists(red_point_template): - logger.error("错误: 模板文件不存在") - return + if dialogue_log: + logger.info("📢 识别到的最后一条消息:") + for line in dialogue_log: + logger.info(f" {line}") + else: + logger.warning("⚠️ 未识别到任何消息") + + if input_pos: + logger.info(f"📍 识别到输入框位置: {input_pos}") - # 2. 识别逻辑 - 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) - - logger.info(f"发现语音图标数量: {len(audio_matches)}") - logger.info(f"发现红点数量: {len(red_points)}") - - # 3. 读取图片并绘制 - img = cv2.imread(image_path) - if img is None: - logger.error("错误: 无法读取图片") - return - - for ax, ay in audio_matches: - # 排除顶部标题栏和底部输入区 (假设 300-1800 为有效区) - if ay < 300 or ay > 1800: - continue - - sender = "对方" if ax < 500 else "我" - - # --- 1. 先判断是否未读 (寻找附近的红点) --- - is_unread = False - for rx, ry in red_points: - if abs(ry - ay) < 50 and rx > ax: - is_unread = True - break - - # --- 2. 根据状态选择颜色 --- - # BGR 格式: 红色 (0, 0, 255), 绿色 (0, 255, 0) - color = (0, 0, 255) if is_unread else (0, 255, 0) - status_text = "未读" if is_unread else "已读" - - # --- 3. 绘制标注 --- - # 语音图标框 (加粗) - cv2.rectangle(img, (int(ax-35), int(ay-35)), (int(ax+35), int(ay+35)), color, 3) - - # 中心点击位置 (实心圆) - cv2.circle(img, (int(ax), int(ay)), 15, color, -1) - - # 如果是未读,把原本识别到的红点也再次标出 - if is_unread: - for rx, ry in red_points: - if abs(ry - ay) < 50 and rx > ax: - cv2.circle(img, (int(rx), int(ry)), 12, (0, 0, 255), -1) - - logger.info(f"标注语音消息: ({ax}, {ay}), 发送者: {sender}, 状态: {status_text}") - - # 保存结果 - cv2.imwrite(output_path, img) - logger.info(f"✅ 调试图片已保存至: {output_path}") - -if __name__ == "__main__": - run_cv_debug() +if __name__ == "__main__": + asyncio.run(run_cv_debug()) diff --git a/WeiXin/T5_AutoChatMonitor.py b/WeiXin/T5_AutoChatMonitor.py index 9027a43..5edb893 100644 --- a/WeiXin/T5_AutoChatMonitor.py +++ b/WeiXin/T5_AutoChatMonitor.py @@ -5,6 +5,7 @@ import os import sys import time from datetime import datetime +import hashlib # 添加项目根目录到 sys.path project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -12,11 +13,9 @@ if project_root not in sys.path: sys.path.append(project_root) from Util import Win32Patch - from WeiXin import WxUtil -from WeiXin.WxUtil import perform_input_action, clean_screenshots_dir, find_template_match, find_all_template_matches +from WeiXin.WxUtil import perform_input_action from Util.LlmUtil import get_llm_response -from Util.EasyOcrKit import EasyOcrKit # 配置日志 log_dir = WxUtil.LOG_DIR @@ -27,30 +26,23 @@ if not os.path.exists(log_dir): logger = logging.getLogger("T5_AutoChatMonitor") logger.setLevel(logging.INFO) -# 清除现有的 handlers,防止重复打印或配置冲突 if logger.hasHandlers(): logger.handlers.clear() -# 创建 FileHandler log_file_path = os.path.join(log_dir, "T5_AutoChatMonitor.log") file_handler = logging.FileHandler(log_file_path, encoding='utf-8', mode='w') file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) logger.addHandler(file_handler) -# 创建 StreamHandler stream_handler = logging.StreamHandler() stream_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) logger.addHandler(stream_handler) -# 防止日志传播到 root logger,避免重复输出 logger.propagate = False - -# 打印日志文件位置,方便确认 logger.info(f"日志文件路径: {log_file_path}") # 配置参数 CHECK_INTERVAL = 5 # 检查频率 (秒) -TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Templates") class ChatBot: def __init__(self): @@ -60,14 +52,10 @@ class ChatBot: self.d = WxUtil.connect_device() if not self.d: raise Exception("无法连接到设备,任务终止") - self.last_message_text = "" - self.last_processed_msg_id = None # 记录上一条已处理的消息标识 (文本+坐标) - self.screenshot_dir = WxUtil.OUTPUT_DIR - - self.ocr_kit = EasyOcrKit(gpu=True) - self.is_first_run = True # 首次运行标志 - + self.last_processed_msg_hash = None # 记录最后一条已处理消息的哈希值 + self.screenshot_dir = WxUtil.OUTPUT_DIR + self.persona = ( "你是一名1999年毕业、拥有27年一线教学经验的小学高级女教师,名叫‘大张老师’。你目前在‘长春市少惠林作文素养培养中心’工作。" "你不仅是一位作文教学专家,更是一位心思细腻、能与家长共情的教育智者。" @@ -85,13 +73,12 @@ class ChatBot: "- 每学期开学招收小学三年级至六年级,初中七年级的学生入学,其它年段不招生。\n" ) - async def get_reply(self, history_text): + async def get_reply(self, last_message_text): prompt = ( f"【教师人设】:{self.persona}\n\n" - f"【近期聊天记录】:\n{history_text}\n\n" + f"【最后一条消息】:\n{last_message_text}\n\n" "【任务要求】:\n" - "请作为大张老师回复家长。**必须且只能针对聊天记录中的最后一条消息进行回复!**\n" - "之前的聊天记录仅供参考上下文,如果之前的问题已经回答过,绝对不要重复回答。\n" + "请作为大张老师回复家长。**必须且只能针对最后一条消息进行回复!**\n" "严禁发散,严禁编造家长没说过的情况。如果不清楚家长的意图,就温柔询问。\n" "字数严格控制在 50 字以内。直接输出回复正文。" ) @@ -101,367 +88,63 @@ class ChatBot: full_response += chunk return full_response.strip().strip('"').strip('“').strip('”') - async def process_single_voice(self, voice_msg, next_msg=None, input_box_y=None): - """ - 处理单个语音消息的完整流程: - 长按 -> CV找转文字 -> 点击 -> 等待 -> 截图OCR -> 长按 -> CV找取消转文字 -> 点击 - 返回: 转换后的文本内容 (如果没有转换成功,返回 None) - """ - vx, vy = voice_msg['coordinates'] - content = voice_msg.get('content', '0"') - logger.info(f"🎤 开始处理语音消息: {content}, 坐标: ({vx}, {vy})") - - try: - # 1. 长按语音消息 - logger.info("👆 正在长按语音消息...") - self.d.long_click(vx, vy, 0.6) - logger.info("✅ 长按完成,等待菜单...") - time.sleep(0.3) - - # 2. CV 模板匹配寻找 "转文字" 按钮 - menu_shot_path = os.path.join(self.screenshot_dir, "t6_menu_shot_convert.jpg") - logger.info(f"📸 截取菜单图: {menu_shot_path}") - self.d.screenshot(menu_shot_path) - - convert_template = os.path.join(TEMPLATE_DIR, "zhun_wen_zi.jpg") - logger.info(f"🔍 寻找模板: {convert_template}") - convert_btn = find_template_match(menu_shot_path, convert_template, threshold=0.6) - - if not convert_btn: - logger.warning("❌ CV 未找到 '转文字' 按钮,尝试小范围 OCR 兜底...") - # 尝试在该区域进行 OCR 识别,寻找 "转文字" 三个字 - ocr_results_menu = self.ocr_kit.read_text(menu_shot_path) - for bbox, text, conf in ocr_results_menu: - if "转文字" in text or "转文" in text or "文字" in text: - cx = (bbox[0][0] + bbox[2][0]) / 2 - cy = (bbox[0][1] + bbox[2][1]) / 2 - convert_btn = (cx, cy) - logger.info(f"✅ OCR 兜底找到 '转文字' 按钮: {convert_btn}") - break - - if not convert_btn: - logger.warning("❌ CV 和 OCR 均未找到 '转文字' 按钮,取消操作。") - # 点击屏幕中心区域的空白处关闭菜单,避免点到顶部返回键 - self.d.click(500, 500) - return None - - logger.info(f"✅ 最终找到 '转文字' 按钮坐标: {convert_btn}") - self.d.click(convert_btn[0], convert_btn[1]) - - # 3. 动态等待转换 - duration_str = content.replace('"', '').strip() - try: - duration = int(duration_str) - except: - duration = 10 - wait_seconds = max(2, duration / 5.0) - logger.info(f"⏳ 语音时长 {duration}s,等待转换 {wait_seconds:.1f}s...") - time.sleep(wait_seconds) - - # 4. 截图并 OCR 识别内容 - ocr_shot_path = os.path.join(self.screenshot_dir, "t6_ocr_shot.jpg") - logger.info(f"📸 截取 OCR 识别图: {ocr_shot_path}") - self.d.screenshot(ocr_shot_path) - - # OCR 识别 - # 策略:识别整个屏幕,但只提取位于当前语音消息下方,且在下一条消息(如果有)上方的内容 - logger.info("📖 开始 OCR 识别...") - ocr_results = self.ocr_kit.read_text(ocr_shot_path) - logger.info(f"✅ OCR 识别完成,获取 {len(ocr_results)} 个文本块") - except Exception as e: - logger.error(f"❌ process_single_voice 发生异常: {e}", exc_info=True) - return None - - # 按 Y 坐标排序,确保从上往下处理 - ocr_results.sort(key=lambda x: (x[0][0][1] + x[0][2][1]) / 2) - - extracted_text = [] - - # 准备下一条消息的内容片段作为停止条件 - next_msg_snippet = None - if next_msg and next_msg.get("type") == "text": - c = next_msg.get("content", "").strip() - if c: - next_msg_snippet = c[:8] # 取前8个字符作为指纹 - - for bbox, text, conf in ocr_results: - # bbox center y - c_y = (bbox[0][1] + bbox[2][1]) / 2 - - # 1. 过滤掉当前语音气泡及以上的内容 - # 语音气泡中心是 vy,底部大概在 vy + 30 左右 - if c_y <= vy + 25: - continue - - # 2. 如果有输入框坐标,过滤掉输入框以下的内容 - if input_box_y and c_y >= input_box_y - 30: - continue - - # 3. 如果遇到下一条消息的内容,停止读取 - if next_msg_snippet and next_msg_snippet in text: - logger.info(f"🛑 遇到下一条消息内容 '{text}',停止 OCR 录入。") - break - - # 4. 如果下一条是语音,尝试通过时长文本判断停止 - if next_msg and next_msg.get("type") == "voice": - v_dur = next_msg.get("content", "").strip() - # 语音时长通常比较短,且包含 " 符号 - if v_dur and v_dur in text and len(text) < 10: - logger.info(f"🛑 遇到下一条语音时长 '{text}',停止 OCR 录入。") - break - - # 5. 安全兜底:如果距离当前语音气泡太远(超过600像素),停止 - # 这可以防止读取到屏幕底部无关的内容 - if c_y > vy + 600: - break - - extracted_text.append(text) - - full_text = " ".join(extracted_text) - logger.info(f"📝 OCR 识别结果: {full_text}") - - # 5. 再次长按语音消息 (为了取消转换) - # 注意:转换出文字后,界面可能会发生位移。 - # 但通常语音气泡的相对位置(如果是最后一条)可能变化不大,或者我们假设用户不滑动 - # 更稳妥的是:重新识别一次语音气泡位置? - # 用户说:"这样原来什么样,识别完就是什么样",意味着我们要恢复原状。 - # 我们假设点击原来的位置还能点到语音气泡(如果它没被顶上去太多) - # 或者,我们可以点击转换出来的文字区域? - # 让我们尝试点击原来的坐标。 - - self.d.long_click(vx, vy, 0.6) - time.sleep(0.3) - - # 6. CV 模板匹配寻找 "取消转文字" 按钮 - menu_shot_path_cancel = os.path.join(self.screenshot_dir, "t6_menu_shot_cancel.jpg") - self.d.screenshot(menu_shot_path_cancel) - - cancel_template = os.path.join(TEMPLATE_DIR, "cancel_zhuan_wen_zi.jpg") - cancel_btn = find_template_match(menu_shot_path_cancel, cancel_template, threshold=0.6) - - if cancel_btn: - logger.info(f"✅ CV 找到 '取消转文字' 按钮: {cancel_btn}") - self.d.click(cancel_btn[0], cancel_btn[1]) - else: - logger.warning("❌ CV 未找到 '取消转文字' 按钮,点击中心区域关闭菜单。") - self.d.click(500, 500) - - return full_text - async def run(self): - logger.info("🚀 大张老师自动巡课系统启动...") - - last_screen_md5 = None + logger.info("🚀 大张老师自动巡课系统启动 (CV版)...") while True: try: - logger.info("🔍 正在扫描当前界面内容...") - - # 1. 截图 - tmp_shot = os.path.join(self.screenshot_dir, "t6_monitor_temp.jpg") - logger.info(f"📸 正在截取屏幕... ({datetime.now().strftime('%H:%M:%S')})") - self.d.screenshot(tmp_shot) + # 1. 截图并分析 + image_path = os.path.join(self.screenshot_dir, "current_screen.jpg") + self.d.screenshot(image_path) - # 计算 MD5 并去重 - import hashlib - with open(tmp_shot, 'rb') as f: - current_md5 = hashlib.md5(f.read()).hexdigest() - - if last_screen_md5 and current_md5 == last_screen_md5: - logger.info("😴 屏幕内容未变,跳过本次循环。") + # 使用 WxUtil 的集中式分析逻辑 + # 它会自动处理语音转文字,并返回对话列表和输入框坐标 + dialogue_log, input_pos = await WxUtil.analyze_chat_image(image_path, self.screenshot_dir, device=self.d) + + if not dialogue_log: + logger.info("😴 未发现有效消息,等待下一次轮询。") await asyncio.sleep(CHECK_INTERVAL) continue - - last_screen_md5 = current_md5 - # 2. 本地视觉分析 (替代 VLM) - logger.info("�️ 正在进行本地视觉扫描...") + # 2. 只关注最后一条消息 + last_msg = dialogue_log[-1] + logger.info(f"最后一条消息: {last_msg}") - # A. 寻找语音图标 (audio.jpg) 和 红点 (red_point.jpg) - audio_template = os.path.join(TEMPLATE_DIR, "audio.jpg") - red_point_template = os.path.join(TEMPLATE_DIR, "red_point.jpg") + # 计算最后一条消息的哈希值,用于去重 + current_msg_hash = hashlib.md5(last_msg.encode('utf-8')).hexdigest() - audio_matches = find_all_template_matches(tmp_shot, audio_template, threshold=0.8) - red_points = find_all_template_matches(tmp_shot, red_point_template, threshold=0.8) - - # B. 本地 OCR 识别全文以构建上下文 - ocr_results = self.ocr_kit.read_text(tmp_shot) - # 按 Y 坐标排序 - ocr_results.sort(key=lambda x: (x[0][0][1] + x[0][2][1]) / 2) - - dialogue_log = [] - voice_messages = [] - - # 准备可视化调试图 - import cv2 - import numpy as np - debug_img = cv2.imread(tmp_shot) - - # 记录已匹配到语音图标的 OCR 块索引 - matched_ocr_indices = set() - - # 先处理语音图标匹配 - for ax, ay in audio_matches: - # 排除顶部标题栏(0-300)和底部输入区(1800+) - if ay < 300 or ay > 1800: - logger.info(f"⏭️ 忽略区域外语音图标: ({ax}, {ay})") - continue - - sender = "对方" if ax < 500 else "我" - logger.info(f"🎙️ 发现语音图标: x={ax}, y={ay}, 发送者={sender}") - is_unread = False - if red_points: - for rx, ry in red_points: - # 红点通常在语音图标右侧,且 Y 轴相近 - if abs(ry - ay) < 50 and rx > ax: - is_unread = True - # 绘制红点 - cv2.circle(debug_img, (int(rx), int(ry)), 12, (0, 0, 255), -1) - break - - # 寻找附近的时长文字 (OCR) - duration_text = "语音" - for idx, (bbox, text, conf) in enumerate(ocr_results): - c_x = (bbox[0][0] + bbox[2][0]) / 2 - c_y = (bbox[0][1] + bbox[2][1]) / 2 - if abs(c_y - ay) < 40 and abs(c_x - ax) < 300: - if '"' in text or text.isdigit(): - duration_text = text - matched_ocr_indices.add(idx) - break - - # 计算点击坐标:直接点击语音图标中心 - click_x, click_y = ax, ay - - # 绘制视觉反馈 - # 1. 语音图标用绿框 - cv2.rectangle(debug_img, (int(ax-30), int(ay-30)), (int(ax+30), int(ay+30)), (0, 255, 0), 3) - # 2. 点击位置用红点 (用户偏好) - cv2.circle(debug_img, (int(click_x), int(click_y)), 15, (0, 0, 255), -1) - - v_msg = { - "type": "voice", - "content": duration_text, - "coordinates": [click_x, click_y], - "sender": sender, - "is_unread": is_unread - } - if sender == "对方": - voice_messages.append(v_msg) - dialogue_log.append({ - "y": ay, - "text": f"{sender}: [语音] {duration_text}", - "is_voice": True, - "id": f"voice_{ax}_{ay}", - "v_msg": v_msg - }) - - # 处理剩余的 OCR 文字块 (普通文本) - for idx, (bbox, text, conf) in enumerate(ocr_results): - if idx in matched_ocr_indices: continue - - x_min, x_max = bbox[0][0], bbox[2][0] - y_min, y_max = bbox[0][1], bbox[2][1] - c_x, c_y = (x_min + x_max) / 2, (y_min + y_max) / 2 - - if c_y < 300 or c_y > 1800: continue - - if x_min < 250 and x_max < 700: - sender, color = "对方", (0, 255, 0) - elif x_max > 800 and x_min > 300: - sender, color = "我", (255, 0, 0) - else: - sender, color = "系统", (128, 128, 128) - - if sender != "系统": - logger.info(f"💬 发现文本消息: x={c_x}, y={c_y}, 发送者={sender}, 内容={text}") - cv2.rectangle(debug_img, (int(x_min), int(y_min)), (int(x_max), int(y_max)), color, 1) - dialogue_log.append({ - "y": c_y, - "text": f"{sender}: {text}", - "is_voice": False - }) - - # 按 Y 轴重新排序整个对话日志 - dialogue_log.sort(key=lambda x: x['y']) - - # 保存调试图 - debug_shot_path = os.path.join(self.screenshot_dir, "t6_debug_view.jpg") - cv2.imwrite(debug_shot_path, debug_img) - logger.info(f"🎨 已保存视觉调试图: {debug_shot_path}") - - # C. 寻找输入框 (CV 模板匹配) - input_template = os.path.join(TEMPLATE_DIR, "input_box.jpg") # 假设有这个模板 - input_center = find_template_match(tmp_shot, input_template, threshold=0.6) - if not input_center: - # 几何兜底:屏幕底部 88% 处 - from PIL import Image - with Image.open(tmp_shot) as img: - w, h = img.size - input_center = [w // 2, int(h * 0.88)] - logger.info(f"� 使用几何兜底输入框坐标: {input_center}") - - # 4. & 5. 统一处理最后一条消息逻辑 (只看最后一条) - should_reply = False - input_y = input_center[1] if input_center else None - - if dialogue_log: - last_item = dialogue_log[-1] - last_text = last_item["text"] - # 构造唯一标识符:文本内容 + 坐标 (Y坐标取整到10像素以容纳轻微位移) - current_msg_id = f"{last_text}_{int(last_item['y']/10)*10}" - - # 核心规则:只有当最后一条消息是“对方”说的,且内容未处理过,才回复。 - if last_text.startswith("对方"): - if current_msg_id != self.last_processed_msg_id: - logger.info(f"💡 发现新消息: {last_text}") - - # 如果是语音,且需要回复,则先转换 - if last_item.get("is_voice"): - v_msg = last_item.get("v_msg") - if v_msg: - logger.info(f"🎤 最后一条是语音,开始转换: {v_msg['content']}") - converted_text = await self.process_single_voice(v_msg, None, input_y) - if converted_text: - # 更新文本内容以便 LLM 理解 - last_item["text"] = f"对方: [语音转文字: {converted_text}]" - logger.info(f"✅ 语音转换成功: {converted_text}") - else: - logger.warning("⚠️ 语音转换未提取到文字,将尝试直接回复或跳过。") - - should_reply = True + # 3. 判断是否需要回复 + # 规则:最后一条消息由“对方”发送,且不是上一次处理过的消息 + if "对方:" in last_msg: + if current_msg_hash != self.last_processed_msg_hash: + logger.info(f"💡 发现新消息,准备生成回复: {last_msg}") + + # 生成回复 + reply = await self.get_reply(last_msg) + + if reply: + logger.info(f"🤖 LLM 回复: {reply}") + # 执行输入和发送 + if input_pos: + perform_input_action(self.d, input_pos, reply) + logger.info("✅ 回复已发送") + # 成功发送后更新最后处理的消息哈希 + self.last_processed_msg_hash = current_msg_hash + else: + logger.warning("❌ 未找到输入框位置,无法发送回复") else: - # 消息已处理过 - should_reply = False + logger.warning("⚠️ LLM 未生成有效回复") else: - # 最后一条是我发送的,或者是系统消息 - should_reply = False - # 记录一下,避免在没有新消息时重复进入逻辑 - if current_msg_id != self.last_processed_msg_id: - self.last_processed_msg_id = current_msg_id - logger.info(f"⚪ 最后一条消息非对方发送,无需回复: {last_text}") + # 消息已处理过,不重复回复 + pass + else: + # 最后一条是我发送的或者是系统消息,更新哈希以防之后重复处理(如果之后又变成对方发) + # 或者简单地跳过 + if current_msg_hash != self.last_processed_msg_hash: + logger.info(f"⚪ 最后一条消息非对方发送,无需回复: {last_msg}") + self.last_processed_msg_hash = current_msg_hash - if should_reply: - logger.info("🤖 准备调用 LLM 生成回复...") - # 立即更新状态,防止在回复生成期间重复触发 - self.last_processed_msg_id = current_msg_id - - # 构建完整历史用于上下文 - final_dialogue_texts = [item['text'] for item in dialogue_log] - history_text = "\n".join(final_dialogue_texts) - - reply = await self.get_reply(history_text) - if reply: - logger.info(f"💡 LLM 回复: {reply}") - if input_center: - perform_input_action(self.d, input_center, reply) - time.sleep(1) # 等待发送完成 - else: - logger.warning("⚠️ LLM 未生成有效回复。") - - self.is_first_run = False - - # 休眠 + # 4. 休眠 await asyncio.sleep(CHECK_INTERVAL) except Exception as e: diff --git a/WeiXin/WxUtil.py b/WeiXin/WxUtil.py index 42f05b3..4658715 100644 --- a/WeiXin/WxUtil.py +++ b/WeiXin/WxUtil.py @@ -14,11 +14,9 @@ if project_root not in sys.path: sys.path.append(project_root) import json -from Util.VLMKit import VLMKit from Util.EasyOcrKit import EasyOcrKit -# 初始化 VLMKit 和 EasyOcrKit -vlm_kit = VLMKit() +# 初始化 EasyOcrKit ocr_kit = EasyOcrKit() # 配置日志 @@ -72,302 +70,216 @@ def connect_device(): logger.error(f"设备连接失败: {e}") return None -async def get_vlm_json(image_path, prompt): - """ - 通用 VLM 分析函数,返回 JSON 数据 (自动处理归一化坐标的反归一化) - """ - try: - # 调用 VLM - response = await vlm_kit.analyze_image(image_path, prompt) - json_str = vlm_kit.extract_json(response) - result_data = json.loads(json_str) - - # 获取图片尺寸进行坐标反归一化 - try: - from PIL import Image - with Image.open(image_path) as img: - width, height = img.size - - # 定义反归一化函数 - def denormalize(point): - if not point or len(point) != 2: - return point - return [int(point[0] / 1000 * width), int(point[1] / 1000 * height)] - - # 递归遍历字典进行反归一化 (仅针对常见坐标字段 center, input_box) - def recursive_denormalize(data): - if isinstance(data, dict): - for key, value in data.items(): - if key in ["center", "input_box", "coordinates"] and isinstance(value, list) and len(value) == 2: - data[key] = denormalize(value) - elif isinstance(value, (dict, list)): - recursive_denormalize(value) - elif isinstance(data, list): - for item in data: - recursive_denormalize(item) - - recursive_denormalize(result_data) - - except Exception as e: - logger.warning(f"坐标反归一化失败: {e},将使用原始坐标") - - return result_data - except Exception as e: - logger.error(f"VLM Analysis Failed: {e}", exc_info=True) - return None - -async def get_vlm_analysis(image_path): - """ - 仅调用 VLM 分析图片,返回原始 JSON 数据 (dict) - """ - logger.info(f"正在使用 VLM 分析图片: {image_path}") - - # 构造 Prompt - prompt = """ - 请分析这张微信聊天截图,提取所有对话消息。 - - 【核心规则 - 优先级最高】 - 1. 🚀 **从下往上扫描**:必须确保屏幕最底部的消息被识别。很多时候最底部的消息是最重要的。 - 2. 🔴 **未读红点 (Unread)**:极度关注语音气泡右上角的红点。如果有红点,`is_unread` 必须为 true。 - 3. 📦 **完整性**:识别图中【所有】可见的消息气泡,包括文本消息、语音消息、系统提示(如“昨天 10:36”、“你撤回了一条消息”)。 - - 【消息类型判别】 - - **发送者 (Sender)**:左侧头像为“对方”(Other),右侧头像为“我”(Me)。 - - **语音 (Voice)**: - - 气泡内只有时长(如 5")和声波图标。 - - **重点**:如果语音气泡右侧有灰色的“转文字”字样或红点,且下方没有对应的文本翻译气泡,说明它【尚未转换】。 - - `status` 判断:只有当语音气泡【正下方】紧跟着一个相同发送者的文本气泡(内容是翻译结果),`status` 才为 "converted"。否则为 "unconverted"。 - - **文本 (Text)**:气泡内包含具体的文字内容。 - - 【坐标系统】 - - 使用 [0-1000] 归一化坐标。返回气泡的几何中心点 `center`。 - - 识别底部输入框的位置 `input_box`。 - - 【输出格式】 - 请返回纯 JSON 格式: - { - "is_chat_interface": true, - "input_box": [x, y], - "messages": [ - { - "type": "voice" | "text" | "system", - "sender": "对方" | "我" | "系统", - "status": "converted" | "unconverted", - "is_unread": true | false, - "center": [x, y], - "content": "消息内容或时长" - }, - ... - ] - } - """ - 2. � **红点 (Unread)**:极度关注语音气泡右上角的红点。如果有红点,`is_unread` 必须为 true。 - 3. 📦 **完整性**:识别图中【所有】可见的消息气泡。不要遗漏任何一个,特别是连续的语音消息。 - - 【消息类型判别】 - - **发送者 (Sender)**:左侧头像为“对方”(Other),右侧头像为“我”(Me)。 - - **语音 (Voice)**:气泡内只有时长(如 5")和声波图标。 - - 语音气泡右侧可能有“转文字”或“取消”等灰色小字,请忽略这些文字,气泡依然是 Voice。 - - `status` 判断:如果语音气泡下方【紧接着】有一个属于同一人的文本气泡,且内容看起来像翻译结果,则 `status` 为 "converted",否则为 "unconverted"。 - - **文本 (Text)**:气泡内包含具体的文字内容。 - - 【坐标系统】 - - 使用 [0-1000] 归一化坐标。返回气泡的几何中心点 `center`。 - - 识别底部输入框的位置 `input_box`。 - - 【输出格式】 - 请返回纯 JSON 格式: - { - "is_chat_interface": true, - "input_box": [x, y], - "messages": [ - { - "type": "voice", - "sender": "对方" | "我", - "status": "converted" | "unconverted", - "is_unread": true | false, - "center": [x, y], - "content": "8\"" - }, - ... - ] - } - """ - - try: - # 调用 VLM - response = await vlm_kit.analyze_image(image_path, prompt) - logger.info(f"VLM Raw Response: {response}") # 打印原始响应以便调试 - json_str = vlm_kit.extract_json(response) - result_data = json.loads(json_str) - - # 获取图片尺寸进行坐标反归一化 - try: - from PIL import Image - with Image.open(image_path) as img: - width, height = img.size - - # 定义反归一化函数 - def denormalize(point): - if not point or len(point) != 2: - return point - return [int(point[0] / 1000 * width), int(point[1] / 1000 * height)] - - # 反归一化 input_box - if result_data.get("input_box"): - result_data["input_box"] = denormalize(result_data["input_box"]) - - # 反归一化 messages - if result_data.get("messages"): - for msg in result_data["messages"]: - if msg.get("center"): - msg["center"] = denormalize(msg["center"]) - if msg.get("coordinates"): # 兼容旧字段 - msg["coordinates"] = denormalize(msg["coordinates"]) - - except Exception as e: - logger.warning(f"坐标反归一化失败: {e},将使用原始坐标") - - return result_data - except Exception as e: - logger.error(f"VLM Analysis Failed: {e}", exc_info=True) - return None - async def analyze_chat_image(image_path, output_path, device=None, target_name="对方"): """ - 使用 VLM 识别微信聊天截图中的对话内容、语音消息状态以及输入框位置 - 替代原本的 CV/OCR 方案 + 全面采用 CV + OCR 识别微信聊天截图中的最后一条消息 + 不再使用 VLM """ - - # 语音识别标志 - should_trigger_convert = False - try: - result_data = await get_vlm_analysis(image_path) + # 1. 初始化 + d = device if device else connect_device() + if not d: + return [], None + + # 2. 读取图片 + img = cv2.imread(image_path) + if img is None: + logger.error(f"无法读取图片: {image_path}") + return [], None + h, w = img.shape[:2] - if not result_data: - return [], None + # 3. 模板匹配寻找语音图标和红点 + audio_template = os.path.join(TEMPLATE_DIR, "audio.jpg") + red_point_template = os.path.join(TEMPLATE_DIR, "red_point.jpg") + + 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) + + # 5. 整合所有消息 + messages = [] + debug_img = img.copy() # 初始化调试图 - try: - # 检查是否为聊天界面 - is_chat = result_data.get("is_chat_interface", False) - if not is_chat: - logger.warning("VLM 判断当前不是微信聊天界面") - return None, None + # A. 添加语音消息 + for ax, ay in audio_matches: + # 过滤掉顶部和底部的非聊天区域 (经验值: 顶部150, 底部250) + if ay < 150 or ay > h - 250: + continue - if isinstance(result_data, list): - # 兼容旧格式 - messages = result_data - input_field_coordinates = None - else: - messages = result_data.get("messages", []) - input_field_coordinates = result_data.get("input_box") # input_box + 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) - except Exception as e: - logger.error(f"解析 VLM 结果失败: {e}") + # --- 新增:判断是否已转文字 --- + is_converted = False + 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) + # 转换后的文字通常在语音图标下方 30-300 像素内,且水平位置相近 + if 30 < c_y - ay < 300 and abs(c_x - ax) < 200: + is_converted = True + break + + 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) + # --- 结束 --- + + messages.append({ + "type": "voice", + "sender": sender, + "center": (ax, ay), + "y": ay, + "is_unread": is_unread, + "is_converted": is_converted + }) + + # 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) + + # 过滤区域 + if c_y < 150 or c_y > h - 250: + continue + + # 过滤掉单字(可能是头像旁边的文字或杂质)和某些系统词 + if len(text) < 1 and "昨天" not in text and "今天" not in text: + continue + + sender = "对方" if c_x < w / 2 else "我" + messages.append({ + "type": "text", + "sender": sender, + "content": text, + "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}") dialogue_log = [] - unconverted_voices = [] - - # 处理识别结果 - for msg in messages: - sender = msg.get('sender', '未知') - msg_type = msg.get('type', 'other') - content = msg.get('content', '') - coords = msg.get('center', [0, 0]) # center - status = msg.get('status', 'unconverted') - is_unread = msg.get('is_unread', False) - is_converted = (status == "converted") - - unread_mark = "[未读]" if is_unread else "" - - # 记录对话日志 - if msg_type == 'voice': - if is_converted: - dialogue_log.append(f"{sender}: {unread_mark}[语音] {content} (已转换)") - else: - dialogue_log.append(f"{sender}: {unread_mark}[语音] (待转换)") - # 将 center 转换为 coordinates 供后续使用 - msg['coordinates'] = coords - unconverted_voices.append(msg) - elif msg_type == 'text': - dialogue_log.append(f"{sender}: {content}") - - logger.info(f"VLM 识别: {sender} [{msg_type}] {content} (Converted: {is_converted}, Unread: {is_unread})") - - # 处理未转换的语音消息 + input_field_coordinates = (w // 2, int(h * 0.9)) # 默认输入框位置 + + # 7. 自动处理所有“红框 + NO”的语音消息 + unconverted_voices = [m for m in messages if m['type'] == 'voice' and m.get('is_unread') and not m.get('is_converted')] + if unconverted_voices: - # 优先级:1. 有红点的最后一条 2. 没红点的最后一条 - unread_voices = [v for v in unconverted_voices if v.get('is_unread')] - if unread_voices: - logger.info(f"发现 {len(unread_voices)} 条未读语音消息,优先处理最后一条...") - voice_to_process = unread_voices[-1] - else: - logger.info(f"发现 {len(unconverted_voices)} 条未转换语音消息,处理最后一条...") - voice_to_process = unconverted_voices[-1] + logger.info(f"发现 {len(unconverted_voices)} 条未转换的未读语音,开始处理...") + + for v_msg in unconverted_voices: + vx, vy = int(v_msg['center'][0]), int(v_msg['center'][1]) + logger.info(f"--- 正在处理语音消息 ({vx}, {vy}) ---") - # 仅保留选中的一条进行处理 - unconverted_voices = [voice_to_process] + # A. 长按语音 + logger.info(f"正在长按语音消息 ({vx}, {vy})...") + d.long_click(vx, vy, 1.5) + time.sleep(1.5) - # 使用传入的 device 或创建新连接 - d = device if device else connect_device() + # B. 截图寻找“转文字”按钮 + menu_shot = os.path.join(OUTPUT_DIR, f"voice_menu_{vy}.jpg") + d.screenshot(menu_shot) + zhuan_template = os.path.join(TEMPLATE_DIR, "zhun_wen_zi.jpg") - for voice in unconverted_voices: - vx, vy = voice['coordinates'] - logger.info(f"长按语音消息: ({vx}, {vy})") + # 降低阈值到 0.7 以增加匹配成功率 + 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}),点击中...") + d.click(btn_x, btn_y) + + # 等待转换完成 (根据语音长度,通常 3-5 秒足够) + logger.info("等待语音转文字完成...") + time.sleep(5.0) + + # C. 再次截图 OCR 获取转换后的文字 + after_convert_shot = os.path.join(OUTPUT_DIR, f"after_auto_{vy}.jpg") + d.screenshot(after_convert_shot) + convert_ocr = ocr_kit.read_text(after_convert_shot) + + # 提取转换文字:寻找在语音图标下方的文字块 + converted_text = "" + for c_bbox, c_text, c_conf in convert_ocr: + cc_x = (c_bbox[0][0] + c_bbox[2][0]) / 2 + cc_y = (c_bbox[0][1] + c_bbox[2][1]) / 2 + # 转换后的文字通常在语音图标下方 30-300 像素内,且水平位置相近 + if 30 < cc_y - vy < 300 and abs(cc_x - vx) < 250: + converted_text = c_text + break + + if converted_text: + logger.info(f"✨ OCR 识别成功!") + print(f"\n[语音转文字结果]: {converted_text}\n") + # 同步到消息对象 + v_msg['content'] = converted_text + v_msg['is_converted'] = True + # 如果这条消息也是最后一条消息,更新 dialogue_log 需要的内容 + if v_msg == last_msg: + last_msg['content'] = converted_text + else: + logger.warning("❌ OCR 未能提取到转换后的文字内容") + + # D. 长按并点击“取消转文字”恢复界面 + logger.info("正在恢复界面状态 (点击'取消转文字')...") d.long_click(vx, vy, 1.5) time.sleep(1.0) + cancel_shot = os.path.join(OUTPUT_DIR, f"cancel_menu_{vy}.jpg") + d.screenshot(cancel_shot) + cancel_template = os.path.join(TEMPLATE_DIR, "cancel_zhuan_wen_zi.jpg") + cancel_btn = find_template_match(cancel_shot, cancel_template, threshold=0.7) - # 查找“转文字” (使用 OCR) - menu_shot_path = os.path.join(os.path.dirname(image_path), "temp_menu_shot.jpg") - d.screenshot(menu_shot_path) - - # OCR 识别 - ocr_results = ocr_kit.read_text(menu_shot_path) - convert_btn_center = None - - for bbox, text, conf in ocr_results: - if "转文字" in text or "转换为文字" in text: - # bbox is [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] - # Calculate center - c_x = int((bbox[0][0] + bbox[2][0]) / 2) - c_y = int((bbox[0][1] + bbox[2][1]) / 2) - convert_btn_center = (c_x, c_y) - break - - if convert_btn_center: - logger.info(f"OCR 找到 '转文字' 按钮: {convert_btn_center}") - d.click(convert_btn_center[0], convert_btn_center[1]) - should_trigger_convert = True - - # 动态等待: 60s语音约需10s转换,比例约 1/6 - duration_str = voice.get('content', '0').replace('"', '').strip() - try: - duration = int(duration_str) - except: - duration = 10 # 默认值 - - wait_seconds = max(2, duration / 5.0) # 稍微多等一点,用 /5.0 - logger.info(f"语音时长 {duration}s,预计等待转换 {wait_seconds:.1f}s...") - time.sleep(wait_seconds) - + if cancel_btn: + c_btn_x, c_btn_y = int(cancel_btn[0]), int(cancel_btn[1]) + d.click(c_btn_x, c_btn_y) + logger.info(f"✅ 已点击'取消转文字' ({c_btn_x}, {c_btn_y}),界面已恢复") else: - logger.warning("OCR 未找到 '转文字' 菜单项") - # 点击空白处关闭菜单,避免遮挡 - d.click(vx + 200, vy) - - if should_trigger_convert: - # 转换完成后稍微多等一下,确保 UI 刷新 - time.sleep(1.0) - # 即使触发了转换,我们也返回当前的对话日志,但在日志末尾注明正在转换 - dialogue_log.append("系统: [正在转换语音...]") - return dialogue_log, input_field_coordinates + # 兜底:点击语音图标右侧空白处尝试关闭菜单 + logger.warning("⚠️ 未找到'取消转文字'按钮,尝试点击空白处关闭菜单") + d.click(vx + 300, vy) + else: + logger.warning("❌ 未能找到'转文字'按钮,可能长按失败或模板不匹配") + # 尝试点击空白处退出菜单 + d.click(vx + 300, vy) + # 8. 整合对话日志 (仅针对最后一条消息进行反馈) + dialogue_log = [] + if last_msg['type'] == 'voice': + # 优先使用刚才转文字得到的内容 + content = last_msg.get('content') or "[语音]" + dialogue_log.append(f"{last_msg['sender']}: {content}") + else: + dialogue_log.append(f"{last_msg['sender']}: {last_msg['content']}") + return dialogue_log, input_field_coordinates - + except Exception as e: - logger.error(f"VLM 分析失败: {e}", exc_info=True) + logger.error(f"analyze_chat_image 失败: {e}", exc_info=True) return [], None diff --git a/WeiXin/__pycache__/WxUtil.cpython-310.pyc b/WeiXin/__pycache__/WxUtil.cpython-310.pyc index c9ead93afe48f994a17413861282aa94501949e7..bc5fda6f43fb1154b08e5c12a674c7f13a8a122a 100644 GIT binary patch delta 6709 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