'commit'
This commit is contained in:
217
WeiXin/WxUtil.py
217
WeiXin/WxUtil.py
@@ -40,9 +40,16 @@ def parse_wechat_time(time_str):
|
||||
支持: "10:03", "昨天 10:03", "星期三 10:03", "2025年1月1日 10:03"
|
||||
"""
|
||||
try:
|
||||
clean_str = time_str.strip()
|
||||
|
||||
# 0. 预处理:过滤纯数字(防止电话号码被误识别为时间)
|
||||
# 微信时间戳通常包含中文或冒号,单纯的数字串(如 "18686619970")不是有效时间
|
||||
if re.match(r'^\d+$', clean_str):
|
||||
logger.warning(f"忽略疑似电话号码/纯数字的时间字符串: '{clean_str}'")
|
||||
return ""
|
||||
|
||||
now = datetime.now()
|
||||
today = now.date()
|
||||
clean_str = time_str.strip()
|
||||
|
||||
# 1. HH:mm (当天)
|
||||
# 注意:有时候 OCR 会把冒号识别成其他字符,这里假设是标准的 HH:mm
|
||||
@@ -130,10 +137,11 @@ def parse_wechat_time(time_str):
|
||||
dt = datetime.combine(today, datetime.min.time().replace(hour=h, minute=m))
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
return clean_str # 解析失败,返回原串
|
||||
# 解析失败,返回空字符串,避免将无关文本(如电话号码)误认为时间注入到上下文中
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.warning(f"时间解析失败 '{time_str}': {e}")
|
||||
return time_str
|
||||
return ""
|
||||
|
||||
def get_next_debug_path(desc="step"):
|
||||
"""获取下一个顺序命名的调试图片路径 (debug_N_desc.jpg)"""
|
||||
@@ -305,6 +313,11 @@ def _detect_bubble_color(img, bbox):
|
||||
# 且亮度不能太低 (太低可能是黑色背景或深色物体)
|
||||
if g > 150:
|
||||
return "white"
|
||||
|
||||
# 特殊补丁:如果 B, G, R 都很接近且在 130 左右,可能是微信的背景灰 (通常用于时间戳或系统消息)
|
||||
if 110 < r < 160 and 110 < g < 160 and 110 < b < 160:
|
||||
if abs(r - g) < 15 and abs(g - b) < 15:
|
||||
return "system_gray"
|
||||
|
||||
return "unknown"
|
||||
|
||||
@@ -359,7 +372,7 @@ def _scan_chat_messages(image_path):
|
||||
# 微信菜单关键字(用于排除干扰)
|
||||
MENU_KEYWORDS = ["听筒播放", "收藏", "背景播放", "删除", "多选", "取消转文字", "转文字", "引用", "提醒"]
|
||||
# 忽略的系统消息内容
|
||||
IGNORE_CONTENT = ["撤回了一条消息", "打招呼的消息", "拍了拍", "你撤回了一条消息", "引用", "Clear Text", "Switch IME", "Done"]
|
||||
IGNORE_CONTENT = ["撤回了一条消息", "打招呼的消息", "拍了拍", "你撤回了一条消息", "引用", "Clear Text", "Switch IME", "Done", "按住说话", "发送"]
|
||||
|
||||
# 5. 整合所有消息
|
||||
messages = []
|
||||
@@ -367,9 +380,9 @@ def _scan_chat_messages(image_path):
|
||||
|
||||
# 绘制过滤区域边界 (可视化)
|
||||
cv2.line(debug_img, (0, 150), (w, 150), (255, 0, 255), 2) # 顶部线
|
||||
cv2.line(debug_img, (0, h - 100), (w, h - 100), (255, 0, 255), 2) # 底部线
|
||||
cv2.line(debug_img, (0, h - 60), (w, h - 60), (255, 0, 255), 2) # 底部线 (放宽到底部 60px)
|
||||
cv2.putText(debug_img, "TOP_FILTER", (10, 140), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 255), 1)
|
||||
cv2.putText(debug_img, "BOTTOM_FILTER", (10, h - 110), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 255), 1)
|
||||
cv2.putText(debug_img, "BOTTOM_FILTER", (10, h - 70), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 255), 1)
|
||||
|
||||
claimed_ocr_indices = set()
|
||||
|
||||
@@ -379,7 +392,7 @@ def _scan_chat_messages(image_path):
|
||||
cv2.circle(debug_img, (ax, ay), 10, (255, 255, 0), -1)
|
||||
|
||||
# 过滤掉顶部和底部的非聊天区域
|
||||
if ay < 150 or ay > h - 100:
|
||||
if ay < 150 or ay > h - 60:
|
||||
logger.info(f"忽略区域外语音图标: ({ax}, {ay})")
|
||||
cv2.rectangle(debug_img, (ax-35, ay-35), (ax+35, ay+35), (128, 128, 128), 1)
|
||||
cv2.putText(debug_img, "FILTERED", (ax - 40, ay - 45), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (128, 128, 128), 1)
|
||||
@@ -416,25 +429,32 @@ def _scan_chat_messages(image_path):
|
||||
|
||||
if voice_is_left:
|
||||
# 语音在左 (对方): 文本必须也是左对齐
|
||||
# - min_x 必须靠左 (< 300)
|
||||
# - min_x 必须靠左 (< 450) [Fix] 放宽阈值,防止长文本或缩进文本被过滤
|
||||
# - max_x 不能太靠右 (> w - 150),否则可能是"我"的消息
|
||||
if min_x > 300 or max_x > w - 150:
|
||||
if min_x > 450 or max_x > w - 150:
|
||||
logger.debug(f"忽略文本 '{text[:10]}' (Left Voice): min_x={min_x}, max_x={max_x} 不满足左对齐条件")
|
||||
continue
|
||||
else:
|
||||
# 语音在右 (我): 文本必须也是右对齐
|
||||
# - max_x 必须靠右 (> w - 300)
|
||||
# - min_x 不能太靠左 (< 100)
|
||||
if max_x < w - 300 or min_x < 100:
|
||||
logger.debug(f"忽略文本 '{text[:10]}' (Right Voice): min_x={min_x}, max_x={max_x} 不满足右对齐条件")
|
||||
continue
|
||||
|
||||
if -50 < c_y - ay < 800 and abs(c_x - ax) < 500:
|
||||
# [Fix] 放宽 X 轴判定范围 (500 -> 600) 以适应更宽的文本
|
||||
if -50 < c_y - ay < 800 and abs(c_x - ax) < 600:
|
||||
# 检查中间是否有其他语音图标
|
||||
has_intermediate_audio = False
|
||||
for other_ax, other_ay in audio_matches:
|
||||
# 只有当中间的语音图标在 [150, h-60] 的有效聊天区域内时,才视为阻断
|
||||
if ay + 20 < other_ay < c_y - 10:
|
||||
has_intermediate_audio = True
|
||||
logger.info(f"语音({ax},{ay}) 被中间语音图标({other_ax},{other_ay}) 阻断,无法关联文本 '{text[:10]}...'")
|
||||
break
|
||||
if 150 <= other_ay <= h - 60:
|
||||
has_intermediate_audio = True
|
||||
logger.info(f"语音({ax},{ay}) 被中间有效区域内的语音图标({other_ax},{other_ay}) 阻断,无法关联文本 '{text[:10]}...'")
|
||||
break
|
||||
else:
|
||||
logger.info(f"语音({ax},{ay}) 忽略非聊天区域(Y={other_ay})的语音图标阻断")
|
||||
|
||||
if has_intermediate_audio:
|
||||
continue
|
||||
@@ -532,7 +552,7 @@ def _scan_chat_messages(image_path):
|
||||
c_x = int((bbox[0][0] + bbox[2][0]) / 2)
|
||||
c_y = int((bbox[0][1] + bbox[2][1]) / 2)
|
||||
|
||||
if c_y < 150 or c_y > h - 100:
|
||||
if c_y < 150 or c_y > h - 60:
|
||||
continue
|
||||
|
||||
# 判定发送者 (增强版: 几何 + 颜色)
|
||||
@@ -544,6 +564,9 @@ def _scan_chat_messages(image_path):
|
||||
sender = "我"
|
||||
elif sender_color == "white":
|
||||
sender = "对方"
|
||||
elif sender_color == "system_gray":
|
||||
# 灰底文字通常是时间戳或系统消息,由系统发送,几何上居中
|
||||
sender = "system"
|
||||
|
||||
# 2. 几何特征强制修正 (Double Check)
|
||||
# 假设头像+边距约占 15% 宽度
|
||||
@@ -561,12 +584,15 @@ def _scan_chat_messages(image_path):
|
||||
# 规则 B: 如果这一行极其靠左 (小于 35% 宽度),且不靠右,那肯定是"对方"
|
||||
# 扩大判定范围,防止因为 OCR 稍微缩进导致判定失效
|
||||
# 注意:如果颜色明确为"我"(绿色),则跳过此规则,因为"我"的长消息也可能靠左
|
||||
elif min_x < w * 0.35 and max_x < w * 0.85:
|
||||
elif min_x < w * 0.35 and max_x < w * 0.75: # 修正:max_x 阈值从 0.85 降低到 0.75
|
||||
if sender == "我":
|
||||
logger.info(f"Geometry says '对方' (min_x={min_x} < {w*0.35}) but Color is '我' (Green). Trusting Color.")
|
||||
elif sender == "system":
|
||||
# 即使颜色是系统灰,但如果位置极其靠左,也可能是对方的某种特殊气泡
|
||||
pass
|
||||
else:
|
||||
sender = "对方"
|
||||
|
||||
|
||||
# 规则 C: 如果颜色是 unknown,且不在极端位置,使用中心点兜底
|
||||
if sender == "unknown":
|
||||
c_x = int((min_x + max_x) / 2)
|
||||
@@ -584,6 +610,9 @@ def _scan_chat_messages(image_path):
|
||||
# [Fix] 如果颜色明确是绿色,说明是"我"的左对齐文本(长文换行),不应被几何规则强制改为"对方"
|
||||
if sender == "我" and sender_color == "green":
|
||||
logger.info(f"Geometry says '对方' (center={c_x} < {w*0.45}) but Color is 'green'. Keeping '我'.")
|
||||
elif sender == "system":
|
||||
# 系统消息允许居中或偏左
|
||||
pass
|
||||
else:
|
||||
if sender == "我":
|
||||
logger.warning(f"Sender detected as '我' by color but center is left ({c_x} < {w*0.45}). Correcting to '对方'.")
|
||||
@@ -592,7 +621,10 @@ def _scan_chat_messages(image_path):
|
||||
elif c_x > w * 0.55:
|
||||
if sender == "对方":
|
||||
logger.warning(f"Sender detected as '对方' by color but center is right ({c_x} > {w*0.55}). Correcting to '我'.")
|
||||
sender = "我"
|
||||
elif sender == "system":
|
||||
pass
|
||||
else:
|
||||
sender = "我"
|
||||
else:
|
||||
logger.info(f"Message in middle zone ({w*0.45} < {c_x} < {w*0.55}), trusting color detection: {sender}")
|
||||
else:
|
||||
@@ -665,10 +697,12 @@ def _scan_chat_messages(image_path):
|
||||
|
||||
return final_messages_with_time, debug_img, chat_title
|
||||
|
||||
async def analyze_chat_image(image_path, output_path, device=None, target_name="对方", process_strategy="ALL"):
|
||||
async def analyze_chat_image(image_path, output_path, device=None, target_name="对方", process_strategy="ALL", restore_processed_voice=True):
|
||||
"""
|
||||
全面采用 CV + OCR 识别微信聊天截图中的最后一条消息
|
||||
:param process_strategy: 语音处理策略 (ALL/UNREAD/LAST)
|
||||
:param restore_processed_voice: 是否在转文字后还原(隐藏文字)。默认为 True。
|
||||
设为 False 可防止最后一条消息在无内容时陷入"转文字->还原->空内容"的死循环。
|
||||
注意:此函数现在包含一个循环,如果发现需要转文字的语音,会逐个处理并重新截图。
|
||||
"""
|
||||
try:
|
||||
@@ -799,21 +833,39 @@ async def analyze_chat_image(image_path, output_path, device=None, target_name="
|
||||
# 1. 截图 (但不立即 OCR,而是丢给异步任务)
|
||||
peek_shot = get_next_debug_path("step_peek_content")
|
||||
d.screenshot(peek_shot)
|
||||
logger.info("已截图,启动异步OCR任务以提取内容...")
|
||||
logger.info(f"已截图 {peek_shot},启动异步OCR任务以提取内容...")
|
||||
|
||||
async def _async_ocr_task(img_path, target_y):
|
||||
"""内部异步任务:在线程池中运行 OCR"""
|
||||
loop = asyncio.get_running_loop()
|
||||
# 在默认执行器(线程池)中运行耗时的 _scan_chat_messages
|
||||
msgs, _, _ = await loop.run_in_executor(None, _scan_chat_messages, img_path)
|
||||
|
||||
found = None
|
||||
for pm in msgs:
|
||||
if pm['type'] == 'voice' and pm.get('is_converted'):
|
||||
if abs(pm['y'] - target_y) < 50:
|
||||
found = pm.get('content')
|
||||
break
|
||||
return target_y, found
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# 在默认执行器(线程池)中运行耗时的 _scan_chat_messages
|
||||
logger.info(f"🚀 [Async OCR] 开始分析截图 {os.path.basename(img_path)} (目标 Y={target_y})")
|
||||
msgs, _, _ = await loop.run_in_executor(None, _scan_chat_messages, img_path)
|
||||
|
||||
found = None
|
||||
# 收集所有可能是该语音消息转换出的文本
|
||||
all_found_texts = []
|
||||
for pm in msgs:
|
||||
if pm['type'] == 'voice' and pm.get('is_converted'):
|
||||
# 容差稍微放大,因为转文字展开后 Y 坐标会变
|
||||
if abs(pm['y'] - target_y) < 150: # 进一步放宽容差
|
||||
content = pm.get('content', '').strip()
|
||||
if content:
|
||||
all_found_texts.append((pm['y'], content))
|
||||
|
||||
if all_found_texts:
|
||||
# 按 Y 轴排序,确保多行文本顺序正确
|
||||
all_found_texts.sort(key=lambda x: x[0])
|
||||
found = " ".join([t[1] for t in all_found_texts])
|
||||
logger.info(f"✨ [Async OCR] 在 Y={target_y} 附近找到转换文字: {found}")
|
||||
|
||||
if not found:
|
||||
logger.warning(f"⚠️ [Async OCR] 未能在 Y={target_y} 附近找到已转换文字")
|
||||
return target_y, found
|
||||
except Exception as e:
|
||||
logger.error(f"❌ [Async OCR] 任务执行失败: {e}")
|
||||
return target_y, None
|
||||
|
||||
# 创建并保存任务
|
||||
task = asyncio.create_task(_async_ocr_task(peek_shot, vy))
|
||||
@@ -822,44 +874,61 @@ async def analyze_chat_image(image_path, output_path, device=None, target_name="
|
||||
analyze_chat_image._ocr_tasks = []
|
||||
analyze_chat_image._ocr_tasks.append(task)
|
||||
|
||||
# 2. 立即还原状态 (取消转文字)
|
||||
# 2. 还原状态 (取消转文字)
|
||||
# 注意:由于 OCR 还没出结果,我们无法精确定位展开后的文字位置
|
||||
# 但通常点击原语音气泡位置 (vx, vy) 也能触发菜单
|
||||
logger.info("准备还原状态 (取消转文字)...")
|
||||
|
||||
d.long_click(vx, vy, 1.0) # 盲点原坐标
|
||||
|
||||
logger.info("正在快速寻找'隐藏文字'按钮...")
|
||||
cancel_template = os.path.join(TEMPLATE_DIR, "cancel_zhuan_wen_zi.jpg")
|
||||
cancel_btn = None
|
||||
|
||||
poll_start = time.time()
|
||||
while time.time() - poll_start < 3.0:
|
||||
restore_menu_shot = get_next_debug_path("step_restore_poll")
|
||||
d.screenshot(restore_menu_shot)
|
||||
cancel_btn = find_template_match(restore_menu_shot, cancel_template, threshold=0.7)
|
||||
if restore_processed_voice:
|
||||
logger.info("准备还原状态 (取消转文字)...")
|
||||
|
||||
d.long_click(vx, vy, 1.0) # 盲点原坐标
|
||||
|
||||
logger.info("正在快速寻找'隐藏文字'按钮...")
|
||||
cancel_template = os.path.join(TEMPLATE_DIR, "cancel_zhuan_wen_zi.jpg")
|
||||
cancel_btn = None
|
||||
|
||||
poll_start = time.time()
|
||||
while time.time() - poll_start < 3.0:
|
||||
restore_menu_shot = get_next_debug_path("step_restore_poll")
|
||||
d.screenshot(restore_menu_shot)
|
||||
cancel_btn = find_template_match(restore_menu_shot, cancel_template, threshold=0.7)
|
||||
if cancel_btn:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
if cancel_btn:
|
||||
cx, cy = int(cancel_btn[0]), int(cancel_btn[1])
|
||||
logger.info(f"✅ 找到'隐藏文字'按钮: ({cx}, {cy}),点击还原...")
|
||||
safe_device_click(d, cx, cy)
|
||||
convert_closed_count += 1
|
||||
time.sleep(2.0) # 等待收起动画
|
||||
else:
|
||||
logger.warning("❌ 未找到'隐藏文字'按钮,无法还原状态!(后续可能导致重复处理)")
|
||||
|
||||
# 3. 准备下一次循环
|
||||
if len(target_voices) == 1:
|
||||
logger.info("✅ 当前屏幕所有目标语音已处理完毕,无需再次全屏扫描。")
|
||||
final_messages = messages # 使用本轮初始扫描的消息列表
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
if cancel_btn:
|
||||
cx, cy = int(cancel_btn[0]), int(cancel_btn[1])
|
||||
logger.info(f"✅ 找到'隐藏文字'按钮: ({cx}, {cy}),点击还原...")
|
||||
safe_device_click(d, cx, cy)
|
||||
convert_closed_count += 1
|
||||
time.sleep(2.0) # 等待收起动画
|
||||
|
||||
# 重新截图,因为界面可能微调,或者只是恢复了
|
||||
next_screenshot = get_next_debug_path("step_restored")
|
||||
d.screenshot(next_screenshot)
|
||||
|
||||
current_image_path = next_screenshot
|
||||
current_output_path = get_next_debug_path("flag_restored")
|
||||
|
||||
continue
|
||||
else:
|
||||
logger.warning("❌ 未找到'隐藏文字'按钮,无法还原状态!(后续可能导致重复处理)")
|
||||
|
||||
# 3. 准备下一次循环
|
||||
# 重新截图,因为界面可能微调,或者只是恢复了
|
||||
next_screenshot = get_next_debug_path("step_restored")
|
||||
d.screenshot(next_screenshot)
|
||||
|
||||
current_image_path = next_screenshot
|
||||
current_output_path = get_next_debug_path("flag_restored")
|
||||
|
||||
continue
|
||||
logger.info("⏩ [配置] 跳过还原状态步骤 (保持文字展开)。")
|
||||
# 即使不还原,我们也不建议继续处理下一条,因为界面已经大幅变动(展开了文字)。
|
||||
# 除非我们重新截图并重新定位。
|
||||
# 但在这里,如果 restore_processed_voice=False,通常意味着我们只关心最后一条(LAST策略),或者我们接受界面变动。
|
||||
|
||||
# 为了安全起见,如果不还原,我们最好终止循环(假设只处理这一条,或者下一轮主循环再处理其他的)
|
||||
# 否则后续的 target_voices 坐标全都不准了。
|
||||
logger.info("🛑 因不还原状态,终止本轮多语音处理循环,等待下一次主监控循环。")
|
||||
final_messages = messages # 这里的 messages 其实是展开前的,但没关系,我们的内容通过 captured_voice_contents 注入
|
||||
break
|
||||
|
||||
else:
|
||||
logger.warning("❌ 未找到'转文字'按钮,可能是已转换或误判")
|
||||
@@ -887,12 +956,13 @@ async def analyze_chat_image(image_path, output_path, device=None, target_name="
|
||||
if captured_voice_contents:
|
||||
logger.info(f"正在注入 {len(captured_voice_contents)} 条已还原的语音内容...")
|
||||
for m in final_messages:
|
||||
if m['type'] == 'voice' and not m.get('content'):
|
||||
if m['type'] == 'voice' and (not m.get('content') or m.get('content').strip() == ""):
|
||||
for py, content in captured_voice_contents.items():
|
||||
if abs(m['y'] - py) < 30: # 匹配原始 Y 坐标
|
||||
# 注入时的容差也要放大,因为 final_messages 的 Y 可能和点击时的 vy 略有差异
|
||||
if abs(m['y'] - py) < 100:
|
||||
m['content'] = content
|
||||
m['is_converted'] = True # 标记为逻辑上已转换
|
||||
logger.info(f" -> 注入内容: {content[:10]}...")
|
||||
logger.info(f" -> 注入内容到 Y={m['y']} (原 py={py}): {content[:20]}...")
|
||||
break
|
||||
|
||||
# 构造返回值
|
||||
@@ -931,15 +1001,16 @@ async def analyze_chat_image(image_path, output_path, device=None, target_name="
|
||||
# 尝试注入异步获取的语音内容
|
||||
if msg['type'] == 'voice':
|
||||
# 模糊匹配 Y 坐标 (增大容差到 100,应对界面滚动)
|
||||
for y_key, content in captured_voice_contents.items():
|
||||
if abs(msg['y'] - y_key) < 100:
|
||||
msg['is_converted'] = True
|
||||
msg['content'] = content
|
||||
logger.info(f"注入语音内容到最终消息列表: {content}")
|
||||
break
|
||||
# 优先检查 content 是否为空或为 placeholder
|
||||
if not msg.get('content') or msg.get('content').strip() == "":
|
||||
for y_key, content in captured_voice_contents.items():
|
||||
if abs(msg['y'] - y_key) < 100:
|
||||
msg['is_converted'] = True
|
||||
msg['content'] = content
|
||||
logger.info(f"✅ [注入] 成功将异步语音内容 '{content}' 注入到 Y={msg['y']} 的消息中")
|
||||
break
|
||||
|
||||
# 无论是否有内容,都加入 dialogue_log
|
||||
# 如果是语音且没内容,T2 会有兜底逻辑去处理
|
||||
if msg['type'] == 'text':
|
||||
if msg.get('content'): # 文本消息没内容通常是识别错误,可以丢弃
|
||||
dialogue_log.append(msg)
|
||||
|
||||
Reference in New Issue
Block a user