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