修复:OCR环路五个靶点—①##中文(md检测)②位置0##不提取③跨轮指纹去重④引擎标签⑤回复标题去##追踪

This commit is contained in:
youpaishidifu
2026-07-27 16:30:37 +08:00
parent 24146f34d9
commit e90cea7343
+45 -14
View File
@@ -91,6 +91,11 @@ class MessageMonitor:
# 上次回复时间戳(冷却期内跳过该平台 poll) # 上次回复时间戳(冷却期内跳过该平台 poll)
self._last_reply_time: dict = {} # platform → timestamp self._last_reply_time: dict = {} # platform → timestamp
# 本轮选中的 OCR 引擎(用于日志标记)
self._last_chosen_engine: dict = {} # platform → "windows"|"easyocr"
# 最近 N 轮 OCR 指纹(去重环路:屏幕内容没变则跳过本轮)
self._recent_ocr_fingerprints: dict = {} # platform → deque of fingerprint strings
# OCR 引擎(Windows 原生,lazy init) # OCR 引擎(Windows 原生,lazy init)
self._ocr_engine = None self._ocr_engine = None
# EasyOCR 备用引擎(lazy init) # EasyOCR 备用引擎(lazy init)
@@ -694,15 +699,14 @@ class MessageMonitor:
score += min(timestamps * 2, 6) score += min(timestamps * 2, 6)
return max(score, 0.0) return max(score, 0.0)
def _pick_ocr_text(self, win_text: str, easy_text: str, image: Image.Image) -> str: def _pick_ocr_text(self, win_text: str, easy_text: str, image: Image.Image) -> tuple:
"""双引擎对比,选质量更高的结果。日志记录引擎胜负统计。""" """双引擎对比,选质量更高的结果。日志记录引擎胜负统计。返回 (text, engine_name)。"""
if not easy_text: if not easy_text:
return win_text return (win_text, "windows")
if not win_text: if not win_text:
return easy_text return (easy_text, "easyocr")
win_score = self._score_ocr_text(win_text) win_score = self._score_ocr_text(win_text)
easy_score = self._score_ocr_text(easy_text) easy_score = self._score_ocr_text(easy_text)
# 引擎选择统计
chosen = "windows" if win_score >= easy_score else "easyocr" chosen = "windows" if win_score >= easy_score else "easyocr"
self._ocr_engine_wins[chosen] = self._ocr_engine_wins.get(chosen, 0) + 1 self._ocr_engine_wins[chosen] = self._ocr_engine_wins.get(chosen, 0) + 1
total = sum(self._ocr_engine_wins.values()) total = sum(self._ocr_engine_wins.values())
@@ -711,7 +715,7 @@ class MessageMonitor:
f"[ocr] 引擎对比: win={win_score:.1f} vs easy={easy_score:.1f} → 选{chosen} " f"[ocr] 引擎对比: win={win_score:.1f} vs easy={easy_score:.1f} → 选{chosen} "
f"(胜率{ratio:.0%}, {total}轮)" f"(胜率{ratio:.0%}, {total}轮)"
) )
return win_text if chosen == "windows" else easy_text return (win_text, chosen) if chosen == "windows" else (easy_text, chosen)
def _ocr_read_qq(self) -> list: def _ocr_read_qq(self) -> list:
"""OCR 截屏 QQ 消息区域 -> 双引擎(Windows OCR + EasyOCR)提取文本。 """OCR 截屏 QQ 消息区域 -> 双引擎(Windows OCR + EasyOCR)提取文本。
@@ -834,9 +838,10 @@ class MessageMonitor:
logger.debug(f"[ocr] Windows OCR: {win_text[:100]!r}...") logger.debug(f"[ocr] Windows OCR: {win_text[:100]!r}...")
logger.debug(f"[ocr] EasyOCR: {easy_text[:100]!r}...") logger.debug(f"[ocr] EasyOCR: {easy_text[:100]!r}...")
raw_text = self._pick_ocr_text(win_text, easy_text, im) raw_text, chosen_engine = self._pick_ocr_text(win_text, easy_text, im)
if not raw_text: if not raw_text:
return [] return []
self._last_chosen_engine["qq"] = chosen_engine
return self._process_ocr_output(raw_text, "qq") return self._process_ocr_output(raw_text, "qq")
except Exception as e: except Exception as e:
logger.debug(f"[qq] OCR失败: {e}") logger.debug(f"[qq] OCR失败: {e}")
@@ -922,9 +927,10 @@ class MessageMonitor:
logger.debug(f"[wechat] Windows OCR: {win_text[:100]!r}...") logger.debug(f"[wechat] Windows OCR: {win_text[:100]!r}...")
logger.debug(f"[wechat] EasyOCR: {easy_text[:100]!r}...") logger.debug(f"[wechat] EasyOCR: {easy_text[:100]!r}...")
raw_text = self._pick_ocr_text(win_text, easy_text, im) raw_text, chosen_engine = self._pick_ocr_text(win_text, easy_text, im)
if not raw_text: if not raw_text:
return [] return []
self._last_chosen_engine["wechat"] = chosen_engine
return self._process_ocr_output(raw_text, "wechat") return self._process_ocr_output(raw_text, "wechat")
except Exception as e: except Exception as e:
logger.debug(f"[wechat] OCR失败: {e}") logger.debug(f"[wechat] OCR失败: {e}")
@@ -948,14 +954,25 @@ class MessageMonitor:
self._ocr_garbage_count[platform] = 0 self._ocr_garbage_count[platform] = 0
self._ocr_last_text[platform] = raw_text self._ocr_last_text[platform] = raw_text
# —— 跨轮次 OCR 指纹去重(屏幕内容未变→跳过) ——
clean_fp = re.sub(r'\s', '', raw_text)[:500]
recent = self._recent_ocr_fingerprints.setdefault(platform, [])
if clean_fp in recent:
logger.debug(f"[{platform}] OCR 指纹重复(近{len(recent)}轮内相同),跳过本轮")
return []
recent.append(clean_fp)
if len(recent) > 3:
recent.pop(0)
# —— 行切割 + 尾标清理 —— # —— 行切割 + 尾标清理 ——
ocr_lines = [] ocr_lines = []
for l in raw_text.split('\n'): for l in raw_text.split('\n'):
l = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', l).strip() l = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', l).strip()
if l: if l:
ocr_lines.append(l) ocr_lines.append(l)
engine_label = self._last_chosen_engine.get(platform, "")
for i, rl in enumerate(ocr_lines): for i, rl in enumerate(ocr_lines):
logger.info(f"[ocr] raw[{i}] raw_line<{rl!r}>") logger.info(f"[ocr] raw[{i}][{engine_label}] raw_line<{rl!r}>")
# —— 时间戳分割 —— # —— 时间戳分割 ——
expanded = [] expanded = []
@@ -1383,6 +1400,9 @@ class MessageMonitor:
# 行首是 # 标题(# ## ### #### 等) # 行首是 # 标题(# ## ### #### 等)
if re.match(r'^#{1,6}\s', text): if re.match(r'^#{1,6}\s', text):
return True return True
# ##直接跟汉字(OCR 丢失 ##后空格时常见:##文档导航)
if re.match(r'^#{1,6}[\u4e00-\u9fff]', text):
return True
# 包含 markdown 链接 [text](url) # 包含 markdown 链接 [text](url)
if re.search(r'\[.+?\]\(.+?\)', text): if re.search(r'\[.+?\]\(.+?\)', text):
return True return True
@@ -1413,12 +1433,16 @@ class MessageMonitor:
本方法找到第一个 md 标记位置,提取其前面的用户文本; 本方法找到第一个 md 标记位置,提取其前面的用户文本;
再找到末尾连续非 md 尾巴,提取为第二条用户文本。 再找到末尾连续非 md 尾巴,提取为第二条用户文本。
""" """
# 找第一个 markdown 起始标记的位置 # ── 整段以 ## 开头 → 无用户文本(无空格 ##中文 也捕获)──
if re.match(r'^#{1,6}(?:\s|\[|[\u4e00-\u9fff])', text):
return []
# ── 找第一个 markdown 起始标记的位置 ──
# 标记:## 标题、[text](url)、.md)、| 表格分隔、``` 代码块 # 标记:## 标题、[text](url)、.md)、| 表格分隔、``` 代码块
md_start = len(text) # 默认整段都是 md md_start = len(text) # 默认整段都是 md
# ## 标题(在文本中间出现的) # ## 标题(放宽:允许 ##后直接跟汉字/[,不限于空格)
m = re.search(r'(?<=\S)\s*#{1,6}\s', text) m = re.search(r'(?<=\S)\s*#{1,6}(?:\s|\[|[\u4e00-\u9fff])', text)
if m and m.start() < md_start: if m and m.start() < md_start:
md_start = m.start() md_start = m.start()
@@ -1432,7 +1456,7 @@ class MessageMonitor:
if m and m.start() < md_start: if m and m.start() < md_start:
md_start = m.start() md_start = m.start()
# | 表格(前面有非空白字符说明不是行首) # | 表格
m = re.search(r'(?<=\S)\s*\|', text) m = re.search(r'(?<=\S)\s*\|', text)
if m and m.start() < md_start: if m and m.start() < md_start:
md_start = m.start() md_start = m.start()
@@ -1612,6 +1636,12 @@ class MessageMonitor:
if part and len(part) > 3: if part and len(part) > 3:
self._replied_texts.add(part) self._replied_texts.add(part)
self._seen_messages.add(part) self._seen_messages.add(part)
# 同时追踪去掉 ## 前缀的标题文本(OCR 常丢失 ## 后的空格)
if part.startswith('#'):
no_hash = re.sub(r'^#+\s*', '', part).strip()
if no_hash and len(no_hash) > 2:
self._replied_texts.add(no_hash)
self._seen_messages.add(no_hash)
self._last_reply_time[platform] = time.time() self._last_reply_time[platform] = time.time()
return True return True
except Exception as e: except Exception as e:
@@ -2049,7 +2079,8 @@ class MessageMonitor:
# 有纯粹的新文本 → 逐一报告 # 有纯粹的新文本 → 逐一报告
for item in new_items: for item in new_items:
self._last_msg[platform] = item self._last_msg[platform] = item
logger.info(f"[{platform}] 新消息: {item[:60]}") engine_tag = self._last_chosen_engine.get(platform, "")
logger.info(f"[{platform}] 新消息[{engine_tag}]: {item[:60]}")
if self._on_message: if self._on_message:
self._on_message(platform, item) self._on_message(platform, item)
if platform == "qq": if platform == "qq":