diff --git a/.gitignore b/.gitignore
index 26e1e18..af33a29 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,9 @@ __pycache__/
.DS_Store
.venv/
venv/
+.serena/
+models/*
+!models/.gitkeep
+usb_bundle/
+
diff --git a/README.md b/README.md
index 2a28e97..fff366f 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
- 自动识别信封图片中的文字信息
- 结构化提取:编号、邮编、地址、联系人、电话
- 支持批量处理,结果导出为 Excel
-- 提供 Web 界面,操作简单
+- 提供桌面应用,支持摄像头实时拍照识别
## 系统要求
@@ -41,76 +41,51 @@ python src/main.py
# 结果保存在 data/output/result.xlsx
```
-**Web 界面**
+**桌面应用**
```bash
-streamlit run src/app.py --server.port 8501
+python src/desktop.py
-# 浏览器访问 http://localhost:8501
+# 启动 PyQt6 窗口,可选择摄像头实时拍照识别
```
-## 部署方案
+---
-### 方案一:内网服务器部署(推荐)
+## Windows 桌面离线版(zip 目录包)
-适合多人使用,有内网环境的工厂。
+本项目桌面版入口为 `src/desktop.py`(PyQt6 + OpenCV),适合现场工位离线使用。
+
+### 1. 准备离线模型(在有网机器执行一次)
```bash
-# 启动服务(监听所有网卡)
-streamlit run src/app.py --server.address 0.0.0.0 --server.port 8501
-
-# 工人通过浏览器访问: http://服务器IP:8501
+pip install -r requirements.txt
+python scripts/prepare_models.py --models-dir models
```
-### 方案二:Docker 容器化部署
+执行完成后会生成 `models/whl/...` 目录结构;该 `models/` 目录需要与最终的 exe 同级分发。
-适合需要隔离环境或快速部署的场景。
+### 2. Windows 打包(建议使用 PyInstaller 的 onedir)
-```bash
-# 构建镜像
-docker build -t envelope-ocr .
+请在 Windows 机器上构建 Windows 包(不要跨平台交叉打包)。
-# 运行容器
-docker run -d -p 8501:8501 --name envelope-ocr envelope-ocr
+```powershell
+pip install -r requirements.txt
+pip install pyinstaller
+
+pyinstaller --noconfirm --clean --windowed --onedir `
+ --name "post-ocr-desktop" `
+ --paths "src" `
+ --collect-all "Cython" `
+ --collect-all "paddleocr" `
+ --collect-all "paddle" `
+ --add-data "models;models" `
+ "src/desktop.py"
```
-Dockerfile:
-```dockerfile
-FROM python:3.10-slim
-RUN apt-get update && apt-get install -y libgl1-mesa-glx libglib2.0-0 && rm -rf /var/lib/apt/lists/*
-WORKDIR /app
-COPY . .
-RUN pip install --no-cache-dir -r requirements.txt
-EXPOSE 8501
-CMD ["streamlit", "run", "src/app.py", "--server.address", "0.0.0.0"]
-```
+打包完成后,将 `dist\post-ocr-desktop\` 整个目录压缩为 zip 交付即可。
-### 方案三:系统服务(开机自启)
-
-适合长期稳定运行的生产环境。
-
-创建服务文件 `/etc/systemd/system/envelope-ocr.service`:
-```ini
-[Unit]
-Description=Envelope OCR Service
-After=network.target
-
-[Service]
-User=www-data
-WorkingDirectory=/opt/post-ocr
-ExecStart=/usr/bin/streamlit run src/app.py --server.address 0.0.0.0 --server.port 8501
-Restart=always
-RestartSec=5
-
-[Install]
-WantedBy=multi-user.target
-```
-
-启用服务:
-```bash
-sudo systemctl daemon-reload
-sudo systemctl enable envelope-ocr
-sudo systemctl start envelope-ocr
-```
+注意:
+- 本项目默认使用 PaddleOCR 2.10.0(PP-OCRv4 中文)离线模型目录结构
+- 若 `models/` 缺失,程序会直接报错提示,避免触发联网下载
## 目录结构
@@ -121,7 +96,7 @@ post-ocr/
│ └── output/ # 结果 Excel 及处理日志
├── src/
│ ├── main.py # 命令行入口
-│ ├── app.py # Web 界面
+│ ├── desktop.py # 桌面应用入口
│ └── processor.py # 核心处理逻辑
├── requirements.txt
└── README.md
@@ -130,7 +105,7 @@ post-ocr/
## 技术栈
- OCR 引擎: PaddleOCR 2.10 (PP-OCRv4)
-- Web 框架: Streamlit
+- 桌面框架: PyQt6
- 数据处理: Pandas
## 常见问题
diff --git a/heartbeat.py b/heartbeat.py
deleted file mode 100644
index 1373da2..0000000
--- a/heartbeat.py
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/usr/bin/env python3
-"""心跳程序 - 保持服务活跃"""
-import sys
-import time
-import subprocess
-import requests
-from datetime import datetime
-
-# 禁用输出缓冲
-sys.stdout.reconfigure(line_buffering=True)
-
-
-def log(msg):
- print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}", flush=True)
-
-
-def check_streamlit():
- """检查 Streamlit 服务"""
- try:
- r = requests.get("http://localhost:8501", timeout=5)
- return r.status_code == 200
- except:
- return False
-
-
-def restart_streamlit():
- """重启 Streamlit"""
- subprocess.run(["pkill", "-f", "streamlit run"], capture_output=True)
- time.sleep(2)
- subprocess.Popen(
- ["streamlit", "run", "src/app.py", "--server.port", "8501", "--server.address", "0.0.0.0"],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
- print(f"[{datetime.now():%H:%M:%S}] Streamlit 已重启")
-
-
-def main():
- log("心跳程序启动")
-
- while True:
- if not check_streamlit():
- log("Streamlit 无响应,正在重启...")
- restart_streamlit()
- time.sleep(10)
- else:
- log("✓ 服务正常")
-
- time.sleep(60) # 每分钟检查一次
-
-
-if __name__ == "__main__":
- main()
diff --git a/requirements-desktop.txt b/requirements-desktop.txt
deleted file mode 100644
index 4931fca..0000000
--- a/requirements-desktop.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-# 桌面版依赖(本地电脑安装)
-paddleocr>=2.6,<3
-paddlepaddle>=2.5,<3
-pandas
-openpyxl
-pydantic
-PyQt6
-opencv-python
diff --git a/requirements.txt b/requirements.txt
index feb491a..4931fca 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,8 @@
+# 桌面版依赖(本地电脑安装)
paddleocr>=2.6,<3
paddlepaddle>=2.5,<3
pandas
openpyxl
pydantic
-tqdm
-streamlit
+PyQt6
+opencv-python
diff --git a/scripts/camera_probe.py b/scripts/camera_probe.py
new file mode 100755
index 0000000..9dd9d32
--- /dev/null
+++ b/scripts/camera_probe.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+摄像头探测脚本(用于排查 macOS/iPhone 连续互通相机无画面问题)
+
+用法:
+ source .venv/bin/activate
+ python scripts/camera_probe.py
+
+输出:
+- 列出 0~9 号摄像头是否可打开、是否可读到有效帧、帧尺寸与亮度均值
+"""
+
+from __future__ import annotations
+
+import sys
+
+
+def open_cap(cv2, cam_id: int):
+ if sys.platform == "darwin" and hasattr(cv2, "CAP_AVFOUNDATION"):
+ return cv2.VideoCapture(cam_id, cv2.CAP_AVFOUNDATION)
+ return cv2.VideoCapture(cam_id)
+
+
+def main() -> int:
+ import cv2 # pylint: disable=import-error
+
+ print(f"平台: {sys.platform}")
+ print(f"OpenCV: {cv2.__version__}")
+ print("")
+
+ found_any = False
+ for cam_id in range(10):
+ cap = open_cap(cv2, cam_id)
+ opened = cap.isOpened()
+ ok = False
+ shape = None
+ mean = None
+ if opened:
+ for _ in range(30):
+ ret, frame = cap.read()
+ if ret and frame is not None and frame.size > 0:
+ ok = True
+ shape = frame.shape
+ mean = float(frame.mean())
+ break
+ cap.release()
+
+ if opened:
+ found_any = True
+ status = "OK" if ok else ("打开但无画面" if opened else "无法打开")
+ print(f"摄像头 {cam_id}: {status}", end="")
+ if ok:
+ print(f" | shape={shape} | mean={mean:.1f}")
+ else:
+ print("")
+
+ if not found_any:
+ print("\n未检测到可打开的摄像头。")
+ else:
+ print("\n如果出现“打开但无画面”,优先检查 macOS 相机权限。")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
diff --git a/scripts/prepare_models.py b/scripts/prepare_models.py
new file mode 100755
index 0000000..2ec9cab
--- /dev/null
+++ b/scripts/prepare_models.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+离线模型准备脚本(建议在“有网机器”执行一次)
+
+用途:
+- 将 PaddleOCR 2.10.0(PP-OCRv4 中文)所需模型下载到指定 models/ 目录
+- 该 models/ 目录可直接随 Windows zip 目录包分发,实现完全离线运行
+
+设计说明:
+- 脚本只做“下载/补齐”,不做删除或覆盖,避免误删用户已有模型(高风险操作)
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+from pathlib import Path
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="准备 post-ocr 离线模型(PP-OCRv4 中文)")
+ parser.add_argument(
+ "--models-dir",
+ default="models",
+ help="模型输出目录(默认:models,建议与 exe 同级)",
+ )
+ parser.add_argument(
+ "--show-log",
+ action="store_true",
+ help="显示 PaddleOCR 初始化日志(默认关闭)",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ models_dir = Path(args.models_dir).resolve()
+ 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)
+
+ # 延迟导入:确保环境变量在模块加载前生效
+ from paddleocr import PaddleOCR # pylint: disable=import-error
+
+ print(f"将下载/补齐模型到: {models_dir}")
+ print("首次执行需要联网下载(约数百 MB),请耐心等待。")
+
+ # 初始化会自动下载 det/rec/cls 模型到 BASE_DIR/whl/...
+ PaddleOCR(lang="ch", show_log=args.show_log, use_angle_cls=False)
+
+ print("完成。你可以将该 models/ 目录随 zip 目录包一起分发(与 exe 同级)。")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/app.py b/src/app.py
deleted file mode 100644
index c6e2dd1..0000000
--- a/src/app.py
+++ /dev/null
@@ -1,247 +0,0 @@
-import os
-import tempfile
-import base64
-import pandas as pd
-import streamlit as st
-import streamlit.components.v1 as components
-from paddleocr import PaddleOCR
-from processor import extract_info, save_to_excel
-
-os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
-
-st.set_page_config(
- page_title="信封信息提取系统",
- page_icon="📮",
- layout="centered",
- initial_sidebar_state="collapsed",
-)
-
-st.markdown("""
-
-""", unsafe_allow_html=True)
-
-st.title("📮 信封信息提取")
-
-
-@st.cache_resource
-def load_ocr():
- return PaddleOCR(use_textline_orientation=True, lang="ch", show_log=False)
-
-
-ocr = load_ocr()
-
-
-def process_image(image_data):
- """处理图片数据"""
- with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
- tmp.write(image_data)
- tmp_path = tmp.name
-
- try:
- result = ocr.ocr(tmp_path, cls=False)
- ocr_texts = []
- if result and result[0]:
- for line in result[0]:
- if line and len(line) >= 2:
- ocr_texts.append(line[1][0])
- return extract_info(ocr_texts), ocr_texts
- finally:
- os.unlink(tmp_path)
-
-
-# 自定义摄像头组件,带叠加扫描框
-CAMERA_COMPONENT = """
-
-
-
-
-
-
-
-
-
-
-
-
-
邮编
-
地址
-
联系人
-
电话
-
-
-
-
- ↑ 编号在此处 ↑
-
-
-
-
-
- 📌 将信封背面对齐绿色框,编号对准底部
-
-
-
-
-
-
-"""
-
-# 初始化 session state
-if "records" not in st.session_state:
- st.session_state.records = []
-
-# 输入方式选择
-tab_camera, tab_upload = st.tabs(["📷 拍照扫描", "📁 上传图片"])
-
-with tab_camera:
- # 使用自定义摄像头组件
- photo_data = components.html(CAMERA_COMPONENT, height=550)
-
- # 检查是否有拍照数据
- if "captured_image" not in st.session_state:
- st.session_state.captured_image = None
-
- # 文件上传作为备用(用于接收JS传来的数据)
- uploaded_photo = st.file_uploader(
- "或直接上传照片",
- type=["jpg", "jpeg", "png"],
- key="camera_upload",
- label_visibility="collapsed"
- )
-
- if uploaded_photo:
- with st.spinner("识别中..."):
- record, raw_texts = process_image(uploaded_photo.getvalue())
-
- st.success("✅ 识别完成!")
-
- col1, col2 = st.columns(2)
- with col1:
- st.image(uploaded_photo, caption="拍摄图片", use_container_width=True)
- with col2:
- st.metric("邮编", record.get("邮编", "-"))
- st.metric("电话", record.get("电话", "-"))
- st.metric("联系人", record.get("联系人/单位名", "-"))
-
- st.text_area("地址", record.get("地址", ""), disabled=True, height=68)
- st.text_input("编号", record.get("编号", ""), disabled=True)
-
- if st.button("✅ 添加到列表", type="primary", key="add_camera"):
- record["来源"] = "拍照"
- st.session_state.records.append(record)
- st.success(f"已添加!当前共 {len(st.session_state.records)} 条记录")
- st.rerun()
-
-with tab_upload:
- uploaded_files = st.file_uploader(
- "选择图片文件",
- type=["jpg", "jpeg", "png", "bmp"],
- accept_multiple_files=True,
- label_visibility="collapsed",
- )
-
- if uploaded_files:
- if st.button("🚀 开始识别", type="primary"):
- progress = st.progress(0)
-
- for i, file in enumerate(uploaded_files):
- with st.spinner(f"处理 {file.name}..."):
- record, _ = process_image(file.getvalue())
- record["来源"] = file.name
- st.session_state.records.append(record)
- progress.progress((i + 1) / len(uploaded_files))
-
- st.success(f"完成!已添加 {len(uploaded_files)} 条记录")
- st.rerun()
-
-# 显示已收集的记录
-st.divider()
-st.subheader(f"📋 已收集 {len(st.session_state.records)} 条记录")
-
-if st.session_state.records:
- df = pd.DataFrame(st.session_state.records)
- cols = ["来源", "编号", "邮编", "地址", "联系人/单位名", "电话"]
- df = df.reindex(columns=[c for c in cols if c in df.columns])
-
- st.dataframe(df, use_container_width=True, hide_index=True)
-
- col1, col2 = st.columns(2)
-
- with col1:
- output_path = tempfile.mktemp(suffix=".xlsx")
- df.to_excel(output_path, index=False)
- with open(output_path, "rb") as f:
- st.download_button(
- "📥 下载 Excel",
- data=f,
- file_name="信封提取结果.xlsx",
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- )
- os.unlink(output_path)
-
- with col2:
- if st.button("🗑️ 清空列表"):
- st.session_state.records = []
- st.rerun()
-else:
- st.info("👆 使用上方拍照或上传功能添加记录")
diff --git a/src/desktop.py b/src/desktop.py
index c1be899..3b69e97 100644
--- a/src/desktop.py
+++ b/src/desktop.py
@@ -6,8 +6,11 @@
import os
import sys
import cv2
-import tempfile
import pandas as pd
+import time
+import logging
+import threading
+import queue
from datetime import datetime
from pathlib import Path
@@ -17,55 +20,232 @@ from PyQt6.QtWidgets import (
QFileDialog, QMessageBox, QGroupBox, QSplitter, QHeaderView,
QStatusBar, QProgressBar
)
-from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
-from PyQt6.QtGui import QImage, QPixmap, QFont, QAction
+from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QObject, pyqtSlot
+from PyQt6.QtGui import QImage, QPixmap, QFont, QAction, QKeySequence, QShortcut
-from paddleocr import PaddleOCR
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")
-class OCRWorker(QThread):
- """OCR 识别线程"""
- finished = pyqtSignal(dict, list)
- error = pyqtSignal(str)
- def __init__(self, ocr, image_path):
+def setup_logging() -> Path:
+ """
+ 日志输出:
+ - 终端实时打印
+ - 写入 data/output/desktop.log(便于用户反馈与排查)
+ """
+
+ level_name = os.environ.get("POST_OCR_LOG_LEVEL", "INFO").upper().strip()
+ level = getattr(logging, level_name, logging.INFO)
+
+ log_dir = Path("data/output").resolve()
+ log_dir.mkdir(parents=True, exist_ok=True)
+ log_file = log_dir / "desktop.log"
+
+ fmt = "%(asctime)s.%(msecs)03d %(levelname)s [%(threadName)s] %(name)s: %(message)s"
+ datefmt = "%Y-%m-%d %H:%M:%S"
+
+ root = logging.getLogger()
+ root.setLevel(level)
+
+ # 清理旧 handler,避免重复输出
+ for h in list(root.handlers):
+ root.removeHandler(h)
+
+ sh = logging.StreamHandler(stream=sys.stdout)
+ sh.setLevel(level)
+ sh.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
+ root.addHandler(sh)
+
+ fh = logging.FileHandler(log_file, encoding="utf-8")
+ fh.setLevel(level)
+ fh.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
+ root.addHandler(fh)
+
+ logger.info("日志已初始化,level=%s, file=%s", level_name, str(log_file))
+ return log_file
+
+
+class OCRService(QObject):
+ """
+ OCR 后台服务(运行在标准 Python 线程内)。
+
+ 关键点:
+ - 避免使用 QThread:在 macOS 上,QThread(Dummy-*) 内 import paddleocr 可能卡死
+ - PaddleOCR 实例在后台线程内创建并使用,避免跨线程调用导致卡死/死锁
+ - 单线程串行处理任务:避免并发推理挤爆内存或引发底层库竞争
+ """
+
+ finished = pyqtSignal(int, dict, list)
+ error = pyqtSignal(int, str)
+ ready = pyqtSignal()
+ init_error = pyqtSignal(str)
+ busy_changed = pyqtSignal(bool)
+
+ def __init__(self, models_base_dir: Path):
super().__init__()
- self.ocr = ocr
- self.image_path = image_path
+ self._models_base_dir = models_base_dir
+ self._ocr = None
+ self._busy = False
+ self._stop_event = threading.Event()
+ self._queue: "queue.Queue[tuple[int, object] | None]" = queue.Queue()
+ self._thread = threading.Thread(target=self._run, name="OCRThread", daemon=True)
+
+ def _set_busy(self, busy: bool) -> None:
+ if self._busy != busy:
+ self._busy = busy
+ self.busy_changed.emit(busy)
+
+ def start(self) -> None:
+ """启动后台线程并执行 warmup。"""
+
+ self._thread.start()
+
+ def stop(self, timeout_ms: int = 8000) -> bool:
+ """请求停止后台线程并等待退出(后台线程为 daemon,退出失败也不阻塞进程)。"""
- def run(self):
try:
- result = self.ocr.ocr(self.image_path, cls=False)
- ocr_texts = []
- if result and result[0]:
- for line in result[0]:
- if line and len(line) >= 2:
- ocr_texts.append(line[1][0])
- record = extract_info(ocr_texts)
- self.finished.emit(record, ocr_texts)
+ self._stop_event.set()
+ # 用 sentinel 唤醒阻塞在 queue.get() 的线程
+ try:
+ self._queue.put_nowait(None)
+ except Exception:
+ pass
+ self._thread.join(timeout=max(0.0, timeout_ms / 1000.0))
+ return not self._thread.is_alive()
+ except Exception:
+ return False
+
+ 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)
+ logger.info("OCR ensure_ocr: PaddleOCR 创建完成")
+ self.ready.emit()
+
+ def _warmup(self) -> None:
+ """提前加载 OCR 模型,避免首次识别时才初始化导致“像卡死”"""
+
+ logger.info("OCR 预热开始(线程=%s)", threading.current_thread().name)
+ self._ensure_ocr()
+ logger.info("OCR 预热完成")
+
+ def _run(self) -> None:
+ try:
+ self._warmup()
except Exception as e:
- self.error.emit(str(e))
+ logger.exception("OCR 预热失败:%s", str(e))
+ self.init_error.emit(str(e))
+ return
+
+ while not self._stop_event.is_set():
+ item = None
+ try:
+ item = self._queue.get()
+ except Exception:
+ continue
+
+ if item is None:
+ # sentinel: stop
+ break
+
+ job_id, images = item
+ if self._stop_event.is_set():
+ break
+ self._process_job(job_id, images)
+
+ @pyqtSlot(int, object)
+ def process(self, job_id: int, images: object) -> None:
+ """接收 UI 请求:把任务放进队列,由后台线程串行处理。"""
+
+ if self._stop_event.is_set():
+ self.error.emit(job_id, "OCR 服务正在关闭,请稍后重试。")
+ return
+ # 忙碌或已有排队任务时,直接拒绝,避免积压导致“看起来一直在识别”
+ if self._busy or (not self._queue.empty()):
+ self.error.emit(job_id, "OCR 正在进行中,请稍后再试。")
+ return
+ try:
+ # 注意:这里不做耗时工作,只入队,避免阻塞 UI
+ self._queue.put_nowait((job_id, images))
+ except Exception as e:
+ self.error.emit(job_id, f"OCR 入队失败:{str(e)}")
+
+ def _process_job(self, job_id: int, images: object) -> None:
+ self._set_busy(True)
+ try:
+ self._ensure_ocr()
+ if not isinstance(images, (list, tuple)) or len(images) == 0:
+ raise ValueError("内部错误:未传入有效图片数据")
+
+ shapes = []
+ for img in images:
+ try:
+ shapes.append(getattr(img, "shape", None))
+ except Exception:
+ shapes.append(None)
+ logger.info("OCR job=%s 开始,images=%s", job_id, shapes)
+
+ ocr_texts: list[str] = []
+ for img in images:
+ if img is None:
+ continue
+ result = self._ocr.ocr(img, cls=False)
+ if result and result[0]:
+ for line in result[0]:
+ if line and len(line) >= 2:
+ ocr_texts.append(line[1][0])
+
+ record = extract_info(ocr_texts)
+ logger.info(
+ "OCR job=%s 完成,lines=%s, record_keys=%s",
+ job_id,
+ len(ocr_texts),
+ list(record.keys()),
+ )
+ self.finished.emit(job_id, record, ocr_texts)
+ except Exception as e:
+ logger.exception("OCR job=%s 失败:%s", job_id, str(e))
+ self.error.emit(job_id, str(e))
+ finally:
+ self._set_busy(False)
class MainWindow(QMainWindow):
+ request_ocr = pyqtSignal(int, object)
+
def __init__(self):
super().__init__()
self.setWindowTitle("📮 信封信息提取系统")
self.setMinimumSize(1200, 700)
- # 初始化 OCR
- self.statusBar().showMessage("正在加载 OCR 模型...")
- QApplication.processEvents()
- self.ocr = PaddleOCR(use_textline_orientation=True, lang="ch", show_log=False)
- self.statusBar().showMessage("OCR 模型加载完成")
+ # OCR 工作线程(避免 UI 卡死)
+ self._ocr_job_id = 0
+ self._ocr_start_time_by_job: dict[int, float] = {}
+ self._ocr_ready = False
+ self._ocr_busy = False
+ self._shutting_down = False
+ self._ocr_timeout_prompted = False
# 摄像头
self.cap = None
self.timer = QTimer()
self.timer.timeout.connect(self.update_frame)
+ self._frame_fail_count = 0
+
+ # 状态栏进度(识别中显示)
+ self._progress = QProgressBar()
+ self._progress.setMaximumWidth(160)
+ self._progress.setVisible(False)
+ self.statusBar().addPermanentWidget(self._progress)
+
+ # OCR 看门狗:显示耗时、并在疑似卡住时提示重启
+ self._ocr_watchdog = QTimer()
+ self._ocr_watchdog.setInterval(300)
+ self._ocr_watchdog.timeout.connect(self._tick_ocr_watchdog)
# 数据
self.records = []
@@ -73,6 +253,191 @@ class MainWindow(QMainWindow):
self.init_ui()
self.load_cameras()
+ # 主线程预加载:在 macOS 上,必须在主线程 import paddleocr,否则后台线程会卡死
+ self.statusBar().showMessage("正在加载 OCR 模块...")
+ QApplication.processEvents()
+ try:
+ logger.info("主线程预加载:import paddleocr")
+ import paddleocr # noqa: F401
+ logger.info("主线程预加载:paddleocr 导入完成")
+ except Exception as e:
+ logger.error("主线程预加载失败:%s", e, exc_info=True)
+ QMessageBox.critical(self, "启动失败", f"无法加载 OCR 模块:{e}")
+ raise
+
+ # OCR 服务放在 UI 初始化之后启动,避免 ready/busy 信号回调时 btn_capture 尚未创建
+ self.statusBar().showMessage("正在启动 OCR 服务...")
+ QApplication.processEvents()
+ try:
+ self._init_ocr_service()
+ except FileNotFoundError as e:
+ QMessageBox.critical(self, "离线模型缺失", str(e))
+ raise
+ except Exception as e:
+ QMessageBox.critical(self, "启动失败", str(e))
+ raise
+
+ def shutdown(self, force: bool = False) -> None:
+ """停止摄像头并关闭后台服务,避免退出时后台任务仍在运行。"""
+
+ if self._shutting_down:
+ return
+ self._shutting_down = True
+
+ # 先停止摄像头,避免继续读帧
+ try:
+ if self.cap:
+ self.timer.stop()
+ self.cap.release()
+ self.cap = None
+ except Exception:
+ pass
+
+ try:
+ self._stop_ocr_service(force=force)
+ except Exception:
+ pass
+
+ def _stop_ocr_service(self, force: bool = False) -> None:
+ """仅停止 OCR 服务(用于超时重启/退出)。"""
+
+ try:
+ self._ocr_watchdog.stop()
+ except Exception:
+ pass
+
+ self._ocr_ready = False
+ self._ocr_busy = False
+ self._ocr_timeout_prompted = False
+ try:
+ self._progress.setVisible(False)
+ except Exception:
+ pass
+
+ try:
+ svc = getattr(self, "_ocr_service", None)
+ if svc is not None:
+ ok = svc.stop(timeout_ms=8000 if force else 3000)
+ if (not ok) and force:
+ # Python 线程无法可靠“强杀”,这里只做提示并继续退出流程。
+ logger.warning("OCR 服务停止超时:后台线程可能仍在运行,建议重启应用。")
+ except Exception:
+ pass
+
+ try:
+ self._ocr_service = None
+ except Exception:
+ pass
+
+ def _restart_ocr_service(self) -> None:
+ """重启 OCR 服务(用于超时恢复)。"""
+
+ if self._shutting_down:
+ return
+ self.statusBar().showMessage("正在重启 OCR 服务...")
+ self._stop_ocr_service(force=True)
+ self._init_ocr_service()
+
+ def _init_ocr_service(self) -> None:
+ models_dir = get_models_base_dir()
+
+ # 先校验模型路径是否存在(缺失直接抛错给 UI)
+ # create_offline_ocr 内部会做更完整校验,这里不提前创建模型,避免阻塞 UI
+ if not models_dir.exists():
+ raise FileNotFoundError(f"离线模型目录不存在:{models_dir}")
+
+ self._ocr_service = OCRService(models_base_dir=models_dir)
+
+ # 注意:OCRService 内部使用 Python 线程做 warmup 与推理。
+ # 这里强制使用 QueuedConnection,确保 UI 回调始终在主线程执行。
+ self.request_ocr.connect(self._ocr_service.process, Qt.ConnectionType.QueuedConnection)
+ self._ocr_service.ready.connect(self._on_ocr_ready, Qt.ConnectionType.QueuedConnection)
+ self._ocr_service.init_error.connect(self._on_ocr_init_error, Qt.ConnectionType.QueuedConnection)
+ self._ocr_service.busy_changed.connect(self._on_ocr_busy_changed, Qt.ConnectionType.QueuedConnection)
+ self._ocr_service.finished.connect(self._on_ocr_finished_job, Qt.ConnectionType.QueuedConnection)
+ self._ocr_service.error.connect(self._on_ocr_error_job, Qt.ConnectionType.QueuedConnection)
+
+ self._ocr_service.start()
+
+ def _on_ocr_ready(self) -> None:
+ try:
+ self._ocr_ready = True
+ self.statusBar().showMessage("OCR 模型已加载(离线)")
+ btn = getattr(self, "btn_capture", None)
+ if btn is not None:
+ btn.setEnabled(self.cap is not None and not self._ocr_busy)
+ logger.info("OCR ready")
+ except Exception as e:
+ logger.exception("处理 OCR ready 回调失败:%s", str(e))
+
+ def _on_ocr_init_error(self, error: str) -> None:
+ self.statusBar().showMessage("OCR 模型加载失败")
+ QMessageBox.critical(self, "OCR 初始化失败", error)
+ logger.error("OCR init error: %s", error)
+
+ def _on_ocr_busy_changed(self, busy: bool) -> None:
+ try:
+ self._ocr_busy = busy
+ if busy:
+ self._progress.setRange(0, 0) # 不确定进度条
+ self._progress.setVisible(True)
+ self._ocr_timeout_prompted = False
+ self._ocr_watchdog.start()
+ else:
+ self._progress.setVisible(False)
+ self._ocr_watchdog.stop()
+ btn = getattr(self, "btn_capture", None)
+ if btn is not None:
+ btn.setEnabled(self.cap is not None and self._ocr_ready and not busy)
+ except Exception as e:
+ logger.exception("处理 OCR busy 回调失败:%s", str(e))
+
+ def _tick_ocr_watchdog(self) -> None:
+ """识别进行中:更新耗时,超时则提示是否重启 OCR 服务。"""
+
+ if not self._ocr_busy:
+ return
+ start_t = self._ocr_start_time_by_job.get(self._ocr_job_id)
+ if start_t is None:
+ return
+ cost = time.monotonic() - start_t
+ self.statusBar().showMessage(f"正在识别...(已用 {cost:.1f}s)")
+
+ # 超时保护:底层推理偶发卡住时,让用户可以自救
+ if cost >= 45 and not self._ocr_timeout_prompted:
+ self._ocr_timeout_prompted = True
+ reply = QMessageBox.question(
+ self,
+ "识别超时",
+ "识别已超过 45 秒仍未完成,可能卡住。\n\n是否重启 OCR 服务?\n(若仍无响应,建议直接退出并重新打开应用)",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ )
+ if reply == QMessageBox.StandardButton.Yes:
+ self._restart_ocr_service()
+
+ def _on_ocr_finished_job(self, job_id: int, record: dict, texts: list) -> None:
+ start_t = self._ocr_start_time_by_job.pop(job_id, None)
+
+ # 只处理最新一次请求,避免旧结果回写
+ if job_id != self._ocr_job_id:
+ return
+
+ self.records.append(record)
+ self.update_table()
+ cost = ""
+ if start_t is not None:
+ cost = f"(耗时 {time.monotonic() - start_t:.1f}s)"
+ self.statusBar().showMessage(f"识别完成: {record.get('联系人/单位名', '未知')}{cost}")
+ logger.info("OCR job=%s UI 回写完成 %s", job_id, cost)
+
+ def _on_ocr_error_job(self, job_id: int, error: str) -> None:
+ self._ocr_start_time_by_job.pop(job_id, None)
+ if job_id != self._ocr_job_id:
+ return
+ self.statusBar().showMessage("识别失败")
+ QMessageBox.warning(self, "识别失败", error)
+ logger.error("OCR job=%s error: %s", job_id, error)
+
def init_ui(self):
central = QWidget()
self.setCentralWidget(central)
@@ -111,7 +476,7 @@ class MainWindow(QMainWindow):
self.btn_capture.setFont(QFont("", 14))
self.btn_capture.setStyleSheet("background-color: #ff4b4b; color: white; border-radius: 8px;")
self.btn_capture.clicked.connect(self.capture_and_recognize)
- self.btn_capture.setEnabled(False)
+ self.btn_capture.setEnabled(False) # 等摄像头连接 + OCR ready 后启用
left_layout.addWidget(self.btn_capture)
# 右侧:结果列表
@@ -152,27 +517,81 @@ class MainWindow(QMainWindow):
layout.addWidget(splitter)
# 快捷键
- self.shortcut_capture = QAction(self)
- self.shortcut_capture.setShortcut("Space")
- self.shortcut_capture.triggered.connect(self.capture_and_recognize)
- self.addAction(self.shortcut_capture)
+ # macOS/Qt 下 Space 经常被控件吞掉(按钮激活/表格选择等),用 ApplicationShortcut 更稳
+ self._shortcut_capture2 = QShortcut(QKeySequence("Space"), self)
+ self._shortcut_capture2.setContext(Qt.ShortcutContext.ApplicationShortcut)
+ self._shortcut_capture2.activated.connect(self.capture_and_recognize)
def load_cameras(self):
"""扫描可用摄像头"""
self.cam_combo.clear()
- for i in range(10):
- cap = cv2.VideoCapture(i)
- if cap.isOpened():
- ret, _ = cap.read()
- if ret:
- self.cam_combo.addItem(f"摄像头 {i}", i)
- cap.release()
+ # macOS 上设备编号会变化(尤其“连续互通相机”/虚拟摄像头),这里多扫一些更稳。
+ # 若你想减少探测范围,可设置环境变量 POST_OCR_MAX_CAMERAS,例如:POST_OCR_MAX_CAMERAS=3
+ try:
+ max_probe = int(os.environ.get("POST_OCR_MAX_CAMERAS", "").strip() or "10")
+ except Exception:
+ max_probe = 10
+ logger.info("开始扫描摄像头:max_probe=%s", max_probe)
- if self.cam_combo.count() == 0:
- self.cam_combo.addItem("未检测到摄像头", -1)
- self.statusBar().showMessage("未检测到摄像头,请连接 Droidcam")
+ found = 0
+ for i in range(max_probe):
+ cap = None
+ try:
+ cap = self._open_capture(i)
+ if cap is None or (not cap.isOpened()):
+ continue
+
+ # 暖机:有些设备首帧为空或延迟较大(尤其手机/虚拟摄像头)
+ has_frame = False
+ for _ in range(25):
+ ret, frame = cap.read()
+ if ret and frame is not None and frame.size > 0:
+ has_frame = True
+ break
+ label = f"摄像头 {i}" if has_frame else f"摄像头 {i}(未验证画面)"
+ self.cam_combo.addItem(label, i)
+ logger.info("摄像头探测:id=%s opened, has_frame=%s", i, has_frame)
+ found += 1
+ finally:
+ try:
+ if cap is not None:
+ cap.release()
+ except Exception:
+ pass
+
+ if found == 0:
+ # 自动探测可能因权限/占用/设备延迟失败;仍提供手动尝试入口,避免用户被“无设备”卡住
+ for i in range(max_probe):
+ self.cam_combo.addItem(f"摄像头 {i}(手动尝试)", i)
+ self.statusBar().showMessage(
+ "未能自动检测到可用摄像头。"
+ "如为 macOS,请在 系统设置->隐私与安全->相机 中允许当前终端/应用访问;"
+ "并确保 iPhone 已解锁且未被其他应用占用。"
+ )
else:
- self.statusBar().showMessage(f"检测到 {self.cam_combo.count()} 个摄像头")
+ self.statusBar().showMessage(f"检测到 {found} 个摄像头")
+ logger.info("摄像头扫描结束:found=%s", found)
+
+ def _open_capture(self, cam_id: int):
+ """
+ 打开摄像头。
+
+ macOS 上优先使用 AVFoundation 后端(对“连续互通相机”等更友好)。
+ """
+
+ if 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():
+ return cap
+ except Exception:
+ pass
+ try:
+ if cap is not None:
+ cap.release()
+ except Exception:
+ pass
+ return cv2.VideoCapture(cam_id)
def toggle_camera(self):
"""连接/断开摄像头"""
@@ -182,19 +601,51 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "错误", "请先选择有效的摄像头")
return
- self.cap = cv2.VideoCapture(cam_id)
- self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
- self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
+ self.cap = self._open_capture(cam_id)
if self.cap.isOpened():
+ # 不强制分辨率:某些设备(尤其虚拟摄像头/连续互通相机)被强设后会输出黑屏
+ # self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
+ # self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
+
+ # 暖机读取,尽早发现“能打开但无画面”的情况
+ ok = False
+ for _ in range(20):
+ ret, frame = self.cap.read()
+ if ret and frame is not None and frame.size > 0:
+ ok = True
+ break
+ 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",
+ )
+ return
+
self.timer.start(30) # ~33 FPS
self.btn_connect.setText("⏹ 断开")
- self.btn_capture.setEnabled(True)
+ self.btn_capture.setEnabled(self._ocr_ready and not self._ocr_busy)
self.cam_combo.setEnabled(False)
self.statusBar().showMessage("摄像头已连接")
else:
self.cap = None
- QMessageBox.warning(self, "错误", "无法打开摄像头")
+ QMessageBox.warning(
+ self,
+ "无法打开摄像头",
+ "无法打开摄像头。\n\n"
+ "排查建议:\n"
+ "1) macOS:系统设置 -> 隐私与安全 -> 相机,允许当前运行的终端/应用访问\n"
+ "2) 如果有其他应用正在使用摄像头(微信/会议软件/浏览器),请先退出再试\n"
+ "3) 连续互通相机:保持 iPhone 解锁并靠近 Mac,且未被其他应用占用\n"
+ "4) 在下拉框中切换不同编号(0/1/2/3...)重试\n",
+ )
else:
self.timer.stop()
self.cap.release()
@@ -211,7 +662,8 @@ class MainWindow(QMainWindow):
return
ret, frame = self.cap.read()
- if ret:
+ if ret and frame is not None and frame.size > 0:
+ self._frame_fail_count = 0
# 绘制扫描框
h, w = frame.shape[:2]
# 框的位置:上方 70%,编号在下方
@@ -247,10 +699,21 @@ class MainWindow(QMainWindow):
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format.Format_RGB888)
scaled = qimg.scaled(self.video_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
self.video_label.setPixmap(QPixmap.fromImage(scaled))
+ else:
+ self._frame_fail_count += 1
+ if self._frame_fail_count == 1:
+ self.statusBar().showMessage("摄像头无画面:请检查权限/切换摄像头")
def capture_and_recognize(self):
"""拍照并识别"""
if self.cap is None:
+ self.statusBar().showMessage("请先连接摄像头")
+ return
+ if not self._ocr_ready:
+ self.statusBar().showMessage("OCR 模型尚未就绪,请稍等")
+ return
+ if self._ocr_busy:
+ self.statusBar().showMessage("正在识别中,请稍后再按空格")
return
ret, frame = self.cap.read()
@@ -258,35 +721,56 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage("拍照失败")
return
- # 保存临时文件
- tmp_path = tempfile.mktemp(suffix=".jpg")
- cv2.imwrite(tmp_path, frame)
+ # 裁剪两块 ROI(主信息框 + 编号区域),显著减小像素量,提升速度与稳定性
+ h, w = frame.shape[:2]
+ x1, y1 = int(w * 0.06), int(h * 0.08)
+ x2 = int(w * 0.94)
+ y2_box = int(h * 0.78)
+
+ roi_images = []
+ try:
+ roi_box = frame[y1:y2_box, x1:x2]
+ if roi_box is not None and roi_box.size > 0:
+ roi_images.append(roi_box)
+ except Exception:
+ pass
+
+ try:
+ # 编号一般在底部中间,取较小区域即可
+ nx1, nx2 = int(w * 0.30), int(w * 0.70)
+ ny1, ny2 = int(h * 0.80), int(h * 0.98)
+ roi_num = frame[ny1:ny2, nx1:nx2]
+ if roi_num is not None and roi_num.size > 0:
+ roi_images.append(roi_num)
+ except Exception:
+ pass
+
+ if not roi_images:
+ self.statusBar().showMessage("拍照失败:未截取到有效区域")
+ return
+
+ # 超大分辨率下适当缩放(提高稳定性与速度)
+ resized_images = []
+ for img in roi_images:
+ try:
+ max_w = 1400
+ if img.shape[1] > max_w:
+ scale = max_w / img.shape[1]
+ img = cv2.resize(img, (int(img.shape[1] * scale), int(img.shape[0] * scale)))
+ except Exception:
+ pass
+ resized_images.append(img)
+
+ logger.info("UI 触发识别:frame=%s, rois=%s", getattr(frame, "shape", None), [getattr(i, "shape", None) for i in resized_images])
self.statusBar().showMessage("正在识别...")
self.btn_capture.setEnabled(False)
- # 启动 OCR 线程
- self.worker = OCRWorker(self.ocr, tmp_path)
- self.worker.finished.connect(lambda r, t: self.on_ocr_finished(r, t, tmp_path))
- self.worker.error.connect(lambda e: self.on_ocr_error(e, tmp_path))
- self.worker.start()
-
- def on_ocr_finished(self, record, texts, tmp_path):
- """OCR 完成"""
- os.unlink(tmp_path)
- self.btn_capture.setEnabled(True)
-
- # 添加到记录
- self.records.append(record)
- self.update_table()
-
- self.statusBar().showMessage(f"识别完成: {record.get('联系人/单位名', '未知')}")
-
- def on_ocr_error(self, error, tmp_path):
- """OCR 错误"""
- os.unlink(tmp_path)
- self.btn_capture.setEnabled(True)
- self.statusBar().showMessage(f"识别失败: {error}")
+ # 派发到 OCR 工作线程
+ self._ocr_job_id += 1
+ job_id = self._ocr_job_id
+ self._ocr_start_time_by_job[job_id] = time.monotonic()
+ self.request_ocr.emit(job_id, resized_images)
def update_table(self):
"""更新表格"""
@@ -334,18 +818,33 @@ class MainWindow(QMainWindow):
def closeEvent(self, event):
"""关闭窗口"""
- if self.cap:
- self.timer.stop()
- self.cap.release()
+ if self._ocr_busy:
+ reply = QMessageBox.question(
+ self,
+ "正在识别",
+ "当前正在识别,直接关闭可能导致任务中断。\n\n是否强制退出?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ )
+ if reply == QMessageBox.StandardButton.No:
+ event.ignore()
+ return
+ self.shutdown(force=True)
+ event.accept()
+ return
+
+ self.shutdown(force=False)
event.accept()
def main():
+ log_file = setup_logging()
app = QApplication(sys.argv)
app.setStyle("Fusion")
window = MainWindow()
window.show()
+ app.aboutToQuit.connect(lambda: window.shutdown(force=False))
+ logger.info("应用启动完成,PID=%s,日志=%s", os.getpid(), str(log_file))
sys.exit(app.exec())
diff --git a/src/ocr_offline.py b/src/ocr_offline.py
new file mode 100644
index 0000000..2af82d0
--- /dev/null
+++ b/src/ocr_offline.py
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+"""
+离线 OCR 初始化工具
+
+目标:
+1. Windows 交付 zip 目录包时,模型随包携带,程序完全离线可用
+2. 如果模型缺失,明确报错并阻止 PaddleOCR 自动联网下载
+3. 统一桌面版 / Web 版 / 命令行的 OCR 初始化逻辑,避免参数漂移
+"""
+
+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
+
+
+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 等动态库,通常位于打包目录的:
+ - /_internal/paddle/libs
+
+ 某些情况下动态库加载不会自动命中该路径(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"
+
+ candidates = [
+ internal_dir / "paddle" / "libs",
+ internal_dir / "paddle",
+ internal_dir,
+ app_base_dir,
+ ]
+
+ # 同时设置 PATH,兼容不走 add_dll_directory 的加载路径
+ path_parts = [os.environ.get("PATH", "")]
+ for p in candidates:
+ if p.exists():
+ if add_dll_dir is not 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:
+ """
+ 校验离线模型是否存在。
+
+ 设计选择:
+ - 直接抛异常:由上层(桌面/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")
+ 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)
+ _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)
+
+ # 延迟导入:确保环境变量在 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))
+ 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),
+ )
+ log.info("create_offline_ocr: PaddleOCR created")
+ return ocr