Compare commits

..

17 Commits

Author SHA1 Message Date
let5sne.win10
737d4b2760 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>
2026-02-14 19:18:33 +08:00
let5sne.win10
4df84ffebe merge: 合并远程最新代码
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:45:57 +08:00
let5sne.win10
522c2cb000 feat: 添加 Android 应用
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:42:07 +08:00
let5sne.win10
ec8c15191b fix: 添加调试模式支持
- 添加 --debug 参数生成带控制台的版本
- 便于诊断运行时错误

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:09:03 +08:00
let5sne.win10
299504c283 fix: 使用 --paths 参数修复 PyInstaller 导入路径问题
- 改用 --paths=src 代替 --add-data
- 这样 src 目录会被添加到 Python 路径
- 避免 processor 等模块导入错误

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:06:44 +08:00
let5sne.win10
8501f17b6c fix: 修复打包脚本兼容性
- 使用 Python -m PyInstaller 方式提高兼容性
- 简化命令参数
- 添加文件大小显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:04:05 +08:00
let5sne.win10
7e4f2c92b1 feat: 添加桌面程序打包支持
- 添加 PyInstaller 打包脚本 build_exe.py
- 更新 requirements.txt 添加 PyQt6 和 opencv-python
- .gitignore 添加 PyInstaller 打包产物忽略规则

使用方法:
  pip install pyinstaller
  python build_exe.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:00:57 +08:00
let5sne.win10
109946a461 chore: 从 git 追踪中移除构建产物
使用 git rm --cached 移除已追踪的 build/.gradle/.idea 文件
这些目录已在 .gitignore 中配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:49:33 +08:00
let5sne.win10
e7e4fa8a80 chore: 优化 .gitignore 配置
- 移除重复的 .gradle/ 条目
- 添加更多 Android 构建产物忽略规则
- 完善 Python 和 Keystore 文件忽略规则

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:47:34 +08:00
let5sne.win10
6757a82cee fix: 添加 INTERNET 权限以支持 MJPEG HTTP 服务器
MjpegServer 需要监听 8080 端口提供 HTTP 流媒体服务

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:44:59 +08:00
let5sne.win10
da080a8f03 fix: 修复 MjpegServer.ClientHandler.isAlive 方法名冲突
重命名为 isClientAlive 以避免与 Thread.isAlive() 冲突

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:41:07 +08:00
let5sne.win10
c68ed15ed5 refactor: 改进 CameraHelper 资源清理的错误处理
- 添加 stopRepeating() 和 abortCaptures() 调用
- 为每个资源关闭操作添加独立的 try-catch
- 确保即使部分资源关闭失败,其他资源仍会被清理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:39:54 +08:00
let5sne.win10
e9741b4dd2 fix: 修复 MainActivity 多行字符串格式和 UI 线程安全问题
- 使用 Kotlin 原始字符串语法替代错误的拼接格式
- 修复 stopCamera() 中 UI 更新不在主线程的问题
- 添加 cameraHelper 重复启动检查

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:39:42 +08:00
let5sne.win10
0699931fd4 fix: 修复 MjpegServer.kt 文件丢失
- 确保所有源代码文件完整
2026-02-12 22:37:54 +08:00
let5sne.win10
b9b1c2d876 feat: Android 应用保持屏幕常亮
- 添加 WAKE_LOCK 权限
- 设置 keepScreenOn=true
2026-02-12 22:28:46 +08:00
let5sne.win10
38b68c9987 feat: 添加 Android USB 摄像头 APP 源代码
- 源代码文件: MainActivity, CameraHelper, MjpegServer
- 布局文件: activity_main.xml
- 优化 .gitignore 只忽略构建产物
2026-02-12 22:26:40 +08:00
let5sne.win10
767271d499 feat: 添加 USB 摄像头连接功能
- 新增 Android USB 摄像头 APP (MJPEG 服务器)
- 电脑端支持 ADB 端口转发连接
- 修复 .gitignore 忽略 Android 文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:23:43 +08:00
26 changed files with 1823 additions and 142 deletions

53
.gitignore vendored
View File

