'commit'
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 110 KiB |
BIN
WeiXin/Screenshots/t6_debug_result.jpg
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
WeiXin/Screenshots/t6_debug_temp.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
WeiXin/Screenshots/t6_menu_shot.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
@@ -24,7 +24,7 @@ logging.basicConfig(
|
|||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.FileHandler(os.path.join(log_dir, "T5_AutoChatMonitor.log"), encoding='utf-8'),
|
logging.FileHandler(os.path.join(log_dir, "T5_AutoChatMonitor.log"), encoding='utf-8', mode='w'),
|
||||||
logging.StreamHandler()
|
logging.StreamHandler()
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -46,18 +46,19 @@ class ChatBot:
|
|||||||
os.makedirs(self.screenshot_dir)
|
os.makedirs(self.screenshot_dir)
|
||||||
|
|
||||||
self.persona = (
|
self.persona = (
|
||||||
"你是一名1999年毕业、拥有27年一线教学经验的小学高级女教师,名叫‘小张老师’(家长们也常叫你张老师)。你目前在‘长春市少惠林作文素养培养中心’工作。"
|
"你是一名1999年毕业、拥有27年一线教学经验的小学高级女教师,名叫‘大张老师’。你目前在‘长春市少惠林作文素养培养中心’工作。"
|
||||||
"你不仅是一位作文教学专家,批阅过超过2万篇作文,更是一位心思细腻、懂得孩子心理、能与家长共情的教育智者。"
|
"你不仅是一位作文教学专家,更是一位心思细腻、能与家长共情的教育智者。"
|
||||||
"你的回复风格应该是:温柔、知性、亲切且极具亲和力,就像一位邻家大姐姐或贴心的闺蜜在和家长聊天。"
|
"你的回复风格应该是:温柔、知性、亲切,就像一位邻家大姐姐在聊天。"
|
||||||
"严禁使用任何列表格式(如 1. 2. 3. 或 A. B. C.),严禁使用‘首先、其次、最后’等刻板的逻辑词。"
|
"【严格约束】:\n"
|
||||||
"要用连贯、优美、富有生活气息的文字进行叙述。每一句话都要带温度,要善于用‘咱们孩子’、‘作为老师我也理解’等词汇拉近距离。"
|
"1. 绝对禁止发散!绝对禁止幻觉!\n"
|
||||||
"回复内容要有深度,不要只给结论,要讲透背后的教育逻辑。字数必须严格控制在 100-200 字之间。"
|
"2. 知道什么就说什么,不要乱讲话,不要自己编造内容!\n"
|
||||||
"如果涉及到校区信息,必须且只能使用以下真实数据,绝对严禁使用任何占位符:\n"
|
"3. 仅针对家长明确表达的内容进行回复。\n"
|
||||||
|
"4. 严禁使用列表格式。严禁使用‘首先、其次’等逻辑词。\n"
|
||||||
|
"5. 回复必须简练,字数严格控制在 50 字以内!\n"
|
||||||
|
"如果涉及到校区信息,必须且只能使用以下真实数据:\n"
|
||||||
"- 单位:长春市少惠林作文素养培养中心\n"
|
"- 单位:长春市少惠林作文素养培养中心\n"
|
||||||
"- 地址:南环城路与临河街交汇,TOUCH12街3楼325号\n"
|
"- 地址:南环城路与临河街交汇,TOUCH12街3楼325号\n"
|
||||||
"- 联系人:小张老师(电话:18686619970)\n"
|
"- 联系人:小张老师(电话:18686619970)"
|
||||||
"- 课程:线上/线下作文课、读书营/阅读策略营(假期开设)\n"
|
|
||||||
"- 上课:作文通常在周二/周四晚上,周六/周日全天"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_reply(self, history_text, is_proactive=False):
|
async def get_reply(self, history_text, is_proactive=False):
|
||||||
@@ -67,18 +68,17 @@ class ChatBot:
|
|||||||
f"【对话背景】:家长已经超过5分钟没有回应了。\n"
|
f"【对话背景】:家长已经超过5分钟没有回应了。\n"
|
||||||
f"【近期聊天记录】:\n{history_text}\n\n"
|
f"【近期聊天记录】:\n{history_text}\n\n"
|
||||||
"【任务要求】:\n"
|
"【任务要求】:\n"
|
||||||
"请作为小张老师,给家长发一段主动关怀的消息。不要催促,而是以‘刚才突然想到’或者‘又想起咱们孩子之前提到的’为由头,"
|
"请作为大张老师,给家长发一段简短的关怀消息。不要催促,语气温柔。"
|
||||||
"再补充一点有价值的教学点滴,或者分享一个能缓解焦虑的小故事。语气要温柔亲切,字数在 100-200 字之间。"
|
"字数严格控制在 50 字以内。不要编造事实。"
|
||||||
"全文必须是连贯的段落,严禁列条目!如果提到联系方式或地址,必须使用人设中的真实数据,严禁占位符。"
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
prompt = (
|
prompt = (
|
||||||
f"【教师人设】:{self.persona}\n\n"
|
f"【教师人设】:{self.persona}\n\n"
|
||||||
f"【近期聊天记录】:\n{history_text}\n\n"
|
f"【近期聊天记录】:\n{history_text}\n\n"
|
||||||
"【任务要求】:\n"
|
"【任务要求】:\n"
|
||||||
"请作为小张老师,给家长写一段暖心且有深度的回复。针对家长最后的消息,先给予情感上的关怀,再结合27年经验给出具体指导。"
|
"请作为大张老师回复家长。针对家长的具体问题或话语进行回复。"
|
||||||
"展现出资深女教师的温柔与智慧。全文必须是一个或两个完整的自然段,绝对禁止分点列项!字数严格在 100-200 字之间。"
|
"严禁发散,严禁编造家长没说过的情况。如果不清楚家长的意图,就温柔询问。"
|
||||||
"如果提到联系方式或地址,必须使用人设中的真实数据,严禁占位符。直接输出回复的正文内容。"
|
"字数严格控制在 50 字以内。直接输出回复正文。"
|
||||||
)
|
)
|
||||||
|
|
||||||
full_response = ""
|
full_response = ""
|
||||||
@@ -94,11 +94,11 @@ class ChatBot:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# 1. 检查是否在微信聊天界面
|
# 1. 检查是否在微信聊天界面 (改为通过 VLM 识别结果判断,不再使用 UI 检查)
|
||||||
if not is_in_chat_interface(self.d):
|
# if not is_in_chat_interface(self.d):
|
||||||
logger.warning("⚠️ 当前不在微信聊天界面,等待下一次扫描...")
|
# logger.warning("⚠️ 当前不在微信聊天界面,等待下一次扫描...")
|
||||||
await asyncio.sleep(CHECK_INTERVAL)
|
# await asyncio.sleep(CHECK_INTERVAL)
|
||||||
continue
|
# continue
|
||||||
|
|
||||||
logger.info("🔍 正在扫描当前界面内容...")
|
logger.info("🔍 正在扫描当前界面内容...")
|
||||||
# 1. 截图并分析
|
# 1. 截图并分析
|
||||||
@@ -109,7 +109,13 @@ class ChatBot:
|
|||||||
self.d.screenshot(tmp_shot)
|
self.d.screenshot(tmp_shot)
|
||||||
|
|
||||||
logger.info("🎨 正在分析聊天界面内容 (检测头像与对话)...")
|
logger.info("🎨 正在分析聊天界面内容 (检测头像与对话)...")
|
||||||
dialogue_log = analyze_chat_image(tmp_shot, analyzed_shot)
|
# analyze_chat_image 现在会返回 None, None 如果不是聊天界面
|
||||||
|
dialogue_log, input_center = await analyze_chat_image(tmp_shot, analyzed_shot, device=self.d)
|
||||||
|
|
||||||
|
if dialogue_log is None:
|
||||||
|
logger.warning("⚠️ VLM 判断当前不在微信聊天界面,或无法识别。")
|
||||||
|
await asyncio.sleep(CHECK_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
# 语音转文字处理
|
# 语音转文字处理
|
||||||
if dialogue_log == "VOICE_CONVERTING":
|
if dialogue_log == "VOICE_CONVERTING":
|
||||||
@@ -131,13 +137,25 @@ class ChatBot:
|
|||||||
|
|
||||||
# 判断逻辑:如果最后一条消息是“对方”发的,且与上次不同,则回复
|
# 判断逻辑:如果最后一条消息是“对方”发的,且与上次不同,则回复
|
||||||
if "对方:" in current_last_msg and current_last_msg != self.last_message_text:
|
if "对方:" in current_last_msg and current_last_msg != self.last_message_text:
|
||||||
|
# 关键检查:如果包含 "(待转换)",说明语音还没转文字,绝对不能回复
|
||||||
|
if "(待转换)" in current_last_msg:
|
||||||
|
logger.info(f"🚫 检测到未转换的语音消息,跳过回复生成,等待转文字... ({current_last_msg})")
|
||||||
|
await asyncio.sleep(2) # 稍作等待
|
||||||
|
continue
|
||||||
|
|
||||||
logger.info(f"📩 检测到新消息: {current_last_msg}")
|
logger.info(f"📩 检测到新消息: {current_last_msg}")
|
||||||
|
|
||||||
reply = await self.get_reply(history_text)
|
reply = await self.get_reply(history_text)
|
||||||
logger.info(f"🤖 生成回复: {reply}")
|
logger.info(f"🤖 生成回复: {reply}")
|
||||||
|
|
||||||
# 执行输入发送
|
# 执行输入发送
|
||||||
|
if input_center:
|
||||||
|
center_point = input_center
|
||||||
|
logger.info(f"📍 使用 VLM 识别的输入框坐标: {center_point}")
|
||||||
|
else:
|
||||||
center_point, _ = find_input_box_center(tmp_shot)
|
center_point, _ = find_input_box_center(tmp_shot)
|
||||||
|
logger.info(f"📍 使用 CV 识别的输入框坐标: {center_point}")
|
||||||
|
|
||||||
# 即使 CV 没找到坐标,也尝试执行,因为 perform_input_action 内部有原生控件识别
|
# 即使 CV 没找到坐标,也尝试执行,因为 perform_input_action 内部有原生控件识别
|
||||||
perform_input_action(self.d, center_point, reply, auto_send=True)
|
perform_input_action(self.d, center_point, reply, auto_send=True)
|
||||||
self.last_message_text = f"我: {reply}" # 更新状态,避免重复回复自己
|
self.last_message_text = f"我: {reply}" # 更新状态,避免重复回复自己
|
||||||
@@ -154,7 +172,11 @@ class ChatBot:
|
|||||||
proactive_reply = await self.get_reply(history_text, is_proactive=True)
|
proactive_reply = await self.get_reply(history_text, is_proactive=True)
|
||||||
logger.info(f"🤖 发起主动询问: {proactive_reply}")
|
logger.info(f"🤖 发起主动询问: {proactive_reply}")
|
||||||
|
|
||||||
|
if input_center:
|
||||||
|
center_point = input_center
|
||||||
|
else:
|
||||||
center_point, _ = find_input_box_center(tmp_shot)
|
center_point, _ = find_input_box_center(tmp_shot)
|
||||||
|
|
||||||
# 同上,解耦 CV 坐标
|
# 同上,解耦 CV 坐标
|
||||||
perform_input_action(self.d, center_point, proactive_reply, auto_send=True)
|
perform_input_action(self.d, center_point, proactive_reply, auto_send=True)
|
||||||
self.proactive_count += 1
|
self.proactive_count += 1
|
||||||
|
|||||||
163
WeiXin/T6_VLM_Voice_Debug.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# coding=utf-8
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import uiautomator2 as u2
|
||||||
|
|
||||||
|
# 添加项目根目录到 sys.path
|
||||||
|
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 get_vlm_analysis
|
||||||
|
from Util.EasyOcrKit import EasyOcrKit
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger("T6_Debug")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("🚀 T6 VLM 语音坐标调试工具启动...")
|
||||||
|
|
||||||
|
# 连接设备
|
||||||
|
try:
|
||||||
|
d = u2.connect()
|
||||||
|
logger.info(f"设备已连接: {d.info.get('serial')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"设备连接失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 截图目录
|
||||||
|
screenshots_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Screenshots")
|
||||||
|
if not os.path.exists(screenshots_dir):
|
||||||
|
os.makedirs(screenshots_dir)
|
||||||
|
|
||||||
|
# 截图
|
||||||
|
screenshot_path = os.path.join(screenshots_dir, "t6_debug_temp.jpg")
|
||||||
|
logger.info("📸 正在截图...")
|
||||||
|
d.screenshot(screenshot_path)
|
||||||
|
|
||||||
|
# 调用 VLM 分析
|
||||||
|
logger.info("🧠 正在调用 VLM 分析图片...")
|
||||||
|
result_data = await get_vlm_analysis(screenshot_path)
|
||||||
|
|
||||||
|
if not result_data:
|
||||||
|
logger.error("❌ VLM 分析返回为空")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"VLM 返回结果: {result_data}")
|
||||||
|
|
||||||
|
# 读取图片用于绘制
|
||||||
|
img = cv2.imread(screenshot_path)
|
||||||
|
if img is None:
|
||||||
|
logger.error("❌ 无法读取截图文件")
|
||||||
|
return
|
||||||
|
|
||||||
|
messages = result_data.get("messages", [])
|
||||||
|
voice_count = 0
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
msg_type = msg.get("type")
|
||||||
|
content = msg.get("content")
|
||||||
|
coords = msg.get("coordinates") or msg.get("center")
|
||||||
|
|
||||||
|
if not coords:
|
||||||
|
continue
|
||||||
|
|
||||||
|
x, y = coords
|
||||||
|
|
||||||
|
if msg_type == "voice":
|
||||||
|
voice_count += 1
|
||||||
|
logger.info(f"🎤 发现语音消息: {content}, 坐标: ({x}, {y})")
|
||||||
|
|
||||||
|
# 绘制绿框 (语音)
|
||||||
|
w, h = 300, 80
|
||||||
|
top_left = (int(x - w/2), int(y - h/2))
|
||||||
|
bottom_right = (int(x + w/2), int(y + h/2))
|
||||||
|
|
||||||
|
cv2.rectangle(img, top_left, bottom_right, (0, 255, 0), 3)
|
||||||
|
cv2.circle(img, (int(x), int(y)), 5, (0, 0, 255), -1)
|
||||||
|
label = f"Voice ({x},{y})"
|
||||||
|
cv2.putText(img, label, (top_left[0], top_left[1] - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
|
||||||
|
|
||||||
|
# 保存结果图片
|
||||||
|
output_path = os.path.join(screenshots_dir, "t6_debug_result.jpg")
|
||||||
|
cv2.imwrite(output_path, img)
|
||||||
|
logger.info(f"✅ 结果已保存至: {output_path}")
|
||||||
|
logger.info(f"共标记了 {voice_count} 条语音消息。请检查图片是否准确。")
|
||||||
|
|
||||||
|
# --- 验证转文字功能 (处理最后一条未转换语音) ---
|
||||||
|
logger.info("="*30)
|
||||||
|
logger.info("🔍 开始验证“转文字”功能 (仅针对最后一条未转换语音)...")
|
||||||
|
|
||||||
|
# 筛选未转换的语音
|
||||||
|
unconverted_voices = []
|
||||||
|
for msg in messages:
|
||||||
|
if msg.get("type") == "voice" and msg.get("status") == "unconverted":
|
||||||
|
coords = msg.get("coordinates") or msg.get("center")
|
||||||
|
if coords:
|
||||||
|
msg["coordinates"] = coords
|
||||||
|
unconverted_voices.append(msg)
|
||||||
|
|
||||||
|
if not unconverted_voices:
|
||||||
|
logger.info("⚠️ 没有发现未转换的语音消息,跳过验证。")
|
||||||
|
else:
|
||||||
|
last_voice = unconverted_voices[-1]
|
||||||
|
vx, vy = last_voice['coordinates']
|
||||||
|
content = last_voice.get('content', '0"')
|
||||||
|
logger.info(f"🎯 目标语音: {content}, 坐标: ({vx}, {vy})")
|
||||||
|
|
||||||
|
# 1. 长按
|
||||||
|
logger.info(f"👆 长按语音消息...")
|
||||||
|
d.long_click(vx, vy, 1.5)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
# 2. 截图菜单
|
||||||
|
menu_shot_path = os.path.join(screenshots_dir, "t6_menu_shot.jpg")
|
||||||
|
logger.info(f"📸 截取菜单: {menu_shot_path}")
|
||||||
|
d.screenshot(menu_shot_path)
|
||||||
|
|
||||||
|
# 3. OCR 识别
|
||||||
|
logger.info("🧠 正在进行 OCR 识别菜单...")
|
||||||
|
ocr_kit = EasyOcrKit()
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
logger.info(f"✅ OCR 找到 '{text}' 按钮: {convert_btn_center}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if convert_btn_center:
|
||||||
|
# 4. 点击转文字
|
||||||
|
logger.info(f"👆 点击转文字按钮: {convert_btn_center}")
|
||||||
|
d.click(convert_btn_center[0], convert_btn_center[1])
|
||||||
|
|
||||||
|
# 5. 动态等待
|
||||||
|
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)
|
||||||
|
logger.info("✅ 流程执行完毕!请检查手机屏幕是否已开始转换。")
|
||||||
|
else:
|
||||||
|
logger.error("❌ OCR 未找到 '转文字' 按钮!")
|
||||||
|
# 点击空白处关闭
|
||||||
|
d.click(vx + 200, vy)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||||
|
asyncio.run(main())
|
||||||
944
WeiXin/WxUtil.py
@@ -13,698 +13,348 @@ project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||||||
if project_root not in sys.path:
|
if project_root not in sys.path:
|
||||||
sys.path.append(project_root)
|
sys.path.append(project_root)
|
||||||
|
|
||||||
from Util.EasyOcrKit import get_easyocr_reader
|
import json
|
||||||
|
from Util.VLMKit import VLMKit
|
||||||
|
from Util.EasyOcrKit import EasyOcrKit
|
||||||
|
|
||||||
|
# 初始化 VLMKit 和 EasyOcrKit
|
||||||
|
vlm_kit = VLMKit()
|
||||||
|
ocr_kit = EasyOcrKit()
|
||||||
|
|
||||||
# 配置日志
|
# 配置日志
|
||||||
# 注意:作为库模块,不应直接调用 basicConfig,以免干扰调用者的日志配置
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
# 调用者应自行配置日志(包括 FileHandler 等)
|
|
||||||
logger = logging.getLogger("WxUtil")
|
logger = logging.getLogger("WxUtil")
|
||||||
|
|
||||||
def is_in_chat_interface(d):
|
|
||||||
|
async def get_vlm_analysis(image_path):
|
||||||
"""
|
"""
|
||||||
判断当前是否处于微信聊天界面
|
仅调用 VLM 分析图片,返回原始 JSON 数据 (dict)
|
||||||
识别标准:左侧和右侧同时存在小方块(头像)
|
|
||||||
"""
|
"""
|
||||||
try:
|
logger.info(f"正在使用 VLM 分析图片: {image_path}")
|
||||||
current = d.app_current()
|
|
||||||
if current.get('package') != 'com.tencent.mm':
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 1. 基础 UI 检查 (快速判断)
|
# 构造 Prompt
|
||||||
# 如果能直接搜到返回按钮和输入框/表情按钮,基本就是了
|
prompt = """
|
||||||
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:
|
1. 🔊 **语音消息 (Voice)**:
|
||||||
return True # 无法截图时保守返回 True
|
- **视觉特征**:
|
||||||
|
- **高度**:固定(单行)。
|
||||||
|
- **宽度**:随时长(1"~60")变化。
|
||||||
|
- **极短 (1"-2")**:气泡非常短,形状接近一个小正方形。
|
||||||
|
- **极长 (60")**:气泡很长,宽度接近屏幕的一半。
|
||||||
|
- **内容**:气泡内**只有一个**表示时长的数字(如 `8"`)和一个声波图标。
|
||||||
|
- **绝对排除**:凡是包含汉字、长句子的气泡,**统统不是**语音消息。
|
||||||
|
|
||||||
height, width = img.shape[:2]
|
2. 📝 **文本消息 (Text)**:
|
||||||
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)
|
【坐标系统】
|
||||||
|
**必须使用 [0-1000] 的归一化坐标系。**
|
||||||
|
- 左上角为 [0, 0],右下角为 [1000, 1000]。
|
||||||
|
- 请返回气泡的**几何中心点**的归一化坐标。
|
||||||
|
|
||||||
left_avatars = 0
|
【输出格式】
|
||||||
right_avatars = 0
|
请返回纯 JSON 格式:
|
||||||
|
{
|
||||||
for cnt in contours:
|
"is_chat_interface": true,
|
||||||
x, y, w, h = cv2.boundingRect(cnt)
|
"input_box": [x, y],
|
||||||
# 头像特征:宽高比接近 1,大小在一定范围 (50-150px 针对常见分辨率)
|
"messages": [
|
||||||
aspect_ratio = float(w) / h
|
{
|
||||||
if 0.8 < aspect_ratio < 1.2 and 40 < w < 160:
|
"type": "voice",
|
||||||
# 排除状态栏 (顶部 10%) 和 底部极小区域
|
"status": "converted" | "unconverted",
|
||||||
if y < height * 0.1 or y > height * 0.95:
|
"center": [x, y],
|
||||||
continue
|
"content": "8\""
|
||||||
|
},
|
||||||
# 微信头像通常紧贴左右两侧
|
{
|
||||||
if x < width * 0.3: # 左侧头像
|
"type": "text",
|
||||||
left_avatars += 1
|
"center": [x, y],
|
||||||
elif x + w > width * 0.7: # 右侧头像
|
"content": "这里是文本内容"
|
||||||
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 目录下的所有文件
|
|
||||||
"""
|
|
||||||
screenshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Screenshots")
|
|
||||||
if os.path.exists(screenshot_dir):
|
|
||||||
logger.info(f"正在清除截图目录: {screenshot_dir}")
|
|
||||||
for file in os.listdir(screenshot_dir):
|
|
||||||
file_path = os.path.join(screenshot_dir, file)
|
|
||||||
try:
|
|
||||||
if os.path.isfile(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"无法删除文件 {file_path}: {e}")
|
|
||||||
else:
|
|
||||||
logger.info(f"截图目录不存在,无需清除: {screenshot_dir}")
|
|
||||||
|
|
||||||
def find_input_box_center(image_path):
|
|
||||||
"""
|
|
||||||
识别底部输入框的中心坐标,返回 (center_x, center_y, rect_box)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
img_data = np.fromfile(image_path, dtype=np.uint8)
|
|
||||||
img = cv2.imdecode(img_data, cv2.IMREAD_COLOR)
|
|
||||||
if img is None:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
height, width = img.shape[:2]
|
|
||||||
|
|
||||||
# --- 新增:模板匹配优先策略 ---
|
|
||||||
# 用户指定的模板路径 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), "中上区域")
|
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
注意:
|
||||||
|
1. 坐标 `center` 和 `input_box` 必须是 [0-1000] 的归一化坐标。
|
||||||
|
2. `status` 判断:如果语音气泡的正下方紧挨着一条文本消息(通常是转换出的文字),则为 `converted`,否则为 `unconverted`。
|
||||||
|
3. 请按从上到下的顺序输出所有消息。
|
||||||
|
"""
|
||||||
|
|
||||||
for y_start, y_end, region_name in search_regions:
|
try:
|
||||||
crop = img[y_start:y_end, 0:width]
|
# 调用 VLM
|
||||||
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
response = await vlm_kit.analyze_image(image_path, prompt)
|
||||||
# 使用更灵敏的边缘检测
|
json_str = vlm_kit.extract_json(response)
|
||||||
edges = cv2.Canny(gray, 30, 100)
|
result_data = json.loads(json_str)
|
||||||
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)
|
|
||||||
|
|
||||||
candidates = []
|
# 获取图片尺寸进行坐标反归一化
|
||||||
region_h = y_end - y_start
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
width, height = img.size
|
||||||
|
|
||||||
for cnt in contours:
|
# 定义反归一化函数
|
||||||
x, y, w, h = cv2.boundingRect(cnt)
|
def denormalize(point):
|
||||||
# 微信输入框通常很宽,高度固定在一定范围
|
if not point or len(point) != 2:
|
||||||
# 宽度: 40%-98%, 高度: 40px - 150px (针对 720p 左右分辨率)
|
return point
|
||||||
if width * 0.4 < w < width * 0.98 and 40 < h < 200:
|
return [int(point[0] / 1000 * width), int(point[1] / 1000 * height)]
|
||||||
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 candidates:
|
# 反归一化 input_box
|
||||||
# 优先选择面积适中且最靠下的(避免选到顶部的搜索框)
|
if result_data.get("input_box"):
|
||||||
candidates.sort(key=lambda c: c['global_y'], reverse=True)
|
result_data["input_box"] = denormalize(result_data["input_box"])
|
||||||
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'])
|
|
||||||
|
|
||||||
# 策略 3: 基于常见 UI 比例的几何兜底
|
# 反归一化 messages
|
||||||
# 0.93 可能过于靠下(容易点到 Home Indicator 区域),调整为 0.88 以提高命中率
|
if result_data.get("messages"):
|
||||||
# 同时保留一个备用的点击区域 rect
|
for msg in result_data["messages"]:
|
||||||
logger.warning("CV 识别输入框失败,使用改进的几何兜底策略 (Y=0.88)")
|
if msg.get("center"):
|
||||||
return (width // 2, int(height * 0.88)), (int(width*0.05), int(height*0.86), int(width*0.9), int(height*0.04))
|
msg["center"] = denormalize(msg["center"])
|
||||||
|
if msg.get("coordinates"): # 兼容旧字段
|
||||||
|
msg["coordinates"] = denormalize(msg["coordinates"])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"查找输入框失败: {e}")
|
logger.warning(f"坐标反归一化失败: {e},将使用原始坐标")
|
||||||
return None, None
|
|
||||||
|
|
||||||
def find_send_button(d):
|
return result_data
|
||||||
"""
|
except Exception as e:
|
||||||
截图并寻找发送按钮 (绿色按钮)
|
logger.error(f"VLM Analysis Failed: {e}", exc_info=True)
|
||||||
扩大搜索范围以适应键盘弹出的情况
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 截图到 Screenshots 目录方便调试
|
|
||||||
screenshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Screenshots")
|
|
||||||
if not os.path.exists(screenshot_dir):
|
|
||||||
os.makedirs(screenshot_dir)
|
|
||||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
||||||
debug_shot_path = os.path.join(screenshot_dir, f"debug_send_check_{timestamp}.jpg")
|
|
||||||
|
|
||||||
d.screenshot(debug_shot_path)
|
|
||||||
logger.info(f"发送按钮查找调试截图已保存: {debug_shot_path}")
|
|
||||||
|
|
||||||
img = cv2.imread(debug_shot_path)
|
|
||||||
if img is None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
h, w = img.shape[:2]
|
async def analyze_chat_image(image_path, output_path, device=None, target_name="对方"):
|
||||||
|
|
||||||
# ROI: 底部 60% (考虑到键盘弹出,按钮可能被顶上去)
|
|
||||||
# 且只关注右侧 30%
|
|
||||||
roi_h = int(h * 0.6)
|
|
||||||
roi_w = int(w * 0.3)
|
|
||||||
y_start = h - roi_h
|
|
||||||
x_start = w - roi_w
|
|
||||||
|
|
||||||
roi = img[y_start:h, x_start:w]
|
|
||||||
|
|
||||||
# 转换 HSV
|
|
||||||
hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
|
|
||||||
|
|
||||||
# 绿色范围 (WeChat Green)
|
|
||||||
lower_green = np.array([35, 80, 80])
|
|
||||||
upper_green = np.array([90, 255, 255])
|
|
||||||
|
|
||||||
mask = cv2.inRange(hsv, lower_green, upper_green)
|
|
||||||
|
|
||||||
# 查找轮廓
|
|
||||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
if contours:
|
|
||||||
# 找符合条件的轮廓
|
|
||||||
valid_candidates = []
|
|
||||||
for cnt in contours:
|
|
||||||
area = cv2.contourArea(cnt)
|
|
||||||
x, y, cw, ch = cv2.boundingRect(cnt)
|
|
||||||
|
|
||||||
# 过滤太小的噪点和太大的区域(例如全屏背景)
|
|
||||||
# 发送按钮通常面积在 2000-15000 之间 (视分辨率而定)
|
|
||||||
if 500 < area < 30000:
|
|
||||||
# 宽高比检查:发送按钮通常接近正方形或微扁 (ratio < 2.5)
|
|
||||||
ratio = float(cw) / ch
|
|
||||||
if 0.5 < ratio < 3.0:
|
|
||||||
# 坐标还原到原图
|
|
||||||
global_y = y_start + y
|
|
||||||
valid_candidates.append({
|
|
||||||
'cnt': cnt,
|
|
||||||
'area': area,
|
|
||||||
'y': global_y,
|
|
||||||
'rect': (x, y, cw, ch)
|
|
||||||
})
|
|
||||||
|
|
||||||
if valid_candidates:
|
|
||||||
# 核心逻辑:发送按钮一定是所有绿色元素中最靠下的 (Y坐标最大)
|
|
||||||
# 且在最右侧
|
|
||||||
# 先按 Y 坐标降序排序
|
|
||||||
valid_candidates.sort(key=lambda c: c['y'], reverse=True)
|
|
||||||
|
|
||||||
# 取最靠下的一个 (可能是发送按钮)
|
|
||||||
best = valid_candidates[0]
|
|
||||||
|
|
||||||
# 获取中心点
|
|
||||||
bx, by, bw, bh = best['rect']
|
|
||||||
cx = x_start + bx + bw // 2
|
|
||||||
cy = y_start + by + bh // 2
|
|
||||||
|
|
||||||
logger.info(f"通过图像识别找到发送按钮 (Bottom-Most): ({cx}, {cy}), 面积: {best['area']}")
|
|
||||||
return cx, cy
|
|
||||||
|
|
||||||
logger.warning("未通过图像识别找到绿色发送按钮")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"查找发送按钮出错: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def take_debug_screenshot(d, step_name):
|
|
||||||
"""
|
"""
|
||||||
调试专用截图函数
|
使用 VLM 识别微信聊天截图中的对话内容、语音消息状态以及输入框位置
|
||||||
|
替代原本的 CV/OCR 方案
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
screenshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Screenshots")
|
|
||||||
if not os.path.exists(screenshot_dir):
|
|
||||||
os.makedirs(screenshot_dir)
|
|
||||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
||||||
filename = f"debug_{timestamp}_{step_name}.jpg"
|
|
||||||
save_path = os.path.join(screenshot_dir, filename)
|
|
||||||
d.screenshot(save_path)
|
|
||||||
logger.info(f"📸 [调试截图] {step_name} 已保存: {filename}")
|
|
||||||
return save_path
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"截图失败 ({step_name}): {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def perform_input_action(d, coords, text, auto_send=True):
|
|
||||||
"""
|
|
||||||
点击坐标并输入文本
|
|
||||||
@param d: uiautomator2 device object
|
|
||||||
@param coords: (x, y) 坐标
|
|
||||||
@param text: 输入文本
|
|
||||||
@param auto_send: 是否自动点击发送,默认为 True
|
|
||||||
"""
|
|
||||||
if d is None:
|
|
||||||
d = u2.connect()
|
|
||||||
|
|
||||||
# 1. 检查是否处于“语音模式”
|
|
||||||
if d(description="切换到键盘").exists:
|
|
||||||
logger.info("检测到处于语音模式,正在切换到键盘...")
|
|
||||||
d(description="切换到键盘").click()
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
# 2. 优先尝试使用 uiautomator2 的原生控件查找 (更稳健)
|
|
||||||
try:
|
|
||||||
# 查找 EditText 控件
|
|
||||||
input_elem = d(className="android.widget.EditText")
|
|
||||||
|
|
||||||
if input_elem.exists:
|
|
||||||
logger.info("发现原生输入框控件,尝试点击...")
|
|
||||||
|
|
||||||
# 1. 截图:点击前
|
|
||||||
take_debug_screenshot(d, "native_01_before_click")
|
|
||||||
|
|
||||||
# 点击策略
|
|
||||||
input_elem.click()
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
# 2. 截图:点击后 (预期键盘弹出)
|
|
||||||
take_debug_screenshot(d, "native_02_after_click_keyboard")
|
|
||||||
|
|
||||||
logger.info(f"输入文本: {text}")
|
|
||||||
|
|
||||||
# 尝试 set_text
|
|
||||||
input_elem.set_text(text)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
# 检查文本是否输入成功,如果没有,尝试 send_keys
|
|
||||||
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")
|
|
||||||
|
|
||||||
# 点击发送
|
|
||||||
if auto_send:
|
|
||||||
send_msg(d)
|
|
||||||
return True
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning("未找到输入框元素 (Native),转入坐标点击模式...")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"原生控件操作失败,降级为坐标点击: {e}")
|
|
||||||
|
|
||||||
# 3. 降级方案:使用坐标点击
|
|
||||||
if not coords:
|
|
||||||
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
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. 截图:点击输入框前
|
|
||||||
take_debug_screenshot(d, "coord_01_before_click_input")
|
|
||||||
|
|
||||||
# 点击输入框
|
|
||||||
logger.info(f"点击坐标: ({x}, {y})")
|
|
||||||
d.click(x, y)
|
|
||||||
time.sleep(1.0)
|
|
||||||
|
|
||||||
# 2. 截图:点击输入框后
|
|
||||||
take_debug_screenshot(d, "coord_02_after_click_input_keyboard")
|
|
||||||
|
|
||||||
# 输入文本
|
|
||||||
logger.info(f"输入文本 (SendKeys): {text}")
|
|
||||||
d.send_keys(text)
|
|
||||||
|
|
||||||
# 3. 截图:输入文本后
|
|
||||||
take_debug_screenshot(d, "coord_03_after_input_text")
|
|
||||||
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if auto_send:
|
|
||||||
send_msg(d)
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as 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
|
should_trigger_convert = False
|
||||||
convert_text_btn_rect = None
|
|
||||||
has_unread_dot = False
|
|
||||||
|
|
||||||
# 读取图片(支持中文路径)
|
|
||||||
try:
|
try:
|
||||||
img_data = np.fromfile(image_path, dtype=np.uint8)
|
result_data = await get_vlm_analysis(image_path)
|
||||||
img = cv2.imdecode(img_data, cv2.IMREAD_COLOR)
|
|
||||||
|
if not result_data:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 检查是否为聊天界面
|
||||||
|
is_chat = result_data.get("is_chat_interface", False)
|
||||||
|
if not is_chat:
|
||||||
|
logger.warning("VLM 判断当前不是微信聊天界面")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"读取图片失败: {e}")
|
logger.error(f"解析 VLM 结果失败: {e}")
|
||||||
return None
|
return [], None
|
||||||
|
|
||||||
if img is None:
|
|
||||||
logger.error("图片读取为空")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 备份一份干净的图片用于 OCR (避免识别到画上去的框)
|
|
||||||
img_clean = img.copy()
|
|
||||||
|
|
||||||
height, width = img.shape[:2]
|
|
||||||
logger.info(f"图片尺寸: {width}x{height}")
|
|
||||||
|
|
||||||
# 1. 检测小红点
|
|
||||||
red_dots = find_red_dots(img)
|
|
||||||
if red_dots:
|
|
||||||
logger.info(f"检测到 {len(red_dots)} 个红色标记")
|
|
||||||
|
|
||||||
# 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)} 个文本区域")
|
|
||||||
|
|
||||||
# 将 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. 过滤顶部状态栏 (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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# 预处理文本:移除所有非数字、非引号、非字母的干扰字符,再进行语音匹配
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 调试日志:输出所有保留的行
|
|
||||||
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})
|
|
||||||
|
|
||||||
sorted_ocr.sort(key=lambda x: x['y'])
|
|
||||||
|
|
||||||
# 识别头像位置
|
|
||||||
# --- 轮廓查找头像逻辑 ---
|
|
||||||
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 = []
|
dialogue_log = []
|
||||||
voice_msg_rect = None
|
unconverted_voices = []
|
||||||
convert_text_btn_rect = None
|
|
||||||
|
|
||||||
# 遍历 OCR 结果,合并语音和文字
|
# 处理识别结果
|
||||||
i = 0
|
for msg in messages:
|
||||||
while i < len(sorted_ocr):
|
sender = msg.get('sender', '未知')
|
||||||
item = sorted_ocr[i]
|
msg_type = msg.get('type', 'other')
|
||||||
# 查找发言人
|
content = msg.get('content', '')
|
||||||
speaker = "未知"
|
coords = msg.get('center', [0, 0]) # center
|
||||||
for (ax, ay, aw, ah) in avatars:
|
status = msg.get('status', 'unconverted')
|
||||||
if abs(item['y'] - ay) < 100:
|
is_converted = (status == "converted")
|
||||||
speaker = target_name if ax < item['x'] else "我"
|
|
||||||
break
|
|
||||||
|
|
||||||
# 处理语音转换逻辑
|
# 记录对话日志
|
||||||
# 只要是语音格式,且在左侧(或者发言人是对方),就考虑转换
|
if msg_type == 'voice':
|
||||||
is_target_voice = item['is_voice'] and (speaker == target_name or (speaker == "未知" and item['x'] < width * 0.4))
|
if is_converted:
|
||||||
|
dialogue_log.append(f"{sender}: [语音] {content} (已转换)")
|
||||||
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:
|
else:
|
||||||
dialogue_log.append(f"{speaker}: [语音] (已转换)")
|
dialogue_log.append(f"{sender}: [语音] (待转换)")
|
||||||
logger.info(f"检测到转换控制按钮 '{next_item['text']}',认为已完成转换")
|
# 将 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})")
|
||||||
convert_text_btn_rect = None
|
|
||||||
i = j # 跳过已处理的行
|
|
||||||
break
|
|
||||||
if has_text_below:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 如果下方没有文字,这才是真正需要点击转文字的对象
|
# 处理未转换的语音消息
|
||||||
if not has_text_below:
|
if unconverted_voices:
|
||||||
# 只要没识别,就锁定它。如果有多个没识别的,锁定最后一个(最下面的)
|
logger.info(f"发现 {len(unconverted_voices)} 条未转换的语音消息,将仅处理最后一条...")
|
||||||
if not voice_msg_rect or item['y'] > voice_msg_rect['y']:
|
# 仅保留最后一条语音消息进行处理
|
||||||
voice_msg_rect = item
|
unconverted_voices = [unconverted_voices[-1]]
|
||||||
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
|
# 使用传入的 device 或创建新连接
|
||||||
|
d = device if device else u2.connect()
|
||||||
|
|
||||||
# 检查是否需要触发转文字
|
for voice in unconverted_voices:
|
||||||
has_unread_dot = False
|
vx, vy = voice['coordinates']
|
||||||
# 检查转文字按钮或语音消息旁的红点
|
|
||||||
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})")
|
logger.info(f"长按语音消息: ({vx}, {vy})")
|
||||||
d.long_click(vx, vy, 1.5)
|
d.long_click(vx, vy, 1.5)
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
if d(text="转文字").exists:
|
|
||||||
d(text="转文字").click()
|
# 查找“转文字” (使用 OCR)
|
||||||
else:
|
menu_shot_path = os.path.join(os.path.dirname(image_path), "temp_menu_shot.jpg")
|
||||||
# 截图找菜单
|
d.screenshot(menu_shot_path)
|
||||||
temp_menu_path = take_debug_screenshot(d, "voice_menu_retry")
|
|
||||||
menu_results = reader.read_text(temp_menu_path)
|
# OCR 识别
|
||||||
for (m_bbox, m_text, m_prob) in menu_results:
|
ocr_results = ocr_kit.read_text(menu_shot_path)
|
||||||
if "转文字" in m_text:
|
convert_btn_center = None
|
||||||
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))
|
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
|
break
|
||||||
time.sleep(2.0)
|
|
||||||
return "VOICE_CONVERTING"
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"转换操作失败: {e}")
|
|
||||||
|
|
||||||
# 保存结果图
|
if convert_btn_center:
|
||||||
if input_rect:
|
logger.info(f"OCR 找到 '转文字' 按钮: {convert_btn_center}")
|
||||||
ix, iy, iw, ih = input_rect
|
d.click(convert_btn_center[0], convert_btn_center[1])
|
||||||
cv2.rectangle(img, (ix, iy), (ix + iw, iy + ih), (255, 0, 0), 2)
|
should_trigger_convert = True
|
||||||
|
|
||||||
|
# 动态等待: 60s语音约需10s转换,比例约 1/6
|
||||||
|
duration_str = voice.get('content', '0').replace('"', '').strip()
|
||||||
try:
|
try:
|
||||||
is_success, buffer = cv2.imencode(".jpg", img)
|
duration = int(duration_str)
|
||||||
if is_success:
|
except:
|
||||||
buffer.tofile(output_path)
|
duration = 10 # 默认值
|
||||||
|
|
||||||
|
wait_seconds = max(2, duration / 5.0) # 稍微多等一点,用 /5.0
|
||||||
|
logger.info(f"语音时长 {duration}s,预计等待转换 {wait_seconds:.1f}s...")
|
||||||
|
time.sleep(wait_seconds)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning("OCR 未找到 '转文字' 菜单项")
|
||||||
|
# 点击空白处关闭菜单,避免遮挡
|
||||||
|
d.click(vx + 200, vy)
|
||||||
|
|
||||||
|
if should_trigger_convert:
|
||||||
|
# 转换完成后稍微多等一下,确保 UI 刷新
|
||||||
|
time.sleep(1.0)
|
||||||
|
return "VOICE_CONVERTING", input_field_coordinates
|
||||||
|
|
||||||
|
return dialogue_log, input_field_coordinates
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"保存失败: {e}")
|
logger.error(f"VLM 分析失败: {e}", exc_info=True)
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
|
||||||
|
def clean_screenshots_dir():
|
||||||
|
"""清理截图目录"""
|
||||||
|
screenshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Screenshots")
|
||||||
|
if not os.path.exists(screenshot_dir):
|
||||||
|
os.makedirs(screenshot_dir)
|
||||||
|
return
|
||||||
|
|
||||||
|
for f in os.listdir(screenshot_dir):
|
||||||
|
if f.lower().endswith(('.jpg', '.png', '.jpeg')):
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(screenshot_dir, f))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete {f}: {e}")
|
||||||
|
|
||||||
|
def is_in_chat_interface(d):
|
||||||
|
"""
|
||||||
|
检查是否在微信聊天界面
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 底部语音/键盘切换按钮
|
||||||
|
if d(description="切换到语音").exists or d(description="切换到键盘").exists:
|
||||||
|
return True
|
||||||
|
# 2. 底部输入框
|
||||||
|
if d(className="android.widget.EditText").exists:
|
||||||
|
return True
|
||||||
|
# 3. 底部“按住说话”按钮
|
||||||
|
if d(text="按住说话").exists:
|
||||||
|
return True
|
||||||
|
# 4. 右上角更多按钮
|
||||||
|
if d(description="聊天信息").exists:
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"is_in_chat_interface check failed: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def find_input_box_center(image_path):
|
||||||
|
"""
|
||||||
|
寻找输入框中心坐标 (兜底策略)
|
||||||
|
优先使用几何特征 (底部 88% 处)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
return (540, 2100), None
|
||||||
|
|
||||||
|
img = cv2.imread(image_path)
|
||||||
|
if img is None:
|
||||||
|
return (540, 2100), None
|
||||||
|
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
|
||||||
|
# 策略:直接返回屏幕底部 88% 处的中心点
|
||||||
|
center_x = int(w * 0.5)
|
||||||
|
center_y = int(h * 0.88)
|
||||||
|
|
||||||
|
logger.info(f"find_input_box_center fallback: ({center_x}, {center_y})")
|
||||||
|
return (center_x, center_y), None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"find_input_box_center error: {e}")
|
||||||
|
return (540, 2100), None
|
||||||
|
|
||||||
|
def perform_input_action(d, center_point, text, auto_send=True):
|
||||||
|
"""
|
||||||
|
执行输入操作
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 尝试找到原生输入框并输入
|
||||||
|
edit_text = d(className="android.widget.EditText")
|
||||||
|
input_success = False
|
||||||
|
|
||||||
|
if edit_text.exists:
|
||||||
|
logger.info("Found native EditText, using set_text")
|
||||||
|
try:
|
||||||
|
edit_text.click()
|
||||||
|
time.sleep(0.5)
|
||||||
|
edit_text.set_text(text)
|
||||||
|
input_success = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Native input failed: {e}")
|
||||||
|
|
||||||
|
# 2. 如果原生输入失败,使用坐标点击 + 粘贴/输入
|
||||||
|
if not input_success:
|
||||||
|
cx, cy = center_point
|
||||||
|
logger.info(f"Using coordinate input: {center_point}")
|
||||||
|
d.click(cx, cy)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
d.send_keys(text)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("send_keys failed, trying set_clipboard")
|
||||||
|
d.set_clipboard(text)
|
||||||
|
d.click(cx, cy)
|
||||||
|
time.sleep(0.5)
|
||||||
|
# 尝试粘贴
|
||||||
|
d.press("paste")
|
||||||
|
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
# 3. 发送
|
||||||
|
if auto_send:
|
||||||
|
if d(text="发送").exists:
|
||||||
|
d(text="发送").click()
|
||||||
|
logger.info("Clicked '发送'")
|
||||||
|
else:
|
||||||
|
d.press("enter")
|
||||||
|
logger.info("Pressed Enter")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"perform_input_action error: {e}")
|
||||||
|
|
||||||
return dialogue_log
|
|
||||||
|
|
||||||
|
|||||||