diff --git a/requirements.txt b/requirements.txt index ef9b6a2..1cdc0e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # 桌面版依赖(本地电脑安装) -paddleocr>=2.6,<3 -paddlepaddle>=2.5,<3 +# ⚠️ PaddleOCR 3.x 有 PIR+oneDNN 兼容性问题,必须使用 2.x +paddleocr==2.10.0 +paddlepaddle==2.6.2 # 数据处理 pandas diff --git a/scripts/prepare_models.py b/scripts/prepare_models.py index 2ec9cab..688274b 100755 --- a/scripts/prepare_models.py +++ b/scripts/prepare_models.py @@ -25,11 +25,6 @@ def parse_args() -> argparse.Namespace: default="models", help="模型输出目录(默认:models,建议与 exe 同级)", ) - parser.add_argument( - "--show-log", - action="store_true", - help="显示 PaddleOCR 初始化日志(默认关闭)", - ) return parser.parse_args() @@ -39,7 +34,6 @@ def main() -> int: models_dir.mkdir(parents=True, exist_ok=True) # 关键:把 PaddleOCR 默认 base_dir 指到我们指定的 models/ - os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" os.environ["PADDLE_OCR_BASE_DIR"] = str(models_dir) # 延迟导入:确保环境变量在模块加载前生效 @@ -48,8 +42,8 @@ def main() -> int: print(f"将下载/补齐模型到: {models_dir}") print("首次执行需要联网下载(约数百 MB),请耐心等待。") - # 初始化会自动下载 det/rec/cls 模型到 BASE_DIR/whl/... - PaddleOCR(lang="ch", show_log=args.show_log, use_angle_cls=False) + # 初始化会自动下载 det/rec 模型到 BASE_DIR/whl/... + PaddleOCR(lang="ch", use_angle_cls=False, show_log=False) print("完成。你可以将该 models/ 目录随 zip 目录包一起分发(与 exe 同级)。") return 0 diff --git a/src/desktop.py b/src/desktop.py index 3b69e97..19a8517 100644 --- a/src/desktop.py +++ b/src/desktop.py @@ -11,6 +11,7 @@ import time import logging import threading import queue +import subprocess from datetime import datetime from pathlib import Path @@ -26,8 +27,6 @@ from PyQt6.QtGui import QImage, QPixmap, QFont, QAction, QKeySequence, QShortcut from processor import extract_info from ocr_offline import create_offline_ocr, get_models_base_dir -os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" - logger = logging.getLogger("post_ocr.desktop") @@ -122,7 +121,7 @@ class OCRService(QObject): def _ensure_ocr(self) -> None: if self._ocr is None: logger.info("OCR ensure_ocr: 开始创建 PaddleOCR(线程=%s)", threading.current_thread().name) - self._ocr = create_offline_ocr(models_base_dir=self._models_base_dir, show_log=False) + self._ocr = create_offline_ocr(models_base_dir=self._models_base_dir) logger.info("OCR ensure_ocr: PaddleOCR 创建完成") self.ready.emit() @@ -525,7 +524,13 @@ class MainWindow(QMainWindow): def load_cameras(self): """扫描可用摄像头""" self.cam_combo.clear() - # macOS 上设备编号会变化(尤其“连续互通相机”/虚拟摄像头),这里多扫一些更稳。 + + # 始终提供手机 MJPEG 流入口(Android 端 MjpegServer 默认端口 8080) + # 使用前需:1) USB 连接手机 2) adb forward tcp:8080 tcp:8080 + mjpeg_url = os.environ.get("POST_OCR_MJPEG_URL", "http://localhost:8080").strip() + self.cam_combo.addItem(f"📱 手机摄像头 (USB)", mjpeg_url) + + # macOS 上设备编号会变化(尤其"连续互通相机"/虚拟摄像头),这里多扫一些更稳。 # 若你想减少探测范围,可设置环境变量 POST_OCR_MAX_CAMERAS,例如:POST_OCR_MAX_CAMERAS=3 try: max_probe = int(os.environ.get("POST_OCR_MAX_CAMERAS", "").strip() or "10") @@ -560,26 +565,93 @@ class MainWindow(QMainWindow): pass if found == 0: - # 自动探测可能因权限/占用/设备延迟失败;仍提供手动尝试入口,避免用户被“无设备”卡住 - for i in range(max_probe): + # 自动探测失败时,仅提供少量手动入口(0~2),避免列出大量不存在的设备误导用户 + fallback_count = min(3, max_probe) + for i in range(fallback_count): self.cam_combo.addItem(f"摄像头 {i}(手动尝试)", i) - self.statusBar().showMessage( - "未能自动检测到可用摄像头。" - "如为 macOS,请在 系统设置->隐私与安全->相机 中允许当前终端/应用访问;" - "并确保 iPhone 已解锁且未被其他应用占用。" - ) + if sys.platform == "win32": + hint = ( + "未检测到摄像头。请确认:1) 已连接摄像头或已启动 Droidcam/Iriun;" + "2) 其他应用未占用摄像头;3) 可手动选择编号后点击「连接」尝试。" + ) + else: + hint = ( + "未检测到摄像头。" + "macOS 请在 系统设置->隐私与安全->相机 中允许访问;" + "并确保 iPhone 已解锁且未被其他应用占用。" + ) + self.statusBar().showMessage(hint) else: self.statusBar().showMessage(f"检测到 {found} 个摄像头") logger.info("摄像头扫描结束:found=%s", found) - def _open_capture(self, cam_id: int): + def _adb_forward(self, local_port: int = 8080, remote_port: int = 8080) -> bool: + """自动执行 adb forward,将手机端口映射到本地。成功返回 True。""" + cmd = ["adb", "forward", f"tcp:{local_port}", f"tcp:{remote_port}"] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if r.returncode == 0: + logger.info("adb forward 成功:%s", " ".join(cmd)) + return True + # adb 存在但执行失败(如无设备) + stderr = (r.stderr or "").strip() + logger.warning("adb forward 失败(rc=%s): %s", r.returncode, stderr) + QMessageBox.warning( + self, + "ADB 端口转发失败", + f"执行 adb forward 失败:\n{stderr}\n\n" + "排查建议:\n" + "1) 手机通过 USB 数据线连接电脑\n" + "2) 手机开启 USB 调试(开发者选项)\n" + "3) 首次连接时在手机上点击「允许 USB 调试」\n", + ) + return False + except FileNotFoundError: + logger.warning("adb 未找到,请确认已安装 Android SDK Platform-Tools") + QMessageBox.warning( + self, + "未找到 ADB", + "未找到 adb 命令。\n\n" + "请安装 Android SDK Platform-Tools 并确保 adb 在 PATH 中。\n" + "下载地址:https://developer.android.com/tools/releases/platform-tools", + ) + return False + except subprocess.TimeoutExpired: + logger.warning("adb forward 超时") + QMessageBox.warning(self, "ADB 超时", "adb forward 执行超时,请检查 USB 连接。") + return False + + def _open_capture(self, cam_id): """ 打开摄像头。 - macOS 上优先使用 AVFoundation 后端(对“连续互通相机”等更友好)。 + cam_id 可以是: + - int: 本地摄像头索引(0, 1, 2...) + - str: MJPEG 流 URL(如 http://localhost:8080) + + 本地摄像头: + - Windows 优先使用 DirectShow 后端(更快更稳定) + - macOS 优先使用 AVFoundation 后端(对"连续互通相机"等更友好) """ - if sys.platform == "darwin" and hasattr(cv2, "CAP_AVFOUNDATION"): + # MJPEG 流 URL:直接用 OpenCV 打开 + if isinstance(cam_id, str): + logger.info("打开 MJPEG 流:%s", cam_id) + return cv2.VideoCapture(cam_id) + + if sys.platform == "win32" and hasattr(cv2, "CAP_DSHOW"): + cap = cv2.VideoCapture(cam_id, cv2.CAP_DSHOW) + try: + if cap is not None and cap.isOpened(): + return cap + except Exception: + pass + try: + if cap is not None: + cap.release() + except Exception: + pass + elif sys.platform == "darwin" and hasattr(cv2, "CAP_AVFOUNDATION"): cap = cv2.VideoCapture(cam_id, cv2.CAP_AVFOUNDATION) try: if cap is not None and cap.isOpened(): @@ -597,9 +669,22 @@ class MainWindow(QMainWindow): """连接/断开摄像头""" if self.cap is None: cam_id = self.cam_combo.currentData() - if cam_id is None or cam_id < 0: + if cam_id is None: QMessageBox.warning(self, "错误", "请先选择有效的摄像头") return + # int 类型的 cam_id 需 >= 0;str 类型为 MJPEG URL + if isinstance(cam_id, int) and cam_id < 0: + QMessageBox.warning(self, "错误", "请先选择有效的摄像头") + return + + is_mjpeg = isinstance(cam_id, str) + if is_mjpeg: + self.statusBar().showMessage("正在设置 ADB 端口转发...") + QApplication.processEvents() + if not self._adb_forward(): + return + self.statusBar().showMessage(f"正在连接手机摄像头 {cam_id} ...") + QApplication.processEvents() self.cap = self._open_capture(cam_id) @@ -618,15 +703,25 @@ class MainWindow(QMainWindow): if not ok: self.cap.release() self.cap = None - QMessageBox.warning( - self, - "摄像头无画面", - "摄像头已打开,但读取不到画面。\n\n" - "排查建议:\n" - "1) macOS:系统设置 -> 隐私与安全 -> 相机,允许当前运行的终端/应用访问\n" - "2) 连续互通相机:保持 iPhone 解锁并靠近 Mac,且未被其他应用占用\n" - "3) 依次切换“摄像头 0/1/2”尝试\n", - ) + if is_mjpeg: + QMessageBox.warning( + self, + "手机摄像头无画面", + "已连接但读取不到画面。\n\n" + "排查建议:\n" + "1) 确认手机端 App 已点击「启动」\n" + "2) 确认已执行:adb forward tcp:8080 tcp:8080\n" + "3) 检查 USB 线是否为数据线(非纯充电线)\n", + ) + else: + QMessageBox.warning( + self, + "摄像头无画面", + "摄像头已打开,但读取不到画面。\n\n" + "排查建议:\n" + "1) 确认摄像头未被其他应用占用\n" + "2) 依次切换「摄像头 0/1/2」尝试\n", + ) return self.timer.start(30) # ~33 FPS @@ -636,16 +731,27 @@ class MainWindow(QMainWindow): self.statusBar().showMessage("摄像头已连接") else: self.cap = None - QMessageBox.warning( - self, - "无法打开摄像头", - "无法打开摄像头。\n\n" - "排查建议:\n" - "1) macOS:系统设置 -> 隐私与安全 -> 相机,允许当前运行的终端/应用访问\n" - "2) 如果有其他应用正在使用摄像头(微信/会议软件/浏览器),请先退出再试\n" - "3) 连续互通相机:保持 iPhone 解锁并靠近 Mac,且未被其他应用占用\n" - "4) 在下拉框中切换不同编号(0/1/2/3...)重试\n", - ) + if is_mjpeg: + QMessageBox.warning( + self, + "无法连接手机摄像头", + f"无法连接 {cam_id}\n\n" + "排查步骤:\n" + "1) 手机通过 USB 数据线连接电脑\n" + "2) 手机开启 USB 调试(开发者选项)\n" + "3) 手机端 App 点击「启动」\n" + "4) 电脑终端执行:adb forward tcp:8080 tcp:8080\n" + "5) 再点击「连接」\n", + ) + else: + QMessageBox.warning( + self, + "无法打开摄像头", + "无法打开摄像头。\n\n" + "排查建议:\n" + "1) 确认摄像头未被其他应用占用\n" + "2) 在下拉框中切换不同编号重试\n", + ) else: self.timer.stop() self.cap.release() diff --git a/src/ocr_offline.py b/src/ocr_offline.py index 2af82d0..2b3ad3f 100644 --- a/src/ocr_offline.py +++ b/src/ocr_offline.py @@ -2,44 +2,30 @@ """ 离线 OCR 初始化工具 -目标: -1. Windows 交付 zip 目录包时,模型随包携带,程序完全离线可用 -2. 如果模型缺失,明确报错并阻止 PaddleOCR 自动联网下载 -3. 统一桌面版 / Web 版 / 命令行的 OCR 初始化逻辑,避免参数漂移 +适配 PaddleOCR 2.10.0(PP-OCRv4)。 + +模型默认缓存在 ~/.paddleocr/whl/,首次运行会自动下载。 """ from __future__ import annotations import os import sys -from dataclasses import dataclass from pathlib import Path import logging -@dataclass(frozen=True) -class OCRModelPaths: - """PP-OCRv4(中文)模型目录结构(对应 paddleocr==2.10.0 默认下载结构)""" - - base_dir: Path - det_dir: Path - rec_dir: Path - cls_dir: Path - - def _is_frozen() -> bool: """判断是否为 PyInstaller 打包后的运行环境""" - return bool(getattr(sys, "frozen", False)) def get_app_base_dir() -> Path: """ - 获取“应用根目录”: + 获取"应用根目录": - 开发态:项目根目录(src 的上一级) - 打包态:exe 所在目录 """ - if _is_frozen(): return Path(sys.executable).resolve().parent return Path(__file__).resolve().parent.parent @@ -47,26 +33,10 @@ def get_app_base_dir() -> Path: def get_models_base_dir(app_base_dir: Path | None = None) -> Path: """默认模型目录:与应用同级的 models/""" - base = app_base_dir or get_app_base_dir() return base / "models" -def get_ppocr_v4_ch_model_paths(models_base_dir: Path | None = None) -> OCRModelPaths: - """ - 返回 PP-OCRv4(中文)默认模型目录。 - - 注意:这里的目录结构与 PaddleOCR 2.x 默认下载到 ~/.paddleocr 的结构一致, - 只是我们把 BASE_DIR 指向了随包的 models/,从而实现离线。 - """ - - base = models_base_dir or get_models_base_dir() - det_dir = base / "whl" / "det" / "ch" / "ch_PP-OCRv4_det_infer" - rec_dir = base / "whl" / "rec" / "ch" / "ch_PP-OCRv4_rec_infer" - cls_dir = base / "whl" / "cls" / "ch_ppocr_mobile_v2.0_cls_infer" - return OCRModelPaths(base_dir=base, det_dir=det_dir, rec_dir=rec_dir, cls_dir=cls_dir) - - def _configure_windows_dll_search_path(app_base_dir: Path) -> None: """ Windows 下 PaddlePaddle 依赖的 mkml.dll 等动态库,通常位于打包目录的: @@ -74,11 +44,9 @@ def _configure_windows_dll_search_path(app_base_dir: Path) -> None: 某些情况下动态库加载不会自动命中该路径(error code 126),需要显式加入 DLL 搜索路径。 """ - if not sys.platform.startswith("win"): return - # Python 3.8+ on Windows 支持 os.add_dll_directory add_dll_dir = getattr(os, "add_dll_directory", None) internal_dir = app_base_dir / "_internal" @@ -89,7 +57,6 @@ def _configure_windows_dll_search_path(app_base_dir: Path) -> None: app_base_dir, ] - # 同时设置 PATH,兼容不走 add_dll_directory 的加载路径 path_parts = [os.environ.get("PATH", "")] for p in candidates: if p.exists(): @@ -97,82 +64,30 @@ def _configure_windows_dll_search_path(app_base_dir: Path) -> None: try: add_dll_dir(str(p)) except Exception: - # add_dll_directory 在某些权限/路径场景可能失败,PATH 兜底 pass path_parts.insert(0, str(p)) os.environ["PATH"] = ";".join([x for x in path_parts if x]) -def _check_infer_dir(dir_path: Path) -> bool: - """判断一个推理模型目录是否完整(至少包含 inference.pdmodel / inference.pdiparams)""" - - return (dir_path / "inference.pdmodel").exists() and (dir_path / "inference.pdiparams").exists() - - -def verify_offline_models_or_raise(model_paths: OCRModelPaths) -> None: +def create_offline_ocr(models_base_dir: Path | None = None): """ - 校验离线模型是否存在。 + 创建 PaddleOCR 2.x 实例(PP-OCRv4 中文)。 - 设计选择: - - 直接抛异常:由上层(桌面/UI/CLI)决定如何展示错误 - - 不允许缺失时继续初始化:避免触发 PaddleOCR 自动联网下载 + 首次运行会自动下载模型到 ~/.paddleocr/whl/。 """ - - missing = [] - if not _check_infer_dir(model_paths.det_dir): - missing.append(str(model_paths.det_dir)) - if not _check_infer_dir(model_paths.rec_dir): - missing.append(str(model_paths.rec_dir)) - if not _check_infer_dir(model_paths.cls_dir): - missing.append(str(model_paths.cls_dir)) - - if missing: - hint = ( - "离线模型缺失,无法在离线模式启动。\n\n" - "缺失目录:\n- " - + "\n- ".join(missing) - + "\n\n" - "解决方式:\n" - "1) 在有网机器执行:python scripts/prepare_models.py --models-dir models\n" - "2) 将生成的 models/ 目录随 zip 包一起分发(与 exe 同级)" - ) - raise FileNotFoundError(hint) - - -def create_offline_ocr(models_base_dir: Path | None = None, show_log: bool = False): - """ - 创建 PaddleOCR(离线模式)。 - - 关键点: - - 通过环境变量 PADDLE_OCR_BASE_DIR 将默认下载/查找目录指向随包 models/(与 paddleocr==2.10.0 行为匹配) - - 显式传入 det/rec/cls 的模型目录,避免目录不一致导致重复下载 - - 如果模型缺失,提前报错,阻止联网下载 - """ - log = logging.getLogger("post_ocr.ocr") - model_paths = get_ppocr_v4_ch_model_paths(models_base_dir=models_base_dir) - verify_offline_models_or_raise(model_paths) - # Windows 打包运行时,先配置 DLL 搜索路径,避免 mkml.dll 等加载失败(error code 126) + # Windows 打包运行时,先配置 DLL 搜索路径 _configure_windows_dll_search_path(get_app_base_dir()) - # 禁用联网检查(加快启动),并把默认 base_dir 指向随包 models/ - os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" - os.environ["PADDLE_OCR_BASE_DIR"] = str(model_paths.base_dir) + log.info("create_offline_ocr: importing paddleocr") + from paddleocr import PaddleOCR - # 延迟导入:确保环境变量在 paddleocr 模块加载前设置生效 - log.info("create_offline_ocr: importing paddleocr (base_dir=%s)", str(model_paths.base_dir)) - from paddleocr import PaddleOCR # pylint: disable=import-error - - # 注意:paddleocr==2.10.0 不支持 use_textline_orientation 这类 3.x pipeline 参数 - log.info("create_offline_ocr: creating PaddleOCR(det=%s, rec=%s)", str(model_paths.det_dir), str(model_paths.rec_dir)) + log.info("create_offline_ocr: creating PaddleOCR(lang=ch)") ocr = PaddleOCR( lang="ch", - show_log=show_log, use_angle_cls=False, - det_model_dir=str(model_paths.det_dir), - rec_model_dir=str(model_paths.rec_dir), - cls_model_dir=str(model_paths.cls_dir), + show_log=False, ) log.info("create_offline_ocr: PaddleOCR created") return ocr