'commit'
BIN
WeiXin/Screenshots/chat_interface_check.jpg
Normal file
|
After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 152 KiB |
BIN
WeiXin/Screenshots/debug_send_check_20260125_115042.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 323 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 136 KiB |
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
BIN
WeiXin/Templates/input_text.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
798
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
|
||||
|
||||
|
||||