@@ -1,15 +1,66 @@
# ==================================================
# 数据文件
# ==================================================
data/input/* data/input/*
!data/input/.gitkeep !data/input/.gitkeep
data/output/* data/output/*
!data/output/.gitkeep !data/output/.gitkeep
# ==================================================
# Python
# ==================================================
__pycache__/ __pycache__/
*.pyc *.py[cod]
*$py.class
*.so
.Python
.pytest_cache/
.coverage
htmlcov/
.DS_Store .DS_Store
.venv/ .venv/
venv/ venv/
.env
.serena/ .serena/
models/* models/*
!models/.gitkeep !models/.gitkeep
usb_bundle/ usb_bundle/
# ==================================================
# Android - 构建产物
# ==================================================
android-app/app/build/
android-app/build/
android-app/.gradle/
android-app/.idea/
android-app/local.properties
android-app/captures/
android-app/*.apk
android-app/*.aab
android-app/*.ap_
android-app/output/
android-app/.externalNativeBuild
android-app/.cxx
# Gradle
android-app/gradle-app.setting
android-app/.navigation/
android-app/*/build/
# Keystore files
android-app/*.jks
android-app/*.keystore
# Debug
android-app/**/kotlin/
android-app/**/tmp/
android-app/**/.kotlin/
# ==================================================
# PyInstaller 打包产物
# ==================================================
*.spec
build/
dist/
*.exe
*.db

112
android-app/README.md Normal file
View File

@@ -0,0 +1,112 @@
# USB 摄像头连接方案
## 概述
通过 USB 数据线将 Android 手机作为摄像头连接到电脑,无需 WiFi/网络。
## 架构
```
┌─────────────────┐ USB ┌─────────────────┐
│ Android APP │ ◄─────────────► │ 电脑 ADB │
│ (MJPEG服务) │ adb forward │ desktop.py │
│ 端口 8080 │ │ localhost:8080 │
└─────────────────┘ └─────────────────┘
```
## 使用步骤
### 1. 准备 Android APP
#### 方式A: 使用 Android Studio 编译(推荐)
1. 安装 Android Studio
2. 打开 `android-app` 目录
3. 连接手机或启动模拟器
4. 点击 Run 按钮
#### 方式B: 下载预编译 APK
如需预编译 APK请联系开发者或自行编译。
### 2. 手机端操作
1. **安装并启动**「USB摄像头」APP
2. **开启 USB 调试**
- 设置 → 关于手机 → 连续点击"版本号" 7次
- 设置 → 开发者选项 → USB调试开启
3. 点击 APP 中的「启动」按钮
4. 屏幕显示:"服务运行中 端口: 8080"
### 3. 电脑端操作
#### 安装 ADB
```bash
# Windows: 下载 platform-tools
# https://developer.android.com/tools/releases/platform-tools
# 或使用 winget
winget install Google.PlatformTools
# 验证安装
adb version
```
#### 连接手机
```bash
# 1. USB 连接手机,手机上弹出"允许USB调试"时点击"允许"
# 2. 验证连接
adb devices
# 应显示类似:
# List of devices attached
# XXXXXXXX device
```
#### 运行桌面程序
```bash
cd d:\code\post-ocr
py -3.12 src\desktop.py
```
#### 连接摄像头
1. 在程序中点击 **"🔌 USB连接"** 按钮
2. 程序会自动执行 `adb forward tcp:8080 tcp:8080`
3. 连接成功后显示实时画面
## 工作原理
1. 手机 APP 启动 MJPEG 流服务器(监听 8080 端口)
2. ADB 将手机端口转发到电脑:`adb forward tcp:8080 tcp:8080`
3. 电脑 OpenCV 读取:`cv2.VideoCapture("http://localhost:8080")`
4. 画面实时显示,支持拍照识别
## 故障排查
### 问题ADB 找不到设备
- 检查 USB 线是否支持数据传输(非仅充电线)
- 手机上是否允许 USB 调试
- 尝试更换 USB 端口
### 问题:连接失败
- 确保 APP 已启动并显示"服务运行中"
- 检查端口 8080 是否被占用
- 尝试重启 APP
### 问题:画面卡顿
- 降低分辨率:在 CameraHelper.kt 中修改预览尺寸
- 检查 USB 线质量
## 技术栈
- **Android**: Kotlin + Camera2 API
- **网络**: MJPEG over HTTP
- **电脑端**: Python + OpenCV + PyQt6
- **通信**: ADB 端口转发

View File

@@ -0,0 +1,35 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
namespace 'com.usbwebcam'
compileSdk 34
defaultConfig {
applicationId "com.usbwebcam"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
}

