双引擎OCR并发: Windows OCR + EasyOCR同时跑最多等10秒,谁先出用谁

之前: Windows OCR串行等完→再串行等EasyOCR(微信端甚至无超时)。
EasyOCR子进程挂起=OCR整条链路都卡死。

改为 _ocr_dual_engine 方法:
- Windows OCR + EasyOCR 通过 ThreadPoolExecutor 同时跑
- concurrent.futures.wait(timeout=10.0, ALL_COMPLETED) 等最多10秒
- 两引擎都在10秒内出结果→_pick_ocr_text质量对比选优
- 只有一个在10秒内出结果→直接用那个
- 都超时→返回空
- Windows OCR bad allocation 自动降级到原图重试(在worker线程内处理)

两个平台调用点(_ocr_read_qq / _ocr_read_wechat)都改为调_ocr_dual_engine。
微信端同时补上im_original备份(之前缺bad allocation降级)。
This commit is contained in:
youpaishidifu
2026-07-29 17:51:52 +08:00
parent 68d364a0c6
commit 722d2a5438
+84 -91
View File
@@ -1049,6 +1049,85 @@ class MessageMonitor:
)
return (win_text, chosen) if chosen == "windows" else (easy_text, chosen)
def _ocr_dual_engine(self, im_processed: Image.Image, im_original: Image.Image, platform: str) -> tuple:
"""双引擎并发 OCR:Windows OCR + EasyOCR 同时跑,最多等 10 秒。
哪個先出用哪個,兩個都出時 _pick_ocr_text 質量對比選優。
im_processed: 预处理后的图(2x resize + sharpen + contrast, RGBA)
im_original: 原始截图(bad allocation 降级用)
返回 (raw_text, engine_name) 含義同 _pick_ocr_text。
"""
import concurrent.futures
engine = self._ocr_ensure_engine()
if not engine:
return ("", "")
win_text: list[str] = [""]
easy_text: list[str] = [""]
def _win_worker():
try:
im = im_processed
if im.mode != 'RGBA':
im = im.convert('RGBA')
sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height)
sb.copy_from_buffer(im.tobytes())
text = engine.recognize_async(sb).get().text.strip()
win_text[0] = text
except (RuntimeError, OSError) as e:
estr = str(e)
if "bad allocation" in estr or "memory" in estr.lower():
try:
im2 = im_original
if im2.mode != 'RGBA':
im2 = im2.convert('RGBA')
sb2 = SoftwareBitmap(BitmapPixelFormat.RGBA8, im2.width, im2.height)
sb2.copy_from_buffer(im2.tobytes())
text2 = engine.recognize_async(sb2).get().text.strip()
win_text[0] = text2
except Exception:
pass
# 其他异常静默处理
def _easy_worker():
if not _HAS_EASYOCR or self._easyocr_broken:
return
img_np = _np.array(im_processed)
text = self._call_easyocr_safe(img_np, timeout=10)
if text:
easy_text[0] = text
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
win_fut = ex.submit(_win_worker)
easy_fut = ex.submit(_easy_worker)
done, not_done = concurrent.futures.wait(
[win_fut, easy_fut], timeout=10.0,
return_when=concurrent.futures.ALL_COMPLETED
)
for f in not_done:
f.cancel()
wt = win_text[0]
et = easy_text[0]
# 日志(原始输出逐行记录,用於長期對比兩引擎能力)
if wt:
for i, l in enumerate(wt.split('\n')):
if l.strip():
logger.info(f"[ocr] [windows/原始] line[{i}]: {l.strip()[:120]!r}")
if et:
for i, l in enumerate(et.split('\n')):
if l.strip():
logger.info(f"[ocr] [easyocr/原始] line[{i}]: {l.strip()[:120]!r}")
if not wt and not et:
return ("", "")
return self._pick_ocr_text(wt, et, im_processed)
def _ocr_read_qq(self) -> list:
"""OCR 截屏 QQ 消息区域 -> 双引擎(Windows OCR + EasyOCR)提取文本。
包含窗口验证 + 垃圾过滤 + 连续垃圾清缓存。
@@ -1179,64 +1258,8 @@ class MessageMonitor:
except Exception as e:
logger.debug(f"[ocr] 预处理失败(跳过): {e}")
# ── 双引擎 OCR ──────────────────────────────────────
# Windows OCR
if im.mode != 'RGBA':
im = im.convert('RGBA')
logger.debug("[ocr] Windows OCR 开始...")
try:
sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height)
sb.copy_from_buffer(im.tobytes())
win_result = engine.recognize_async(sb).get()
win_text = win_result.text.strip()
logger.debug(f"[ocr] Windows OCR 完成: {len(win_text)} chars")
except (RuntimeError, OSError) as e:
estr = str(e)
if "bad allocation" in estr or "memory" in estr.lower():
logger.debug(f"[ocr] Windows OCR bad allocation({im.width}x{im.height}),降级到原图重试")
# 用原图(跳过 2x resize)重试
try:
im2 = im_original
if im2.mode != 'RGBA':
im2 = im2.convert('RGBA')
sb2 = SoftwareBitmap(BitmapPixelFormat.RGBA8, im2.width, im2.height)
sb2.copy_from_buffer(im2.tobytes())
win_result2 = engine.recognize_async(sb2).get()
win_text = win_result2.text.strip()
im = im2 # EasyOCR 也用原图
logger.debug(f"[ocr] Windows OCR 降级重试完成: {len(win_text)} chars")
except Exception as e2:
logger.debug(f"[ocr] Windows OCR 降级也失败: {e2}")
win_text = ""
else:
logger.debug(f"[ocr] Windows OCR 异常: {e}")
win_text = ""
# EasyOCR 备用(GPU crash 隔离:通过子进程调用)
easy_text = ""
if _HAS_EASYOCR and not self._easyocr_broken:
logger.debug("[ocr] EasyOCR 子进程...")
img_np = _np.array(im)
easy_text = self._call_easyocr_safe(img_np, timeout=8)
if easy_text is None:
# None = 子进程崩溃或超时;超时已自动杀子进程,下次自动重启
# 只有真正的崩溃(BrokenPipeError / OSError)才永久禁用
easy_text = ""
logger.debug("[ocr] EasyOCR 无结果(超时或崩溃),使用 Windows OCR 结果")
elif easy_text:
logger.debug(f"[ocr] EasyOCR 识别完成: {len(easy_text)} chars")
# 引擎原始输出逐行标记日志(用于长期对比两引擎能力)
if win_text:
for i, l in enumerate(win_text.split('\n')):
if l.strip():
logger.info(f"[ocr] [windows/原始] line[{i}]: {l.strip()[:120]!r}")
if easy_text:
for i, l in enumerate(easy_text.split('\n')):
if l.strip():
logger.info(f"[ocr] [easyocr/原始] line[{i}]: {l.strip()[:120]!r}")
raw_text, chosen_engine = self._pick_ocr_text(win_text, easy_text, im)
# ── 双引擎并发 OCR(Windows OCR + EasyOCR 同时跑,最多等 10 秒)──
raw_text, chosen_engine = self._ocr_dual_engine(im, im_original, "qq")
if not raw_text:
return []
self._last_chosen_engine["qq"] = chosen_engine
@@ -1325,6 +1348,7 @@ class MessageMonitor:
self._ocr_unchanged.pop("wechat", None)
# 图像预处理(同 QQ)
im_original = im # 保留原图,bad allocation 时降级
try:
im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS)
im = ImageEnhance.Sharpness(im).enhance(1.8)
@@ -1332,39 +1356,8 @@ class MessageMonitor:
except Exception as e:
logger.debug(f"[wechat] OCR 预处理失败(跳过): {e}")
# ── 双引擎 OCR ──
if im.mode != 'RGBA':
im = im.convert('RGBA')
logger.debug("[wechat] Windows OCR 开始...")
sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height)
sb.copy_from_buffer(im.tobytes())
win_result = engine.recognize_async(sb).get()
win_text = win_result.text.strip()
logger.debug(f"[wechat] Windows OCR 完成: {len(win_text)} chars")
easy_text = ""
if _HAS_EASYOCR and not self._easyocr_broken:
logger.debug("[wechat] EasyOCR 子进程...")
img_np = _np.array(im)
easy_text = self._call_easyocr_safe(img_np)
if easy_text is None:
self._easyocr_broken = True
easy_text = ""
logger.warning("[wechat] EasyOCR 子进程崩溃,已禁用(本次会话不再尝试)")
elif easy_text:
logger.debug(f"[wechat] EasyOCR 识别完成: {len(easy_text)} chars")
# 引擎原始输出逐行标记日志(用于长期对比两引擎能力)
if win_text:
for i, l in enumerate(win_text.split('\n')):
if l.strip():
logger.info(f"[wechat] [windows/原始] line[{i}]: {l.strip()[:120]!r}")
if easy_text:
for i, l in enumerate(easy_text.split('\n')):
if l.strip():
logger.info(f"[wechat] [easyocr/原始] line[{i}]: {l.strip()[:120]!r}")
raw_text, chosen_engine = self._pick_ocr_text(win_text, easy_text, im)
# ── 双引擎并发 OCR(Windows OCR + EasyOCR 同时跑,最多等 10 秒)──
raw_text, chosen_engine = self._ocr_dual_engine(im, im_original, "wechat")
if not raw_text:
return []
self._last_chosen_engine["wechat"] = chosen_engine