diff --git a/gui.py b/gui.py index 11712f6..7807a36 100644 --- a/gui.py +++ b/gui.py @@ -9,6 +9,8 @@ import threading import os import re import time +import gc +import ctypes import customtkinter as ctk @@ -20,6 +22,83 @@ from auto_reply import AutoReply logger = logging.getLogger("msgbot.gui") +def _force_system_memory_cleanup(): + """强制回收Python进程占用的内存归还系统调度。 + + Windows API: + - EmptyWorkingSet: 清空工作集,强制归还物理内存 + - SetProcessWorkingSetSize: 设置工作集大小为最小值,触发系统回收 + """ + try: + # 获取当前进程句柄 + kernel32 = ctypes.windll.kernel32 + psapi = ctypes.windll.psapi + + # 获取当前进程句柄 + h_process = kernel32.GetCurrentProcess() + + # 方法1: EmptyWorkingSet - 清空工作集 + try: + psapi.EmptyWorkingSet(h_process) + logger.debug("系统内存清理: EmptyWorkingSet 完成") + except Exception as e: + logger.debug(f"EmptyWorkingSet 失败: {e}") + + # 方法2: SetProcessWorkingSetSize - 设置最小工作集 + try: + # dwMinimumWorkingSetSize=1, dwMaximumWorkingSetSize=-1 (无限) + # 设置最小值为1字节,最大值为无限,让系统自由回收 + kernel32.SetProcessWorkingSetSize(h_process, 1, -1) + logger.debug("系统内存清理: SetProcessWorkingSetSize 完成") + except Exception as e: + logger.debug(f"SetProcessWorkingSetSize 失败: {e}") + + # Python GC + torch CUDA 清理 + try: + import torch + if torch.cuda.is_available(): + torch.cuda.empty_cache() + torch.cuda.synchronize() + logger.debug("系统内存清理: torch.cuda.empty_cache 完成") + except ImportError: + pass # torch 未安装 + except Exception as e: + logger.debug(f"torch.cuda.empty_cache 失败: {e}") + + # Python GC 强制回收 + collected = gc.collect() + logger.debug(f"系统内存清理: gc.collect 回收 {collected} 个对象") + + except Exception as e: + logger.debug(f"系统内存清理异常: {e}") + + +def _kill_easyocr_subprocesses(): + """杀死所有EasyOCR子进程,彻底释放GPU/CPU内存。""" + try: + import psutil + current_pid = os.getpid() + killed = 0 + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + # EasyOCR子进程通常以python启动,cmdline包含easyocr + if proc.pid == current_pid: + continue + cmdline = ' '.join(proc.cmdline() or []) + if 'easyocr' in cmdline.lower() or 'ocr_worker' in cmdline.lower(): + proc.kill() + killed += 1 + logger.debug(f"杀死EasyOCR子进程: PID={proc.pid}") + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + if killed: + logger.debug(f"共杀死 {killed} 个EasyOCR子进程") + except ImportError: + pass + except Exception as e: + logger.debug(f"杀死EasyOCR子进程异常: {e}") + + class OCREngineManager: """双引擎OCR管理器:EasyOCR + Windows OCR,得分最高者胜出。 @@ -191,18 +270,29 @@ class OCREngineManager: self._timer_thread.start() def _release(self): - """释放OCR引擎资源""" + """释放OCR引擎资源 + 杀EasyOCR子进程 + 系统内存回收""" with self._lock: self._easyocr_reader = None self._winocr_engine = None self._initialized = False self._log("🗑 OCR引擎已释放") + # 杀EasyOCR子进程(彻底释放GPU内存) + _kill_easyocr_subprocesses() + + # 系统级内存回收 + _force_system_memory_cleanup() + self._log("🧹 系统内存已回收") + def set_monitor_active(self, active: bool): """设置消息监控激活状态""" self._monitor_active = active if active and self._initialized: self._last_use_time = time.time() # 激活时重置计时 + elif not active and self._initialized: + # 监控停止 → 立即释放OCR(杀子进程+系统内存回收) + self._log("⏸ 监控停止,立即释放OCR内存...") + self._release() def recognize(self, image) -> list: """双引擎并发识别,返回得分最高的结果 @@ -463,6 +553,10 @@ class MsgBotApp(ctk.CTk): # 自动回复执行队列(转发到主线程,避免 daemon 线程 SetForegroundWindow 失败) self._reply_queue: queue.Queue[dict] = queue.Queue() + # ⭐ 启动时系统内存清理(强制回收Python进程内存) + self._log("🧹 启动内存清理...") + threading.Thread(target=_force_system_memory_cleanup, daemon=True).start() + # ⭐ OCR引擎管理器(双引擎:EasyOCR + Windows OCR) self._ocr_manager = OCREngineManager(log_callback=self._log) # 预初始化OCR引擎(后台线程,不阻塞UI) @@ -1325,11 +1419,18 @@ class MsgBotApp(ctk.CTk): # ── 窗口关闭 ── def _on_close(self): - # ⭐ 关闭窗口同样把 stop 放到后台线程,完成后再关闭,避免主线程冻结。 + # ⭐ 关闭窗口:释放OCR + 系统内存清理 → stop → destroy def _stop_then_close(): + try: + # 释放OCR引擎(杀子进程+系统内存回收) + self._ocr_manager._release() + except Exception: + pass try: self.monitor.stop() except Exception: pass + # 最终系统内存清理 + _force_system_memory_cleanup() self.after(0, self.destroy) threading.Thread(target=_stop_then_close, daemon=True, name="gui-stop-close").start() diff --git a/main.py b/main.py index c3993b6..7c46eb9 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import logging import os import sys import signal +import threading # ── Worker 模式检测:PyInstaller 打包后,exe 被调用来运行 worker 脚本时, # 通过环境变量区分,避免 bootloader 执行 main() 导致闪退 ── @@ -53,10 +54,26 @@ def main(): app = MsgBotApp() # Ctrl+C 信号处理:Windows Tkinter mainloop 不响应 KeyboardInterrupt - # 需 signal handler 强制停止监控 + 退出 + # 需 signal handler 强制停止监控 + 释放内存 + 退出 def _sigint_handler(sig, frame): logger.info("收到 Ctrl+C,正在关闭...") - app.monitor.stop() + # 后台线程执行内存回收,不阻塞退出 + def _cleanup_in_background(): + try: + # 释放OCR引擎(杀子进程+系统内存回收) + app._ocr_manager._release() + except Exception: + pass + try: + app.monitor.stop() + except Exception: + pass + # 系统内存清理 + from gui import _force_system_memory_cleanup + _force_system_memory_cleanup() + logger.info("后台内存回收完成") + threading.Thread(target=_cleanup_in_background, daemon=True, name="sigint-cleanup").start() + # 立即退出,不等待内存回收完成 app.quit() sys.exit(0) diff --git a/monitor.py b/monitor.py index 003cda8..7c561ef 100644 --- a/monitor.py +++ b/monitor.py @@ -2048,14 +2048,17 @@ class MessageMonitor: OCR 输出有噪声(每次勖彐碎片、行首数字、尾部截断不同),精确匹配无法去重。 用 difflib.SequenceMatcher 的 quick_ratio() 做快速上界剪枝。 + ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写。 """ if not new_text or len(new_text) < 4: return False + new_text_lower = new_text.lower() for existing in seen_set: if abs(len(existing) - len(new_text)) > max(len(existing), len(new_text)) * 0.4: # 长度差距超 40%,不可能相似(quick_ratio 上界 = 2*min/max < 阈值) continue - ratio = difflib.SequenceMatcher(None, new_text, existing).quick_ratio() + # 使用小写文本进行比较 + ratio = difflib.SequenceMatcher(None, new_text_lower, existing.lower()).quick_ratio() if ratio >= threshold: return True return False @@ -8893,15 +8896,17 @@ class MessageMonitor: # 剥离后继续走后续过滤(不 continue),让干净的用户提问正常进入 new_items # ⭐ 近5分钟时间窗口查重:如果消息在近5分钟的bot回复中,跳过 # ⭐ v2:拆行匹配——recent_replies存完整多行回复,OCR只读首行 + # ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写 if recent_replies: _in_recent = False + ts_lower = ts.lower() for _rr in recent_replies: - if ts in _rr: + if ts_lower in _rr.lower(): _in_recent = True break # 拆行:OCR读到的可能是bot回复的某一行 for _rr_line in _rr.split('\n'): - if ts.strip() == _rr_line.strip(): + if ts_lower == _rr_line.strip().lower(): _in_recent = True break if _in_recent: @@ -8910,7 +8915,8 @@ class MessageMonitor: logger.debug(f"[{platform}] 时间窗口查重跳过(近5分钟): {ts[:50]}") continue # _replied_texts 优先检查(永不裁切,100% 拦住自己的回复) - if ts in replied: + # ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写 + if any(ts.lower() == r.lower() for r in replied): continue # _replied_texts 模糊匹配:OCR 噪声导致精确匹配失效(长文本尤甚) if self._fuzzy_duplicate(ts, replied, threshold=0.75): @@ -8982,13 +8988,15 @@ class MessageMonitor: # _replied_texts 子串包含:OCR 提取的 md 表格细胞值(如"依赖插件可选/必选") # 是 bot 回复表的一部分,不是用户提问。长度>5才检查避免误杀短消息。 # OCR 常把 ,转 , 等标点差异,做双向子串检查(原始+归一化标点) + # ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写 if len(ts) > 5: - if any(ts in r for r in replied): + ts_lower = ts.lower() + if any(ts_lower in r.lower() for r in replied): logger.debug(f"[{platform}] 回复子串包含跳过({len(ts)}c): {ts[:50]}") continue # 归一化标点后重试(OCR 噪声:,→, 。→. !→! ?→?) norm_ts = ts.replace(',', ',').replace('.', '。').replace('!', '!').replace('?', '?') - if norm_ts != ts and any(norm_ts in r for r in replied): + if norm_ts != ts and any(norm_ts.lower() in r.lower() for r in replied): logger.debug(f"[{platform}] 回复子串包含跳过(归一化,{len(ts)}c): {ts[:50]}") continue # _replied_texts 长公共子串检测:OCR 截取的碎片开头/结尾有噪声,精确/子串匹配失效 @@ -8996,16 +9004,18 @@ class MessageMonitor: # ⭐ Task #1534: 问句豁免——用户提问即使与旧回复有部分匹配也放行 # 例:"PHP默认的那管理后台管理密码是多少,php有2种登陆模式..." # 该问句与旧回复共享"php有2种登陆模式"子串,但不应被跳过 + # ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写 is_question = bool(re.search(r'[??]', ts)) or any(qw in ts for qw in ('吗', '呢', '多少', '怎么', '如何', '哪', '什么')) if len(ts) > 8 and not is_question: import difflib matched_r = False + ts_lower = ts.lower() # 第一轮:原始文本匹配(阈值 6,OCR 噪声导致连续匹配可能中断) for r in replied: if len(r) < 5: continue - sm = difflib.SequenceMatcher(None, ts, r) - if sm.find_longest_match(0, len(ts), 0, len(r)).size >= 6: + sm = difflib.SequenceMatcher(None, ts_lower, r.lower()) + if sm.find_longest_match(0, len(ts_lower), 0, len(r.lower())).size >= 6: matched_r = True break # 第二轮:CJK-only 降阈值到 5(去除标点空格干扰后更稳定) @@ -9230,7 +9240,8 @@ class MessageMonitor: _last_reply_lines = [last_reply] _blocked_by_reply = False for _lr in _last_reply_lines: - similarity = SequenceMatcher(None, cleaned_item, _lr).ratio() + # ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写 + similarity = SequenceMatcher(None, cleaned_item.lower(), _lr.lower()).ratio() if similarity >= 0.65: logger.info(f"[{platform}] OCR噪声检测: 与最近bot回复相似度{similarity:.2%},跳过: {cleaned_item[:60]}") _blocked_by_reply = True