10
android-app/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,10 @@
# ProGuard rules for USB Webcam
# Keep public class members
-keep public class * {
public *;
}
# Keep Kotlin coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keep class kotlinx.coroutines.** { *; }

Binary file not shown.

View File

@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.usbwebcam",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:label="USB摄像头"
android:theme="@style/Theme.AppCompat.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:keepScreenOn="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,180 @@
package com.usbwebcam
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageFormat
import android.graphics.Rect
import android.graphics.YuvImage
import android.hardware.camera2.*
import android.media.Image
import android.media.ImageReader
import android.os.Handler
import android.os.HandlerThread
import android.util.Size
import android.view.Surface
import android.view.SurfaceHolder
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.*
class CameraHelper(
private val context: Context,
private val onFrame: (ByteArray, Int, Int) -> Unit
) {
private var cameraDevice: CameraDevice? = null
private var captureSession: CameraCaptureSession? = null
private var imageReader: ImageReader? = null
private var backgroundThread: HandlerThread? = null
private var backgroundHandler: Handler? = null
private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
private val cameraId = cameraManager.cameraIdList.firstOrNull {
cameraManager.getCameraCharacteristics(it)
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
} ?: cameraManager.cameraIdList.first()
fun start() {
startBackgroundThread()
openCamera()
}
fun stop() {
closeCamera()
stopBackgroundThread()
}
private fun startBackgroundThread() {
backgroundThread = HandlerThread("CameraBackground").apply {
start()
backgroundHandler = Handler(looper)
}
}
private fun stopBackgroundThread() {
backgroundThread?.quitSafely()
backgroundThread?.join()
backgroundThread = null
backgroundHandler = null
}
private fun openCamera() {
try {
cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) {
cameraDevice = camera
createCameraPreviewSession()
}
override fun onDisconnected(camera: CameraDevice) {
camera.close()
cameraDevice = null
}
override fun onError(camera: CameraDevice, error: Int) {
camera.close()
cameraDevice = null
}
}, backgroundHandler!!)
} catch (e: SecurityException) {
e.printStackTrace()
}
}
private fun createCameraPreviewSession() {
try {
// 获取支持的尺寸
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
val previewSize = map?.getOutputSizes(SurfaceHolder::class.java)?.firstOrNull {
it.width <= 1280 && it.height <= 720
} ?: Size(1280, 720)
imageReader = ImageReader.newInstance(previewSize.width, previewSize.height, ImageFormat.JPEG, 2)
imageReader?.setOnImageAvailableListener(imageAvailableListener, backgroundHandler)
val captureRequest = cameraDevice?.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)?.apply {
addTarget(imageReader!!.surface)
set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
}
cameraDevice?.createCaptureSession(
listOf(imageReader!!.surface),
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
captureSession = session
captureRequest?.let {
session.setRepeatingRequest(it.build(), null, backgroundHandler)
}
}
override fun onConfigureFailed(session: CameraCaptureSession) {}
},
backgroundHandler
)
} catch (e: Exception) {
e.printStackTrace()
}
}
private val imageAvailableListener = ImageReader.OnImageAvailableListener { reader ->
val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener
try {
val buffer = image.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
onFrame(bytes, image.width, image.height)
} finally {
image.close()
}
}
fun captureAndSave() {
// 保存最后一帧
imageReader?.acquireLatestImage()?.use { image ->
val buffer = image.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val file = File(
context.getExternalFilesDir(null),
"envelope_$timestamp.jpg"
)
FileOutputStream(file).use { it.write(bytes) }
}
}
private fun closeCamera() {
try {
captureSession?.stopRepeating()
captureSession?.abortCaptures()
} catch (e: Exception) {
e.printStackTrace()
}
try {
captureSession?.close()
} catch (e: Exception) {
e.printStackTrace()
}
captureSession = null
try {
imageReader?.close()
} catch (e: Exception) {
e.printStackTrace()
}
imageReader = null
try {
cameraDevice?.close()
} catch (e: Exception) {
e.printStackTrace()
}
cameraDevice = null
}
}

View File

