修复EasyOCR CUDA OOM冻结+时间戳day=0崩溃+OCR图片分目录img/raw+processed

- 冻结根因: CUDA OOM后worker不退出, 损坏context持续占显存, 父进程不杀子进程
  反复发图→线程堆积+显存耗尽→桌面冻结
- worker: readtext CUDA/kernel错误→立即退出释放显存(faulerhandler已有兜底)
- 父进程: 收到CUDA错误→立即杀子进程+30s冷却; 连续2次CUDA错误→EASYOCR_FORCE_CPU=1
  强制CPU模式启动(显存不足降级防冻结)
- 时间戳day=0崩溃: _is_timestamp_old跨午夜用replace(day-1), 每月1号day=1→day=0
  抛ValueError day is out of range for month→微信OCR整轮失败; 改用timedelta(days=±1)
- OCR调试图片回源码目录: img/raw(原图PNG) + img/processed(处理后JPG) 分目录存储
- 崩溃日志回源码根 easyocr_crash.log (不写%LOCALAPPDATA%)
- _clean_old_ocr_debug 默认 keep=20 (回退上一轮激进keep=6)
- .gitignore: 忽略 img/ 与 worker_stderr/stdout.txt
This commit is contained in:
youpaishidifu
2026-08-01 22:23:55 +08:00
parent 0405e42a90
commit 4ae189e82f
3 changed files with 76 additions and 42 deletions
+59 -32
View File
@@ -93,7 +93,7 @@ import json
import psutil
import os
import random
from datetime import datetime
from datetime import datetime, timedelta
import signal
from PIL import Image, ImageGrab, ImageEnhance, ImageFilter
from winrt.windows.media.ocr import OcrEngine
@@ -257,6 +257,8 @@ class MessageMonitor:
# EasyOCR 连续失败计数:前2次保留子进程(预留初始化时间),第3次杀进程重来
self._easyocr_consecutive_failures: int = 0
self._easyocr_max_failures: int = 3 # 连续3次失败后杀进程
# EasyOCR CUDA 错误计数:≥2 次 OOM/kernel error → 下次启动强制 CPU(防冻结)
self._easyocr_cuda_oom_count: int = 0
# 引擎选择计数器
self._ocr_engine_wins: dict = {"windows": 0, "easyocr": 0}
# 上次 OCR 原始文本(用于增量检测)
@@ -307,6 +309,7 @@ class MessageMonitor:
self._easyocr_broken = False
self._easyocr_retry_until = 0.0 # 清除崩溃冷却
self._easyocr_call_count = 0
self._easyocr_cuda_oom_count = 0 # 重启后重新评估 GPU/CPU
self._easyocr_proc = None # 确保 clean state
# EasyOCR 已在子进程隔离(easyocr_worker.py),不需在主线程预加载
# ⭐ 预热子进程:后台启动 + 60s 初始化握手,主循环不阻塞
@@ -355,6 +358,7 @@ class MessageMonitor:
self._easyocr_broken = False
self._easyocr_retry_until = 0.0 # 清除崩溃冷却
self._easyocr_call_count = 0
self._easyocr_cuda_oom_count = 0 # 重启后重新评估 GPU/CPU
# 释放钩子回调引用(防止 GC 延迟导致卸载后崩溃)
if hasattr(self, '_esc_hook_proc'):
@@ -1072,6 +1076,11 @@ class MessageMonitor:
return False
try:
_worker_env = dict(os.environ)
if self._easyocr_cuda_oom_count >= 2:
# ⭐ 连续 CUDA OOM/kernel error → 强制 CPU 模式启动(显存不足时防冻结)
_worker_env["EASYOCR_FORCE_CPU"] = "1"
logger.warning("[ocr] EasyOCR 连续CUDA错误≥2次,本次强制 CPU 模式启动 worker")
self._easyocr_proc = subprocess.Popen(
[sys.executable, '-u', worker_path],
stdin=subprocess.PIPE,
@@ -1079,6 +1088,7 @@ class MessageMonitor:
stderr=subprocess.PIPE, # 捕获 worker 错误日志(防崩溃排查)
text=True,
bufsize=1, # 行缓冲
env=_worker_env,
)
logger.debug("[ocr] EasyOCR 子进程已启动,等待初始化握手...")
except Exception as e:
@@ -1228,7 +1238,23 @@ class MessageMonitor:
self._easyocr_call_count = 0
return result["text"]
else:
logger.debug(f"[ocr] EasyOCR worker 错误: {result.get('error', '?')[:100]}")
err = result.get('error', '?')[:100]
logger.debug(f"[ocr] EasyOCR worker 错误: {err}")
# ⭐ CUDA OOM / kernel error:worker 即将自杀退出(context 已损坏),
# 这里立即确认杀掉 + 冷却,避免后续轮询继续往坏进程发图
if 'cuda' in err.lower() or 'out of memory' in err.lower():
self._easyocr_cuda_oom_count += 1
logger.warning(
f"[ocr] EasyOCR CUDA 错误(累计{self._easyocr_cuda_oom_count}次),"
f"杀子进程+冷却,{('下次强制CPU' if self._easyocr_cuda_oom_count >= 2 else '保持GPU')}")
try:
if self._easyocr_proc and self._easyocr_proc.poll() is None:
self._easyocr_proc.kill()
self._easyocr_proc.wait(timeout=3)
except Exception:
pass
self._easyocr_proc = None
self._easyocr_retry_until = time.time() + 30
return None
except (BrokenPipeError, ConnectionError, OSError) as e:
logger.debug(f"[ocr] EasyOCR 子进程通信断开: {e},30s 后自动重试")
@@ -1506,11 +1532,11 @@ class MessageMonitor:
# 保存调试截图(在颜色/哈希检查之前,即使跳过也有图可查)
_dbg_path = ""
try:
debug_dir = _get_ocr_debug_dir()
debug_dir = _get_ocr_raw_dir()
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_dbg_path = os.path.join(debug_dir, f"ocr_debug_qq_{ts}.png")
im.save(_dbg_path, format="PNG")
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_debug_qq_")
_clean_old_ocr_debug(debug_dir, prefix="ocr_debug_qq_")
if os.path.exists(_dbg_path):
logger.info(f"[qq] 调试截图已保存: {_dbg_path}")
else:
@@ -1549,7 +1575,7 @@ class MessageMonitor:
logger.debug(f"[ocr] 预处理: resize 2x + sharpen(1.2) + contrast(1.2)")
# 保存OCR处理后的图片(JPG格式)用于肉眼对比
try:
debug_dir = _get_ocr_debug_dir()
debug_dir = _get_ocr_processed_dir()
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_processed_path = os.path.join(debug_dir, f"ocr_processed_qq_{ts}.jpg")
# 转换为RGB模式保存JPG(不支持RGBA)
@@ -1558,7 +1584,7 @@ class MessageMonitor:
else:
im_rgb = im
im_rgb.save(_processed_path, format="JPEG", quality=95)
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_processed_qq_")
_clean_old_ocr_debug(debug_dir, prefix="ocr_processed_qq_")
if os.path.exists(_processed_path):
logger.debug(f"[qq] OCR处理后图片已保存: {_processed_path}")
except Exception as e:
@@ -1639,11 +1665,11 @@ class MessageMonitor:
# 保存调试截图(在颜色/哈希检查之前,即使跳过也有图可查)
_dbg_path = ""
try:
debug_dir = _get_ocr_debug_dir()
debug_dir = _get_ocr_raw_dir()
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_dbg_path = os.path.join(debug_dir, f"ocr_debug_wx_{ts}.png")
im.save(_dbg_path, format="PNG")
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_debug_wx_")
_clean_old_ocr_debug(debug_dir, prefix="ocr_debug_wx_")
if os.path.exists(_dbg_path):
logger.info(f"[wechat] 调试截图已保存: {_dbg_path}")
else:
@@ -1680,7 +1706,7 @@ class MessageMonitor:
im = ImageEnhance.Contrast(im).enhance(1.2) # 降低对比度,保持笔画连贯
# 保存OCR处理后的图片(JPG格式)用于肉眼对比
try:
debug_dir = _get_ocr_debug_dir()
debug_dir = _get_ocr_processed_dir()
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_processed_path = os.path.join(debug_dir, f"ocr_processed_wx_{ts}.jpg")
# 转换为RGB模式保存JPG(不支持RGBA)
@@ -1689,7 +1715,7 @@ class MessageMonitor:
else:
im_rgb = im
im_rgb.save(_processed_path, format="JPEG", quality=95)
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_processed_wx_")
_clean_old_ocr_debug(debug_dir, prefix="ocr_processed_wx_")
if os.path.exists(_processed_path):
logger.debug(f"[wechat] OCR处理后图片已保存: {_processed_path}")
except Exception as e:
@@ -2954,12 +2980,14 @@ class MessageMonitor:
return False
diff = (now - parsed).total_seconds()
# 跨午夜: diff > 12h 说明时间戳来自昨天
# ⚠ 用 timedelta 回退而非 replace(day-1):每月1号 day=1 时 day-1=0 抛
# ValueError("day is out of range for month") → 微信 OCR 整轮失败
if diff > 43200:
parsed = parsed.replace(day=parsed.day - 1)
parsed = parsed - timedelta(days=1)
diff = (now - parsed).total_seconds()
# 未来时间戳: diff < -12h 说明是明天(当前 23:55,时间戳 00:05)
if diff < -43200:
parsed = parsed.replace(day=parsed.day + 1)
parsed = parsed + timedelta(days=1)
diff = (now - parsed).total_seconds()
# OCR 误读兜底:h<12 且判定为老 → 尝试 h+10 / h+12(OCR 丢失前导1:15→5)
if diff > max_age and h < 12:
@@ -4246,40 +4274,39 @@ class MessageMonitor:
self._cached_hwnd.pop(platform, None)
self._cached_pid.pop(platform, None)
def _get_ocr_debug_dir() -> str:
"""返回 OCR 调试截图保存目录。
def _get_ocr_raw_dir() -> str:
"""OCR 原始截图(PNG)保存目录:源码目录下的 img/raw。
放在用户本地数据目录(%LOCALAPPDATA%/windowmsgbot/ocr_debug),
避免在源码目录频繁创建/删除图片被安全软件误报为勒索行为。
统一存源码目录(历史版本即如此,从未被 360 误报),并按 raw / processed
分目录存放,原图与处理后图片互不混杂,便于排查。
"""
try:
local_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
if local_appdata:
debug_dir = os.path.join(local_appdata, "windowmsgbot", "ocr_debug")
else:
debug_dir = os.path.join(os.path.expanduser("~"), "windowmsgbot", "ocr_debug")
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "img", "raw")
os.makedirs(base, exist_ok=True)
except Exception:
debug_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ocr_debug")
base = os.path.dirname(os.path.abspath(__file__))
return base
def _get_ocr_processed_dir() -> str:
"""OCR 处理后图片(JPG)保存目录:源码目录下的 img/processed。"""
try:
os.makedirs(debug_dir, exist_ok=True)
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "img", "processed")
os.makedirs(base, exist_ok=True)
except Exception:
pass
return debug_dir
base = os.path.dirname(os.path.abspath(__file__))
return base
def _get_easyocr_crash_log() -> str:
"""返回 easyocr_worker 崩溃日志路径,必须与 easyocr_worker.py 中 CRASH_LOG 保持一致。
放在 %LOCALAPPDATA%/windowmsgbot/easyocr_crash.log,避免在源码目录写文件(防 360)。
放在源码目录根(easyocr_crash.log),统一管理、便于父进程读取(不写 %LOCALAPPDATA%)。
"""
try:
local_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
if local_appdata:
base = os.path.join(local_appdata, "windowmsgbot")
else:
base = os.path.join(os.path.expanduser("~"), "windowmsgbot")
except Exception:
base = os.path.dirname(os.path.abspath(__file__))
except Exception:
base = "."
return os.path.join(base, "easyocr_crash.log")