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