diff --git a/WeiXin/Screenshots/chat_interface_check.jpg b/WeiXin/Screenshots/chat_interface_check.jpg new file mode 100644 index 0000000..4a488ac Binary files /dev/null and b/WeiXin/Screenshots/chat_interface_check.jpg differ diff --git a/WeiXin/Screenshots/debug_20260125_102803_coord_01_before_click_input.jpg b/WeiXin/Screenshots/debug_20260125_102803_coord_01_before_click_input.jpg deleted file mode 100644 index 3fd6b97..0000000 Binary files a/WeiXin/Screenshots/debug_20260125_102803_coord_01_before_click_input.jpg and /dev/null differ diff --git a/WeiXin/Screenshots/debug_20260125_102805_coord_02_after_click_input_keyboard.jpg b/WeiXin/Screenshots/debug_20260125_102805_coord_02_after_click_input_keyboard.jpg deleted file mode 100644 index 510d4b5..0000000 Binary files a/WeiXin/Screenshots/debug_20260125_102805_coord_02_after_click_input_keyboard.jpg and /dev/null differ diff --git a/WeiXin/Screenshots/debug_20260125_102806_coord_03_after_input_text.jpg b/WeiXin/Screenshots/debug_20260125_102806_coord_03_after_input_text.jpg deleted file mode 100644 index f8519ac..0000000 Binary files a/WeiXin/Screenshots/debug_20260125_102806_coord_03_after_input_text.jpg and /dev/null differ diff --git a/WeiXin/Screenshots/debug_20260125_102807_coord_04_after_click_send_image.jpg b/WeiXin/Screenshots/debug_20260125_102807_coord_04_after_click_send_image.jpg deleted file mode 100644 index 9d043e7..0000000 Binary files a/WeiXin/Screenshots/debug_20260125_102807_coord_04_after_click_send_image.jpg and /dev/null differ diff --git a/WeiXin/Screenshots/debug_20260125_115039_coord_01_before_click_input.jpg b/WeiXin/Screenshots/debug_20260125_115039_coord_01_before_click_input.jpg new file mode 100644 index 0000000..9463fd0 Binary files /dev/null and b/WeiXin/Screenshots/debug_20260125_115039_coord_01_before_click_input.jpg differ diff --git a/WeiXin/Screenshots/debug_20260125_115040_coord_02_after_click_input_keyboard.jpg b/WeiXin/Screenshots/debug_20260125_115040_coord_02_after_click_input_keyboard.jpg new file mode 100644 index 0000000..c986c5e Binary files /dev/null and b/WeiXin/Screenshots/debug_20260125_115040_coord_02_after_click_input_keyboard.jpg differ diff --git a/WeiXin/Screenshots/debug_20260125_115041_coord_03_after_input_text.jpg b/WeiXin/Screenshots/debug_20260125_115041_coord_03_after_input_text.jpg new file mode 100644 index 0000000..7629b5b Binary files /dev/null and b/WeiXin/Screenshots/debug_20260125_115041_coord_03_after_input_text.jpg differ diff --git a/WeiXin/Screenshots/debug_send_check_20260125_102807.jpg b/WeiXin/Screenshots/debug_send_check_20260125_102807.jpg deleted file mode 100644 index 6aa1a70..0000000 Binary files a/WeiXin/Screenshots/debug_send_check_20260125_102807.jpg and /dev/null differ diff --git a/WeiXin/Screenshots/debug_send_check_20260125_115042.jpg b/WeiXin/Screenshots/debug_send_check_20260125_115042.jpg new file mode 100644 index 0000000..f0fc05b Binary files /dev/null and b/WeiXin/Screenshots/debug_send_check_20260125_115042.jpg differ diff --git a/WeiXin/Screenshots/t4_temp_history_analyzed.jpg b/WeiXin/Screenshots/t4_temp_history_analyzed.jpg deleted file mode 100644 index 97ca4b7..0000000 Binary files a/WeiXin/Screenshots/t4_temp_history_analyzed.jpg and /dev/null differ diff --git a/WeiXin/Screenshots/t4_temp_history_check.jpg b/WeiXin/Screenshots/t4_temp_history_check.jpg deleted file mode 100644 index cb41a10..0000000 Binary files a/WeiXin/Screenshots/t4_temp_history_check.jpg and /dev/null differ diff --git a/WeiXin/Screenshots/t5_monitor_analyzed.jpg b/WeiXin/Screenshots/t5_monitor_analyzed.jpg index ded3e44..f628e26 100644 Binary files a/WeiXin/Screenshots/t5_monitor_analyzed.jpg and b/WeiXin/Screenshots/t5_monitor_analyzed.jpg differ diff --git a/WeiXin/Screenshots/t5_monitor_temp.jpg b/WeiXin/Screenshots/t5_monitor_temp.jpg index 057f3b0..4a488ac 100644 Binary files a/WeiXin/Screenshots/t5_monitor_temp.jpg and b/WeiXin/Screenshots/t5_monitor_temp.jpg differ diff --git a/WeiXin/T4_InputLlmText.py b/WeiXin/T4_InputLlmText.py index ad81d50..ecb7f4d 100644 --- a/WeiXin/T4_InputLlmText.py +++ b/WeiXin/T4_InputLlmText.py @@ -11,7 +11,7 @@ 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.WxUtil import find_input_box_center, perform_input_action, analyze_chat_image, clean_screenshots_dir +from WeiXin.WxUtil import find_input_box_center, perform_input_action, analyze_chat_image, clean_screenshots_dir, is_in_chat_interface from Util.LlmUtil import get_llm_response # 配置日志 @@ -39,6 +39,11 @@ async def generate_and_input(): # 1. 连接设备 d = u2.connect() logger.info(f"设备连接成功: {d.info.get('serial')}") + + # 检查界面状态 + if not is_in_chat_interface(d): + logger.error("🚫 当前不在微信聊天界面,任务终止") + return # 2. 截图并识别对话历史 screenshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Screenshots") @@ -51,6 +56,13 @@ async def generate_and_input(): analyzed_shot = os.path.join(screenshot_dir, "t4_temp_history_analyzed.jpg") dialogue_log = analyze_chat_image(tmp_shot, analyzed_shot) + # 语音转文字处理 + if dialogue_log == "VOICE_CONVERTING": + logger.info("检测到语音正在转文字,等待 3 秒后重新截图分析...") + await asyncio.sleep(3) + d.screenshot(tmp_shot) + dialogue_log = analyze_chat_image(tmp_shot, analyzed_shot) + history_text = "" if dialogue_log: history_text = "\n".join(dialogue_log) @@ -61,25 +73,27 @@ async def generate_and_input(): # 3. 调用 LLM 生成回复 persona = ( - "你是一名99年毕业,有27年教学经验的小学高级教师,大张老师。你在长春市少惠林作文素养培养中心工作," - "校区真实地址就在:长春市南环城路与临河街交汇的TOUCH12街3楼325号。你对作文批阅不下20000个," - "非常熟悉小学阶段、初二以前的初中阶段作文教学。你是一位深受家长和学生信任的教育智者," - "回复时要既专业又充满亲和力,像老朋友交流一样自然、温暖。严禁使用‘1. 2. 3.’或‘首先、其次、最后’等生硬、刻板的列条目回复。" - "要善于用富有经验的话语疏导家长的焦虑,并给出有深度、可操作的详细指导。语言要生动、形象,多用比喻或实例,展现岁月沉淀的智慧。" - "如果涉及到校区信息,必须使用以下真实数据,绝对严禁使用'XX区'、'XX路'、'XX号'等占位符:\n" - "1. 校址:长春市南环城路与临河街交汇的TOUCH12街3楼325号\n" - "2. 课程:线上和线下作文课、假期读书营、阅读策略营\n" - "3. 上课时间:周二/周四晚上,以及周六周日全天\n" - "4. 联系人:小张老师\n" - "5. 电话:18686619970" + "你是一名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" - "请作为大张老师,针对最后的消息给出一个充满关怀、有深度且专业的回复。回复要亲切自然," - "字数控制在 150-250 字之间,不要分点列项,而是用连贯的段落叙述。要把方法讲透,把道理讲细," - "让家长感受到你作为老教师的温度和沉淀。如果提到地址、电话或课程,必须使用人设中提供的真实信息," - "严禁出现任何 'XX' 形式的占位符。直接输出回复内容。" + f"【教师人设】:{persona}\n\n" + f"【近期聊天记录】:\n{history_text}\n\n" + "【任务要求】:\n" + "请作为大张老师,给家长写一段暖心且有深度的回复。要针对家长最后提到的问题或状态,先给予情感上的安抚和理解," + "然后再结合你27年的教学经验,给出具体的建议。建议要讲得细致、感人,展现出老教师的智慧沉淀。" + "全文必须是一个或两个完整的自然段,绝对禁止分点列项!字数严格在 100-200 字之间。" + "如果需要提供联系方式或地址,请自然地揉进段落中,严禁出现'XX'占位符。直接输出回复的正文内容,不要包含任何多余的修饰词或引号。" ) logger.info(f"正在以'亲切而专业的特级教师'身份请求 LLM 生成深度回复...") @@ -97,23 +111,15 @@ async def generate_and_input(): # 4. 识别输入框位置 center_point, rect_box = find_input_box_center(tmp_shot) - if center_point: - cx, cy = center_point - logger.info(f"识别到输入框中心: ({cx}, {cy})") - - # 5. 点击输入框并输入文字并发送 - # 使用 WxUtil 中的 perform_input_action - # 该函数内部会先点击坐标,然后输入内容 - # 设置 auto_send=True,输入文字后点击发送 - logger.info(f"正在点击输入框并输入文字并发送: {llm_text}") - success = perform_input_action(d, center_point, llm_text, auto_send=True) - - if success: - logger.info("✅ T4 执行完成:文字已成功输入并点击发送。") - else: - logger.error("❌ T4 执行失败:输入动作未成功完成。") + # 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("✅ T4 执行完成:文字已成功输入并点击发送。") else: - logger.error("❌ T4 执行失败:未能识别到输入框位置。") + logger.error("❌ T4 执行失败:输入动作未成功完成。") except Exception as e: logger.error(f"❌ T4 执行出错: {e}", exc_info=True) diff --git a/WeiXin/T5_AutoChatMonitor.py b/WeiXin/T5_AutoChatMonitor.py index 15bd80b..fd08be1 100644 --- a/WeiXin/T5_AutoChatMonitor.py +++ b/WeiXin/T5_AutoChatMonitor.py @@ -12,7 +12,7 @@ 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.WxUtil import find_input_box_center, perform_input_action, analyze_chat_image, clean_screenshots_dir +from WeiXin.WxUtil import find_input_box_center, perform_input_action, analyze_chat_image, clean_screenshots_dir, is_in_chat_interface from Util.LlmUtil import get_llm_response # 配置日志 @@ -46,38 +46,39 @@ class ChatBot: os.makedirs(self.screenshot_dir) self.persona = ( - "你是一名99年毕业,有27年教学经验的小学高级教师,大张老师。你在长春市少惠林作文素养培养中心工作," - "校区真实地址就在:长春市南环城路与临河街交汇的TOUCH12街3楼325号。你对作文批阅不下20000个," - "非常熟悉小学阶段、初二以前的初中阶段作文教学。你是一位深受家长和学生信任的教育智者," - "回复时要既专业又充满亲和力,像老朋友交流一样自然、温暖。严禁使用‘1. 2. 3.’或‘首先、其次、最后’等生硬、刻板的列条目回复。" - "要善于用富有经验的话语疏导家长的焦虑,并给出有深度、可操作的详细指导。语言要生动、形象,多用比喻或实例,展现岁月沉淀的智慧。" - "如果涉及到校区信息,必须使用以下真实数据,绝对严禁使用'XX区'、'XX路'、'XX号'等占位符:\n" - "1. 校址:长春市南环城路与临河街交汇的TOUCH12街3楼325号\n" - "2. 课程:线上和线下作文课、假期读书营、阅读策略营\n" - "3. 上课时间:周二/周四晚上,以及周六周日全天\n" - "4. 联系人:小张老师\n" - "5. 电话:18686619970" + "你是一名1999年毕业、拥有27年一线教学经验的小学高级女教师,名叫‘小张老师’(家长们也常叫你张老师)。你目前在‘长春市少惠林作文素养培养中心’工作。" + "你不仅是一位作文教学专家,批阅过超过2万篇作文,更是一位心思细腻、懂得孩子心理、能与家长共情的教育智者。" + "你的回复风格应该是:温柔、知性、亲切且极具亲和力,就像一位邻家大姐姐或贴心的闺蜜在和家长聊天。" + "严禁使用任何列表格式(如 1. 2. 3. 或 A. B. C.),严禁使用‘首先、其次、最后’等刻板的逻辑词。" + "要用连贯、优美、富有生活气息的文字进行叙述。每一句话都要带温度,要善于用‘咱们孩子’、‘作为老师我也理解’等词汇拉近距离。" + "回复内容要有深度,不要只给结论,要讲透背后的教育逻辑。字数必须严格控制在 100-200 字之间。" + "如果涉及到校区信息,必须且只能使用以下真实数据,绝对严禁使用任何占位符:\n" + "- 单位:长春市少惠林作文素养培养中心\n" + "- 地址:南环城路与临河街交汇,TOUCH12街3楼325号\n" + "- 联系人:小张老师(电话:18686619970)\n" + "- 课程:线上/线下作文课、读书营/阅读策略营(假期开设)\n" + "- 上课:作文通常在周二/周四晚上,周六/周日全天" ) async def get_reply(self, history_text, is_proactive=False): if is_proactive: prompt = ( - f"人设:{self.persona}\n\n" - f"对话背景:用户已经超过5分钟没有回应了。\n" - f"历史对话:\n{history_text}\n\n" - "请作为大张老师,给出一个温暖且有深度的询问或阶段性总结。不要只是简单的问候," - "可以根据之前的对话内容,再补充一点点有用的教学建议,或者分享一个具体的教学小故事来宽慰家长。" - "严禁分条列项,要像老朋友拉家常一样,字数 120-200 字左右。" - "如果提到地址、电话或课程,必须使用人设中提供的真实信息,严禁出现任何 'XX' 形式的占位符。" + f"【教师人设】:{self.persona}\n\n" + f"【对话背景】:家长已经超过5分钟没有回应了。\n" + f"【近期聊天记录】:\n{history_text}\n\n" + "【任务要求】:\n" + "请作为小张老师,给家长发一段主动关怀的消息。不要催促,而是以‘刚才突然想到’或者‘又想起咱们孩子之前提到的’为由头," + "再补充一点有价值的教学点滴,或者分享一个能缓解焦虑的小故事。语气要温柔亲切,字数在 100-200 字之间。" + "全文必须是连贯的段落,严禁列条目!如果提到联系方式或地址,必须使用人设中的真实数据,严禁占位符。" ) else: prompt = ( - f"人设:{self.persona}\n\n" - f"以下是微信聊天记录('对方'和'我'的对话):\n{history_text}\n\n" - "请作为大张老师,针对最后的消息给出一个充满关怀、有深度且专业的回复。回复要亲切自然," - "字数控制在 150-250 字之间,不要分点列项,而是用连贯的段落叙述。要把方法讲透,把道理讲细," - "让家长感受到你作为老教师的温度和沉淀。如果提到地址、电话或课程,必须使用人设中提供的真实信息," - "严禁出现任何 'XX' 形式的占位符。直接输出回复内容。" + f"【教师人设】:{self.persona}\n\n" + f"【近期聊天记录】:\n{history_text}\n\n" + "【任务要求】:\n" + "请作为小张老师,给家长写一段暖心且有深度的回复。针对家长最后的消息,先给予情感上的关怀,再结合27年经验给出具体指导。" + "展现出资深女教师的温柔与智慧。全文必须是一个或两个完整的自然段,绝对禁止分点列项!字数严格在 100-200 字之间。" + "如果提到联系方式或地址,必须使用人设中的真实数据,严禁占位符。直接输出回复的正文内容。" ) full_response = "" @@ -93,20 +94,39 @@ class ChatBot: while True: try: - # 1. 截图并分析 - tmp_shot = os.path.join(self.screenshot_dir, "t5_monitor_temp.jpg") - analyzed_shot = os.path.join(self.screenshot_dir, "t5_monitor_analyzed.jpg") - self.d.screenshot(tmp_shot) - - dialogue_log = analyze_chat_image(tmp_shot, analyzed_shot) - - if not dialogue_log: - logger.debug("未识别到有效对话,跳过...") + # 1. 检查是否在微信聊天界面 + if not is_in_chat_interface(self.d): + logger.warning("⚠️ 当前不在微信聊天界面,等待下一次扫描...") await asyncio.sleep(CHECK_INTERVAL) continue + logger.info("🔍 正在扫描当前界面内容...") + # 1. 截图并分析 + tmp_shot = os.path.join(self.screenshot_dir, "t5_monitor_temp.jpg") + analyzed_shot = os.path.join(self.screenshot_dir, "t5_monitor_analyzed.jpg") + + logger.info(f"📸 正在截取屏幕... ({datetime.now().strftime('%H:%M:%S')})") + self.d.screenshot(tmp_shot) + + logger.info("🎨 正在分析聊天界面内容 (检测头像与对话)...") + dialogue_log = analyze_chat_image(tmp_shot, analyzed_shot) + + # 语音转文字处理 + if dialogue_log == "VOICE_CONVERTING": + logger.info("🎙️ 检测到语音消息,已触发转文字,等待处理完成 (5秒)...") + await asyncio.sleep(5) + continue + + if not dialogue_log: + logger.info("⏳ 界面分析完成,未发现有效对话内容,继续监控...") + await asyncio.sleep(CHECK_INTERVAL) + continue + + logger.info(f"📑 界面扫描完成,当前对话历史共 {len(dialogue_log)} 条") + # 2. 检查是否有新消息 current_last_msg = dialogue_log[-1] + logger.info(f"💬 当前最后一条消息: {current_last_msg}") history_text = "\n".join(dialogue_log) # 判断逻辑:如果最后一条消息是“对方”发的,且与上次不同,则回复 @@ -118,13 +138,11 @@ class ChatBot: # 执行输入发送 center_point, _ = find_input_box_center(tmp_shot) - if center_point: - perform_input_action(self.d, center_point, reply, auto_send=True) - self.last_message_text = f"我: {reply}" # 更新状态,避免重复回复自己 - self.last_interaction_time = time.time() - self.proactive_count = 0 # 重置主动询问计数 - else: - logger.error("无法定位输入框") + # 即使 CV 没找到坐标,也尝试执行,因为 perform_input_action 内部有原生控件识别 + perform_input_action(self.d, center_point, reply, auto_send=True) + self.last_message_text = f"我: {reply}" # 更新状态,避免重复回复自己 + self.last_interaction_time = time.time() + self.proactive_count = 0 # 重置主动询问计数 # 3. 检查是否需要主动询问 (用户长时间不响应) elif "我:" in current_last_msg: @@ -137,11 +155,11 @@ class ChatBot: logger.info(f"🤖 发起主动询问: {proactive_reply}") center_point, _ = find_input_box_center(tmp_shot) - if center_point: - perform_input_action(self.d, center_point, proactive_reply, auto_send=True) - self.proactive_count += 1 - self.last_interaction_time = time.time() # 更新时间,避免连续询问 - self.last_message_text = f"我: {proactive_reply}" + # 同上,解耦 CV 坐标 + perform_input_action(self.d, center_point, proactive_reply, auto_send=True) + self.proactive_count += 1 + self.last_interaction_time = time.time() # 更新时间,避免连续询问 + self.last_message_text = f"我: {proactive_reply}" # 更新最后一条消息记录(仅用于对比) if "对方:" in current_last_msg: diff --git a/WeiXin/Templates/input_text.jpg b/WeiXin/Templates/input_text.jpg new file mode 100644 index 0000000..635f6b9 Binary files /dev/null and b/WeiXin/Templates/input_text.jpg differ diff --git a/WeiXin/WxUtil.py b/WeiXin/WxUtil.py index 2c45376..44b8a86 100644 --- a/WeiXin/WxUtil.py +++ b/WeiXin/WxUtil.py @@ -20,6 +20,74 @@ from Util.EasyOcrKit import get_easyocr_reader # 调用者应自行配置日志(包括 FileHandler 等) logger = logging.getLogger("WxUtil") +def is_in_chat_interface(d): + """ + 判断当前是否处于微信聊天界面 + 识别标准:左侧和右侧同时存在小方块(头像) + """ + try: + current = d.app_current() + if current.get('package') != 'com.tencent.mm': + return False + + # 1. 基础 UI 检查 (快速判断) + # 如果能直接搜到返回按钮和输入框/表情按钮,基本就是了 + if (d(resourceId="com.tencent.mm:id/back_btn").exists or d(description="返回").exists) and \ + (d(className="android.widget.EditText").exists or d(description="表情").exists): + logger.info("✅ 通过 UI 元素确认聊天界面") + return True + + # 2. CV 检查 (针对复杂情况,如 "+" 菜单打开时 UI 元素可能发生变化) + screenshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Screenshots") + if not os.path.exists(screenshot_dir): os.makedirs(screenshot_dir) + tmp_check_path = os.path.join(screenshot_dir, "chat_interface_check.jpg") + d.screenshot(tmp_check_path) + + img = cv2.imread(tmp_check_path) + if img is None: + return True # 无法截图时保守返回 True + + height, width = img.shape[:2] + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + # 使用自适应二值化来提取轮廓 + thresh = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY_INV, 11, 2) + + contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + left_avatars = 0 + right_avatars = 0 + + for cnt in contours: + x, y, w, h = cv2.boundingRect(cnt) + # 头像特征:宽高比接近 1,大小在一定范围 (50-150px 针对常见分辨率) + aspect_ratio = float(w) / h + if 0.8 < aspect_ratio < 1.2 and 40 < w < 160: + # 排除状态栏 (顶部 10%) 和 底部极小区域 + if y < height * 0.1 or y > height * 0.95: + continue + + # 微信头像通常紧贴左右两侧 + if x < width * 0.3: # 左侧头像 + left_avatars += 1 + elif x + w > width * 0.7: # 右侧头像 + right_avatars += 1 + + if left_avatars > 0 and right_avatars > 0: + logger.info(f"✅ 通过 CV 确认聊天界面 (左侧头像:{left_avatars}, 右侧头像:{right_avatars})") + return True + + # 兜底:如果检测到常见的聊天界面按钮,也认为是 + if d(descriptionMatches="更多功能按钮.*").exists or d(description="切换到语音").exists or d(text="发送").exists: + logger.info("✅ 通过功能按钮确认聊天界面") + return True + + logger.warning(f"⚠️ 无法确认聊天界面 (左侧头像:{left_avatars}, 右侧头像:{right_avatars})") + return False + except Exception as e: + logger.error(f"检查聊天界面出错: {e}") + return True # 出错时保守返回 True + def clean_screenshots_dir(): """ 清除 d:\\dsWork\\aiData\\WeiXin\\Screenshots 目录下的所有文件 @@ -40,7 +108,6 @@ def clean_screenshots_dir(): def find_input_box_center(image_path): """ 识别底部输入框的中心坐标,返回 (center_x, center_y, rect_box) - rect_box 用于可视化: (x, y, w, h) (相对于原图) """ try: img_data = np.fromfile(image_path, dtype=np.uint8) @@ -49,88 +116,78 @@ def find_input_box_center(image_path): return None, None height, width = img.shape[:2] - - # 截取底部 12% 区域 (缩小范围以精准定位) - bottom_h = int(height * 0.12) - crop_y_start = height - bottom_h - crop = img[crop_y_start:height, 0:width] - - # 预处理 - gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) - - # 策略1: Canny 边缘检测 (对低对比度 UI 更有效) - edges = cv2.Canny(gray, 50, 150) - # 膨胀连接断裂的边缘 - kernel = np.ones((3,3), np.uint8) - dilated = cv2.dilate(edges, kernel, iterations=1) - - contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - - candidates = [] - - for cnt in contours: - x, y, w, h = cv2.boundingRect(cnt) - # 筛选条件优化: - # 1. 宽度: 屏幕宽度的 50% - 98% (通常输入框很长) - # 2. 高度: 30px - 底部区域的 90% - if width * 0.5 < w < width * 0.98 and 30 < h < bottom_h * 0.9: - # 3. 增加水平居中检查:中心点X坐标必须在屏幕水平中心 10% 范围内 - # 这能有效排除左右侧的气泡或按钮 - cnt_center_x = x + w // 2 - if abs(cnt_center_x - width // 2) > width * 0.1: - continue - # 计算中心点 Y 坐标相对于全图 - global_y = crop_y_start + y + h // 2 - - # 排除过于靠下的区域 (导航栏/手势条),通常在最后 2% - if global_y > height * 0.99: - continue - - candidates.append({ - 'x': x, 'y': y, 'w': w, 'h': h, - 'global_y': global_y, - 'area': w * h - }) - - best_candidate = None - if candidates: - # 优先选择最靠下的候选框 (Y 坐标最大) - # 同时考虑宽度不能太离谱 - candidates.sort(key=lambda c: c['global_y'], reverse=True) + # --- 新增:模板匹配优先策略 --- + # 用户指定的模板路径 d:\dsWork\aiData\WeiXin\Templates\input_text.jpg + template_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Templates", "input_text.jpg") + if os.path.exists(template_path): + try: + tmpl_data = np.fromfile(template_path, dtype=np.uint8) + template = cv2.imdecode(tmpl_data, cv2.IMREAD_COLOR) + if template is not None: + # 模板匹配 + res = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED) + min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res) + + # 阈值设定 (例如 0.8) + if max_val > 0.8: + t_h, t_w = template.shape[:2] + top_left = max_loc + cx = top_left[0] + t_w // 2 + cy = top_left[1] + t_h // 2 + logger.info(f"🎯 通过模板匹配找到输入框: ({cx}, {cy}), 匹配度: {max_val:.2f}") + return (cx, cy), (top_left[0], top_left[1], t_w, t_h) + else: + logger.info(f"模板匹配度不足 ({max_val:.2f} < 0.8),转入 CV 轮廓识别") + except Exception as e: + logger.warning(f"模板匹配出错: {e}") + # ----------------------------- + + # 扩展搜索区域:从顶部 40% 到 底部 + # 键盘弹出时输入框可能在中间偏上,或者被顶到 40%-60% 位置 + search_regions = [ + (int(height * 0.85), height, "底部区域"), + (int(height * 0.40), int(height * 0.85), "中上区域") + ] + + for y_start, y_end, region_name in search_regions: + crop = img[y_start:y_end, 0:width] + gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) + # 使用更灵敏的边缘检测 + edges = cv2.Canny(gray, 30, 100) + kernel = np.ones((5,5), np.uint8) # 增大核以连接断开的边缘 + dilated = cv2.dilate(edges, kernel, iterations=1) + contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - # 检查最靠下的候选框是否合理 - # 严格限制:必须在屏幕底部 94% - 99% 之间 (有效排除最后一条消息) - for cand in candidates: - if cand['global_y'] > height * 0.94: - best_candidate = cand - break + candidates = [] + region_h = y_end - y_start - # 如果没有找到符合 >94% 条件的,视为未找到,直接使用几何兜底 - if not best_candidate: - logger.warning(f"找到 {len(candidates)} 个候选框,但没有一个满足 Y > 94% (最高候选 Y={candidates[0]['global_y']/height:.2%})") + for cnt in contours: + x, y, w, h = cv2.boundingRect(cnt) + # 微信输入框通常很宽,高度固定在一定范围 + # 宽度: 40%-98%, 高度: 40px - 150px (针对 720p 左右分辨率) + if width * 0.4 < w < width * 0.98 and 40 < h < 200: + cnt_center_x = x + w // 2 + # 输入框中心通常在屏幕中轴线附近 + if abs(cnt_center_x - width // 2) > width * 0.2: + continue + global_y = y_start + y + h // 2 + candidates.append({'x': x, 'y': y, 'w': w, 'h': h, 'global_y': global_y, 'area': w * h}) - if best_candidate: - x, y, w, h = best_candidate['x'], best_candidate['y'], best_candidate['w'], best_candidate['h'] - center_x = x + w // 2 - center_y = crop_y_start + y + h // 2 - logger.info(f"找到输入框(CV-Canny): ({center_x}, {center_y}), 尺寸: {w}x{h}, 位置: {center_y/height:.2%}") - return (center_x, center_y), (x, crop_y_start + y, w, h) + if candidates: + # 优先选择面积适中且最靠下的(避免选到顶部的搜索框) + candidates.sort(key=lambda c: c['global_y'], reverse=True) + best = candidates[0] + cx = best['x'] + best['w'] // 2 + cy = best['global_y'] + logger.info(f"🎯 在 {region_name} 通过 CV 找到输入框: ({cx}, {cy})") + return (cx, cy), (best['x'], best['global_y'] - best['h']//2, best['w'], best['h']) - # 策略2: 如果 CV 失败,使用几何兜底 (屏幕底部中间区域) - logger.warning("CV 识别输入框失败,使用几何兜底策略 (Bottom Middle)") - # 假设输入框位于屏幕底部 97% 处,宽度为屏幕的 90% - fallback_w = int(width * 0.9) - fallback_h = int(bottom_h * 0.4) # 估算高度 - fallback_x = (width - fallback_w) // 2 - # 估算中心点 Y: 屏幕高度的 97% - center_x = width // 2 - center_y = int(height * 0.97) - - # 构造一个虚拟的 rect_box - fallback_y_rel = center_y - crop_y_start - fallback_h // 2 - - return (center_x, center_y), (fallback_x, crop_y_start + fallback_y_rel, fallback_w, fallback_h) + # 策略 3: 基于常见 UI 比例的几何兜底 + # 0.93 可能过于靠下(容易点到 Home Indicator 区域),调整为 0.88 以提高命中率 + # 同时保留一个备用的点击区域 rect + logger.warning("CV 识别输入框失败,使用改进的几何兜底策略 (Y=0.88)") + return (width // 2, int(height * 0.88)), (int(width*0.05), int(height*0.86), int(width*0.9), int(height*0.04)) except Exception as e: logger.error(f"查找输入框失败: {e}") @@ -251,12 +308,17 @@ def perform_input_action(d, coords, text, auto_send=True): @param text: 输入文本 @param auto_send: 是否自动点击发送,默认为 True """ - # 优先尝试使用 uiautomator2 的原生控件查找 (更稳健) - native_success = False + if d is None: + d = u2.connect() + + # 1. 检查是否处于“语音模式” + if d(description="切换到键盘").exists: + logger.info("检测到处于语音模式,正在切换到键盘...") + d(description="切换到键盘").click() + time.sleep(1) + + # 2. 优先尝试使用 uiautomator2 的原生控件查找 (更稳健) try: - # 如果没有传入 d,尝试连接 - if d is None: - d = u2.connect() # 查找 EditText 控件 input_elem = d(className="android.widget.EditText") @@ -266,71 +328,31 @@ def perform_input_action(d, coords, text, auto_send=True): # 1. 截图:点击前 take_debug_screenshot(d, "native_01_before_click") - # 双击策略 + # 点击策略 input_elem.click() time.sleep(0.5) - input_elem.click() - time.sleep(1) # 2. 截图:点击后 (预期键盘弹出) take_debug_screenshot(d, "native_02_after_click_keyboard") logger.info(f"输入文本: {text}") - # 尝试 set_text + send_keys 组合 - try: - input_elem.set_text(text) - except: - pass - + # 尝试 set_text + input_elem.set_text(text) time.sleep(0.5) # 检查文本是否输入成功,如果没有,尝试 send_keys - try: - current_text = input_elem.get_text() - if not current_text or current_text != text: - logger.warning(f"set_text 似乎未生效 (当前: {current_text}),尝试 send_keys...") - d.send_keys(text) - except: + current_text = input_elem.get_text() + if not current_text or current_text != text: + logger.warning(f"set_text 似乎未生效 (当前: {current_text}),尝试 send_keys...") d.send_keys(text) # 3. 截图:输入文本后 take_debug_screenshot(d, "native_03_after_text_input") - # 尝试发送回车键 - time.sleep(0.5) + # 点击发送 if auto_send: - d.press("enter") - - # 尝试点击发送按钮 - if auto_send: - try: - if d(text="发送").exists: - d(text="发送").click() - logger.info("已点击 '发送' 按钮 (Native Text)") - take_debug_screenshot(d, "native_04_after_send_click_text") - else: - logger.info("未找到 '发送' 文本控件,尝试图像识别...") - send_btn_coords = find_send_button(d) - if send_btn_coords: - sx, sy = send_btn_coords - d.click(sx, sy) - logger.info(f"已点击 '发送' 按钮 (Image Rec): {sx}, {sy}") - take_debug_screenshot(d, "native_04_after_send_click_image") - else: - width, height = d.window_size() - fallback_x = int(width * 0.9) - fallback_y = int(height * 0.965) - logger.info(f"未识别到发送按钮,尝试盲点右下角: {fallback_x}, {fallback_y}") - d.click(fallback_x, fallback_y) - take_debug_screenshot(d, "native_04_after_send_click_fallback") - except Exception as e: - logger.error(f"点击发送按钮失败: {e}") - else: - logger.info("auto_send=False, 跳过发送动作") - - logger.info("输入完成 (Native)") - native_success = True + send_msg(d) return True else: @@ -339,61 +361,39 @@ def perform_input_action(d, coords, text, auto_send=True): except Exception as e: logger.warning(f"原生控件操作失败,降级为坐标点击: {e}") - if native_success: - return True - - # 降级方案:使用坐标点击 + # 3. 降级方案:使用坐标点击 if not coords: - logger.error("坐标无效,无法执行点击输入") - return + logger.error("坐标无效,尝试使用动态兜底点击...") + # 动态寻找表情按钮左侧 + emoji_btn = d(description="表情") + if emoji_btn.exists: + eb = emoji_btn.info['bounds'] + # 点击表情按钮左侧 150 像素的位置 + coords = (eb['left'] - 150, (eb['top'] + eb['bottom']) // 2) + logger.info(f"根据表情按钮位置计算点击坐标: {coords}") + else: + # 绝对兜底 + width, height = d.window_size() + coords = (width // 2, int(height * 0.88)) + logger.info(f"使用绝对兜底坐标: {coords}") x, y = coords - # 坐标安全检查 - if 'd' not in locals(): - d = u2.connect() - try: - width, height = d.window_size() - # 移除过于激进的坐标修正 (95% -> 93% 会导致点到输入框上方) - # 输入框中心通常在 96%-97% 左右 - if y > height * 0.99: - logger.warning(f"检测到的输入框坐标 y={y} 过于靠底,修正为 {height * 0.97}") - y = int(height * 0.97) - except: - pass - - try: - logger.info(f"设备连接成功: {d.info.get('serial')}") - # 1. 截图:点击输入框前 take_debug_screenshot(d, "coord_01_before_click_input") # 点击输入框 logger.info(f"点击坐标: ({x}, {y})") d.click(x, y) - time.sleep(0.5) - d.click(x, y) # Double click - - # 等待键盘弹出 - time.sleep(1.5) + time.sleep(1.0) # 2. 截图:点击输入框后 take_debug_screenshot(d, "coord_02_after_click_input_keyboard") # 输入文本 logger.info(f"输入文本 (SendKeys): {text}") - try: - d.send_keys(text) - except Exception as sk_e: - logger.error(f"send_keys 失败: {sk_e}") - - # 尝试广播 input - try: - escaped_text = text.replace("'", "\\'").replace(" ", "%s") - d.shell(f"input text '{escaped_text}'") - except: - pass + d.send_keys(text) # 3. 截图:输入文本后 take_debug_screenshot(d, "coord_03_after_input_text") @@ -401,58 +401,103 @@ def perform_input_action(d, coords, text, auto_send=True): time.sleep(0.5) if auto_send: - d.press("enter") + send_msg(d) - # 尝试查找发送按钮并点击 - try: - if d(text="发送").exists: - d(text="发送").click() - logger.info("已点击 '发送' 按钮 (Native Text)") - take_debug_screenshot(d, "coord_04_after_click_send_native") - else: - logger.info("未找到 '发送' 文本控件,尝试图像识别...") - send_btn_coords = find_send_button(d) - if send_btn_coords: - sx, sy = send_btn_coords - d.click(sx, sy) - logger.info(f"已点击 '发送' 按钮 (Image Rec): {sx}, {sy}") - take_debug_screenshot(d, "coord_04_after_click_send_image") - else: - width, height = d.window_size() - fallback_x = int(width * 0.9) - fallback_y = int(height * 0.965) - logger.info(f"未识别到发送按钮,尝试盲点右下角: {fallback_x}, {fallback_y}") - d.click(fallback_x, fallback_y) - take_debug_screenshot(d, "coord_04_after_click_send_fallback") - except Exception as e: - logger.error(f"点击发送按钮失败: {e}") - else: - logger.info("auto_send=False, 跳过坐标模式下的发送动作") - - logger.info("输入完成 (Coordinate)") return True - except Exception as e: - logger.error(f"自动化操作失败: {e}") + logger.error(f"坐标点击输入失败: {e}") return False +def send_msg(d): + """统一的发送按钮点击逻辑""" + try: + # 1. 尝试回车 + d.press("enter") + time.sleep(0.5) + + # 2. 尝试 Native 发送按钮 + if d(text="发送").exists: + d(text="发送").click() + logger.info("已点击 '发送' 按钮 (Native Text)") + return True + + # 3. 尝试图像识别发送按钮 + send_btn_coords = find_send_button(d) + if send_btn_coords: + sx, sy = send_btn_coords + d.click(sx, sy) + logger.info(f"已点击 '发送' 按钮 (Image Rec): {sx}, {sy}") + return True + + # 4. 盲点右下角 + width, height = d.window_size() + fallback_x = int(width * 0.9) + fallback_y = int(height * 0.965) + logger.info(f"未识别到发送按钮,尝试盲点右下角: {fallback_x}, {fallback_y}") + d.click(fallback_x, fallback_y) + return True + except Exception as e: + logger.error(f"发送消息失败: {e}") + return False + +def find_red_dots(img): + """ + 检测图片中的小红点(未读消息标志) + """ + try: + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + # 红色的两个范围,进一步放宽饱和度和亮度的限制,以防环境光影响 + lower_red1 = np.array([0, 100, 80]) + upper_red1 = np.array([10, 255, 255]) + lower_red2 = np.array([160, 100, 80]) + upper_red2 = np.array([180, 255, 255]) + + mask1 = cv2.inRange(hsv, lower_red1, upper_red1) + mask2 = cv2.inRange(hsv, lower_red2, upper_red2) + mask = cv2.add(mask1, mask2) + + # 形态学操作去除噪点 + kernel = np.ones((3,3), np.uint8) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + red_dots = [] + for cnt in contours: + area = cv2.contourArea(cnt) + # 小红点通常面积很小,放宽面积范围 + if 5 < area < 2000: + x, y, w, h = cv2.boundingRect(cnt) + # 宽高比接近 1 + if 0.4 < w/h < 2.5: + red_dots.append({'x': x, 'y': y, 'w': w, 'h': h, 'center': (x + w//2, y + h//2)}) + return red_dots + except Exception as e: + logger.error(f"检测红点出错: {e}") + return [] + def analyze_chat_image(image_path, output_path, target_name="对方"): """ 识别微信聊天截图中的头像并画框,识别对话内容 + 同时支持识别语音消息并触发长按转文字 """ logger.info(f"正在分析图片: {image_path}") + # 语音识别标志 + voice_msg_rect = None + convert_text_btn_rect = None + has_unread_dot = False + # 读取图片(支持中文路径) try: img_data = np.fromfile(image_path, dtype=np.uint8) img = cv2.imdecode(img_data, cv2.IMREAD_COLOR) except Exception as e: logger.error(f"读取图片失败: {e}") - return + return None if img is None: logger.error("图片读取为空") - return + return None # 备份一份干净的图片用于 OCR (避免识别到画上去的框) img_clean = img.copy() @@ -460,183 +505,206 @@ def analyze_chat_image(image_path, output_path, target_name="对方"): height, width = img.shape[:2] logger.info(f"图片尺寸: {width}x{height}") - # 调用输入框识别,获取坐标和可视化框 - input_center, input_rect = find_input_box_center(image_path) + # 1. 检测小红点 + red_dots = find_red_dots(img) + if red_dots: + logger.info(f"检测到 {len(red_dots)} 个红色标记") - # 1. 预处理 - # 转为灰度图 - gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + # 2. 获取输入框位置作为过滤参考 + input_center, input_rect = find_input_box_center(image_path) + # 输入框顶部坐标,如果没有识别到,默认底部 5% 区域过滤 + input_top_y = input_rect[1] if input_rect else height * 0.95 + + # 3. 识别头像和对话 (OCR 过程增加日志) + logger.info("开始进行文字识别 (EasyOCR)...") + reader = get_easyocr_reader() + ocr_results = reader.read_text(img_clean) + logger.info(f"OCR 识别完成,共发现 {len(ocr_results)} 个文本区域") - # 使用自适应阈值二值化 - thresh = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, - cv2.THRESH_BINARY_INV, 11, 2) - - # 形态学操作:闭运算,填充内部空洞 - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) - closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) - - # 2. 轮廓查找 - contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - logger.info(f"检测到轮廓数量: {len(contours)}") - - # 收集所有符合条件的头像 - avatars = [] - - # 3. 筛选轮廓 - for contour in contours: - # 获取外接矩形 - x, y, w, h = cv2.boundingRect(contour) + # 将 OCR 结果整理并按 Y 坐标排序 + sorted_ocr = [] + for (bbox, text, prob) in ocr_results: + if prob < 0.2: continue + text = text.strip() + (tl, tr, br, bl) = bbox + tx, ty = int(tl[0]), int(tl[1]) + tw, th = int(br[0] - tl[0]), int(br[1] - tl[1]) - # 筛选条件优化: - # 1. 形状接近正方形 (放宽宽高比限制: 0.8 ~ 1.2) - aspect_ratio = float(w) / h + # --- 这里的过滤逻辑进行了重大调整 --- + # 1. 过滤顶部状态栏 (80px) + # 2. 只有当文字完全位于输入框内部时才过滤。 + # 注意:语音转文字的结果可能紧贴输入框,所以这里逻辑要极度小心 + is_in_input = False + if input_rect: + ix, iy, iw, ih = input_rect + # 如果文字的中心点在输入框矩形内,则认为是输入框内容 + if (ix < tx + tw/2 < ix + iw) and (iy < ty + th/2 < iy + ih): + # 额外检查:如果这个位置上方刚刚有一个语音消息(y轴相近),则不认为是输入框干扰 + is_in_input = True - # 2. 尺寸适中 - # 假设头像宽度在屏幕宽度的 6% 到 15% 之间 - min_w = width * 0.06 - max_w = width * 0.15 - - # 3. 位置筛选 - # 排除底部输入框区域 (假设底部 10% 为输入区域) - if y > height * 0.9: + if ty < 80 or (is_in_input and ty > height * 0.95): # 将过滤阈值进一步提高到 0.95,几乎只过滤输入框最底部的文字 + logger.info(f"跳过干扰项: y={ty}, text='{text}' (原因: {'状态栏' if ty < 80 else '输入框深度区域'})") continue - # 左侧头像:靠左边 (x < width * 0.18) - # 右侧头像:靠右边 (x > width * 0.82) - is_left = x < width * 0.18 - is_right = x > width * 0.82 + # 预处理文本:移除所有非数字、非引号、非字母的干扰字符,再进行语音匹配 + clean_text = re.sub(r'[^\d"a-zA-Z\u4e00-\u9fa5]', '', text) + is_voice = bool(re.search(r'\d+"$', clean_text) or "小" in clean_text and "\"" in clean_text) - if 0.8 <= aspect_ratio <= 1.2 and min_w < w < max_w: - if is_left or is_right: - side = "Left" if is_left else "Right" - avatars.append({ - 'x': x, 'y': y, 'w': w, 'h': h, - 'side': side - }) - - # 按 y 坐标排序 - avatars.sort(key=lambda a: a['y']) + # 调试日志:输出所有保留的行 + logger.info(f"有效 OCR 行: y={ty}, text='{text}', is_voice={is_voice}") + + sorted_ocr.append({'x': tx, 'y': ty, 'w': tw, 'h': th, 'text': text, 'is_voice': is_voice}) - logger.info(f"找到有效头像数量: {len(avatars)}") + sorted_ocr.sort(key=lambda x: x['y']) - # 初始化 OCR - try: - reader = get_easyocr_reader(gpu=True) - logger.info("OCR 初始化成功") - except Exception as e: - logger.error(f"OCR 初始化失败: {e}") - reader = None - + # 识别头像位置 + # --- 轮廓查找头像逻辑 --- + gray = cv2.cvtColor(img_clean, cv2.COLOR_BGR2GRAY) + thresh = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) + contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + avatars = [] + for contour in contours: + x, y, w, h = cv2.boundingRect(contour) + if 0.8 < float(w)/h < 1.2 and 50 < w < 150: + # 头像过滤逻辑同步放宽:只要不在最顶端,且不在输入框的深处,都保留 + if y < 80: continue + if input_rect and y > input_rect[1] + 50: continue # 只有深陷输入框内部的才过滤 + avatars.append((x, y, w, h)) + cv2.rectangle(img, (x, y), (x + w, y + h), (0, 0, 255), 2) + avatars.sort(key=lambda a: a[1]) + + # 处理对话逻辑 dialogue_log = [] - - # 4. 绘制对话内容框 (Green/Red Boxes) - if avatars: - i = 0 - while i < len(avatars): - current_group_start = i - current_side = avatars[i]['side'] - - # 找到当前组的结束位置 (即下一个不同侧头像的索引) - j = i + 1 - while j < len(avatars) and avatars[j]['side'] == current_side: - j += 1 - - # Start Y: 当前组第一个头像的上方 (例如 -10px) - start_y = max(0, avatars[i]['y'] - 10) - - # End Y - if j < len(avatars): - end_y = max(start_y + 10, avatars[j]['y'] - 30) - else: - # 最后一个框的底边,使用输入框的上沿 - if input_rect: - _, input_y, _, _ = input_rect - end_y = max(start_y + 10, input_y - 10) - else: - end_y = int(height * 0.9) # 默认 - - # 绘制大框 - # 左侧 (Left) -> 对方 -> 绿色 (0, 255, 0) - # 右侧 (Right) -> 我 -> 红色 (0, 0, 255) - box_color = (0, 255, 0) if current_side == "Left" else (0, 0, 255) - - cv2.rectangle(img, (0, start_y), (width, end_y), box_color, 5) - - logger.info(f"绘制内容框: 侧别={current_side}, 范围 Y={start_y} to {end_y}") - - # OCR 识别区域文本 - if reader: - try: - safe_start_y = max(0, start_y) - safe_end_y = min(height, end_y) - - if safe_end_y > safe_start_y: - roi_img = img_clean[safe_start_y:safe_end_y, 0:width] - - # 识别 - results = reader.read_text(roi_img) - - # 过滤关键词 - ignore_keywords = ["点击查看对话内容", "以上是打招呼的消息", "和 Kimi 的对话", "Kim智能助手"] - - texts = [] - for res in results: - text = res[1] - prob = res[2] - if prob > 0.3: - # 1. 检查是否包含屏蔽词 - if any(kw in text for kw in ignore_keywords): - continue - - # 2. 检查是否为单行时间 - if re.match(r'^\s*\d{1,2}:\d{2}\s*$', text): - continue - - texts.append(text) - - combined_text = " ".join(texts) - - if combined_text.strip(): - role = target_name if current_side == "Left" else "我" - dialogue_log.append(f"{role}: {combined_text}") - logger.info(f" -> OCR结果: {combined_text}") - except Exception as e: - logger.error(f" -> OCR出错: {e}") - - # 移动到下一组 - i = j - - # 5. 绘制头像框 (Blue/Yellow Boxes) - 画在内容框之上 - for av in avatars: - x, y, w, h = av['x'], av['y'], av['w'], av['h'] - color = (255, 0, 0) if av['side'] == "Left" else (0, 255, 255) + voice_msg_rect = None + convert_text_btn_rect = None + + # 遍历 OCR 结果,合并语音和文字 + i = 0 + while i < len(sorted_ocr): + item = sorted_ocr[i] + # 查找发言人 + speaker = "未知" + for (ax, ay, aw, ah) in avatars: + if abs(item['y'] - ay) < 100: + speaker = target_name if ax < item['x'] else "我" + break - cv2.rectangle(img, (x, y), (x + w, y + h), color, 10) - logger.info(f"绘制头像: 位置=({x},{y}), 侧别={av['side']}") + # 处理语音转换逻辑 + # 只要是语音格式,且在左侧(或者发言人是对方),就考虑转换 + is_target_voice = item['is_voice'] and (speaker == target_name or (speaker == "未知" and item['x'] < width * 0.4)) + + if is_target_voice: + # --- 遵循用户定义的明显规则 --- + # 检查下方是否有文字。如果有文字且距离很近,说明已经识别过了 + has_text_below = False + if i + 1 < len(sorted_ocr): + # 检查接下来的几行(最多3行),看是否有非语音文字 + # 微信转文字后,可能会有 1. 转换出的文字 2. "收起"按钮 3. "原文"按钮 + for j in range(i + 1, min(i + 4, len(sorted_ocr))): + next_item = sorted_ocr[j] + # 判定条件:1. 下一条不是语音 2. Y轴距离在合理范围 (300px内,转文字可能很长) 3. X轴位置相近 + y_dist = next_item['y'] - item['y'] + x_dist = abs(next_item['x'] - item['x']) + + if not next_item['is_voice'] and 0 < y_dist < 300: + if x_dist < 250 or "收起" in next_item['text'] or "原文" in next_item['text']: + has_text_below = True + # 如果是正常的文字内容(不是功能按钮),记录下来 + if "收起" not in next_item['text'] and "原文" not in next_item['text']: + dialogue_log.append(f"{speaker}: [语音] {next_item['text']}") + logger.info(f"语音 {item['text']} 下方已存在识别文字 '{next_item['text']}',跳过转换操作") + else: + dialogue_log.append(f"{speaker}: [语音] (已转换)") + logger.info(f"检测到转换控制按钮 '{next_item['text']}',认为已完成转换") + + # 既然已经转换了,如果有待点击的按钮,也应该清除掉,防止误点关闭 + convert_text_btn_rect = None + i = j # 跳过已处理的行 + break + if has_text_below: + i += 1 + continue + + # 如果下方没有文字,这才是真正需要点击转文字的对象 + if not has_text_below: + # 只要没识别,就锁定它。如果有多个没识别的,锁定最后一个(最下面的) + if not voice_msg_rect or item['y'] > voice_msg_rect['y']: + voice_msg_rect = item + dialogue_log.append(f"{speaker}: [语音] {item['text']} (待转换)") + logger.info(f"发现待转换语音: {item['text']} (y={item['y']})") + elif "转文字" in item['text']: + if not convert_text_btn_rect or item['y'] > convert_text_btn_rect['y']: + convert_text_btn_rect = item + # "转文字" 按钮不计入对话内容 + else: + dialogue_log.append(f"{speaker}: {item['text']}") + + i += 1 - # 6. 保存结果 + # 检查是否需要触发转文字 + has_unread_dot = False + # 检查转文字按钮或语音消息旁的红点 + target_item = convert_text_btn_rect if convert_text_btn_rect else voice_msg_rect + if target_item: + for dot in red_dots: + dist_x = abs(dot['center'][0] - (target_item['x'] + target_item['w'])) + dist_y = abs(dot['center'][1] - (target_item['y'] + target_item['h']//2)) + # 红点可能在左侧 (转文字按钮) 或右侧 (语音) + if (abs(dot['center'][0] - target_item['x']) < 150 or dist_x < 150) and dist_y < 100: + has_unread_dot = True + break + + # 兜底:如果是最后一条是语音且没文字,强制转换 + should_trigger_convert = has_unread_dot + if not should_trigger_convert and voice_msg_rect: + # 只要最后一条消息包含了这个语音的内容(且它是待转换状态),就触发 + # 修正原有的 endswith bug: 因为 dialogue_log 里的语音被标记了 "(待转换)" + if dialogue_log and (voice_msg_rect['text'] in dialogue_log[-1]) and "(待转换)" in dialogue_log[-1]: + logger.info(f"最后一条消息是语音({voice_msg_rect['text']})且未转换,强制触发转文字") + should_trigger_convert = True + + if should_trigger_convert: + try: + d = u2.connect() + if convert_text_btn_rect: + tx = convert_text_btn_rect['x'] + convert_text_btn_rect['w'] // 2 + ty = convert_text_btn_rect['y'] + convert_text_btn_rect['h'] // 2 + logger.info(f"点击 '转文字' 按钮: ({tx}, {ty})") + d.click(tx, ty) + time.sleep(2.0) + return "VOICE_CONVERTING" + elif voice_msg_rect: + vx = voice_msg_rect['x'] + voice_msg_rect['w'] // 2 + vy = voice_msg_rect['y'] + voice_msg_rect['h'] // 2 + logger.info(f"长按语音消息: ({vx}, {vy})") + d.long_click(vx, vy, 1.5) + time.sleep(1.0) + if d(text="转文字").exists: + d(text="转文字").click() + else: + # 截图找菜单 + temp_menu_path = take_debug_screenshot(d, "voice_menu_retry") + menu_results = reader.read_text(temp_menu_path) + for (m_bbox, m_text, m_prob) in menu_results: + if "转文字" in m_text: + m_tl, _, m_br, _ = m_bbox + d.click(int(m_tl[0] + (m_br[0]-m_tl[0])//2), int(m_tl[1] + (m_br[1]-m_tl[1])//2)) + break + time.sleep(2.0) + return "VOICE_CONVERTING" + except Exception as e: + logger.error(f"转换操作失败: {e}") + + # 保存结果图 + if input_rect: + ix, iy, iw, ih = input_rect + cv2.rectangle(img, (ix, iy), (ix + iw, iy + ih), (255, 0, 0), 2) try: - # 如果有识别到输入框,画出来 (紫色) - if input_rect: - rx, ry, rw, rh = input_rect - cv2.rectangle(img, (rx, ry), (rx + rw, ry + rh), (255, 0, 255), 5) - # 画中心点 - if input_center: - cx, cy = input_center - cv2.circle(img, (cx, cy), 10, (255, 0, 255), -1) - logger.info(f"已绘制输入框标记: {input_rect}") - - # cv2.imwrite 不支持中文路径,使用 imencode + tofile - ext = os.path.splitext(output_path)[1] - cv2.imencode(ext, img)[1].tofile(output_path) - logger.info(f"✅ 分析结果已保存至: {output_path}") + is_success, buffer = cv2.imencode(".jpg", img) + if is_success: + buffer.tofile(output_path) except Exception as e: - logger.error(f"保存分析图片失败: {e}") - - logger.info("\n" + "="*30) - logger.info("对话内容汇总:") - for line in dialogue_log: - logger.info(line) - logger.info("="*30 + "\n") + logger.error(f"保存失败: {e}") return dialogue_log + diff --git a/WeiXin/__pycache__/WxUtil.cpython-310.pyc b/WeiXin/__pycache__/WxUtil.cpython-310.pyc index c079683..f3562b6 100644 Binary files a/WeiXin/__pycache__/WxUtil.cpython-310.pyc and b/WeiXin/__pycache__/WxUtil.cpython-310.pyc differ