@@ -0,0 +1,116 @@
package com.usbwebcam
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
class MainActivity : AppCompatActivity() {
private var mjpegServer: MjpegServer? = null
private var cameraHelper: CameraHelper? = null
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
startCamera()
} else {
Toast.makeText(this, "需要相机权限", Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val btnStart = findViewById<Button>(R.id.btn_start)
val btnStop = findViewById<Button>(R.id.btn_stop)
val btnCapture = findViewById<Button>(R.id.btn_capture)
val tvStatus = findViewById<TextView>(R.id.tv_status)
btnStart.setOnClickListener {
if (checkPermission()) {
startCamera()
}
}
btnStop.setOnClickListener {
stopCamera()
}
btnCapture.setOnClickListener {
cameraHelper?.captureAndSave()
Toast.makeText(this, "图片已保存到相册", Toast.LENGTH_SHORT).show()
}
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
startCamera()
}
}
private fun checkPermission(): Boolean {
return if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
true
} else {
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
false
}
}
private fun startCamera() {
if (cameraHelper != null) return
mjpegServer = MjpegServer(8080)
cameraHelper = CameraHelper(this) { frame, _, _ ->
mjpegServer?.updateFrame(frame)
}
// Start the server and update UI once started
mjpegServer?.start {
runOnUiThread {
findViewById<TextView>(R.id.tv_status).text = """
服务运行中
端口: 8080
IP: 无需IP (ADB模式)
USB连接命令:
adb forward tcp:8080 tcp:8080
""".trimIndent()
findViewById<Button>(R.id.btn_start).isEnabled = false
findViewById<Button>(R.id.btn_stop).isEnabled = true
}
}
cameraHelper?.start()
}
private fun stopCamera() {
cameraHelper?.stop()
cameraHelper = null
mjpegServer?.stop()
mjpegServer = null
runOnUiThread {
findViewById<TextView>(R.id.tv_status).text = "服务已停止"
findViewById<Button>(R.id.btn_start).isEnabled = true
findViewById<Button>(R.id.btn_stop).isEnabled = false
}
}
override fun onDestroy() {
super.onDestroy()
stopCamera()
}
}

View File

