merge: 合并远程最新代码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
let5sne.win10
2026-02-14 17:45:57 +08:00
10 changed files with 930 additions and 742 deletions

4
.gitignore vendored
View File

@@ -21,6 +21,10 @@ htmlcov/
.venv/ .venv/
venv/ venv/
.env .env
.serena/
models/*
!models/.gitkeep
usb_bundle/
# ================================================== # ==================================================
# Android - 构建产物 # Android - 构建产物

View File

@@ -7,7 +7,7 @@
- 自动识别信封图片中的文字信息 - 自动识别信封图片中的文字信息
- 结构化提取:编号、邮编、地址、联系人、电话 - 结构化提取:编号、邮编、地址、联系人、电话
- 支持批量处理,结果导出为 Excel - 支持批量处理,结果导出为 Excel
- 提供 Web 界面,操作简单 - 提供桌面应用,支持摄像头实时拍照识别
## 系统要求 ## 系统要求
@@ -41,76 +41,51 @@ python src/main.py
# 结果保存在 data/output/result.xlsx # 结果保存在 data/output/result.xlsx
``` ```
**Web 界面** **桌面应用**
```bash ```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 ```bash
# 启动服务(监听所有网卡) pip install -r requirements.txt
streamlit run src/app.py --server.address 0.0.0.0 --server.port 8501 python scripts/prepare_models.py --models-dir models
# 工人通过浏览器访问: http://服务器IP:8501
``` ```
### 方案二Docker 容器化部署 执行完成后会生成 `models/whl/...` 目录结构;该 `models/` 目录需要与最终的 exe 同级分发。
适合需要隔离环境或快速部署的场景。 ### 2. Windows 打包(建议使用 PyInstaller 的 onedir
```bash 请在 Windows 机器上构建 Windows 包(不要跨平台交叉打包)。
# 构建镜像
docker build -t envelope-ocr .
# 运行容器 ```powershell
docker run -d -p 8501:8501 --name envelope-ocr envelope-ocr 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: 打包完成后,将 `dist\post-ocr-desktop\` 整个目录压缩为 zip 交付即可。
```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"]
```
### 方案三:系统服务(开机自启) 注意:
- 本项目默认使用 PaddleOCR 2.10.0PP-OCRv4 中文)离线模型目录结构
适合长期稳定运行的生产环境。 -`models/` 缺失,程序会直接报错提示,避免触发联网下载
创建服务文件 `/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
```
## 目录结构 ## 目录结构
@@ -121,7 +96,7 @@ post-ocr/
│ └── output/ # 结果 Excel 及处理日志 │ └── output/ # 结果 Excel 及处理日志
├── src/ ├── src/
│ ├── main.py # 命令行入口 │ ├── main.py # 命令行入口
│ ├── app.py # Web 界面 │ ├── desktop.py # 桌面应用入口
│ └── processor.py # 核心处理逻辑 │ └── processor.py # 核心处理逻辑
├── requirements.txt ├── requirements.txt
└── README.md └── README.md
@@ -130,7 +105,7 @@ post-ocr/
## 技术栈 ## 技术栈
- OCR 引擎: PaddleOCR 2.10 (PP-OCRv4) - OCR 引擎: PaddleOCR 2.10 (PP-OCRv4)
- Web 框架: Streamlit - 桌面框架: PyQt6
- 数据处理: Pandas - 数据处理: Pandas
## 常见问题 ## 常见问题

View File

@@ -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()

View File

@@ -1,8 +0,0 @@
# 桌面版依赖(本地电脑安装)
paddleocr>=2.6,<3
paddlepaddle>=2.5,<3
pandas
openpyxl
pydantic
PyQt6
opencv-python

View File

@@ -1,4 +1,4 @@
# OCR 核心依赖 # 桌面版依赖(本地电脑安装)
paddleocr>=2.6,<3 paddleocr>=2.6,<3
paddlepaddle>=2.5,<3 paddlepaddle>=2.5,<3
@@ -6,14 +6,5 @@ paddlepaddle>=2.5,<3
pandas pandas
openpyxl openpyxl
pydantic pydantic
tqdm PyQt6
opencv-python
# Web 界面
streamlit
# 桌面版依赖
PyQt6>=6.6.0
opencv-python>=4.8.0
# 打包工具(仅开发时需要)
pyinstaller>=6.0.0

67
scripts/camera_probe.py Executable file
View File

@@ -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())

59
scripts/prepare_models.py Executable file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
离线模型准备脚本(建议在“有网机器”执行一次)
用途:
- 将 PaddleOCR 2.10.0PP-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())

View File

@@ -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("""
<style>
.stApp { max-width: 100%; }
.stButton>button { width: 100%; height: 3em; font-size: 1.2em; }
.stDownloadButton>button { width: 100%; height: 3em; }
</style>
""", 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 = """
<div id="camera-container" style="position:relative; width:100%; max-width:500px; margin:0 auto;">
<video id="video" autoplay playsinline style="width:100%; border-radius:10px; background:#000;"></video>
<!-- 扫描框叠加层 -->
<div id="overlay" style="
position: absolute;
top: 8%;
left: 50%;
transform: translateX(-50%);
width: 88%;
height: 70%;
border: 3px solid #00ff00;
box-sizing: border-box;
pointer-events: none;
">
<!-- 四角 -->
<div style="position:absolute; top:-3px; left:-3px; width:20px; height:20px; border-top:4px solid #00ff00; border-left:4px solid #00ff00;"></div>
<div style="position:absolute; top:-3px; right:-3px; width:20px; height:20px; border-top:4px solid #00ff00; border-right:4px solid #00ff00;"></div>
<div style="position:absolute; bottom:-3px; left:-3px; width:20px; height:20px; border-bottom:4px solid #00ff00; border-left:4px solid #00ff00;"></div>
<div style="position:absolute; bottom:-3px; right:-3px; width:20px; height:20px; border-bottom:4px solid #00ff00; border-right:4px solid #00ff00;"></div>
<!-- 字段提示:邮编(左上)、地址(中间)、联系人+电话(底部) -->
<div style="position:absolute; top:8px; left:10px; color:rgba(255,255,255,0.6); font-size:12px;">邮编</div>
<div style="position:absolute; top:35%; left:10px; right:10px; color:rgba(255,255,255,0.6); font-size:12px; border-bottom:1px dashed rgba(255,255,255,0.3); padding-bottom:30%;">地址</div>
<div style="position:absolute; bottom:8px; left:10px; color:rgba(255,255,255,0.6); font-size:12px;">联系人</div>
<div style="position:absolute; bottom:8px; right:10px; color:rgba(255,255,255,0.6); font-size:12px;">电话</div>
</div>
<!-- 编号提示在框外底部 -->
<div style="position:absolute; bottom:18%; left:50%; transform:translateX(-50%); color:rgba(255,255,255,0.6); font-size:11px;">
↑ 编号在此处 ↑
</div>
<canvas id="canvas" style="display:none;"></canvas>
<p id="hint" style="text-align:center; color:#666; margin:10px 0; font-size:14px;">
📌 将信封背面对齐绿色框,编号对准底部
</p>
<button id="capture-btn" onclick="capturePhoto()" style="
width: 100%;
padding: 15px;
font-size: 18px;
background: #ff4b4b;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
margin-top: 10px;
">📷 拍照识别</button>
</div>
<script>
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const hint = document.getElementById('hint');
// 启动后置摄像头
async function startCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
video.srcObject = stream;
} catch (err) {
hint.textContent = '❌ 无法访问摄像头: ' + err.message;
console.error(err);
}
}
function capturePhoto() {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
// 发送到 Streamlit
window.parent.postMessage({
type: 'streamlit:setComponentValue',
value: dataUrl
}, '*');
hint.textContent = '✅ 已拍照,正在识别...';
document.getElementById('capture-btn').disabled = true;
}
startCamera();
</script>
"""
# 初始化 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("👆 使用上方拍照或上传功能添加记录")

File diff suppressed because it is too large Load Diff

178
src/ocr_offline.py Normal file
View File

@@ -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 等动态库,通常位于打包目录的:
- <exe_dir>/_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