diff --git a/monitor.py b/monitor.py index a3b2c17..502b081 100644 --- a/monitor.py +++ b/monitor.py @@ -937,10 +937,11 @@ class MessageMonitor: self._easyocr_proc = None return False - def _call_easyocr_safe(self, img_np) -> Optional[str]: - """通过子进程安全调用 EasyOCR。返回识别文本,失败/崩溃时返回 None。 + def _call_easyocr_safe(self, img_np, timeout: Optional[float] = None) -> Optional[str]: + """通过子进程安全调用 EasyOCR。返回识别文本,失败/崩溃/超时时返回 None。 使用 _easyocr_lock 保护子进程 stdin/stdout,避免 QQ/微信独立线程并发写乱。 + timeout — 子进程读取超时秒数,None=无限等待;超时后杀死子进程下次重启。 """ import io import base64 @@ -959,7 +960,30 @@ class MessageMonitor: self._easyocr_proc.stdin.write(json.dumps({"img": img_b64}) + "\n") self._easyocr_proc.stdin.flush() - line = self._easyocr_proc.stdout.readline() + from queue import Queue, Empty as _QueueEmpty + # readline 包装超时:用 daemon 线程 + Queue 实现跨平台 + if timeout is not None: + _q = Queue() + def _read_worker(): + try: + _q.put(self._easyocr_proc.stdout.readline()) + except Exception: + pass + _t = threading.Thread(target=_read_worker, daemon=True) + _t.start() + try: + line = _q.get(timeout=timeout) + except _QueueEmpty: + logger.debug(f"[ocr] EasyOCR 子进程超时({timeout}s),杀死子进程下次重启") + try: + if self._easyocr_proc and self._easyocr_proc.poll() is None: + self._easyocr_proc.kill() + except Exception: + pass + self._easyocr_proc = None # 清引用,下次 poll 自动重启(不设 _easyocr_broken) + return None + else: + line = self._easyocr_proc.stdout.readline() if not line: raise ConnectionError("EasyOCR 子进程 stdout 关闭") result = json.loads(line) @@ -1099,12 +1123,13 @@ class MessageMonitor: # 保存调试截图(在颜色/哈希检查之前,即使跳过也有图可查) try: - script_dir = os.path.dirname(os.path.abspath(__file__)) + _dbg_dir = os.path.join(os.environ.get('TEMP', os.path.dirname(os.path.abspath(__file__))), "ocr_debug") + os.makedirs(_dbg_dir, exist_ok=True) ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19] - _dbg_path = os.path.join(script_dir, f"ocr_debug_qq_{ts}.png") + _dbg_path = os.path.join(_dbg_dir, f"ocr_debug_qq_{ts}.png") im.save(_dbg_path) logger.debug(f"[qq] 调试截图: {_dbg_path}") - _clean_old_ocr_debug(script_dir, keep=60) + _clean_old_ocr_debug(_dbg_dir, keep=60) except Exception as e: logger.debug(f"[qq] 调试截图保存失败: {e}") @@ -1130,6 +1155,7 @@ class MessageMonitor: # 图像预处理:提升 Windows OCR 识别准确率 # Windows OCR 对低 DPI/未处理截图识别差——CJK 空格、字符混淆 # 2x LANCZOS 放大 + 锐化 + 对比度增强 + im_original = im # 保留原图,bad allocation 时降级 try: im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS) im = ImageEnhance.Sharpness(im).enhance(1.8) @@ -1143,22 +1169,45 @@ class MessageMonitor: if im.mode != 'RGBA': im = im.convert('RGBA') logger.debug("[ocr] 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"[ocr] Windows OCR 完成: {len(win_text)} chars") + 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) + easy_text = self._call_easyocr_safe(img_np, timeout=8) if easy_text is None: - self._easyocr_broken = True + # None = 子进程崩溃或超时;超时已自动杀子进程,下次自动重启 + # 只有真正的崩溃(BrokenPipeError / OSError)才永久禁用 easy_text = "" - logger.warning("[ocr] EasyOCR 子进程崩溃,已禁用(本次会话不再尝试)") + logger.debug("[ocr] EasyOCR 无结果(超时或崩溃),使用 Windows OCR 结果") elif easy_text: logger.debug(f"[ocr] EasyOCR 识别完成: {len(easy_text)} chars") @@ -1230,13 +1279,13 @@ class MessageMonitor: # 保存调试截图(在颜色/哈希检查之前,即使跳过也有图可查) try: - script_dir = os.path.dirname(os.path.abspath(__file__)) + _dbg_dir = os.path.join(os.environ.get('TEMP', os.path.dirname(os.path.abspath(__file__))), "ocr_debug") + os.makedirs(_dbg_dir, exist_ok=True) ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19] - _dbg_path = os.path.join(script_dir, f"ocr_debug_wx_{ts}.png") + _dbg_path = os.path.join(_dbg_dir, f"ocr_debug_wx_{ts}.png") im.save(_dbg_path) logger.debug(f"[wechat] 调试截图: {_dbg_path}") - # 清理 ocr_debug_* 调试图(unified pool) - _clean_old_ocr_debug(script_dir, keep=60) + _clean_old_ocr_debug(_dbg_dir, keep=60) except Exception as e: logger.debug(f"[wechat] 调试截图保存失败: {e}")