@@ -0,0 +1,151 @@
package com.usbwebcam
import java.io.OutputStream
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
class MjpegServer(private val port: Int) {
private var serverSocket: ServerSocket? = null
private var serverThread: Thread? = null
private val clients = CopyOnWriteArrayList<ClientHandler>()
private val running = AtomicBoolean(false)
private var currentFrame: ByteArray? = null
private val frameLock = Any()
fun start(onStarted: () -> Unit) {
serverThread = Thread {
try {
serverSocket = ServerSocket(port)
running.set(true)
onStarted()
while (running.get()) {
val client = try {
serverSocket?.accept()
} catch (e: Exception) {
null
} ?: continue
val handler = ClientHandler(client)
clients.add(handler)
handler.start()
// 发送当前帧
val frameToSend = synchronized(frameLock) {
currentFrame
}
if (frameToSend != null) {
handler.sendFrame(frameToSend)
}
}
} catch (e: Exception) {
if (running.get()) {
e.printStackTrace()
}
}
}.apply {
name = "MjpegServerThread"
start()
}
}
fun updateFrame(frame: ByteArray) {
synchronized(frameLock) {
currentFrame = frame
}
// 广播给所有客户端
val iterator = clients.iterator()
while (iterator.hasNext()) {
val handler = iterator.next()
if (handler.isClientAlive()) {
handler.sendFrame(frame)
} else {
iterator.remove()
}
}
}
fun stop() {
if (!running.getAndSet(false)) return
try {
serverSocket?.close()
} catch (e: Exception) {
// ignore
}
clients.forEach { it.close() }
clients.clear()
serverSocket = null
serverThread = null
}
private class ClientHandler(private val socket: Socket) : Thread() {
private var clientStream: OutputStream? = null
@Volatile
private var initialized = false
override fun run() {
try {
val os = socket.getOutputStream()
clientStream = os
socket.setSoTimeout(5000)
// 读取HTTP请求
val buffer = ByteArray(1024)
socket.getInputStream().read(buffer)
// 发送HTTP响应头
val header = byteArrayOf(
*("HTTP/1.1 200 OK\r\n").toByteArray(),
*("Content-Type: multipart/x-mixed-replace; boundary=boundary\r\n").toByteArray(),
*("Cache-Control: no-cache\r\n").toByteArray(),
*("Connection: close\r\n").toByteArray(),
*("\r\n").toByteArray()
)
os.write(header)
os.flush()
initialized = true
} catch (e: Exception) {
close()
}
}
fun sendFrame(frame: ByteArray) {
if (!initialized) return
try {
// MJPEG frame header
val header = byteArrayOf(
*("--boundary\r\n").toByteArray(),
*("Content-Type: image/jpeg\r\n").toByteArray(),
*("Content-Length: ${frame.size}\r\n").toByteArray(),
*("\r\n").toByteArray()
)
clientStream?.write(header)
clientStream?.write(frame)
clientStream?.write("\r\n".toByteArray())
clientStream?.flush()
} catch (e: Exception) {
close()
}
}
fun close() {
try {
socket.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
fun isClientAlive(): Boolean {
return !socket.isClosed && socket.isConnected
}
}
}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<TextView
android:id="@+id/tv_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="等待启动..."
android:textSize="16sp"
android:textColor="#333"
android:layout_marginBottom="32dp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<Button
android:id="@+id/btn_start"
android:layout_width="120dp"
android:layout_height="60dp"
android:text="启动"
android:textSize="18sp"
android:layout_marginEnd="16dp" />
<Button
android:id="@+id/btn_stop"
android:layout_width="120dp"
android:layout_height="60dp"
android:text="停止"
android:textSize="18sp"
android:enabled="false" />
</LinearLayout>
<Button
android:id="@+id/btn_capture"
android:layout_width="200dp"
android:layout_height="70dp"
android:text="拍照保存"
android:textSize="20sp"
android:layout_marginTop="24dp"
android:backgroundTint="#4CAF50" />
</LinearLayout>

23
android-app/build.gradle Normal file
View File

@@ -0,0 +1,23 @@
// Top-level build file
buildscript {
ext.kotlin_version = '2.2.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:9.0.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,14 @@
# Project-wide Gradle settings
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false

View File

@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/248ffb1098f61659502d0c09aa348294/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ac151d55def6b6a9a159dc4cb4642851/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

102
android-app/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,102 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,8 @@
## This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Thu Feb 12 21:01:39 CST 2026
sdk.dir=C\:\\Users\\yuanjian\\AppData\\Local\\Android\\Sdk

View File

@@ -0,0 +1,5 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
rootProject.name = "UsbWebcam"
include ':app'

64
build_exe.py Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""
打包脚本 - 将桌面程序打包成独立可执行文件
使用方法: pip install pyinstaller && python build_exe.py
调试版本: python build_exe.py --debug
"""
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent)
def build(debug=False):
"""使用 PyInstaller 打包"""
print("正在打包,请稍候...")
print(f"工作目录: {PROJECT_ROOT}")
print("-" * 50)
# 使用 Python -m PyInstaller 方式
# --paths 将 src 目录添加到 Python 路径,避免导入问题
cmd = [
sys.executable,
"-m", "PyInstaller",
"--name=信封信息提取系统",
"--onefile",
"--clean",
"--noconfirm",
"--paths=src",
]
# 调试模式:显示控制台窗口,便于查看错误
if not debug:
cmd.append("--windowed")
cmd.append("src/desktop.py")
try:
subprocess.run(cmd, check=True, cwd=str(PROJECT_ROOT))
print("-" * 50)
print("打包完成!")
exe_path = PROJECT_ROOT / "dist" / "信封信息提取系统.exe"
if exe_path.exists():
size_mb = exe_path.stat().st_size / 1024 / 1024
print(f"可执行文件: {exe_path}")
print(f"文件大小: {size_mb:.1f} MB")
else:
print("警告: 未找到输出文件")
except subprocess.CalledProcessError as e:
print("-" * 50)
print(f"打包失败: {e}")
sys.exit(1)
except FileNotFoundError:
print("-" * 50)
print("错误: 未找到 PyInstaller")
print("请先安装: pip install pyinstaller")
sys.exit(1)
if __name__ == "__main__":
debug = "--debug" in sys.argv
build(debug=debug)

View File

@@ -1,6 +1,9 @@
# 桌面版依赖(本地电脑安装) # 桌面版依赖(本地电脑安装)
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
openpyxl openpyxl
pydantic pydantic

View File

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

View File

@@ -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 需 >= 0str 类型为 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()

View File

@@ -2,44 +2,30 @@
""" """
离线 OCR 初始化工具 离线 OCR 初始化工具
目标: 适配 PaddleOCR 2.10.0PP-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